Part VI · The machine · Chapter 24
From scene tree to shader
The builder keeps a scene as a tree: groups, shapes, their sizes and joins. The kernels need the scene's distance as WGSL code (chapter 2's SDF, every shape and join of chapter 3). Turning one into the other is code generation, and how it is done decides what an edit costs: a value written, or the whole program compiled again, seconds each. This chapter is ShadyTracer's three answers, code with uniforms, baked constants, and the scene as data, and why a kernel's size, not its text, decides how long it takes to compile.
1What changes the text
Three things from earlier chapters, in a line each. A scene's distance, SDF (signed
distance function), answers "how far is the nearest surface from this point?" for the whole scene (chapter 2).
The march moves a ray along by that distance, step after step, calling it at every step until the
ray reaches a surface (chapter 2). And a surface's direction, its normal, is found from the differences of the
distance at four points around the hit, four more calls (chapter 4).
A small flat scene of one group: a block, a ball joined to it and a hole cut in it,
blended by k (chapter 3). Written as ShadyTracer's code generator writes a scene: the structure as code,
a line for each shape and join, and every number a uniform read: UB.pos_c
is the ball's position (a uniform is named for its field and its shape, pos of c;
k_g is the blend of the group, g; the shapes are b the block, c the ball, h the hole,
e the ball added; UB is the uniform buffer). That text is compiled into every kernel that needs the scene (chapter 21). One program
compiled so far.
Drag the ball (or the hole, or move the blend slider). The text is the same, so nothing compiles: the new position is written into the uniform buffer, and the next frame uses it. In the builder this is a "live" change, and why dragging a shape is smooth.
Add a ball. That is new code, one new line (marked) and two new uniforms for it, so the
program is compiled again: the second. In ShadyTracer a structural edit, a shape added, moved to another group or
given another material (a material is a name written in the text, section 3), took 3.7 to 23 seconds on the
user's Ampere card for the bundled scenes, uncached (section 4). Uncached is the first time a text is compiled:
the browser keeps what it compiled, across a reload too, and the Noise library's 6.3-second build took 0.2 seconds
the next time (measured on the user's GTX 1080 Ti, compileprogress.js).
Baked: the same code, every uniform turned into a constant by the
engine's own bake (this page runs it): UB.pos_c becomes bk_pos_c, declared at the top
with its value. A compiler can fold what it knows: arithmetic on known values alone is done once while compiling,
and a multiply by 1 or an add of 0 drops out. In the engine's code every shape's point is moved into its own frame
(its own position, turn and size) by a matrix, a small table of numbers that a point is multiplied by (section 3);
a shape neither turned nor scaled has the table that leaves a point as it was, which the compiler can see once
it is a constant, so it may drop those multiplications (inferred in roadmap/ARCHITECTURE.md; the speed in section 4 is
measured). This small scene has no matrices to lose, so here only the text changes. And now every change is new
text: switching to Baked was its first compile, and moving the ball again its second. ShadyTracer bakes in the
background once the edits stand still, and goes back to the code with uniforms while they move.
As data: one text for every such scene, a loop over records, one a
shape: its kind, its join, its numbers. Switching to it was its first compile; then taking the ball away and
adding it back changed a record and no line, and nothing compiled. The text is the same for any scene of the kinds
the loop holds code for (here circles and boxes; a shape whose code the loop does not hold is new text still,
scenedata.js). The price is the loop: at every step it reads each record's numbers and asks its kind,
where codegen's text has the shapes and their places written in (section 4). The blend has a name in each
way: k_g in the figure's code, blend_hs in the engine's (section 3), and a column, k, of
every record as data.
2A kernel's size is its copies
A kernel calls the scene's distance, SDF (chapter 2's signed distance
function), in several places: the march, the normal's differences (four calls, chapter 4), the material lookup.
The compilers behind WebGPU inline nearly every call: the function's body is written in where it is
called, so a function called from four places is compiled four times (wgslgraph.js, read in code). So
what a compile costs follows the kernel's size with every call written out, not its text: over 24 kernels the
inlined size predicted the time with a typical error (rms) of 33%, the text with 366% (compileprogress.js, measured).
Here, a kernel with a scene of 40 objects written as the code generator writes them, called once: 6,794
characters inlined, the kernel's own function once and SDF once: the text near enough (the text's
count, 6,795, keeps the line break between the two functions, which the inlined count leaves out).
Called from four places: the text barely grows, and the inlined size is about four
times, 27,020 (SDF four times, the kernel's own function once). The red curve is the engine's own model
of compile time, fitted on the user's GTX 1080 Ti (a Pascal card) over 16 builds: time = 15.2 ms × (size ÷ 1000)
× (size ÷ 151,000)0.37, so it grows faster than the size. The dashed line leaves out the last factor:
15.2 ms for every 1000 characters, straight. The two agree at 151,000 characters, a 2.3-second build; below that
the fit predicts less than the straight line, above it more, which is why at 40 objects the red curve lies under
the dashed one.
400 objects, four places: 273,500 characters inlined, past the crossing, 5.2 seconds
predicted against the straight line's 4.2. These are this figure's kernels: a few hundred characters an object,
one shape each, called from a main that does nothing else. A real kernel carries far more: ShadyTracer's shade
kernel is 152,000 to 171,000 characters of text in every bundled scene before any inlining
(docs/PERFORMANCE.md, computed), its materials and their code, and a build compiles about fifteen
kernels side by side, taking about as long as its slowest (compileprogress.js). That is why a real
scene of a few shapes took 3.7 to 23 seconds. The lens assembly copied 32 times (a bundled scene of lenses, repeated),
which the straight line put at 97 seconds and the fitted model at 6.5 minutes, was still compiling after ten
minutes: one reason the scene as data exists. The other lever is fewer copies: section 5 has six copies one
function held, and a loop's 32 (another 32) unrolled.
wgslgraph.js; the model is
compileprogress.js's. Point at the curve; set the objects and the call sites.3How ShadyTracer does it
Figure 1's one line d = csgSubtract(d, sdCircle(p - UB.pos_h, UB.r_h), UB.k_g), as the engine's
code generator writes it for a sphere cut out of a box (a case frozen in a test, so any change to what it writes
is seen):
The bake, whole:
Why the scene became data:
And how the compile model was found:
4Tricks and trade-offs
Each is stamped with how it is known; point at a stamp to see what it means.
Baked runs faster, mostly. On the user's GTX 1080 Ti, 1280 × 720 (0.28.0), codegen against
baked, over the benchmark's eight scenes (bench/): the optics bench's march 189.7 to 61.2 ms a sample (−68%), its
whole GPU time 297.4 to 109.1; baked faster on seven of the eight. The exception, the Noise library, was 5% slower to march, not explained. Compiling baked
was no slower anywhere: the optics bench's scene kernels 8,456 to 4,643 ms (roadmap/ARCHITECTURE.md,
the bake run).
Data costs a step. The march as data against codegen, same card (the scenedata run of 0.22.6,
the same document): 4.2, 3.9 and 3.1 times as long on three small scenes, 8.9 and 9.4 on the demo and the Menger
cubes (scenes whose warps and subtraction the data walk keeps frames for), and 0.83 on the optics bench, the
biggest, where data was faster. Not more work a ray: counted on a software GPU, the Menger cubes take 107.6
steps a ray either way, and the data walk visits 11.4 records a step. Inferred in scenedata.js, not
measured: a record's cost is its reads from memory, each waiting for the last, since where the next record is,
is in this one (the engine's records nest: a group skipped jumps past its children, so the walk learns its next
step from the record it is reading; figure 1's flat loop just counts on). So the scene as data was a developer switch, off by default, and the builder edited with code. Since 0.74.0
it is on by default (still a developer setting): the editing build is data and pays this step, so that an edit
that adds no code compiles nothing, and a still picture is the bake's either way, codegen's text with its values
made constants, swapped in once the edits stop
().
A structural edit. A whole compile: 3.7 to 23 s uncached on the user's Ampere card for the
bundled scenes of 2 to 14 objects (docs/PERFORMANCE.md, run 4; section 1's number).
One text whatever the size. The records are copied into a uniform
buffer of 4096 vec4s (a vec4 is four f32 numbers, 16 bytes): 64 KiB, the most every device
allows a uniform buffer (chapter 21). That holds a header and 585 records of 7 vec4s each; any past them are read
from the scene's storage buffer, the larger kind, where they also are, so a scene of any size is the same text
(scenedata.js, the room).
5What went wrong before
- Six copies in one function. The material lookup ran the whole scene's distance again for each patterned material, so a lookup with six such materials held six more copies of the scene at every place it was called, and the Noise library took over a minute to compile ().
- Thirty-two copies of the scene. The march's loop of 32 steps (chapter 22's pass) had a constant bound, so the
compiler unrolled it (wrote its body out 32 times, one after another) and put 32 copies of the scene in the
kernel; the bound now goes through
noUnroll(wavefront.js, the march kernel; chapter 27). - A carved wall in the wrong material. Only a union used to switch the material where a shape won, so a subtraction's carved walls kept the base's: the die in the Combining shapes example, a cube with its pips cut out, came out with white pips (; chapter 3).
- A cut that cut the wrong part. A group used as one shape of a join was folded, not joined: its shapes went into the Hit one by one, which keeps the nearest surface by size, and that says nothing of which side of their union a point is on. Where they overlapped, a subtraction cut the wrong part, and an imported rounded box let rays through its own edges. Such a group's shapes are now joined by a hard union first (; chapter 3).
- Every scene an error. The first builder wrote calls to a function the engine had already renamed, so every scene it produced failed to compile ().
6Try it
- In the first figure, in each of the three ways, drag the ball, move the blend, then add a ball. Count the compiles each way asked for.
- In the second, how many objects called from four places reach the 151,000 characters where the model and the straight line agree?
- In ShadyTracer, add a shape to a scene in the builder and watch the status bar: the editing build, then the bake once you stop.