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).
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):
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.
-
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_environmentruns theibl.wgslcompute 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. -
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 mostMAX_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 throughShadowAtlas.allocate(one for a spot, six for a point light);pack_views_intowrites theShadowViewarray. Withsettings.shadowsand a shadow-casting sun,fit_cascades_intofits the four cascades. -
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. -
Frame uniform (720 bytes,
Frameincommon.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. -
Instances: cull, LOD, batch. Each instance's bounding sphere is its mesh
Boundssphere transformed and scaled by the largest column length.Frustum.cull_spheres_intotests four spheres per SIMD step (skipped whensettings.cullis false).GpuMesh.select_lodpicks a level from the projected diameter. Visible instances are sorted by (mesh, material, LOD, skinned, morphed) and consecutive equal keys form one_Batchdrawn withfirst_instance. The key (_batch_key) scales bylen(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 theinstancesstorage buffer;params.ycarries the flags (bit 0 skinned, bit 1 morphed) and the LOD index in bits 8..15. Blended materials go toblend_batches, sorted back to front. Joint matrices are copied verbatim into thejointsbuffer; morph weights (8 per instance) take one 256-byte slot each, so a morphed instance is always its own batch. Themorph weightsbuffer starts with 32 slots and doubles (rebuilding the object group) when a frame has more morphed instances, so every instance keeps its own weights. -
Frame graph declaration and compile (see below), then
stats.passesandstats.transient_textures. -
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.
-
Recording, in the compiled order:
cascade shadows: four depth-only render passes into the layers of the 2048 pxDepth32Floatcascade 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 thedepthtransient at the frame's MSAA count;fs_maskeddiscards below the alpha cutoff.gtaoandgtao blur: compute passes (8x8 workgroups) from depth only intoao rawthenao(RGBA8). The MSAA variant reads sample 0.cluster light cull: one dispatch ofclusters.wgsl(16 x 9 x 24 froxels, up to 32 lights per cluster) writingcluster_gridandcluster_indicesthrough the external resourcecluster lists.forward+ opaque:forward.wgslinto the RGBA16Fhdrtarget; 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 intohdr 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 bybloom_strength, multiplies by exposure, applies AgX, ACES or none, intotarget(or theldrtransient when FXAA is on), thenfxaaintotarget.
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:
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()isVec3.forward()rotated);planefaces +Y,cubewinds counter-clockwise seen from outside, back faces are culled unless the material isdouble_sided. - Metres; light intensity in lux for the sun and watts for point and spot lights
(
color * intensity, radiometric/ 4 piin 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_zcompares greater-or-equal, the frustum has five planes and the shadow pipelines use a negative bias. - Linear lighting; textures are uploaded as
RGBA8UnormSrgbwhensrgbis true and sampled linear; intermediates areRGBA16Float; the output is the format given at construction, with AgX linearised so an sRGB target encodes once. - Column-major
Mat4;ByteWriter.mat4writes columns, matching WGSLmat4x4<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:
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 radiusMeshes, 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.
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:
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 aTransientDesc(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 exportstarget.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.compilekeeps only passes that transitively feed an exported resource (is_culled,culled_count), orders them with a stable Kahn sort (order, lowest declaration index first), raisesError("FrameGraph: cycle between passes")on a cycle, computes each resource'slifetimeas 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_ofandtexture_ofresolve a resource after compile;find(name)looks a resource up by name;physical_countis reported asFrameStats.transient_textures.to_graphvizreturns 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):
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 #
- Declare its resources in
rendernext to the existing ones:self.graph.createfor a new transient (give its name a persistentStringfield, not a per-frame literal), or reuse an existing one. var p_mine = self.graph.add_pass(String("my pass"))followed byreadandwritecalls. The pass is culled unless something it writes reachestarget.- In the recording loop add an
elif p == p_mine:branch: fetch views withself.graph.view_of, begin a render or compute pass onencoder, bind groups, draw, end. Bind groups that reference transient views must be cached by view (index, generation) like_post_groupand_gtao_group, because views change on resize. - Create the pipeline once in
__init__(or lazily, cached), then runtests/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:
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.pumpincrementsGfx.error_countand appends awgpu.validationLogRecordtoGfx.diagnostics. Every render test assertsgfx.error_count == 0after pumping. A validation error insidewgpuQueueSubmit(for example a readback stride that is not 256-aligned) aborts the process instead (seedocs/MOJO_NOTES.md). - Shader compile errors raise
ShaderErrorwith mappedfile:line:column; reload failures become diagnostics instead of exceptions. - Device loss sets
Gfx.lost;renderraisesGfxError("device lost: ...")on the next call. - Engine-side invariants raise
Error: frame graph cycles,view_ofon an external or unused resource,MeshData.validatefailures, 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, theByteWriters, ...) that are cleared, never recreated.List.clearandByteWriter.resetkeep capacity;resizeonly allocates when growing. - Use the
_intovariants: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;graphvizis only produced whengraph_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. AvoidListliterals ([x]allocates every time), t-strings andStringconcatenation in the frame path. - To find a regression set
Renderer.alloc_marks = True(with the counter library loaded):_markprints the allocation delta after each phase (start,lights+shadows,pass uniforms,frame uniform,instances,graph declare,graph compile,bind groups).