Skip to content

Step 6, the scene: per-camera and per-world blocks, the scene's shaders quoted - #838

Merged
paul-hammant merged 5 commits into
mainfrom
step6-scene
Oct 20, 2026
Merged

paul-hammant merged 5 commits into
mainfrom
step6-scene

Conversation

@paul-hammant

@paul-hammant paul-hammant commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Step 6, the scene (docs/aether_quote_design.md section 11), as Nic directed on 2026-10-20: "Scene: option 1. Split per-camera from per-world now, still with one camera, so every frame stays identical. Put the per-camera data in its own block, so a second camera later just means binding a second one. No multi-camera work before the scene."

Five commits, each one step:

  1. ae3d.gpu: a captured struct's array of structs: the scene's Light[16] (gpu.capture_element_field), with tests and negative controls.
  2. ae3d.gpu: what the scene's shaders need: layouts (gpu.use_layout: a block of whole structs at a set), images and buffers at given bindings, discard, helpers that read their uniforms in place, exact int and uint arithmetic on the CPU, and the calls the scene uses. tests/test_gpu_layouts (31 checks, 7 negative controls).
  3. The scene's block split into the draw's (set 0), the camera's (set 1) and the world's (set 2). One camera; a second camera is a second set 1 bound in the first one's place. The generator now checks every member of every block against glslang (554 offsets). Before this it read only each shader's first block.
  4. The scene's shaders written with quote (ae3d.shaders.lit): FRAGMENT_DEFAULT, FRAGMENT_DEPTH and the scene's vertex shaders, drawn on Vulkan and OpenGL. tests/test_gpu_lit (56 checks, 6 negative controls; plus 2 for the generator).
  5. The hand-written scene shaders deleted: VERTEX_DEFAULT, VERTEX_DEPTH, FRAGMENT_DEPTH, default_shader and six SPIR-V files. FRAGMENT_DEFAULT stays as the ray-query variant's source (see below).

Where each field went

Block Set Bytes Structs Fields
Draw 0 (dynamic offset) 13,056 Draw, DrawMaterial, Surface, Cut, Wounds, Damage, Skin model, prevModel, lightSpaceMatrix, instancing, material, surface (Config), cut, wounds, damage, bones, prevBoneRows (86 uniforms)
Camera 1 800 Eye, ShadowCascades, Grid, Probes viewProjection, prevViewProjection, invViewProjection, viewPos, viewDistance, jitter, screenSize, hasSceneDepth, viewDepth, occlusionHistory, captureChannel, rayReach, rayFrame; hasShadowMap, cascadeMatrices/Splits/TexelWorld/Count; clusterDims/Depth/ViewProjection, lampShadowBase, keyLampSlot; ddgiOn/Dims/Base/Spacing/Atlas
World 2 1,424 WorldState, Weather lights[16], lightCount, shadowDirection, cloudCover/Time/Sun, causticsTime, wetness, normalStrength, occlusionStrength, giOff, rayShadows, sunAngle, rayOcclusion, rayOcclusionStrength, rayLampRadius; enableFog, fogStart, fogEnd, fogRadiance, fogIntensity

Verification (M6, Aether 0.834.0, glslang 16.6.0)

  • generate_shaders --check current, rebuilt before each check.
  • frames_against: 59 of 59 within bounds against origin/main (cc7e968).
  • test_backend_parity at 1x and 4x, Vulkan and OpenGL: 56 frames byte-identical to main's except the clouds (test_backend_parity: 'clouds over the skybox' is not the same frame twice on the same commit #793).
  • Khronos validation with sync and nothing excluded, at 1x and 4x: parity, zombie_city, street_drive and 14 scene suites, 0 errors and 0 warnings.
  • Full local ./ci.sh: green, with the 8 hardware skips (no ray query, no DLSS, ssr leak report).
  • aether-linux-x64 (0.834.0): lane_some 100 builds, 0 failed, 0 warnings; lane_all gcc and clang each 262 built, 0 failed, 0 warnings.
  • GH CI on this PR: every job green. On lavapipe the ray-query suites pass (test_ray_shadows, test_ray_occlusion, test_gi under the layer, and with the probes), which is the only test of the hand-written ray-query shader against the split blocks.

For Nic

  • The ray-query pixel shader (scene_rq_vk.frag) is still hand-written, built from FRAGMENT_DEFAULT. Quoting it needs accelerationStructureEXT and rayQueryEXT in ae3d.gpu, and only CI's lavapipe can test it, since no local device here traces rays. Do you want it quoted next, or kept as it is?
  • No scene uniform became unread, so none was removed. Each of the 134 is read by a quoted shader, by the ray-query shader, or copied by the water.
  • On OpenGL the renderer still sets each value by its hand-written name, and lit.gl_name maps it to the quoted shader's name. Custom GLSL shaders keep their uniform names, compat lightPos and the rest included.
  • The shadow pass now reads viewPos from the camera set, so it gets this frame's value where it used to get last frame's. Only billboard casters depend on it, and the frames are the same.

🤖 Generated with Claude Code

paul-hammant and others added 5 commits October 20, 2026 20:59
capture_field now places a fixed-size array of structs, and the new
capture_element_field places each field of its element, with the
compiler's offsetof and sizeof:

    _l = gpu.capture_field(s, "world", "lights", offsetof(World, lights))
    _c = gpu.capture_element_field(s, "world", "lights", sizeof(Light), "colour", offsetof(Light, colour))

The element is a GLSL struct of its own. It is declared once, before the
struct that holds the array. It has every field that the tree reads of a
value of that type, or sets in a literal of it, in the cell's order. An
element can be handed to a helper whole, and a literal of the type is the
same struct, so its GLSL constructor takes the fields in that order.

The rest follows from that. std140 aligns an element to sixteen and rounds
its size up to sixteen, which is glslang's arrayStride. params_write
repacks each element's fields from the cell, and set_field_words reads
them back from a block's words. OpenGL sets the struct a value at a time
("world.lights[3].colour"). The CPU reads an element as a struct value.
The generator holds each element to glslang's topLevelArrayStride.

The following are refused:
- an element field that is read or set but not placed;
- an array of structs with none of its fields placed;
- a stride that disagrees with the one placed;
- a field placed twice;
- an element field asked of a field that is not an array of structs.

tests/test_gpu_struct_arrays covers this with a Sky of four Lamps. A
Lamp's cell is 64 bytes and its std140 element is 48. Every offset is
held to glslang's reflection, the CPU is held to the same body run as
plain Aether, and each refusal is checked. Six negative controls fail it:
no rounding to sixteen, the cell's stride used in the block, the CPU
reading element 0, the element declared twice, set_field_words ignoring
the element, and the refusal removed. No shader changes:
generate_shaders --check is unchanged, and the other GPU-free gpu suites
pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…elpers read in place)

The scene's pixel and vertex shaders (FRAGMENT_DEFAULT, VERTEX_DEFAULT,
VERTEX_DEPTH, FRAGMENT_DEPTH) need more of ae3d.gpu than the passes quoted
so far. This adds it, apart from any shader. The quoted scene comes in a
later commit of this stack.

Layouts. A layout is a uniform block's structs in order, each field typed
and placed in its struct's cell with offsetof. gpu.layout_* builds one, and
use_layout(s, set, l) gives it to a shader. The shader declares that set's
block with each struct it captures whole, every field read or not. Each
entry it does not capture, before the last one it does, becomes padding of
the entry's size (vec4 name_unread[n]). So a vertex shader and a pixel
shader that read other fields lay one buffer out alike. OpenGL declares
each captured struct as a uniform of its name, and the declaration is the
same in both stages, so link() takes one struct captured by both. Values
are named with their set (uniforms_set). block_size and block_write give a
block for checks on the CPU. A field read as another type than the layout
gives is refused.

Places. capture_binding(s, name, set, binding) puts a texture or a storage
buffer at a set and binding of its own, where the hand-written shaders had
them. A pixel shader may now read a storage buffer, if it is placed. On
OpenGL 4.1, which has no storage buffers, the buffer is a texture of rows:
vec4s_at_i and floats_at_i read 1024 vec4s or 4096 floats to a row, as the
scene's clustered lamps are uploaded there.

Read in place. A @Quotable helper only sees what it is handed, while a
hand-written helper reads its uniforms and varyings where it uses them. On
MoltenVK the two round apart: fast math orders a sum by where its values
were loaded (the water's water_below, docs/aether_quote_design.md section
11). read_in_place(s) changes the GLSL for that case. When every call hands
a parameter the same capture, field of one, varying, attribute or literal
index of one, the parameter is dropped from the GLSL and the helper reads
the value in place, as the hand-written one did. This includes the caller's
own parameter read in place, found to a fixpoint. hand_on(s, helper, param)
keeps one as a parameter where the hand-written helper took it as one. A
helper whose own local would hide the value is refused. Such a shader
declares its varyings before its helpers. It is opt-in, and the shaders
quoted before are written as they were.

Also new:
- discard(), as GLSL's statement; on the CPU it drops the pixel
  (cpu_discarded).
- cut_fragment, a pixel shader that writes nothing (a shadow map's cut
  casters).
- The derivatives (dfdx, dfdy, fwidth: zero on the CPU) and texture_grad.
- int abs, clamp, min, max, uint_of_int, mod, step, ceil, exp2, log,
  inversesqrt, and sin, pow and abs of vectors.
- mul_cols3 (mat3(a, b, c) * v), mat4_rows (transpose(mat4(...))),
  mul_transpose_m2, lane_at, clip_depth (a clip z as a depth map stores it,
  each dialect's own), more swizzles, and vec3_of_v2.
- On the CPU: an array of a captured struct handed to a helper whole, a
  storage buffer handed to one, and int and uint arithmetic held exactly and
  wrapped to 32 bits. Literals are parsed whole, hex too, and an int is
  negated exactly. A hash's ints are past an f32's 2^24, and the CPU had
  rounded them.
- set_base: a vertex shader drawn after several sets (the crowd's, after
  the scene's three, in the next commit).

tests/test_gpu_layouts holds a small pair to all of it. Its blocks and
padding, both stages' declarations, OpenGL's uniforms and rows, its places,
its in-place reads and handed parameters, its discards and its cut shader
are checked. Every offset glslang reports is held to ours. The CPU is held
to the same arithmetic as plain Aether, a hash among it. The refusals are
checked: a mistyped field, an unplaced buffer, a shadowed read, a set given
twice. Seven negative controls fail it: the padding's size, no reads in
place, no hand_on, the CPU ignoring a discard, OpenGL's rows as a sampler1D,
a wrong int shift, and no layout taken. test_gpu_crowd's refusal check now
names the placement. No shader changes: generate_shaders --check is
unchanged, and every GPU-free gpu suite passes, with no leaks in the new
test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…orld's

Nic's option 1 (2026-10-20): the per-camera data is split from the
per-world now, still with one camera, so every frame is the one it was.

The scene's one uniform block is three, each in a descriptor set of its
own on Vulkan:

- set 0, the draw's (13,056 bytes, a dynamic offset a draw): ae3d.shaders.lit's
  Draw, DrawMaterial, Surface, Cut, Wounds, Damage and Skin, with the
  draw's colour, normal, scene-depth and damage images;
- set 1, the camera's (800 bytes): ae3d.shaders.frame's Eye,
  ShadowCascades, Grid and Probes (everything that depends on the view:
  the matrices, the eye, jitter and history, the capture channel, the
  cascades fitted to the view, the light grid, the probes), with the
  shadow atlas, the cluster buffers, the lamps' atlas, the occlusion map
  and the probes' atlases;
- set 2, the world's (1,424 bytes): WorldState (lights: WorldLight[16],
  the clouds, the time, the wetness, the renderer's world settings) and
  Weather's fog, with the sky light, the BRDF table, the irradiance and
  the rays' structure.

Each flat block keeps its members in its structs' std140 order, so the
hand-written shaders and the quoted ones read one staging layout; the
generator checks every member's offset against glslang's in every block
(554; it had read only each shader's first block's). vkdesc writes the
camera and world sets once a frame, at the frame's first bind; a second
camera is a second set 1 bound in the first one's place, and nothing else
changes (docs/aether_quote_design.md section 11 has the table). Shaders
drawn beside the scene take their sets after it (frame.SCENE_SETS): the
crowd's rings at 3, or 4 with its storage buffers.

frames_against 59 of 59 within bounds; one sample 56 frames
byte-identical but the clouds (#793); Khronos validation with sync clean
at 1x and 4x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FRAGMENT_DEFAULT, FRAGMENT_DEPTH and the scene's vertex shaders, quoted,
and drawn on both renderers in place of the hand-written ones:

- lit_vertex and lit_plain_vertex (the plain pipelines', no skin
  stream), lit_pixel; lit_depth_vertex, lit_depth_plain_vertex and
  lit_depth_pixel for the shadow pass. The crowd's vertex shaders are
  drawn with lit_pixel and lit_depth_pixel.
- Each reads the scene's three blocks through gpu.use_layout, whole
  structs at sets 0, 1 and 2, and the images and buffers at the
  hand-written bindings; each helper keeps the hand-written shape and
  reads its uniforms in place, since MoltenVK's fast math rounds by
  where a value was loaded.
- Vulkan: vkpipes builds the scene's and the shadow pass's pipelines from
  the quoted words; vkframe checks each tree against the hash its words
  were made from first (vkquote.fresh, exported).
- OpenGL: the four scene programs are compiled from the quoted GLSL, and
  gl_uniform_location finds a value of theirs by the name the quoted
  shader gives the hand-written uniform it was set by (lit.gl_name).
- The crowd's vertex shaders capture the frame's Eye as `viewer`: on
  OpenGL one name is one uniform across a program, and lit_pixel
  captures the whole camera as `eye`.
- The generator writes the six shaders, and checks every member glslang
  reports of a shader of layouts is one ae3d.gpu placed (it is handed
  every field of a struct, and glslang reports the ones read).
- The ray-query pixel shader is still the hand-written one.

tests/test_gpu_lit: the GLSL in both dialects and compiled; every
varying at FRAGMENT_DEFAULT's place, the plain and crowd vertex shaders
handing them on there; each value in its block's set; every value one
the OpenGL renderer sets by its hand-written name; on the CPU the
capture channels' colour, normal and motion, and the cut's far side not
drawn. Six negative controls fail it, and two the generator's checks.

test_backend_parity at 1x and at 4x: 56 frames byte-identical to the
hand-written scene's on Vulkan and OpenGL but the clouds (#793);
frames_against 59 of 59 within bounds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After the quoted scene's passing run, the shaders it replaced go:
VERTEX_DEFAULT, VERTEX_DEPTH, FRAGMENT_DEPTH, shaders.default_shader (a
model with no shader is drawn by the scene's; only smoke_core called
it), and their SPIR-V: scene_vk.vert, scene_plain_vk.vert,
scene_vk.frag, depth_vk.vert, depth_plain_vk.vert and depth_vk.frag.

FRAGMENT_DEFAULT stays as the ray-query pixel shader's source alone
(scene_rq_vk.frag). The generator lays the three blocks out from their
tables (ae3d.shaders.lit, ae3d.shaders.frame) instead of from the
hand-written declarations, each typed as the ray-query shader declares
it where it does; the blocks and ae3d.vkscene come out byte for byte as
they were.

No scene-block uniform was left unread: each of the 134 is read by a
quoted shader, by the ray-query one or by the water's copy, so none is
removed.

On the stack's head: generate_shaders --check current; frames_against
59 of 59 within bounds against main; test_backend_parity at 1x and 4x
byte-identical to main's on both renderers but the clouds (#793);
Khronos validation with sync, nothing excluded, clean at 1x and 4x over
parity, zombie_city, street_drive and 14 scene suites; ./ci.sh green
(the 8 hardware skips).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@paul-hammant
paul-hammant merged commit 7499ba9 into main Oct 20, 2026
10 checks passed
@paul-hammant
paul-hammant deleted the step6-scene branch October 20, 2026 21:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant