Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ target/
Cargo.lock
*.bak
.DS_Store
# GPU example output (positron-wgpu counter_gpu)
positron-wgpu-frames/
8 changes: 8 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ resolver = "2"
members = [
"positron-core",
"positron-ratatui",
"positron-wgpu",
"examples/counter-cli",
]

Expand All @@ -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"] }
22 changes: 15 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<S>` 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)
Expand Down
25 changes: 18 additions & 7 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<S>` 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.
20 changes: 20 additions & 0 deletions positron-wgpu/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
63 changes: 63 additions & 0 deletions positron-wgpu/examples/counter_gpu.rs
Original file line number Diff line number Diff line change
@@ -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_<n>.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<Counter> 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<dyn std::error::Error>> {
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(())
}
200 changes: 200 additions & 0 deletions positron-wgpu/src/frame.rs
Original file line number Diff line number Diff line change
@@ -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<u8>`) 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<Primitive>,
}

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<u8>,
}

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);
}
}
Loading
Loading