Sign in

    moui_metal_renderer

    Experimental direct Metal renderer for MoUI: no Skia, no wgpu

    moui
    renderer
    metal
    experimental
    Download zip
    Author
    Version
    0.2.6
    License
    Apache-2.0
    Last updated
    6 hours ago
    Downloads
    2

    #MoUI Metal Renderer

    Experimental direct-Metal renderer for wzzc-dev/moui: no Skia and no wgpu are linked. The only graphics dependency is Apple's Metal framework, and the host platform backend keeps owning the window and its CAMetalLayer — this module binds the layer handle the backend hands it and draws into it.

    Plan stages L1 and L2 are landed (docs/plans/active/no-third-party-gpu-route.md, ADR 0044/0045): every PlanOp variant the shared lowering can emit has a draw arm, and degradations are counted rather than hidden. L3 (text fidelity) and L4 (capability truth, GPU recovery) are open. The product class is experimental; nothing here is a usability commitment.

    Where to read the code: renderer.mbt owns the session and presentation, metal_geometry.mbt walks the plan into the frame's buffers, metal_geometry_wire.mbt is the byte layout and the code values MSL interprets, metal_geometry_scopes.mbt the clip/filter/layer stack and pass slots, metal_text.mbt and metal_image.mbt the two caches, and metal_renderer.mm + metal_shaders.h the Metal bridge and its embedded shader source.

    The composition root injects the text engine, so this module depends on no text provider:

    @runtime.run_app("MoUI Showcase", runtime)
    .render(
    @render_metal.metal(
    text_engine=@render_metal.native_platform_text_engine(
    @text_coretext.coretext_text_engine(),
    ),
    ),
    )
    .backend(@macos_backend.entry())
    .run()

    Evidence commands on macOS:

    moon test moui_metal_renderer --target native moon run moui_tests/metal_renderer_smoke/macos --target native MOUI_MACOS_METAL_EXIT_AFTER_FIRST_PRESENT=1 \ moon run examples/showcase/macos_metal --target native

    ADR 0045 records what the route claims, what it refuses to approximate, and the two toolchain link-flag facts it settled.

    MetalFrameReport

    pub struct MetalFrameReport {
    items : Int
    vertices : Int
    approximated_clips : Int
    clipped_away : Int
    text_dropped_glyphs : Int
    text_runs_dropped : Int
    image_textures_released : Int
    image_ops_unresolved : Int
    layers : Int
    blend_modes_unsupported : Int
    blend_modes_approximated : Int
    filter_approximations : Int
    retained_layers_rebuilt : Int
    retained_layers_cached : Int
    retained_layers_updated : Int
    retained_layers_evicted : Int
    shader_effects_fallback_painted : Int
    }

    What one command stream would cost this route: how much it draws, and how much it refuses.

    RendererEvent carries only present and image-load completions, and both host backends ignore anything else, so adding an "unsupported command" event variant would be a channel with no reader. This is the version that is actually readable: a host, a smoke gate or a test can ask what a frame would draw on any host, because the answer is computed on the CPU without a device.

    retained_layers_rebuilt, retained_layers_updated, retained_layers_cached and retained_layers_evicted describe what the cross-frame boundary cache did for this stream, and shader_effects_fallback_painted counts a custom effect drawn with the brush its spec declares for renderers that cannot run the shader. None of them is a silent wrong pixel, so all report separately from refused_count.

    A one-shot report always rebuilds: metal_frame_report has no previous frame to consult, which is the honest answer rather than a limitation — a cache hit is a claim about the frame before this one, and only a renderer that keeps its cache between frames can make it.

    MetalFrameReport::draws_anything

    fn MetalFrameReport::draws_anything(self : MetalFrameReport) -> Bool

    MetalFrameReport::refused_count

    fn MetalFrameReport::refused_count(self : MetalFrameReport) -> Int

    Every way this frame departs from what it was asked to paint: approximated clips, glyphs the text engine could not place, blend modes Metal has no state for, and the composites whose fixed-function factors are only exact over an opaque destination. An op this route cannot draw at all is a compile error in build_metal_range, not a number here.

    metal

    Build a direct-Metal provider for a host GPU surface. The platform backend owns the window and its CAMetalLayer; this provider only configures that layer and draws into it, so nothing here creates a device-backed surface of its own (ADR 0044 boundary: Apple frameworks yes, graphics libraries no).

    The composition root injects the native text engine, which is why this module depends on no text provider: the same Metal route can be driven by CoreText, DirectWrite or Fontconfig coverage.

    metal_frame_report

    fn metal_frame_report(commands~ : Array[
    DrawCommand
    ], width~ : Double, height~ : Double, scale_factor? : Double, text_engine? :
    NativeTextEngine
    ) -> MetalFrameReport

    metal_proof_pixels

    fn metal_proof_pixels(width~ : Int, height~ : Int, commands~ : Array[
    DrawCommand
    ], clear~ :
    Color
    , text_engine? :
    NativeTextEngine
    , image_source? :
    HostImageSource
    , scale_factor? : Double) -> Bytes?

    Render one frame offscreen and return its RGBA8 pixels at device resolution -- width * scale_factor across -- or None when the host has no Metal device. This is the L1 pixel gate: the acceptance frame is asserted from these pixels rather than from a screenshot, so a route that only looks right in a window still has to prove the bytes. The scale exists because a Retina window is the common case and the route's renderer has always taken one; until now the proof pass could not, so nothing read back a device-scale pixel.

    metal_text_smoke

    fn metal_text_smoke(text_engine~ :
    NativeTextEngine
    ) -> Unit

    Real-provider text smoke: the L1 acceptance leg that a glyph run reaches a Metal render target as coverage, not as a filled rectangle.

    A renderer-local test cannot cover this, because the module depends on no text provider (that is what plan stage L0.5 bought). So the composition root injects the platform engine and this function asserts on read-back pixels. The shape check is the point: an ink ratio inside the band can only come from partial coverage, and the noop control proves the ink came from text.

    native_noop_text_engine