DocsGuides

cinder/gfx guide

cinder/gfx is the safe, typed layer over wgpu-native. It owns the wgpu instance, adapter, device and queue (Gfx), keeps every GPU object the engine creates in generational pools behind typed handles (Resources), compiles WGSL with editor-friendly diagnostics and hot reload (ShaderManager), translates engine-side descriptors into wgpu descriptor graphs (pipeline), records and submits command buffers (commands), configures and presents a window surface (Swapchain) and draws debug lines (DebugDraw). It sits above cinder/core and cinder/platform; the renderer, tools and app layers build on it and never call wgpu directly.

Source: cinder/gfx/*.mojo. Reference: docs/api/gfx.md. Design: ADR-0009. Tests: tests/gfx/test_device.mojo, test_resources.mojo, test_shaders.mojo, test_render.mojo, test_debug_draw.mojo (headless, lavapipe in CI) and tests/platform/test_swapchain.mojo (windowed).

Key types and ownership #

Type Owns Lifetime rules
Gfx instance, adapter, device, queue; the shim event queue (events, capacity 4096); the window surface when constructed from a Window. One per device. Other gfx objects hold Pointer[Gfx, MutUntrackedOrigin], so it must outlive them and must not move (the app keeps it in a Boxed). __deinit__ releases everything in reverse order.
GfxError A recoverable failure: adapter or device request, await_event timeout, surface problems. Raised by Gfx, Swapchain, wait_for_queue.
Resources One Pool per kind: buffers, textures, texture views, samplers, shaders, bind group layouts, pipeline layouts, bind groups, render pipelines, compute pipelines. Holds a pointer to Gfx; destroy it before the Gfx. __deinit__ releases whatever is still alive (imported views are only dropped) and, if anything was, appends a gfx.resources warning with the counts to Gfx.diagnostics.
BufferHandle, TextureHandle, TextureViewHandle, SamplerHandle, ShaderHandle, BindGroupLayoutHandle, PipelineLayoutHandle, BindGroupHandle, RenderPipelineHandle, ComputePipelineHandle Nothing: Handle[Tag] values (cinder.core.handles) with one phantom tag per kind. Copyable; stale handles raise invalid handle. A TextureHandle cannot be passed where a BufferHandle is expected.
BufferDesc, TextureDesc, SamplerDesc Creation parameters (label, size/extent, usage bits, format). Plain values.
ShaderManager The list of loaded shader files (path, mtime); the modules themselves live in Resources.shaders. Holds pointers to Gfx and Resources.
ShaderError, ShaderMessage Compile failure with path:line:column: error: text messages. Raised by compile_source and load.
LayoutEntry, BindingKind, BindingResource, VertexLayout, VertexAttribute, DepthState, BlendMode, ColorTarget, RenderPipelineDesc Engine-side descriptors. Plain values; create_* functions translate them.
CommandEncoder, RenderPass, ComputePass, ColorAttachment, DepthAttachment One command buffer and its passes. finish_and_submit(deinit self) consumes the encoder; an encoder dropped without it is released and its commands discarded. RenderPass.end / ComputePass.end are idempotent; a pass dropped without end is ended and released by its destructor right after its last use.
Swapchain, SurfaceFrame The surface configuration; one acquired texture and its view per frame. present(frame^) or discard(frame^) consumes the frame. __deinit__ unconfigures the surface.
DebugDraw, Color8 Its shader, pipeline, camera buffer and bind group, and a doubling vertex buffer, all in Resources. Holds pointers to Gfx and Resources.
util: string_view, empty_string_view, ptr_to, opaque_to, c_message, zero_event, handle_from Conversions for raw calls. string_view(text) and ptr_to(value) borrow; the pointee must be keep-fenced past the C call.

Swapchain views are the only raw WGPUTextureViews outside the pools: their lifetime is one frame and they belong to the surface. ColorAttachment therefore takes raw views (frame.view or res.texture_view(h).raw), and Resources.import_texture_view / forget_texture_view wrap a swapchain view under a TextureViewHandle without releasing it. destroy_texture_view raises on an imported view and forget_texture_view raises on an owned one, so neither can release a view the caller owns or leak one Resources owns.

Creating a device #

mojo
from cinder.ffi.cinder_native import WGPUSurface
from cinder.gfx import Gfx
from cinder.platform import NativeLibrary

var lib = NativeLibrary()
var gfx = Gfx(lib.ptr(), WGPUSurface())          # headless; or Gfx(lib.ptr(), window)
assert_true(Bool(gfx.device) and Bool(gfx.queue))
gfx.pump(False)
assert_equal(gfx.error_count, 0)
keep(gfx)
keep(lib)

Construction creates the instance, requests an adapter (compatible with the surface when one is given), requests a device with the optional features the adapter has (wireframe_supported for wgpu-native's PolygonModeLine, bc_supported for BC texture compression) and reads adapter_name and backend. Each async request is awaited through the shim queue (see below); a failure tears everything down and raises GfxError.

Pulling callbacks #

No wgpu callback ever runs Mojo code (ADR-0003). The shim registers every callback and writes a CnEvent into Gfx.events. Gfx.pump(wait) calls cn_wgpu_pump (wgpuInstanceProcessEvents plus wgpuDevicePoll) and drains the queue:

  • CN_EVENT_WGPU_LOG becomes a LogRecord in diagnostics with category wgpu (error, warn or info by wgpu level; the shim only forwards Warn and above).
  • CN_EVENT_WGPU_UNCAPTURED_ERROR increments error_count and adds an error record with category wgpu.validation and a type field.
  • CN_EVENT_WGPU_DEVICE_LOST sets lost = True and adds an error record with category wgpu.device.
  • Everything else (adapter, device, error scope, buffer mapped, work done) is held in _pending for await_event(kind, request_id, timeout_ns=10 s), which pumps with wait=True until the matching event arrives and raises GfxError("timed out waiting for wgpu event ...") otherwise. take_pending moves matching events out without blocking.

The frame loop calls gfx.pump(False) once per frame and forwards take_diagnostics() to the logger. Device loss is reported, not handled: the application decides what to do with gfx.lost.

Resources #

mojo
from cinder.gfx.resources import BufferDesc, Resources, SamplerDesc, TextureDesc

var res = Resources(_gfx_ptr(gfx), 64)           # pool capacity for buffers/textures/views/bind groups
var buf = res.create_buffer(BufferDesc(String("vertices"), 1024,
    WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst | WGPUBufferUsage_CopySrc, False))
var tex = res.create_texture(TextureDesc(String("albedo"), 64, 32, 1, 1, 1,
    WGPUTextureFormat_RGBA8Unorm, WGPUTextureUsage_TextureBinding | WGPUTextureUsage_RenderAttachment))
var view = res.create_texture_view(tex)
var sampler = res.create_sampler(SamplerDesc(String("linear"), True, WGPUAddressMode_Repeat, 1))
res.write_buffer(buf, 0, Span(data))             # raises when offset + len exceeds the buffer
res.destroy_texture_view(view)
res.destroy_texture(tex)
res.destroy_sampler(sampler)
res.destroy_buffer(buf)
assert_equal(res.live_counts().total(), 0)

Resources(gfx, capacity=4096) sizes the buffer, texture, view and bind group pools by capacity; samplers get 256 and shaders, layouts and pipelines 1024 each. create_* raise when wgpu returns NULL (out of memory); wgpu validation failures do not return NULL and surface through Gfx.pump instead. write_buffer and write_texture upload through the queue and copy immediately (no mapping). create_texture_view_ex selects dimension, mip and layer ranges; create_comparison_sampler is the reverse-Z PCF sampler (GreaterEqual). Layouts, bind groups and pipelines are created by cinder.gfx.pipeline and adopted (adopt_*); replace_render_pipeline and replace_compute_pipeline swap the object behind an existing handle for hot reload. adopt_* raise on a null object; adopt_* and create_* release the new object when its pool is full, so ownership passes even when they raise. A texture cannot be destroyed while views of it are alive: destroy_texture raises (releasing nothing) until those views are destroyed; texture_view_count(h) reports how many there are. live_counts() reports what is alive per kind; the engine asserts total() == 0 before shutdown. Leaked objects are released by __deinit__ without error, and it appends one gfx.resources warning (released N objects left alive at shutdown: LiveCounts(...)) to gfx.diagnostics, readable afterwards with take_diagnostics (test_shutdown_releases_leaks_and_counts_them).

Shaders #

mojo
from cinder.gfx.shaders import ShaderManager

var shaders = ShaderManager(_ptr(gfx), _ptr(res))
var handle = shaders.load(String("shaders/lit.wgsl"))      # compiles, adopts, registers for reload
...
for result in shaders.check_for_changes():                  # once per frame or on a timer
    if result.success:
        print("reloaded", result.path)                    # same handle, new module behind it:
                                                            # rebuild pipelines that use it
for record in shaders.take_diagnostics():
    logger.log(record.level, record.category, record.message)

compile_source(path, source) -> WGPUShaderModule is the primitive: wgpuShaderModuleGetCompilationInfo is unimplemented in wgpu-native 29 (it aborts the process), so the module is created inside a Validation error scope that is popped through the shim queue (cn_wgpu_device_pop_error_scope, CN_EVENT_WGPU_ERROR_SCOPE). naga marks locations as wgsl:LINE:COLUMN; parse_wgsl_report turns each mark into a ShaderMessage(path, line, column, is_error, text) carrying the whole report, so the raised ShaderError prints as bad.wgsl:4:18: error: .... A report with no mark yields one message at line 0. The module returned by compile_source is not pooled; Resources.adopt_shader does that (load does both).

check_for_changes stats every registered file and recompiles those whose mtime changed. Success updates Resources.shaders under the same ShaderHandle, releases the old module and logs reloaded <path> at info; failure keeps the last good module, increments reload_failures and logs reload failed, keeping last good module: <path:line:col ...> at error. The result list says which handles changed so pipeline owners can build_render_pipeline and replace_render_pipeline under the handles they already hold. wgpu also reports a failed compile as an uncaptured validation error, so gfx.error_count rises; tests that expect a failure drain gfx.take_diagnostics() afterwards.

Pipelines, commands, readback #

The render test draws a triangle offscreen; abridged:

mojo
var module = shaders.compile_source(String("test_render.wgsl"), String(SHADER))
var shader = res.adopt_shader(module, String("test_render.wgsl"))
var uniform_layout = create_bind_group_layout(res, String("tint uniform"),
    [LayoutEntry(0, UInt64(WGPUShaderStage_Vertex | WGPUShaderStage_Fragment), BindingKind.uniform_buffer(), False, 16)])
var render_layout = create_pipeline_layout(res, String("render"), [uniform_layout])
var uniform_group = create_bind_group(res, String("tint uniform"), uniform_layout,
    [BindingResource.for_buffer(0, tint, 0, 16)])
var pipeline = create_render_pipeline(res, RenderPipelineDesc(
    String("triangle"), render_layout, shader, String("vs_main"), String("fs_main"),
    [VertexLayout(8, [VertexAttribute(WGPUVertexFormat_Float32x2, 0, 0)])],
    [ColorTarget(WGPUTextureFormat_RGBA8Unorm, BlendMode.opaque())]))

var encoder = CommandEncoder(_ptr(gfx), _ptr(res), String("frame"))
var compute = encoder.begin_compute_pass()
compute.set_pipeline(doubler)
compute.set_bind_group(0, storage_group)
compute.dispatch(1)
compute.end()
var pass_ = encoder.begin_render_pass(
    [ColorAttachment.cleared(res.texture_view(target_view).raw, 0.0, 0.0, 1.0, 1.0)],
    DepthAttachment.none())
pass_.set_pipeline(pipeline)
pass_.set_bind_group(0, uniform_group)
pass_.set_vertex_buffer(0, vertices)
pass_.draw(3)
pass_.end()
encoder.copy_texture_to_buffer(target, readback, BYTES_PER_ROW, SIZE, SIZE)
encoder^.finish_and_submit()
wait_for_queue(gfx)
var pixels = read_buffer(gfx, res, readback, BYTES_PER_ROW * SIZE)

create_bind_group_layout maps each BindingKind (uniform, storage, read-only storage, 2D / cube / array / depth / multisampled-depth / unfilterable textures, filtering / non-filtering / comparison samplers, write-only storage textures with LayoutEntry.format) onto the four wgpu binding layouts. BindingResource.for_buffer / for_texture / for_sampler build bind group entries; a buffer size of 0 means "to the end of the buffer". RenderPipelineDesc defaults to triangle lists, no culling, one sample, CCW front faces and DepthState.none(); DepthState.reverse_z(format) uses GreaterEqual and reverse_z_biased adds shadow-map bias. wireframe=True chains WGPUPrimitiveStateExtras and needs gfx.wireframe_supported. build_render_pipeline / build_compute_pipeline return the raw object for hot reload; create_* adopt it. Both builders call _creation_error, which pumps once; when the creation produced an uncaptured error they release the invalid object and raise pipeline '<label>' is invalid: <message>, where the message is the first wgpu.validation record of that drain, so a broken pipeline fails at creation instead of aborting at the next submit.

CommandEncoder.begin_render_pass(colors, depth) accepts up to MAX_COLOR_ATTACHMENTS (4) colour attachments in a stack array; ColorAttachment.cleared, cleared_resolving (MSAA with resolve target) and loaded are the constructors, DepthAttachment.cleared_reverse_z clears depth to 0.0. RenderPass exposes set_pipeline, set_bind_group, set_bind_group_dynamic, set_vertex_buffer, set_index_buffer, set_viewport, set_scissor, draw, draw_indexed; ComputePass has set_pipeline, set_bind_group(_dynamic), dispatch. set_vertex_buffer / set_index_buffer raise when offset is past the end of the buffer. CommandEncoder(gfx, res, label) passes label to wgpu, so validation messages name the encoder. Copies: copy_buffer_to_buffer, copy_texture_to_buffer, copy_raw_texture_to_buffer (for a swapchain texture).

wait_for_queue (cn_wgpu_queue_on_submitted_work_done awaited through the queue) and read_buffer (cn_wgpu_buffer_map_async, copy out, unmap) block and exist for tests, tools and shutdown; the frame loop never calls them.

Swapchain #

mojo
var gfx = Gfx(lib.ptr(), window)
var swapchain = Swapchain(_ptr(gfx), gfx.surface, size[0], size[1], True, True)   # prefer sRGB, readback

var frame_opt = swapchain.acquire()
if frame_opt:                                   # None while minimised (frames_skipped counts them)
    var frame = frame_opt.take()
    var encoder = CommandEncoder(_ptr(gfx), _ptr(res), String("clear"))
    var pass_ = encoder.begin_render_pass(
        [ColorAttachment.cleared(frame.view, r, g, b, 1.0)], DepthAttachment.none())
    pass_.end()
    encoder^.finish_and_submit()
    swapchain.present(frame^)

Construction queries wgpuSurfaceGetCapabilities, scores the 8-bit BGRA/RGBA formats (sRGB first when prefer_srgb), requires RenderAttachment (plus CopySrc when readback) and configures with Fifo presentation. resize(w, h) only records the size; acquire reconfigures lazily, returns None for a zero-area window, and on Outdated, Lost or Timeout reconfigures and retries exactly once before raising GfxError. reconfigures and frames_skipped are counters the windowed test asserts on. Swapchain readbacks are BGRA; Image.swap_rb converts them.

Debug draw #

mojo
var draw = DebugDraw(_ptr(gfx), _ptr(res), shaders, WGPUTextureFormat_RGBA8Unorm)   # depth_format optional
draw.line(Vec3(-1.0, 0.0, 0.0), Vec3(1.0, 0.0, 0.0), Color8.red())
draw.aabb(Vec3(-0.5, -0.5, -0.5), Vec3(0.5, 0.5, 0.5), Color8.white())
draw.sphere(Vec3.zero(), 0.25, Color8.yellow(), 12)
draw.axes(Vec3.zero(), 1.0)
draw.grid(2, 0.1, Color8.blue())
draw.upload(view_proj)        # before the render pass opens: camera uniform + vertex buffer
...
draw.flush(pass_)             # inside an open RenderPass; clears the frame's lines

Vertices (Float32x3 position + Unorm8x4 colour, 16 bytes) accumulate on the CPU. upload grows the GPU vertex buffer by doubling when needed (grows counts reallocations; the test asserts it stays flat in steady state) and writes camera and vertices. One line-list pipeline with alpha blending draws them; with a depth_format it depth-tests reverse-Z without writing, otherwise it draws on top.

Threading and error handling #

  • Every call in this module is main-thread only. wgpu-native's internal threads post events to the shim queue; Mojo never runs on them (ADR-0003, docs/THREADING.md).
  • GfxError (typed, raises GfxError): Gfx construction, await_event timeout, Swapchain construction and acquire, wait_for_queue. Plain Error: Resources.create_* on NULL, write_buffer / write_texture bounds, every Pool lookup with a stale handle (invalid handle), create_* in pipeline, _creation_error failures, adopt_* on a null object, destroy_texture with live views, destroy_texture_view / forget_texture_view on the wrong kind of view, set_vertex_buffer / set_index_buffer offsets past the end, CommandEncoder construction and begin_*, finish_and_submit on a NULL command buffer, read_buffer on a failed map or a NULL mapped range (the buffer is unmapped first). ShaderError: compile_source, load.
  • Validation errors from resource and pipeline creation do not raise at the call site; wgpu reports them through the uncaptured-error callback and they appear as wgpu.validation diagnostics and error_count after the next pump. Pipeline builders pump immediately so the error names the pipeline. Tests assert gfx.error_count == 0 after gfx.pump(False).
  • A validation error raised inside wgpuQueueSubmit (for example a texture-to-buffer copy whose bytesPerRow is not a multiple of 256) is fatal: wgpu-native panics and aborts the process (docs/MOJO_NOTES.md). Keep copy strides 256-aligned; the copy helpers take the stride explicitly.
  • Device loss sets gfx.lost and logs under wgpu.device; nothing in this module recovers from it.
  • Nothing here calls cinder_assert; invariants are enforced by the typed handles and the pools.

Pitfalls #

  • Gfx, Resources, ShaderManager, DebugDraw, RenderPass and CommandEncoder hold untracked pointers to Gfx and Resources. Keep both in engine-lifetime storage (Boxed in the app), never move them after handing out _ptr(gfx), and keep them at the end of a test.
  • Destruction order: Resources before Gfx, Gfx before the Window it created a surface for, everything before the NativeLibrary. In a struct, declare the library last.
  • Every raw descriptor passed to wgpu is a stack value or a List whose pointer wgpu reads during the call; the keep(...) calls after each C call are the lifetime fence (ADR-0009, docs/MOJO_NOTES.md). Do not remove them, and add one for any new pointee.
  • A pass must be ended before anything else is recorded on its encoder. end is idempotent; a pass dropped without end is ended by its destructor right after its last use, but call end explicitly so the order of recording is visible.
  • finish_and_submit consumes the encoder (encoder^.finish_and_submit()); the encoder cannot be reused.
  • present and discard consume the SurfaceFrame; a frame that is neither presented nor discarded leaks the surface texture.
  • import_texture_view wraps a view it does not own; drop it with forget_texture_view (destroy_texture_view raises on it).
  • Destroy a texture's views before the texture; destroy_texture raises while any are alive.
  • ShaderManager.compile_source returns a raw module; adopt it or release it. load adopts for you.
  • compile_source and _creation_error call await_event / pump, which also drain unrelated diagnostics into gfx.diagnostics; read them with take_diagnostics afterwards.
  • Check live_counts().total() before destroying Resources to assert a clean shutdown; after teardown the only record of leaks is the gfx.resources warning in gfx.diagnostics.
  • write_buffer does not pad; std140/std430 layout is the caller's job (cinder.core.bytes.ByteWriter).
  • Line rasterisation follows the diamond-exit rule; pixel tests put lines through pixel centres (docs/MOJO_NOTES.md, tests/gfx/test_debug_draw.mojo).
  • ADR-0009 docs/adr/0009-gfx-layer.md: pooled handles, error scopes for shader diagnostics, keep-fenced descriptor building, lazy swapchain reconfiguration, debug draw, depth convention.
  • ADR-0003 docs/adr/0003-native-shim-pull-model.md: the pull model Gfx.pump implements, and the cn_wgpu_pump amendment (no wgpuInstanceWaitAny in wgpu-native 29).
  • ADR-0007 docs/adr/0007-dev-environment-container.md: lavapipe is the CI device.
  • docs/MOJO_NOTES.md: untracked pointers, keep, fatal submit-time validation errors.
  • docs/THREADING.md, docs/ARCHITECTURE.md (conventions: reverse-Z, sRGB targets, column-major).
  • Tests: tests/gfx/test_device.mojo (device, validation capture), test_resources.mojo (handles, live counts, leaks at shutdown), test_shaders.mojo (error locations, hot reload, last-good fallback), test_render.mojo (typed pipelines, compute + render, readback), test_debug_draw.mojo (pixel positions, bounded growth), tests/platform/test_swapchain.mojo (resize, minimise, readback).
View as MarkdownSource: docs/guides/gfx.md