cyb/honeycrisp/aruminium/.claude/plans/raster-support.md

Add raster support to aruminium — RenderPipelineState + RenderPassDescriptor + RenderEncoder

context

aruminium today is compute-only (Pipeline wraps MTLComputePipelineState, Encoder is a compute encoder, Texture has format query but no render-target construction). It is the highest-performance Metal driver in the Rust ecosystem for compute and zero-copy memory — but it can't draw triangles.

evy is a unified-memory game engine designed for Apple Silicon first. The earlier architectural choice was "wgpu for raster, aruminium for compute, share one MTLDevice between them." Concrete numbers showed that choice cost 9–13 ms per frame at typical AAA load (draw-call overhead, mesh upload bandwidth, lost MTLSharedEvent sync, WGSL→MSL inefficiency). At a 16.6 ms budget, that's most of the frame.

The corrected architecture: on Apple Silicon, aruminium owns the renderer entirely — both raster and compute. wgpu becomes the portable fallback for non-Apple platforms only. The previous device-sharing constructors were reverted in commit 48f1bc8. This proposal adds the raster API aruminium needs to become the complete renderer.

scope — two phases

phase 1: minimum useful raster

Enough to draw a triangle with a vertex+fragment shader pair into a color texture and present it. Roughly mirrors aruminium's existing compute path (Pipeline + Encoder + dispatch) but for the render pipeline.

New types in src/render/ (a new submodule, mirroring how the existing modules organize compute):

// src/render/pipeline.rs
pub struct RenderPipeline {
    raw: ObjcId,  // id<MTLRenderPipelineState>
    // descriptor metadata for debug + reflection
}

impl RenderPipeline {
    pub fn as_raw(&self) -> ObjcId;
}

impl Gpu {
    /// Build a RenderPipeline from a vertex function + fragment function +
    /// pixel formats.
    pub fn render_pipeline(
        &self,
        vertex: &Shader,
        fragment: &Shader,
        color_formats: &[MTLPixelFormat],
    ) -> Result<RenderPipeline, GpuError>;
}
// src/render/pass.rs
pub struct RenderPassDescriptor {
    raw: ObjcId,  // id<MTLRenderPassDescriptor>
}

pub struct ColorAttachmentDesc<'a> {
    pub texture: &'a Texture,
    pub load_action: LoadAction,
    pub store_action: StoreAction,
    pub clear_color: [f64; 4],
}

pub enum LoadAction { DontCare, Load, Clear }
pub enum StoreAction { DontCare, Store, MultisampleResolve }

impl RenderPassDescriptor {
    pub fn new() -> Self;
    pub fn color_attachment(&mut self, index: usize, attachment: ColorAttachmentDesc<'_>);
    pub fn as_raw(&self) -> ObjcId;
}
// src/render/encoder.rs
pub struct RenderEncoder {
    raw: ObjcId,  // id<MTLRenderCommandEncoder>
}

impl Commands {
    pub fn render_encoder(&self, desc: &RenderPassDescriptor)
        -> Result<RenderEncoder, GpuError>;
}

impl RenderEncoder {
    pub fn bind(&self, pipeline: &RenderPipeline);
    pub fn set_vertex_buffer(&self, index: u32, buffer: &Buffer, offset: usize);
    pub fn set_fragment_buffer(&self, index: u32, buffer: &Buffer, offset: usize);
    pub fn set_vertex_texture(&self, index: u32, texture: &Texture);
    pub fn set_fragment_texture(&self, index: u32, texture: &Texture);
    pub fn set_viewport(&self, x: f64, y: f64, w: f64, h: f64, near: f64, far: f64);
    pub fn set_scissor(&self, x: u32, y: u32, w: u32, h: u32);
    pub fn draw(&self, primitive: PrimitiveType, start: u32, count: u32);
    pub fn end(self);
}

pub enum PrimitiveType { Triangle, TriangleStrip, Line, LineStrip, Point }

Texture extension: add render-target construction.

// extend src/texture.rs
impl Gpu {
    pub fn render_target(
        &self,
        width: u32,
        height: u32,
        format: MTLPixelFormat,
    ) -> Result<Texture, GpuError>;
}

LOC: ~600 lines of impl + ~200 lines of tests + a examples/triangle.rs.

phase 2: production raster

Adds what real game engines need:

  • Depth/stencil: DepthAttachmentDesc, depth pipeline state, depth texture format support, depth compare functions
  • MSAA: sample-count on pipeline, MS-texture construction, resolve attachment support
  • Vertex descriptors: MTLVertexDescriptor for typed vertex input layout (currently we'd have to encode vertex format inline in the vertex shader; that's not how production engines work)
  • Indexed draws: drawIndexedPrimitives: with index buffer + offset
  • Indirect draws: GPU-driven rendering with drawPrimitives:indirectBuffer:
  • Blending state: per-attachment blend factors + operations
  • Cull mode + winding: setCullMode:, setFrontFacingWinding:
  • Depth bias: setDepthBias:slopeScale:clamp:

LOC: another ~600 lines + tests.

what's deferred (phase 3+, not in this proposal)

  • Argument buffers (Apple-specific binding optimization)
  • Tile shaders (Apple-specific imageblock workflow)
  • Mesh shaders (post-MTL3 feature)
  • Acceleration structures (raytracing)

acceptance criteria

  1. src/render/ module with pipeline.rs, pass.rs, encoder.rs
  2. Phase 1 impls land: RenderPipeline, RenderPassDescriptor (color only), RenderEncoder (basic), Gpu::render_target
  3. Phase 2 impls land: depth/stencil, MSAA + resolve, vertex descriptors, indexed draws, blend state, cull/winding
  4. examples/triangle.rs — minimal renderable triangle, runnable on M-series
  5. examples/textured_quad.rs — quad with vertex buffer + sampled texture
  6. examples/depth_cube.rs — phase-2 demo: depth-tested rotating cube
  7. Tests: ~10 unit tests covering descriptor construction, encoder lifecycle, pipeline state object cache, lifetime safety
  8. specs/README.md updated with the render API surface
  9. cargo test --workspace, cargo clippy --workspace -- -W warnings, cargo fmt --all all clean
  10. cargo run --example triangle displays a triangle on M-series
  11. Existing compute examples (vecadd, matmul) still pass

what stays unchanged

  • All existing compute API (Pipeline, Encoder, Commands, Queue, Buffer, Texture query methods, Shader, ShaderLib)
  • Gpu::open(), Gpu::new_command_queue(), retain/release semantics
  • FFI layer (src/ffi/*)
  • examples that don't touch raster (vecadd, matmul, bench)
  • unimem, acpu, rane siblings

scope estimate

  • session 1: phase 1 (RenderPipeline + RenderPassDescriptor + RenderEncoder
    • render_target Texture + triangle example) — ~6 pomodoros
  • session 2: phase 1 polish + textured_quad example + tests — ~4 pomodoros
  • session 3: phase 2 first half (depth/stencil + cull/winding + indexed draws) — ~6 pomodoros
  • session 4: phase 2 second half (MSAA + vertex descriptors + blend state)
    • depth_cube example + tests + spec — ~6 pomodoros

total: ~3–4 sessions (~22 pomodoros).

why now

evy step 3 is now reframed: aruminium = renderer (raster + compute) on Apple Silicon. Without raster support, evy can't render anything on Apple without falling back to wgpu — which we've explicitly rejected on performance and architectural grounds (see commit 48f1bc8 body, evy spec §5.2 after revision).

This is the foundational upstream piece. evy steps 5 (bevy_mesh on unimem), 6 (bevy_transform), 8 (glia + neural materials), 9 (mir tier passes), 14 (bevy_pbr REWRITE) all depend on aruminium being a complete renderer.

conventions to follow (from honeycrisp/aruminium/CLAUDE.md)

  • atomic commits per phase (or smaller — one commit per new file is fine)
  • conventional prefix: feat: aruminium raster — <what>
  • no Co-Authored-By trailers
  • format clean, clippy clean, tests pass, examples run
  • 500-line per-file limit (split if exceeded)
  • specs/ stays canonical — update in the same commit as the code
  • never push without explicit request
  • do not touch Cargo.toml dependency versions
  • do not touch FFI signatures in src/ffi/* (must match Metal.framework)

references

  • aruminium current source: src/{device,command,encoder,pipeline,texture,shader}.rs
  • Metal documentation: render pipeline + render encoder + render pass descriptor (Apple's developer docs; the type/method names map 1:1)
  • corsix Metal reference for raw selectors if needed
  • evy spec §5.2 (renderer composition), §10 (rendering model), §3.2 (capability matrix — Apple Silicon gets the full aruminium path)

Graph