> Cinder 0.1.0 documentation: cinder/gfx guide
> Canonical page: https://cinder.ash10.dev/docs/guides/gfx/ · Source: `docs/guides/gfx.md` in the engine repository (engine commit 47c9352, imported 2026-11-09)
> Index of every page for agents: https://cinder.ash10.dev/llms.txt

# `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 `WGPUTextureView`s 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`).

## Related documents

- 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).
