DocsGuides

cinder/render: the renderer

cinder/render turns a RenderScene (camera, lights, instances, joint palettes, environment) into one submitted command buffer per frame. It is a clustered forward+ renderer with cascaded and atlas shadow maps, GPU-baked image-based lighting, GTAO, physically based bloom, AgX or ACES tone mapping, optional MSAA and FXAA, meshoptimizer LODs, GPU skinning and morph targets, and a frame graph that orders passes and aliases transient textures. The design decisions are recorded in ADR-0010; the allocation-free steady state in ADR-0014.

In the layering of docs/ARCHITECTURE.md the module sits above cinder/gfx (typed wgpu handles, ShaderManager), cinder/core (math, ByteWriter, logging) and cinder/platform (NativePtr for meshoptimizer and MikkTSpace), and below cinder/scene (the ECS bridge RenderBridge that fills a RenderScene), cinder/app (which owns the Renderer in a Boxed cell and calls render once per frame) and cinder/tools (the debug overlay shows graph.to_graphviz()). It also imports CookedMesh from cinder/assets/cooked for pre-cooked meshes. Every GPU call goes through cinder.gfx; nothing in this module waits on the GPU.

docs/api/render.md lists every public symbol with its signature.

Key types and ownership #

Type Role Owned by
Renderer Pipelines, layouts, persistent buffers and textures, mesh and material registries, the FrameGraph, ShaderLibrary, ShadowAtlas, frame scratch. The application (App boxes it). Holds raw pointers to Gfx and Resources, which must outlive it.
RenderSettings Quality knobs; Renderer.settings is read at the start of every render. Copied into the renderer; mutate renderer.settings between frames.
RenderScene camera: Camera, lights: List[Light], instances: List[RenderInstance], joints: List[Mat4], environment: Environment. The caller. The renderer only reads it.
RenderInstance mesh and material indices into the renderer's registries, transform, joint_offset (-1 when not skinned), morph_weights, cast_shadows. Inside RenderScene.instances.
Camera, Frustum, Plane Perspective camera (position, rotation, fov_y, aspect, near), five-plane frustum, SIMD sphere culling. Values.
MeshData, GpuMesh, MeshLod, MorphTarget, Bounds CPU mesh, uploaded buffers, LOD index ranges. GpuMesh lives in Renderer.meshes; destroy_mesh frees it.
Material, GpuMaterial, AlphaMode glTF metallic-roughness description and its uniform buffer plus bind group. Renderer.materials and Renderer.material_params.
GpuTexture, DefaultTextures Uploaded images with mips; 1x1 white, flat-normal and black fallbacks with two samplers. Renderer.defaults; textures referenced by a Material are the caller's.
Light, LightKind, ClusterParams Directional, point and spot lights; logarithmic z-slice parameters. Values.
Cascade, ShadowView, ShadowAtlas Cascade fit results; atlas tiles for spot (1) and point (6) lights. Renderer._cascades, Renderer.atlas.
FrameGraph, TransientDesc Pass and resource declarations, compiled order, pooled physical textures. Renderer.graph.
ShaderLibrary, AssembledShader #include assembly, error mapping, hot reload over ShaderManager. Renderer.library wraps Renderer.shaders.
FrameStats, DebugMode, Tonemapper Per-frame counters; enums mirrored in common.wgsl and post.wgsl. Renderer.stats, settings.

Handles (BufferHandle, TextureViewHandle, ...) are generational indices into Resources; Renderer.release frees the frame graph's pooled textures and cached bind groups, everything else is released with Resources.

Setting up a renderer #

Adapted from tests/render/test_renderer.mojo. The native library, Gfx and Resources must be declared so that they are destroyed after the renderer (struct fields die in declaration order).

mojo
var lib = NativeLibrary()
var gfx = Gfx(lib.ptr(), WGPUSurface())            # headless: no surface
var res = Resources(_ptr(gfx), 4096)
var settings = RenderSettings()
var renderer = Renderer(_ptr(gfx), _ptr(res), lib.ptr(), settings, WGPUTextureFormat_RGBA8Unorm)

var ground = renderer.add_mesh(String("ground"), plane(8.0, 4), False)   # no LODs
var ball = renderer.add_mesh(String("sphere"), uv_sphere(0.5, 24, 48), True)
var red = Material(String("red"))
red.base_color = Vec4(0.8, 0.1, 0.1, 1.0)
var m_red = renderer.add_material(red)

var camera = Camera.look_at(Vec3(4.0, 3.0, 6.0), Vec3(0.0, 0.5, 0.0), Vec3.unit_y(), 0.9, 4.0 / 3.0, 0.1)
var scene = RenderScene(camera)
scene.instances.append(RenderInstance(ground, m_red, Mat4.identity(), -1, False))
scene.instances.append(RenderInstance(ball, m_red, Mat4.translation(Vec3(-1.6, 0.5, 0.4))))
scene.lights.append(Light.sun(Vec3(0.5, -1.0, 0.4), Vec3(1.0, 0.96, 0.9), 4.0, True))
scene.lights.append(Light.point_light(Vec3(2.0, 1.5, 2.0), Vec3(1.0, 0.8, 0.5), 60.0, 8.0, True))

The last constructor argument is the format of the view passed to render; App passes the swapchain format. Rendering a frame and reading it back (tests only; the engine never waits):

mojo
renderer.render(scene, target, W, H)
var encoder = CommandEncoder(_ptr(gfx), _ptr(res), String("readback"))
encoder.copy_texture_to_buffer(target_tex, readback, STRIDE, W, H)   # STRIDE is 256-aligned
encoder^.finish_and_submit()
wait_for_queue(gfx)
var rows = read_buffer(gfx, res, readback, STRIDE * H)
gfx.pump(False)
assert_equal(gfx.error_count, 0, "no validation errors")

render raises GfxError when gfx.lost is set (device loss); the renderer and its resources must then be recreated. A zero-sized viewport returns without recording.

The frame, in order #

Renderer.render(scene, target, width, height) performs the CPU work below, then records every pass of the compiled graph into one CommandEncoder and submits it.

  1. Environment bake (only when changed). If scene.environment (zenith, ground, sun_intensity, equirect) or the sun direction differs from the last bake, or shaders were reloaded, _bake_environment runs the ibl.wgsl compute passes: the 256 px environment cube with a full mip chain (cs_environment, procedural sky or equirect), 6 GGX-prefiltered mips at 128 px with 64 filtered importance samples each (cs_prefilter), a 32 px irradiance cube (cs_irradiance) and the 256 px split-sum BRDF LUT (cs_brdf_lut). These are submitted separately before the frame.

  2. Lights and shadow views. The first directional light is the sun; other directional lights are ignored. Point and spot lights are packed with pack_lights_into (64 bytes each, at most MAX_LIGHTS = 1024; later lights in scene order are dropped before any shadow tile is handed out). Shadow-casting local lights among the kept ones get atlas tiles through ShadowAtlas.allocate (one for a spot, six for a point light); pack_views_into writes the ShadowView array. With settings.shadows and a shadow-casting sun, fit_cascades_into fits the four cascades.

  3. Pass uniforms. One 256-byte slot per view-projection in pass_uniforms: slots 0..3 the cascades, then one per atlas view, then the camera (MAX_PASS_VIEWS = 128 in total). Depth passes select their slot with a dynamic offset on group 3.

  4. Frame uniform (720 bytes, Frame in common.wgsl): view, projection, their inverses, camera position and near, screen size, ClusterParams (near to 200 m), light count, sun direction, colour and shadow flag, cascade splits, matrices and texel sizes, IBL intensity and mip count, time, exposure, debug mode and the AO flag. The writer asserts the byte count.

  5. Instances: cull, LOD, batch. Each instance's bounding sphere is its mesh Bounds sphere transformed and scaled by the largest column length. Frustum.cull_spheres_into tests four spheres per SIMD step (skipped when settings.cull is false). GpuMesh.select_lod picks a level from the projected diameter. Visible instances are sorted by (mesh, material, LOD, skinned, morphed) and consecutive equal keys form one _Batch drawn with first_instance. The key (_batch_key) scales by len(materials) and the largest LOD count, so it never collides however many materials or LODs exist. Per-instance data (model, normal matrix, params) is 144 bytes in the instances storage buffer; params.y carries the flags (bit 0 skinned, bit 1 morphed) and the LOD index in bits 8..15. Blended materials go to blend_batches, sorted back to front. Joint matrices are copied verbatim into the joints buffer; morph weights (8 per instance) take one 256-byte slot each, so a morphed instance is always its own batch. The morph weights buffer starts with 32 slots and doubles (rebuilding the object group) when a frame has more morphed instances, so every instance keeps its own weights.

  6. Frame graph declaration and compile (see below), then stats.passes and stats.transient_textures.

  7. Bind groups that depend on this frame's views: the frame group is rebuilt only when the AO view changes; the object group only when the instance, joint or morph buffer changes.

  8. Recording, in the compiled order:

    • cascade shadows: four depth-only render passes into the layers of the 2048 px Depth32Float cascade array (depth.wgsl, pipeline kind 2, no culling, negative depth bias because of reverse-Z).
    • atlas shadows: one render pass over the 4096 px atlas; each view sets viewport and scissor to its 512 px tile.
    • depth prepass: camera depth into the depth transient at the frame's MSAA count; fs_masked discards below the alpha cutoff.
    • gtao and gtao blur: compute passes (8x8 workgroups) from depth only into ao raw then ao (RGBA8). The MSAA variant reads sample 0.
    • cluster light cull: one dispatch of clusters.wgsl (16 x 9 x 24 froxels, up to 32 lights per cluster) writing cluster_grid and cluster_indices through the external resource cluster lists.
    • forward+ opaque: forward.wgsl into the RGBA16F hdr target; depth is loaded from the prepass and not written. Sun with cascaded shadows, clustered local lights with atlas shadows, IBL, emissive, debug outputs.
    • sky: a full-screen triangle at depth 0 (infinitely far) sampling prefiltered mip 0.
    • forward+ blend: alpha-blended batches; with MSAA this pass resolves into hdr resolved.
    • bloom down 0..5, bloom up 5..1: half-resolution chain; the first downsample applies the soft threshold, Karis averaging and a clamp against non-finite texels; upsamples are additive tent filters.
    • tonemap: mixes bloom by bloom_strength, multiplies by exposure, applies AgX, ACES or none, into target (or the ldr transient when FXAA is on), then fxaa into target.

    Post and GTAO bind groups are cached by the (index, generation) of the views they bind.

Render settings and defaults #

Field Default Effect
msaa 1 1 or 4 samples for depth, HDR and the sky; GTAO switches to gtao_msaa.wgsl. Changing it rebuilds pipelines lazily (cached per sample count).
bloom, bloom_strength, bloom_threshold True, 0.04, 1.0 Enables the 6-mip chain; strength is the mix factor in fs_tonemap; threshold is the soft knee on the first downsample.
tonemapper Tonemapper.agx() agx, aces or none (clamp).
exposure 1.0 Linear multiplier before the tone curve.
fxaa False Adds the ldr transient and the fxaa pass.
ao True GTAO; when false the AO passes are culled from the graph and the forward pass binds the white default.
shadows True Cascades and atlas. When false no shadow views are allocated and nothing is drawn into the shadow maps.
shadow_distance 60.0 End of the last cascade (practical split, lambda 0.75).
cascade_resolution 2048 Cascade array size; read at construction only.
atlas_size, atlas_tile 4096, 512 Atlas texture and tile size (64 tiles); construction only.
ibl_intensity 1.0 Scales the IBL term and the sky.
debug DebugMode.none() normals, cascades, clusters, overdraw, lod, albedo, ao. Debug output bypasses exposure, bloom and the tone curve. overdraw switches the forward pipelines to additive blending with the depth test off (each fragment adds 0.1, the sky is black); lod colours by each instance's LOD index, morphed ones included.
wireframe False Forward pipelines use line polygon mode when Gfx.wireframe_supported; otherwise ignored.
cull True Frustum culling of instances.

Environment defaults: zenith (0.25, 0.45, 0.9), ground (0.28, 0.25, 0.22), sun_intensity 1.0, no equirect. A set equirect view (a float 2D texture) replaces the procedural sky.

Toggling at runtime, from test_variants_msaa_fxaa_debug_and_lods:

mojo
h.renderer.settings.debug = DebugMode.normals()
var normals = h.render()
h.renderer.settings.debug = DebugMode.none()
h.renderer.settings.ao = False
h.renderer.settings.bloom = False
var plain_dot = h.renderer.graph_dot()
assert_true("gtao" not in plain_dot or "culled: gtao" in plain_dot)

Conventions #

  • Right-handed, Y up, the camera looks down -Z (Camera.forward() is Vec3.forward() rotated); plane faces +Y, cube winds counter-clockwise seen from outside, back faces are culled unless the material is double_sided.
  • Metres; light intensity in lux for the sun and watts for point and spot lights (color * intensity, radiometric / 4 pi in the shader); angles in radians.
  • Reverse-Z with an infinite far plane everywhere: Mat4.perspective_reverse_z_infinite, depth cleared to 0 (DepthAttachment.cleared_reverse_z), DepthState.reverse_z compares greater-or-equal, the frustum has five planes and the shadow pipelines use a negative bias.
  • Linear lighting; textures are uploaded as RGBA8UnormSrgb when srgb is true and sampled linear; intermediates are RGBA16Float; the output is the format given at construction, with AgX linearised so an sRGB target encodes once.
  • Column-major Mat4; ByteWriter.mat4 writes columns, matching WGSL mat4x4<f32>.
  • Vertex stream of 56 bytes (position, normal, tangent with handedness in w, uv0, uv1) on slot 0; skinning (u16x4 joints, f32x4 weights, 24 bytes) on slot 1; u32 indices.
  • Bind groups: 0 frame (common.wgsl), 1 objects (instances, joints, morph deltas, morph weights with a dynamic offset), 2 material (material.wgsl), 3 pass-specific (view-projection slot, cluster outputs or GTAO textures).

Camera and culling #

Camera.look_at builds the rotation quaternion from Mat4.look_at; view, projection, view_projection and frustum derive from the fields, and set_viewport fixes the aspect from the target size (render does this every frame). From tests/render/test_render_core.mojo:

mojo
var cam = Camera.look_at(Vec3(0.0, 2.0, 5.0), Vec3.zero(), Vec3.unit_y(), 1.0, 1.0, 0.1)
var f = cam.frustum()
assert_true(f.contains_sphere(Vec3(0.0, 0.0, -50.0), 1.0), "infinite far plane keeps distant objects")
assert_false(f.contains_point(Vec3(0.0, 2.0, 5.05)), "closer than near")
var visible = f.cull_spheres(spheres)        # List[Vec4]: xyz centre, w radius

Meshes, LODs, skinning, morph targets #

MeshData holds parallel per-vertex lists; tangents, uv1, joints and weights may be empty (defaults are packed at upload), but joints and weights are a pair: both empty or both one per vertex (has_skin requires both). validate checks counts (including that pairing) and index ranges, compute_normals accumulates area-weighted normals, compute_tangents(lib) runs MikkTSpace through the shim. Primitives: cube(half), uv_sphere(radius, rings, segments), plane(half, subdivisions).

Renderer.add_mesh(label, data, lods=True) builds LODs with MeshData.build_lods when the mesh has more than 64 triangles: level 0 is vertex-cache optimised, each further level targets half the triangles with a 2% error bound and locked borders, and all levels share one index buffer (MeshLod.first_index, index_count). GpuMesh.index_count is the full-detail count (lods[0].index_count) for both upload_mesh_lods and cooked meshes. select_lod switches to level 1 below a quarter of the frame height, level 2 below an eighth, level 3 below a sixteenth. add_cooked_mesh, replace_cooked_mesh (hot reload under the same index) and remove_mesh (slot reused) serve the asset server; mesh_lod_count reports the levels.

Skinning: a mesh with joints and weights gets a second vertex buffer and the vs_skinned entry point; the instance's joint_offset indexes RenderScene.joints (joint world matrix times inverse bind, written by the animation system through JointPalette). Morph targets: MorphTarget position and normal deltas are packed as two vec4 per vertex per target into a storage buffer; RenderInstance.morph_weights supplies up to 8 weights (MAX_MORPH_TARGETS). Each morphed mesh's delta buffer gets its own cached object group (morph_groups, keyed by the buffer handle and released when no batch used it in a frame), so any number of different morphed meshes can draw in one frame; meshes without targets share object_group. Weight slots start at 32 per frame and the weights buffer doubles when a frame needs more.

Materials and textures #

Material mirrors glTF 2.0 metallic-roughness plus KHR_texture_transform (uv_scale, uv_offset, uv_rotation, applied to every map) and emissive_strength. textures holds five optional views (TEX_BASE_COLOR, TEX_METALLIC_ROUGHNESS, TEX_NORMAL, TEX_OCCLUSION, TEX_EMISSIVE), texcoords picks uv0 or uv1 per map, and sampler defaults to DefaultTextures.sampler_linear_repeat. pack_uniform produces the 96-byte layout documented at the top of material.mojo and in material.wgsl.

mojo
var tex = upload_image(res, String("checker"), img, True, True)   # sRGB, with mips
var mat = Material(String("test"))
mat.base_color = Vec4(0.5, 0.25, 1.0, 1.0)
mat.textures[0] = tex.view
mat.texcoords[2] = 1
mat.alpha_mode = AlphaMode.mask()
mat.uv_scale = Vec2(2.0, 2.0)
var gpu = upload_material(res, material_bind_group_layout(res), defaults, mat)

upload_image builds the mip chain on the CPU with downsample (2x2 box filter); mip_count gives the level count. update_material re-uploads parameters only; textures are fixed per bind group, so a texture change needs a new material.

The renderer's material registry: Renderer.add_material(material) -> Int uploads and always appends. Renderer.replace_material(index: Int, material: Material) raises uploads the new version into the same slot first (a failed upload leaves the slot untouched), then frees the old uniform buffer and bind group and stores a copy in material_params[index]; instances keep their index, which is how hot reload swaps a material. Renderer.remove_material(index: Int) raises frees the slot's uniform buffer and bind group and leaves null handles (removing an empty slot does nothing). Indices are stable: a removed slot is never handed out by add_material, only revived by replace_material on the same index, and render raises Renderer: instance <i> uses removed material <m> when a visible instance still uses it. Renderer.material_is_live(index) -> Bool reports whether a slot exists and is not removed. Both calls raise on an out-of-range index. Texture views and samplers referenced by a Material stay the caller's on replace and remove (destroy them yourself afterwards, as the asset server does).

Lights and clustered culling #

Light.sun(direction, color, lux, cast_shadows), Light.point_light(position, color, watts, range, cast_shadows) and Light.spot_light(position, direction, color, watts, range, inner, outer, cast_shadows) construct lights; direction is the direction light travels. ClusterParams.compute (near, far) gives the logarithmic slice scale and bias shared with cluster_z_slice in common.wgsl; the renderer uses camera.near to 200 m. cs_cull tests each local light as a sphere against each cluster's view-space AABB and writes up to CLUSTER_MAX_LIGHTS indices per cluster; the fragment shader walks that list. DebugMode.clusters() shows the per-cluster count as a heat map.

Shadows #

cascade_splits_into(out, near, shadow_distance) fills a stack array of four end distances; fit_cascade fits an orthographic projection to the bounding sphere of a frustum slice (radius quantised to 1/16) and snaps its centre to whole shadow texels in a fixed light-space basis, so the footprint is rotation invariant and edges do not shimmer; fit_cascades_into does all four. The shader (sample_cascade_shadow) selects the cascade by view depth, applies a normal offset scaled by Cascade.texel_world and takes 3x3 PCF taps through a comparison sampler.

ShadowAtlas(size, tile_size) hands out tiles in order: allocate(light) returns the first view index or -1 when full; a spot light gets a perspective view of 2 * outer_angle + 0.05, a point light six 90-degree faces with a small overlap. sample_atlas_shadow picks the face from the dominant axis, clamps inside the tile so PCF never reads a neighbour, and filters 3x3. From tests/render/test_shader_programs.mojo:

mojo
var atlas = ShadowAtlas(2048, 512)
assert_equal(atlas.capacity(), 16)
assert_equal(atlas.allocate(spot), 0)
assert_equal(atlas.allocate(point), 1)       # six views: 1..6
assert_equal(len(atlas.views), 7)

The frame graph #

The graph is declared from scratch every frame and compiled by FrameGraph.compile(res):

  • create(name, desc) declares a transient texture from a TransientDesc (size, format, usage, sample count, mips). import_view(name, view, desc) declares a view the caller owns (swapchain, the shadow maps): never aliased, never freed. create_external(name) declares a buffer-backed resource (the cluster lists) that only carries ordering and liveness. export(resource) marks what must survive the frame; the renderer exports target.
  • add_pass(name), read(pass, resource), write(pass, resource) record the data flow. A reader depends on the most recent earlier writer (or the first writer if the producer is declared later); writers of one resource are chained and each waits for the previous version's readers (write-after-read). Readers declared before the first writer read the first writer's version, so the second writer waits for them too.
  • compile keeps only passes that transitively feed an exported resource (is_culled, culled_count), orders them with a stable Kahn sort (order, lowest declaration index first), raises Error("FrameGraph: cycle between passes") on a cycle, computes each resource's lifetime as an interval of order indices and assigns pooled physical textures: transients with equal descriptions and disjoint lifetimes share one texture. Pooled textures not requested this frame (after a resize or a settings change) are destroyed.
  • view_of and texture_of resolve a resource after compile; find(name) looks a resource up by name; physical_count is reported as FrameStats.transient_textures. to_graphviz returns DOT text (executed passes as boxes in order, culled passes dashed, resources as ellipses with their physical index and lifetime); Renderer.graph_dot() produces it on demand.

From tests/render/test_frame_graph.mojo (passes declared deliberately out of order):

mojo
var graph = FrameGraph()
var backbuffer = graph.import_view(String("backbuffer"), view, desc)
graph.export(backbuffer)
var depth = graph.create(String("depth"), _depth(64, 64))
var hdr = graph.create(String("hdr"), _hdr(64, 64))
var tonemap = graph.add_pass(String("tonemap"))
graph.read(tonemap, hdr)
graph.write(tonemap, backbuffer)
var prepass = graph.add_pass(String("depth prepass"))
graph.write(prepass, depth)
var forward = graph.add_pass(String("forward"))
graph.read(forward, depth)
graph.write(forward, hdr)
graph.compile(res)
assert_equal(graph.order, [prepass, forward, tonemap])

The graph never calls back into user code: the renderer walks graph.order and compares each pass index with the ones it declared. wgpu forbids sampling a texture that the same pass writes, so the renderer keeps frame_group variants (shadow_frame_group, cluster_frame_group) with dummies in the conflicting slots.

Adding a pass #

  1. Declare its resources in render next to the existing ones: self.graph.create for a new transient (give its name a persistent String field, not a per-frame literal), or reuse an existing one.
  2. var p_mine = self.graph.add_pass(String("my pass")) followed by read and write calls. The pass is culled unless something it writes reaches target.
  3. In the recording loop add an elif p == p_mine: branch: fetch views with self.graph.view_of, begin a render or compute pass on encoder, bind groups, draw, end. Bind groups that reference transient views must be cached by view (index, generation) like _post_group and _gtao_group, because views change on resize.
  4. Create the pipeline once in __init__ (or lazily, cached), then run tests/render/test_renderer.mojo (validation clean, deterministic second frame) and the zero-allocation test.

Shaders: assembly, errors, hot reload #

WGSL has no preprocessor. assemble(directory, entry) reads entry and splices every #include "file.wgsl" line (each file once per program, depth limit 16) into one AssembledShader whose chunks remember which file and line each run of text came from. ShaderLibrary.load(entry) assembles, compiles through ShaderManager.compile_source, adopts the module as a ShaderHandle and records the modification times of every file involved. The renderer loads eight programs: forward.wgsl, depth.wgsl, clusters.wgsl, sky.wgsl, ibl.wgsl, post.wgsl, gtao.wgsl, gtao_msaa.wgsl (the last two include gtao_body.wgsl after declaring depth_tex, load_depth and ao_out).

Compile errors raise ShaderError (from cinder.gfx.shaders) whose ShaderMessages print as path:line:column: error: text. naga reports positions in the assembled text; _compile_assembled maps every wgsl:LINE:COL mark back through AssembledShader.locate so the message names the including file and its own line:

mojo
var library = ShaderLibrary(_ptr(gfx), _ptr(res), _ptr(shaders), dir)
try:
    _ = library.load(String("broken.wgsl"))
except e:
    message = String(e)
assert_true("broken.wgsl" in message and ":4:" in message)

Hot reload: ShaderLibrary.check_for_changes re-assembles programs whose files (including includes) changed, swaps the module under the same handle, lists the handles in reloaded and returns the count; a failing recompile keeps the last good module and appends an error LogRecord to diagnostics (take_diagnostics). Renderer.reload_shaders calls it and, when anything changed, rebuilds the cached scene pipelines in place (replace_render_pipeline, handles unchanged), recreates the sky pipeline and marks the environment for re-bake. Note that the compute pipelines (clusters, GTAO, IBL) and the post pipelines are not rebuilt by reload_shaders today; a change to those programs needs a restart.

Adding a shader include #

Put the file in cinder/render/shaders/, reference it with #include "name.wgsl" from the program that needs it (includes resolve relative to SHADER_DIR), and keep the declaration order in mind: an include sees everything spliced before it. If the include declares bindings, mirror the layout in Renderer.__init__ (create_bind_group_layout) and any CPU packing in Mojo with a byte-count assertion like the frame uniform's. tests/render/test_shader_programs.mojo:: test_all_programs_compile must still compile every program and assemble must report the expected file count.

How validation errors surface #

  • wgpu validation errors are uncaptured errors delivered through the shim's event queue; Gfx.pump increments Gfx.error_count and appends a wgpu.validation LogRecord to Gfx.diagnostics. Every render test asserts gfx.error_count == 0 after pumping. A validation error inside wgpuQueueSubmit (for example a readback stride that is not 256-aligned) aborts the process instead (see docs/MOJO_NOTES.md).
  • Shader compile errors raise ShaderError with mapped file:line:column; reload failures become diagnostics instead of exceptions.
  • Device loss sets Gfx.lost; render raises GfxError("device lost: ...") on the next call.
  • Engine-side invariants raise Error: frame graph cycles, view_of on an external or unused resource, MeshData.validate failures, a frame uniform of the wrong size.

Golden tests #

tests/golden/test_golden_gltf.mojo renders 16 Khronos glTF-Sample-Assets models headless at 512 x 512 with RenderSettings() plus msaa = 4, a sun from (-0.5, -1.0, -0.4) at 3 lux, the procedural sky, and shadow_distance scaled to the model; the camera is framed from the model's bounds (Sponza is viewed from inside). The result is compared with tests/golden/<platform>/<Model>.png by cinder.core.image.compare: luma SSIM over 8x8 windows must be at least 0.985 and at most 0.5% of pixels may differ by more than 8/255 in any channel. Both thresholds, and the review of every reference image, live in docs/RENDER_VALIDATION.md.

Running: pixi run test-golden (also part of tools/run_tests.sh; models come from tools/fetch_sample_assets.py). Updating: pixi run update-golden sets CINDER_UPDATE_GOLDEN=1, which rewrites the references instead of comparing. After an intentional rendering change, inspect each new image against the Khronos screenshot, update the review table in docs/RENDER_VALIDATION.md, and commit the PNGs. Rendered frames are also written to .cache/golden/<Model>.png on every run. Only Linux (lavapipe) references exist; PLATFORM in the test selects the directory.

Render stats #

Renderer.stats (FrameStats) after each frame: instances_submitted, instances_drawn, draw_calls (forward opaque and blend), prepass_draw_calls, shadow_draw_calls, lights, shadow_views, passes (executed) and transient_textures (pooled physical textures).

Zero-allocation rules #

tests/perf/test_zero_alloc.mojo (pixi run test-zero-alloc, libcinder_alloccount.so preloaded) renders a representative scene for 90 warm-up frames and then requires exactly zero Mojo heap allocations over 60 frames. Any change to the frame path must keep that true:

  • Per-frame data lives in persistent scratch fields of Renderer (_lights, _cascades, _spheres, _visible, _order, _keys, batches, instance_bytes, the ByteWriters, ...) that are cleared, never recreated. List.clear and ByteWriter.reset keep capacity; resize only allocates when growing.
  • Use the _into variants: cull_spheres_into, fit_cascades_into, cascade_splits_into, pack_lights_into, pack_views_into. The allocating forms (cull_spheres, fit_cascades, cascade_splits, pack_lights, pack_views) exist for tests only.
  • Small fixed-size data goes in stack Arrays (cascade splits, frustum corners, attachments).
  • No per-frame strings: pass and resource names that vary by index are built once in __init__ (_bloom_names, _bloom_down_names, _bloom_up_names); change detection compares numbers (_env_*, _object_key, post_group_keys) rather than formatted keys; graphviz is only produced when graph_dot() is called.
  • Bind groups that depend on transient views are cached by (index, generation) keys (_post_group, _gtao_group) rather than created per frame.
  • The frame graph recycles pass records (_spare_passes) and its compile scratch; declaring and compiling a steady-state graph allocates nothing.
  • GPU buffers grow by doubling (instances_buffer, joints_buffer) only when the scene outgrows them: expected during warm-up, not in steady state. Avoid List literals ([x] allocates every time), t-strings and String concatenation in the frame path.
  • To find a regression set Renderer.alloc_marks = True (with the counter library loaded): _mark prints the allocation delta after each phase (start, lights+shadows, pass uniforms, frame uniform, instances, graph declare, graph compile, bind groups).
View as MarkdownSource: docs/guides/render.md