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 #
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_LOGbecomes aLogRecordindiagnosticswith categorywgpu(error, warn or info by wgpu level; the shim only forwardsWarnand above).CN_EVENT_WGPU_UNCAPTURED_ERRORincrementserror_countand adds an error record with categorywgpu.validationand atypefield.CN_EVENT_WGPU_DEVICE_LOSTsetslost = Trueand adds an error record with categorywgpu.device.- Everything else (adapter, device, error scope, buffer mapped, work done) is held in
_pendingforawait_event(kind, request_id, timeout_ns=10 s), which pumps withwait=Trueuntil the matching event arrives and raisesGfxError("timed out waiting for wgpu event ...")otherwise.take_pendingmoves 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 #
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 #
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:
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 #
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 #
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 linesVertices (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):Gfxconstruction,await_eventtimeout,Swapchainconstruction andacquire,wait_for_queue. PlainError:Resources.create_*on NULL,write_buffer/write_texturebounds, everyPoollookup with a stale handle (invalid handle),create_*inpipeline,_creation_errorfailures,adopt_*on a null object,destroy_texturewith live views,destroy_texture_view/forget_texture_viewon the wrong kind of view,set_vertex_buffer/set_index_bufferoffsets past the end,CommandEncoderconstruction andbegin_*,finish_and_submiton a NULL command buffer,read_bufferon 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.validationdiagnostics anderror_countafter the nextpump. Pipeline builders pump immediately so the error names the pipeline. Tests assertgfx.error_count == 0aftergfx.pump(False). - A validation error raised inside
wgpuQueueSubmit(for example a texture-to-buffer copy whosebytesPerRowis 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.lostand logs underwgpu.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,RenderPassandCommandEncoderhold untracked pointers toGfxandResources. Keep both in engine-lifetime storage (Boxedin the app), never move them after handing out_ptr(gfx), andkeepthem at the end of a test.- Destruction order:
ResourcesbeforeGfx,Gfxbefore theWindowit created a surface for, everything before theNativeLibrary. In a struct, declare the library last. - Every raw descriptor passed to wgpu is a stack value or a
Listwhose pointer wgpu reads during the call; thekeep(...)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.
endis idempotent; a pass dropped withoutendis ended by its destructor right after its last use, but callendexplicitly so the order of recording is visible. finish_and_submitconsumes the encoder (encoder^.finish_and_submit()); the encoder cannot be reused.presentanddiscardconsume theSurfaceFrame; a frame that is neither presented nor discarded leaks the surface texture.import_texture_viewwraps a view it does not own; drop it withforget_texture_view(destroy_texture_viewraises on it).- Destroy a texture's views before the texture;
destroy_textureraises while any are alive. ShaderManager.compile_sourcereturns a raw module; adopt it or release it.loadadopts for you.compile_sourceand_creation_errorcallawait_event/pump, which also drain unrelated diagnostics intogfx.diagnostics; read them withtake_diagnosticsafterwards.- Check
live_counts().total()before destroyingResourcesto assert a clean shutdown; after teardown the only record of leaks is thegfx.resourceswarning ingfx.diagnostics. write_bufferdoes 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 modelGfx.pumpimplements, and thecn_wgpu_pumpamendment (nowgpuInstanceWaitAnyin 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).