From f002cdebd6ce0d0e3a99989c0f36fc6278f23b12 Mon Sep 17 00:00:00 2001 From: Joel Teply Date: Thu, 2 Jul 2026 22:17:22 -0500 Subject: [PATCH] =?UTF-8?q?feat(wgpu):=20O4=20=E2=80=94=20GPU=20renderer,?= =?UTF-8?q?=20outlier=20B=20(closes=20the=20outlier=20pair)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit positron-wgpu is a `Renderer` whose `type Output` is a GPU `Frame` — colored quads destined for a vertex buffer, not any CPU text tree. That is the point: `counter-cli`'s `String` and `positron-ratatui`'s `Paragraph` are both CPU text (a weak outlier pair); GPU geometry is maximally different. With `Renderer` fitting a `String`, terminal cells, AND GPU quads without forcing, the contract provably carries no hidden DOM/text assumption — the middle (mobile, DOM) is guaranteed. This is the "web ≠ DOM" claim made real: one Rust wgpu renderer for native (Metal/Vulkan/DX12) + web (WebGPU/WASM). Splits along the same seam as outlier A: - frame.rs — pure data (Frame/Primitive/Rect/Rgba/RgbaFrame), no wgpu. The render(state) -> Frame projection is unit-tested headlessly. RgbaFrame is deliberately continuum's avatar-frame shape for the O5 reconcile. - gpu.rs — the only file touching wgpu. Gpu::rasterize renders a Frame offscreen (wgpu 22, Rgba8Unorm target, padded copy_texture_to_buffer readback). Missing GPU is a loud GpuError::NoAdapter, never a software fallback. - lib.rs — render_to_rgba: project a ViewState through a Renderer and rasterize in one call (GPU twin of positron_ratatui::render_to_buffer). The hardware path can't be a #[test] (CI runners have no adapter); it's proven by examples/counter_gpu.rs, which rasterizes the same Counter the tests project and writes PNGs. Verified on Metal: 3→three green quads, -4→four red. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo --- .gitignore | 2 + Cargo.toml | 8 + README.md | 22 +- docs/ARCHITECTURE.md | 25 +- positron-wgpu/Cargo.toml | 20 ++ positron-wgpu/examples/counter_gpu.rs | 63 +++++ positron-wgpu/src/frame.rs | 200 ++++++++++++++ positron-wgpu/src/gpu.rs | 371 ++++++++++++++++++++++++++ positron-wgpu/src/lib.rs | 133 +++++++++ 9 files changed, 830 insertions(+), 14 deletions(-) create mode 100644 positron-wgpu/Cargo.toml create mode 100644 positron-wgpu/examples/counter_gpu.rs create mode 100644 positron-wgpu/src/frame.rs create mode 100644 positron-wgpu/src/gpu.rs create mode 100644 positron-wgpu/src/lib.rs diff --git a/.gitignore b/.gitignore index 66d1fd3..79eb7c9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ target/ Cargo.lock *.bak .DS_Store +# GPU example output (positron-wgpu counter_gpu) +positron-wgpu-frames/ diff --git a/Cargo.toml b/Cargo.toml index 1db7929..af62aae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "positron-core", "positron-ratatui", + "positron-wgpu", "examples/counter-cli", ] @@ -25,3 +26,10 @@ serde_json = "1" # its matching crossterm, so consumers use `ratatui::crossterm` and never # risk a version skew against a separately-pinned crossterm. ratatui = "0.29" +# GPU renderer surface (positron-wgpu, outlier B). wgpu is the run-everywhere +# GPU abstraction (Metal/Vulkan/DX12 native + WebGPU under WASM); pollster +# blocks on its async device bring-up off any runtime; bytemuck casts vertex +# structs to bytes for upload. +wgpu = "22" +pollster = "0.4" +bytemuck = { version = "1", features = ["derive"] } diff --git a/README.md b/README.md index 5524c0a..39cd3ac 100644 --- a/README.md +++ b/README.md @@ -83,13 +83,21 @@ standing on its own. Run the proof: `cargo run -p counter-cli`. -Landed since: `positron-ratatui` — terminal renderer + `Host` event loop (O3, -outlier A: a genuinely different `type Output` — terminal cells, not a `String`; -headless-testable `drive` loop + live-TTY `run_crossterm`). Run it: -`cargo run -p positron-ratatui --example counter_tui`. - -Next (see `docs/ARCHITECTURE.md` § roadmap O4–O6): -- `positron-wgpu` — one Rust GPU renderer for native (Metal/Vulkan/DX12) + web (WebGPU/WASM) + AR/VR (O4, outlier B — "web ≠ DOM") +Landed since: +- `positron-ratatui` — terminal renderer + `Host` event loop (O3, outlier A: a + genuinely different `type Output` — terminal cells, not a `String`; + headless-testable `drive` loop + live-TTY `run_crossterm`). Run it: + `cargo run -p positron-ratatui --example counter_tui`. +- `positron-wgpu` — the GPU renderer (O4, outlier B): a `Renderer` whose + `type Output` is a GPU `Frame` (colored quads for a vertex buffer, not any CPU + text tree), rasterized offscreen through wgpu to an `RgbaFrame`. That closes + the outlier pair — `Renderer` now spans a `String`, terminal cells, and GPU + geometry without forcing, so it carries no hidden DOM/text assumption ("web ≠ + DOM"). The pure `render(state) -> Frame` projection is unit-tested headlessly; + the hardware path is proven by `cargo run -p positron-wgpu --example counter_gpu` + (missing GPU is a loud `GpuError::NoAdapter`, never a software fallback). + +Next (see `docs/ARCHITECTURE.md` § roadmap O4b–O6): - `positron-lit` *(optional)* — Lit DOM renderer for a11y / text-reflow (O4b) - `ContinuumHost` (in continuum) — session ↔ Commands/Events, first real `ViewState` (O5) - persona `Observer` → RAG/tool bridge (O6) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d0d4fcb..148fb48 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -185,11 +185,22 @@ the view from `ViewState` alone, the state type is incomplete — not the render | **O5** | `ContinuumHost` (in continuum) | positron session ↔ Commands/Events; first real `ViewState` (`ChatViewState`) flows to a positron renderer; reconciles positron's frame output with continuum's existing `RenderBackend`/`RgbaFrame` GPU seam; resolves the two-wire merge question | O3 or O4 | | **O6** | persona `Observer` → RAG/tool bridge (in continuum) | perception into cognition + action as `CommandEnvelope` — closes "AI persona rag/tool integration" | O5 | -O1–O3 have landed: the boundary is pinned; `examples/counter-cli` proves "one +O1–O4 have landed: the boundary is pinned; `examples/counter-cli` proves "one `ViewState`, many renderers, plus an observer perceiving the same state" in a -single process; and `positron-ratatui` proves the first real stateful -`Renderer` + `Host` event loop against a genuinely different `type Output` -(terminal cells, not a `String`) — outlier A. The render/event loop is -headless-testable (`drive` over a `TestBackend`) with a thin live-TTY wrapper -(`run_crossterm`). **O4 is the next unit** — `positron-wgpu`, the run-everywhere -GPU renderer (outlier B) that makes the "web ≠ DOM" claim real. +single process; `positron-ratatui` proves the first real stateful `Renderer` + +`Host` event loop against a genuinely different `type Output` (terminal cells, +not a `String`) — outlier A; and `positron-wgpu` closes the outlier pair — a +`Renderer` whose `type Output` is a GPU `Frame` (colored quads for a vertex +buffer, not any CPU text tree), rasterized offscreen through wgpu to an +`RgbaFrame`. That proves `Renderer` carries **no** CPU-tree assumption: the +same trait spans a `String`, terminal cells, and GPU geometry without forcing — +so the middle (mobile, DOM) is guaranteed. It splits along the same seam as +outlier A: the pure `render(state) -> Frame` projection is unit-tested headlessly, +while the hardware path (`Gpu::rasterize`) is proven by the `counter_gpu` +example (any real machine) and kept off the CI test surface (runners have no +adapter; a missing GPU is a loud `GpuError::NoAdapter`, never a software +fallback). `RgbaFrame` is deliberately continuum's avatar-frame shape. +**O5 is the next unit** — `ContinuumHost`: the first real `ViewState` +(`ChatViewState`) flowing to a positron renderer, the session↔Commands/Events +lowering, and the reconcile of positron's `RgbaFrame` with continuum's existing +`RenderBackend`/`RgbaFrame` GPU seam. diff --git a/positron-wgpu/Cargo.toml b/positron-wgpu/Cargo.toml new file mode 100644 index 0000000..9d17a74 --- /dev/null +++ b/positron-wgpu/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "positron-wgpu" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "GPU reference renderer for positron (outlier B): a Renderer whose Output is GPU geometry, rasterized offscreen via wgpu to an RgbaFrame. Runs native (Metal/Vulkan/DX12) and web (WebGPU/WASM) from one source." + +[dependencies] +positron-core = { path = "../positron-core" } +wgpu = { workspace = true } +pollster = { workspace = true } +bytemuck = { workspace = true } + +[dev-dependencies] +# The counter_gpu example writes rasterized frames to PNG so the GPU path is +# eyeball-verifiable; image stays a dev-dependency and never ships in the crate. +image = "0.25" diff --git a/positron-wgpu/examples/counter_gpu.rs b/positron-wgpu/examples/counter_gpu.rs new file mode 100644 index 0000000..20ca6ba --- /dev/null +++ b/positron-wgpu/examples/counter_gpu.rs @@ -0,0 +1,63 @@ +//! Rasterize the same `Counter` `ViewState` the crate's tests project, but for +//! real: bring up a GPU, render each state offscreen via wgpu, and write the +//! pixels to PNG. This is the "define once, project many" thesis on the GPU +//! surface — the identical `Renderer` that a `TestBackend`-style unit test +//! asserts against here drives actual hardware. +//! +//! Run: `cargo run -p positron-wgpu --example counter_gpu` +//! Output: `./positron-wgpu-frames/counter_.png` + +use positron_core::{Renderer, ViewState}; +use positron_wgpu::{render_to_rgba, Frame, Gpu, Rect, Rgba}; + +#[derive(Debug, Clone)] +struct Counter { + value: i64, +} + +impl ViewState for Counter { + fn kind(&self) -> &'static str { + "counter" + } +} + +struct CounterRenderer; +impl Renderer for CounterRenderer { + type Output = Frame; + fn render(&self, state: &Counter) -> Frame { + let color = if state.value >= 0 { + Rgba::rgb(0.0, 1.0, 0.0) + } else { + Rgba::rgb(1.0, 0.0, 0.0) + }; + let mut frame = Frame::new(320, 64, Rgba::rgb(0.05, 0.05, 0.08)); + for i in 0..state.value.unsigned_abs() { + let x = 8.0 + i as f32 * 32.0; + frame = frame.with_quad(Rect::new(x, 8.0, 24.0, 24.0), color); + } + frame + } +} + +fn main() -> Result<(), Box> { + let gpu = Gpu::headless()?; + let out_dir = std::path::Path::new("positron-wgpu-frames"); + std::fs::create_dir_all(out_dir)?; + + for value in [0i64, 3, 7, -4] { + let rgba = render_to_rgba(&gpu, &CounterRenderer, &Counter { value })?; + let name = format!("counter_{value}.png"); + let path = out_dir.join(&name); + image::save_buffer( + &path, + &rgba.pixels, + rgba.width, + rgba.height, + image::ColorType::Rgba8, + )?; + println!("wrote {} ({}x{})", path.display(), rgba.width, rgba.height); + } + + println!("\nGPU rasterization complete — {} frames.", 4); + Ok(()) +} diff --git a/positron-wgpu/src/frame.rs b/positron-wgpu/src/frame.rs new file mode 100644 index 0000000..fecb540 --- /dev/null +++ b/positron-wgpu/src/frame.rs @@ -0,0 +1,200 @@ +//! The GPU renderer's data types — the `type Output` a [`Renderer`] projects a +//! [`ViewState`] into, and the pixels a GPU rasterizes it to. +//! +//! [`Frame`] is **pure data**: no `wgpu`, no device, no I/O. That is the whole +//! point of outlier B — a [`Renderer`](positron_core::Renderer)'s output here is +//! GPU-bound *geometry* (colored quads destined for a vertex buffer), not a CPU +//! text tree like `counter-cli`'s `String` or `positron-ratatui`'s `Paragraph`. +//! Projecting state into a `Frame` needs no GPU, so it is unit-tested headlessly; +//! only turning a `Frame` into an [`RgbaFrame`] touches the hardware. +//! +//! [`RgbaFrame`] is intentionally the same shape as continuum's avatar-renderer +//! output (`width`/`height`/tightly-packed RGBA8 `Vec`) so the two reconcile +//! at O5 without either side reshaping its frame type. + +/// A linear RGBA color, each channel in `0.0..=1.0`. Kept as `f32` because that +/// is what the GPU clear value and vertex colors want; the rasterized +/// [`RgbaFrame`] is where it becomes `u8`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Rgba { + /// Red, `0.0..=1.0`. + pub r: f32, + /// Green, `0.0..=1.0`. + pub g: f32, + /// Blue, `0.0..=1.0`. + pub b: f32, + /// Alpha, `0.0..=1.0`. + pub a: f32, +} + +impl Rgba { + /// Opaque color from RGB channels (alpha = 1.0). + pub const fn rgb(r: f32, g: f32, b: f32) -> Self { + Self { r, g, b, a: 1.0 } + } + + /// The four channels as an array, for upload to the GPU. + pub const fn to_array(self) -> [f32; 4] { + [self.r, self.g, self.b, self.a] + } +} + +/// An axis-aligned rectangle in **pixel space**: origin top-left, `+x` right, +/// `+y` down — the coordinate system the substrate thinks in. The rasterizer is +/// the one place this is mapped to GPU normalized-device coordinates, so nothing +/// upstream carries an NDC assumption. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Rect { + /// Left edge, pixels from the frame's left. + pub x: f32, + /// Top edge, pixels from the frame's top. + pub y: f32, + /// Width in pixels. + pub w: f32, + /// Height in pixels. + pub h: f32, +} + +impl Rect { + /// A rectangle from its top-left corner and size. + pub const fn new(x: f32, y: f32, w: f32, h: f32) -> Self { + Self { x, y, w, h } + } +} + +/// One thing to draw. A closed set of GPU-native primitives: today just a +/// colored quad (two triangles). New surfaces (textured quads, glyph runs) are +/// new variants — the renderer projects into them, the rasterizer learns to +/// draw them, and nothing else changes. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Primitive { + /// A solid-colored rectangle. + Quad { + /// Where it sits, in pixel space. + rect: Rect, + /// Its fill color. + color: Rgba, + }, +} + +/// A complete GPU draw description: the surface size, a background clear color, +/// and the primitives to draw over it. This is a [`Renderer`](positron_core::Renderer)'s +/// `type Output` for the wgpu surface — pure data, GPU-shaped, no hardware +/// touched until [`crate::Gpu::rasterize`] consumes it. +#[derive(Debug, Clone, PartialEq)] +pub struct Frame { + /// Target width in pixels. + pub width: u32, + /// Target height in pixels. + pub height: u32, + /// The color the frame is cleared to before primitives are drawn. + pub clear: Rgba, + /// The primitives, drawn in order (later ones over earlier ones). + pub primitives: Vec, +} + +impl Frame { + /// An empty frame of the given size, cleared to `clear`, no primitives. + pub fn new(width: u32, height: u32, clear: Rgba) -> Self { + Self { + width, + height, + clear, + primitives: Vec::new(), + } + } + + /// Push a colored quad and return `self`, for fluent construction. + #[must_use] + pub fn with_quad(mut self, rect: Rect, color: Rgba) -> Self { + self.primitives.push(Primitive::Quad { rect, color }); + self + } +} + +/// Rasterized output: a tightly-packed RGBA8 pixel buffer. `pixels.len()` is +/// always `width * height * 4`. Same shape as continuum's avatar `RgbaFrame`, so +/// LiveKit / PNG consumers (and the O5 reconcile) need no adapter. +#[derive(Debug, Clone, PartialEq)] +pub struct RgbaFrame { + /// Width in pixels. + pub width: u32, + /// Height in pixels. + pub height: u32, + /// Row-major RGBA8, `width * height * 4` bytes, no row padding. + pub pixels: Vec, +} + +impl RgbaFrame { + /// The RGBA8 pixel at `(x, y)`, or `None` if out of bounds. Reading a corner + /// or a known quad center is how the GPU tests assert without a golden image. + pub fn pixel(&self, x: u32, y: u32) -> Option<[u8; 4]> { + if x >= self.width || y >= self.height { + return None; + } + let i = ((y * self.width + x) * 4) as usize; + Some([ + self.pixels[i], + self.pixels[i + 1], + self.pixels[i + 2], + self.pixels[i + 3], + ]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // what this catches: the pure builder produces exactly the geometry asked + // for, in order — the projection half of outlier B that needs no GPU. If + // this drifts, every rasterized frame is wrong before the hardware is even + // involved. + #[test] + fn frame_builder_accumulates_quads_in_order() { + let red = Rgba::rgb(1.0, 0.0, 0.0); + let blue = Rgba::rgb(0.0, 0.0, 1.0); + let frame = Frame::new(64, 32, Rgba::rgb(0.0, 0.0, 0.0)) + .with_quad(Rect::new(0.0, 0.0, 8.0, 8.0), red) + .with_quad(Rect::new(8.0, 0.0, 8.0, 8.0), blue); + + assert_eq!(frame.width, 64); + assert_eq!(frame.height, 32); + assert_eq!(frame.clear, Rgba::rgb(0.0, 0.0, 0.0)); + assert_eq!( + frame.primitives, + vec![ + Primitive::Quad { + rect: Rect::new(0.0, 0.0, 8.0, 8.0), + color: red, + }, + Primitive::Quad { + rect: Rect::new(8.0, 0.0, 8.0, 8.0), + color: blue, + }, + ] + ); + } + + // what this catches: RgbaFrame::pixel indexes row-major RGBA8 correctly and + // bounds-checks — the readback accessor the GPU tests trust. An off-by-one + // here would make a green rasterization assert as passing against garbage. + #[test] + fn rgba_frame_indexes_row_major_and_bounds_checks() { + // 2x2, second pixel of row 1 is green: pixel index (1*2 + 1) = 3. + let mut pixels = vec![0u8; 2 * 2 * 4]; + let i = 3 * 4; + pixels[i + 1] = 255; + pixels[i + 3] = 255; + let frame = RgbaFrame { + width: 2, + height: 2, + pixels, + }; + + assert_eq!(frame.pixel(0, 0), Some([0, 0, 0, 0])); + assert_eq!(frame.pixel(1, 1), Some([0, 255, 0, 255])); + assert_eq!(frame.pixel(2, 0), None); + assert_eq!(frame.pixel(0, 2), None); + } +} diff --git a/positron-wgpu/src/gpu.rs b/positron-wgpu/src/gpu.rs new file mode 100644 index 0000000..26ca025 --- /dev/null +++ b/positron-wgpu/src/gpu.rs @@ -0,0 +1,371 @@ +//! [`Gpu`] — the one place that touches wgpu. It turns a pure [`Frame`] of +//! colored quads into an [`RgbaFrame`] by rendering offscreen and reading the +//! pixels back. No window, no surface: this is the "render anywhere" path — the +//! same code drives Metal (macOS), Vulkan (Linux), and DX12 (Windows), and the +//! very same crate compiles to WebGPU under WASM. +//! +//! It is deliberately kept out of the `#[test]` surface: CI runners have no GPU +//! adapter, so the hardware path is proven by the `counter_gpu` example (which +//! runs on any real machine), while the pure [`Frame`] projection is what the +//! unit tests assert. Missing hardware is a **loud, named** [`GpuError::NoAdapter`], +//! never a silent software fallback. + +use std::error::Error; +use std::fmt; + +use wgpu::util::DeviceExt; + +use crate::frame::{Frame, Primitive, RgbaFrame}; + +/// The minimal colored-quad shader: pass NDC position + vertex color straight +/// through. Inline (not an `include_str!`) so there is no on-disk asset to +/// resolve relative to a CWD. +const SHADER: &str = r" +struct VsOut { + @builtin(position) pos: vec4, + @location(0) color: vec4, +}; + +@vertex +fn vs_main(@location(0) pos: vec2, @location(1) color: vec4) -> VsOut { + var out: VsOut; + out.pos = vec4(pos, 0.0, 1.0); + out.color = color; + return out; +} + +@fragment +fn fs_main(in: VsOut) -> @location(0) vec4 { + return in.color; +} +"; + +/// Linear texture format so a rasterized channel reads back as exactly +/// `round(channel * 255)` — no sRGB curve to reason about when asserting pixels. +const TARGET_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +/// wgpu requires each copied texture row to be a multiple of this many bytes. +const COPY_ALIGN: u32 = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +struct Vertex { + pos: [f32; 2], + color: [f32; 4], +} + +/// What can go wrong bringing up or driving the GPU. Every variant names the +/// cause — there is no fallback path to hide behind. +#[derive(Debug)] +pub enum GpuError { + /// No GPU adapter was available (e.g. a headless CI runner). Loud on + /// purpose: the caller decides, we never quietly software-render. + NoAdapter, + /// The adapter refused a device with our (downlevel) requirements. + RequestDevice(wgpu::RequestDeviceError), + /// Mapping the readback buffer failed. + BufferMap(wgpu::BufferAsyncError), +} + +impl fmt::Display for GpuError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + GpuError::NoAdapter => write!( + f, + "no GPU adapter available (headless environment?) — positron-wgpu does not software-fallback" + ), + GpuError::RequestDevice(e) => write!(f, "GPU device request failed: {e}"), + GpuError::BufferMap(e) => write!(f, "GPU readback buffer map failed: {e}"), + } + } +} + +impl Error for GpuError {} + +/// A ready GPU device + queue, reusable across many [`rasterize`](Gpu::rasterize) +/// calls. Construction is the expensive part (adapter + device); hold one and +/// render many frames through it. +pub struct Gpu { + device: wgpu::Device, + queue: wgpu::Queue, +} + +impl Gpu { + /// Bring up a headless GPU: pick an adapter, request a device with + /// downlevel-default limits (so it runs on modest hardware and under WebGPU). + /// Fails loud with [`GpuError::NoAdapter`] when there is no GPU at all. + pub fn headless() -> Result { + let instance = wgpu::Instance::default(); + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + force_fallback_adapter: false, + compatible_surface: None, + })) + .ok_or(GpuError::NoAdapter)?; + + let (device, queue) = pollster::block_on(adapter.request_device( + &wgpu::DeviceDescriptor { + label: Some("positron-wgpu device"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::downlevel_defaults(), + memory_hints: wgpu::MemoryHints::default(), + }, + None, + )) + .map_err(GpuError::RequestDevice)?; + + Ok(Self { device, queue }) + } + + /// Render `frame` offscreen and read the pixels back as an [`RgbaFrame`]. + /// Clears to `frame.clear`, then draws each [`Primitive::Quad`] as two + /// triangles in pixel space. + pub fn rasterize(&self, frame: &Frame) -> Result { + let (width, height) = (frame.width, frame.height); + let vertices = quads_to_vertices(frame); + + let texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("positron-wgpu target"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: TARGET_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let pipeline = self.build_pipeline(); + let vertex_buffer = self + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("positron-wgpu vertices"), + contents: bytemuck::cast_slice(&vertices), + usage: wgpu::BufferUsages::VERTEX, + }); + + // Readback needs each row padded to COPY_ALIGN; we strip the padding + // after mapping so the returned RgbaFrame is tightly packed. + let unpadded_bytes_per_row = width * 4; + let padded_bytes_per_row = padded_row(unpadded_bytes_per_row); + let output_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("positron-wgpu readback"), + size: (padded_bytes_per_row * height) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("positron-wgpu encoder"), + }); + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("positron-wgpu pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(clear_color(frame)), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + if !vertices.is_empty() { + pass.set_pipeline(&pipeline); + pass.set_vertex_buffer(0, vertex_buffer.slice(..)); + pass.draw(0..vertices.len() as u32, 0..1); + } + } + + encoder.copy_texture_to_buffer( + wgpu::ImageCopyTexture { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::ImageCopyBuffer { + buffer: &output_buffer, + layout: wgpu::ImageDataLayout { + offset: 0, + bytes_per_row: Some(padded_bytes_per_row), + rows_per_image: Some(height), + }, + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + self.queue.submit(Some(encoder.finish())); + + self.read_back(&output_buffer, width, height, padded_bytes_per_row) + } + + fn build_pipeline(&self) -> wgpu::RenderPipeline { + let shader = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("positron-wgpu shader"), + source: wgpu::ShaderSource::Wgsl(SHADER.into()), + }); + let layout = self + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("positron-wgpu layout"), + bind_group_layouts: &[], + push_constant_ranges: &[], + }); + self.device + .create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("positron-wgpu pipeline"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: "vs_main", + compilation_options: wgpu::PipelineCompilationOptions::default(), + buffers: &[wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x4], + }], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: "fs_main", + compilation_options: wgpu::PipelineCompilationOptions::default(), + targets: &[Some(wgpu::ColorTargetState { + format: TARGET_FORMAT, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }) + } + + fn read_back( + &self, + output_buffer: &wgpu::Buffer, + width: u32, + height: u32, + padded_bytes_per_row: u32, + ) -> Result { + let slice = output_buffer.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |result| { + // Send can only fail if the receiver was dropped; we wait on it below. + let _ = tx.send(result); + }); + self.device.poll(wgpu::Maintain::Wait); + rx.recv() + .expect("map_async callback never fired") + .map_err(GpuError::BufferMap)?; + + let unpadded_bytes_per_row = (width * 4) as usize; + let padded = padded_bytes_per_row as usize; + let mapped = slice.get_mapped_range(); + let mut pixels = Vec::with_capacity(unpadded_bytes_per_row * height as usize); + for row in 0..height as usize { + let start = row * padded; + pixels.extend_from_slice(&mapped[start..start + unpadded_bytes_per_row]); + } + drop(mapped); + output_buffer.unmap(); + + Ok(RgbaFrame { + width, + height, + pixels, + }) + } +} + +/// Map a pixel-space [`Frame`] of quads to a flat triangle-list vertex buffer in +/// normalized device coordinates (`-1..1`, `+y` up — so pixel `y` is flipped). +fn quads_to_vertices(frame: &Frame) -> Vec { + let (fw, fh) = (frame.width as f32, frame.height as f32); + let ndc = |x: f32, y: f32| [x / fw * 2.0 - 1.0, 1.0 - y / fh * 2.0]; + + let mut vertices = Vec::with_capacity(frame.primitives.len() * 6); + for Primitive::Quad { rect, color } in &frame.primitives { + let c = color.to_array(); + let tl = ndc(rect.x, rect.y); + let tr = ndc(rect.x + rect.w, rect.y); + let br = ndc(rect.x + rect.w, rect.y + rect.h); + let bl = ndc(rect.x, rect.y + rect.h); + for pos in [tl, tr, br, tl, br, bl] { + vertices.push(Vertex { pos, color: c }); + } + } + vertices +} + +fn clear_color(frame: &Frame) -> wgpu::Color { + wgpu::Color { + r: frame.clear.r as f64, + g: frame.clear.g as f64, + b: frame.clear.b as f64, + a: frame.clear.a as f64, + } +} + +/// Round `unpadded` up to the next multiple of [`COPY_ALIGN`]. +fn padded_row(unpadded: u32) -> u32 { + unpadded.div_ceil(COPY_ALIGN) * COPY_ALIGN +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::frame::{Rect, Rgba}; + + // what this catches: the pixel→NDC mapping and triangle winding that the GPU + // path depends on but can't assert without hardware. A quad at the frame's + // top-left corner must map to NDC top-left (-1, +1); a full-frame quad must + // span the whole clip cube. Get this wrong and every rasterized frame is + // mirrored or offset — invisibly, since CI never runs the GPU. + #[test] + fn quads_map_pixel_space_to_ndc_with_flipped_y() { + let frame = Frame::new(100, 100, Rgba::rgb(0.0, 0.0, 0.0)) + .with_quad(Rect::new(0.0, 0.0, 100.0, 100.0), Rgba::rgb(1.0, 1.0, 1.0)); + let verts = quads_to_vertices(&frame); + + assert_eq!(verts.len(), 6, "one quad = two triangles = six vertices"); + // First vertex is the top-left corner: pixel (0,0) → NDC (-1, +1). + assert_eq!(verts[0].pos, [-1.0, 1.0]); + // Third vertex is bottom-right: pixel (100,100) → NDC (+1, -1). + assert_eq!(verts[2].pos, [1.0, -1.0]); + // Color rides through unchanged. + assert_eq!(verts[0].color, [1.0, 1.0, 1.0, 1.0]); + } + + // what this catches: row padding math — readback copies rows padded to + // COPY_ALIGN (256). 64px * 4 = 256 is already aligned; 65px * 4 = 260 must + // round up to 512. An off-by-one here corrupts every row of every readback. + #[test] + fn padded_row_rounds_up_to_copy_alignment() { + assert_eq!(COPY_ALIGN, 256); + assert_eq!(padded_row(256), 256); + assert_eq!(padded_row(260), 512); + assert_eq!(padded_row(1), 256); + } +} diff --git a/positron-wgpu/src/lib.rs b/positron-wgpu/src/lib.rs new file mode 100644 index 0000000..99f1ddd --- /dev/null +++ b/positron-wgpu/src/lib.rs @@ -0,0 +1,133 @@ +#![forbid(unsafe_code)] +#![warn(missing_docs)] +#![warn(rust_2018_idioms)] + +//! # positron-wgpu +//! +//! The GPU reference [`Renderer`](positron_core::Renderer) for positron — +//! **outlier B** in the contract's outlier-validation. Where `counter-cli`'s +//! `String` and `positron-ratatui`'s `Paragraph` are both CPU text trees (a weak +//! outlier pair), here a renderer's `type Output` is a [`Frame`]: GPU-bound +//! *geometry* (colored quads headed for a vertex buffer). If [`Renderer`] fits +//! this without forcing, it carries no hidden CPU-tree assumption — which is the +//! whole claim behind "web ≠ the DOM": one Rust wgpu renderer runs native +//! (Metal / Vulkan / DX12) and web (WebGPU under WASM) from the same source. +//! +//! The crate splits cleanly along the same seam as `positron-ratatui`: +//! - [`Frame`] / [`Primitive`] / [`RgbaFrame`] — pure data, no hardware. The +//! projection `render(state) -> Frame` is unit-tested headlessly. +//! - [`Gpu`] — the one type that touches wgpu; [`Gpu::rasterize`] turns a +//! [`Frame`] into pixels. Proven by the `counter_gpu` example (runs on any real +//! machine), kept out of the CI test surface (runners have no GPU adapter). +//! - [`render_to_rgba`] — the consumer entry point: project a [`ViewState`] +//! through a [`Renderer`] and rasterize it in one call. +//! +//! positron owns the *contract*; this crate owns *one surface projection*. It +//! knows nothing of any substrate's state — it renders whatever `ViewState` it +//! is given into quads. + +mod frame; +mod gpu; + +pub use frame::{Frame, Primitive, Rect, Rgba, RgbaFrame}; +pub use gpu::{Gpu, GpuError}; + +use positron_core::{Renderer, ViewState}; + +/// Project a [`ViewState`] through a [`Renderer`] (whose `Output` is a [`Frame`]) +/// and rasterize it to pixels on `gpu`. The GPU-side twin of +/// `positron_ratatui::render_to_buffer`: one call, state in, [`RgbaFrame`] out. +pub fn render_to_rgba(gpu: &Gpu, renderer: &R, state: &S) -> Result +where + S: ViewState, + R: Renderer, +{ + gpu.rasterize(&renderer.render(state)) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Outlier-B fixture: a counter rendered as GPU geometry — one colored quad + // per unit of magnitude, NOT a text tree. This is the point of the crate. + #[derive(Debug, Clone)] + struct Counter { + value: i64, + } + + impl ViewState for Counter { + fn kind(&self) -> &'static str { + "counter" + } + } + + const CANVAS: (u32, u32) = (320, 64); + const CELL: f32 = 24.0; + const GAP: f32 = 8.0; + const BG: Rgba = Rgba::rgb(0.05, 0.05, 0.08); + const POSITIVE: Rgba = Rgba::rgb(0.0, 1.0, 0.0); + const NEGATIVE: Rgba = Rgba::rgb(1.0, 0.0, 0.0); + + struct CounterRenderer; + impl Renderer for CounterRenderer { + // The whole reason this crate exists: Output is a GPU Frame, not a String. + type Output = Frame; + fn render(&self, state: &Counter) -> Frame { + let color = if state.value >= 0 { POSITIVE } else { NEGATIVE }; + let mut frame = Frame::new(CANVAS.0, CANVAS.1, BG); + for i in 0..state.value.unsigned_abs() { + let x = GAP + i as f32 * (CELL + GAP); + frame = frame.with_quad(Rect::new(x, GAP, CELL, CELL), color); + } + frame + } + } + + // what this catches: a Renderer whose Output is GPU geometry (not text) + // projects a ViewState into the expected quads — magnitude → count, sign → + // color — with no GPU involved. This is outlier B's headless half: proof the + // contract carries a non-text surface. The rasterization half is the example. + #[test] + fn renderer_projects_view_state_into_gpu_quads() { + let frame = CounterRenderer.render(&Counter { value: 3 }); + assert_eq!(frame.width, CANVAS.0); + assert_eq!(frame.clear, BG); + assert_eq!(frame.primitives.len(), 3, "magnitude 3 → three quads"); + assert!( + frame + .primitives + .iter() + .all(|p| matches!(p, Primitive::Quad { color, .. } if *color == POSITIVE)), + "positive value → green quads", + ); + + // Negative magnitude → same count, negative color. + let neg = CounterRenderer.render(&Counter { value: -2 }); + assert_eq!(neg.primitives.len(), 2); + assert!(matches!( + neg.primitives[0], + Primitive::Quad { color, .. } if color == NEGATIVE + )); + + // Zero → nothing to draw but a cleared frame. + assert!(CounterRenderer + .render(&Counter { value: 0 }) + .primitives + .is_empty()); + } + + // what this catches: quads are laid out left-to-right without overlap — the + // Nth quad starts one cell+gap past the (N-1)th. If the stride math drifts, + // the rasterized bars would overlap or drift off-canvas. + #[test] + fn quads_lay_out_left_to_right_without_overlap() { + let frame = CounterRenderer.render(&Counter { value: 3 }); + let xs: Vec = frame + .primitives + .iter() + .map(|Primitive::Quad { rect, .. }| rect.x) + .collect(); + assert_eq!(xs, vec![GAP, GAP + (CELL + GAP), GAP + 2.0 * (CELL + GAP)]); + } +}