This page runs from a web server, not from a file: in the repository run python3 -m http.server 8000 and open http://localhost:8000/tutorial/.

Part I · Seeing with rays · Chapter 3

Building and bending shapes

A die with dots cut into it, a lamp shade melted onto its stand, a twisted column, a rough stone: none of them is a sphere or a box, and all of them are built from spheres and boxes. Each way of combining or changing a shape is a small change to its distance formula. The hard part is keeping the one rule of chapter 2 while doing it: a distance must never be too large.

1Three ways to join two shapes

Chapter 2 joined a circle and a box by keeping the smaller of their two distances: the nearest surface is the nearer of the two. Everything in either shape is in the result, a union. The faint outlines are the two shapes as they were; the bold line is the surface of the result. Drag either shape by the circled cross in its middle.

Keep the larger of the two distances instead, and only what is in both shapes is left: an intersection. A point outside either shape has a positive distance to it, and the larger number says so.

To cut the circle out of the box, turn the circle inside out first: change the sign of its distance, so that inside becomes outside. Then take the larger, as for an intersection: what is in the box and in the circle's outside is left. That is a subtraction. These three, with the shapes themselves, are constructive solid geometry: a die is a rounded box with a sphere subtracted for every dot.

Each of the three can be blended. The smooth blend of chapter 2 takes a little off the smaller distance where the two are within k of each other, and a fillet fills the corner. Its twin for the larger of the two adds a little instead.

The smooth blend's corner is no circle, and its size is not k but a share of it. Where a part needs a fillet of a given radius, as a machined or moulded part does, ShadyTracer has two more shapes a join can take: a round join, the circle of radius r touching both shapes, and a chamfer, the straight cut between the same two touching lines. The circle's centre is where both distances are −r, and where the two faces meet at a right angle the two distances plus r are a point's coordinates from it, so its distance to the arc is their length less r: exact for two flat faces square to each other, and for a flat face meeting a cylinder squarely (a hole's rim), which is the fillet CAD makes. At other angles the corner is rounded but not a circle. The two normals are not used: the renderer marches with distances alone (chapter 26), and a join whose distance read them stopped the march on the sharp corner while the shading saw the fillet (builder/joins.js works it through, and says what went black).

A blended subtraction rounds the edges of the cut, where the circle's bite meets the box's sides (in 3D: the rim of a hole drilled into a block). Move the blend k slider to see how far the rounding reaches. At the lower right the circle's edge runs close to the box's bottom edge, and the strip of box left between them is thin; the blend, which takes off up to k/4 wherever the two distances are within k of each other, thins it further, to a point. Move the circle up or down and watch the tail change.

The mathsMin, max and their smooth twins

dtogether = min(a, b),   doverlap = max(a, b),   da minus b = max(a, −b)

h = max(k − |a − b|, 0),   smin = min(a, b) − h24k,   smax = max(a, b) + h24k

a, b
the two shapes' distances at the same point
−b
the second shape turned inside out: negative outside it, positive inside
k
the blend: how close the two distances must be for the blend to change anything. With k = 0 the blend is the plain min or max

Are these still distances? Outside, min of two true distances is the true distance to the nearer shape. Inside, and for max, the result is not always the true distance, but it is never larger than it, which is all chapter 2's rule asks. The blends take off or add at most k/4, and stay safe too: their slope is a mix of the two shapes' slopes (section 2's maths), so it is never steeper than 1, and a formula that is 0 on its surface and never steeper than 1 is never larger than the distance to that surface.

A circle and a box joined: together (min), overlapping (max), or the box with the circle cut out. The faint outlines are the two shapes, the bold line the result's surface, the thin lines places of equal distance. Drag the circled crosses to move the shapes.

2Which way does a fillet face?

Light bounces off a surface according to which way the surface faces: its normal, the arrow straight out of it. Two families of shape functions meet here. The shapes the scene builder offers are mostly made from functions that hand back their normal along with their distance (Nsphere, Nbox; chapter 4 says why that saves work). The plain functions of the renderer's shape library (sdBox and the other sd… ones), which the comment in section 6 calls "most primitives", hand back none. A blend has to make one for the fillet, which neither shape has. Here a ball sits on a box, blended. The arrows are normals, and the thick line is the surface lit from the upper left: brighter where its normal faces the lamp. In the blend's zone the arrows are drawn in the accent colour (red). Every arrow stands square to the surface, and the light runs smoothly over the fillet. On the fillet, which curves inwards, arrows square to the surface lean towards each other and cross a little way out: that is how they should look on a hollow.

This is how it looked before a bug was fixed. Here the box is one of the shapes that give no normal (an arrow of length zero), like the plain box of ShadyTracer's shape library; the ball gives one. The blend mixed the two arrows and stretched the mix back to length one, and a mix of nothing with the ball's arrow is the ball's arrow, whatever the mix. So the whole fillet was lit as if it were part of the ball: look at the accent arrows on the box's top near the ball, pointing away from the ball's centre instead of straight up.

With a wider blend the wrong zone is wider. The surface itself was always in the right place, since the distances were right; only its light was wrong, so the fillet looked warped, and no smaller step could fix it. The fix: if either shape gives no normal, the blend gives none either, and the renderer works it out from the blended distance itself, which is always right (chapter 4 shows how). That is the Now button.

The mathsThe slope of a blend

A normal is the direction in which the distance grows fastest, its gradient, written ∇. Where a is the nearer shape and the blend is active (h > 0), smin = a − h²/4k with h = k − (b − a). Working out how it changes when a or b changes (a step of calculus; a check: where the two distances are equal, t = ½ and the two normals are mixed half and half) gives

∇ smin = (1 − t) ∇a + t ∇b,   t = h2k

∇a, ∇b
the two shapes' normals
t
how much of b's normal goes into the mix: 0 outside the blend, ½ where the two distances are equal

So the blended normal is exactly a mix of the two normals, mix(∇a, ∇b, t), made one unit long, as long as both exist. With ∇a = 0, the mix is t ∇b, and made one unit long that is ∇b for any t above 0: the bug, in one line.

A ball blended onto a box. Arrows: the normals ShadyTracer would shade the surface with, in the blend's zone in the accent colour (where a shape gives no normal, the arrow is worked out from the distances, as chapter 4 explains). The thick line: the surface lit by the lamp at the upper left through those normals, brighter where it faces the lamp. Before the fix shows the fillet lit with the ball's normal.

3Which solid is a point in?

A renderer asks a joined scene two different questions at a point. Which surface is nearest, so a ray knows how far it may step and what it hits? And which solid is the point inside, so a ray travelling through glass or water knows what it is in? (Light bends by how much the two sides of a surface differ, which chapter 10 explains.) Here a glass ball lies in a pool of water. Drag the probe, and the ball. The readout gives both answers; the glass-coloured region is everywhere the renderer would say "in glass".

Why two questions? Here the probe is in the water, just outside the ball: the nearest surface is the ball's, 0.10 away, the one whose distance is smallest in size, inside or out; but the point is in water. The solid a point is in, ShadyTracer decides another way: of all the solids the point is inside (negative distances), the one it is least deep in. A ball lying wholly in the water wins inside itself, always, because no path from a point in the ball reaches the water's surface without crossing the ball's first: the ball's surface is the nearer.

Drag the ball half out of the water. Now the rule is a guess, and here it guesses wrong: the probe is inside the glass, but nearer the water's surface than the ball's, so the renderer says "water". Part of the ball, near the water's edge, has lost its glass. Two solids claiming the same space have no right answer, so build scenes with one inside the other, or cut one out of the other with a subtraction.

A glass ball in a pool of water (drawn as a disc). The glass-coloured region is where the renderer's rule says a point is in glass. The probe (the accent-coloured ⊕) has a circle reaching the nearest surface; the other ⊕ moves the ball.

4Bending space

To twist a column, ShadyTracer does not change the column. It changes the point the column is asked about: before asking, it turns the point the other way, by an angle that grows with the height. Every slice of the column comes out turned. Changing the point instead of the shape is a warp; the same trick bends, mirrors and repeats shapes. The flat world has no height, so here the angle grows with the distance from the middle instead: a bar, about to become a swirl.

Turned. The shape looks right, but look at the lines of equal distance: on the arms they crowd together. A warp stretches space, and the farther from the middle, the more. A short move across an arm changes the formula by up to 1.77 times what a true distance could change. That breaks chapter 2's rule: the dots mark where it is broken, and the probe's circle, drawn red when it does, claims empty space that is not: it reaches well into the arm.

The fix is chapter 2's: divide by the steepest slope (the formula's Lipschitz bound). ShadyTracer's twist divides by √(1 + c²), with c = the turn rate × the extent, how far the shape reaches from the axis (the dashed circle). That is how much the twist stretches a move straight along the axis (in the flat swirl: straight away from the middle). But some dots stay: a move partly along and partly around is stretched more. The steepest slope left is 1.13, and the probe's circle still cuts a little way into the arm.

The most the map can stretch any move is (c + √(c² + 4)) / 2. Divided by that, no dot is left anywhere inside the extent, the steepest slope is 1.00, and the circle only touches the arm. Both divisors are worked out for the extent: beyond it the swirl stretches more, so the probe stays inside the dashed circle. (Whether the 3D twist's steps are safe beyond its extent is not measured here.)

The mathsHow much a twist stretches

Near a point at distance r from the axis, take two directions: along the axis (up), and around it. A small move up by u turns the point by an extra angle rate × u, and turning a point at distance r by an angle φ moves it r·φ along its circle: so the turned point moves around by c·u, with c = rate × r. A move around by v stays a move around by v. So a move (u, v) becomes (u, v + c u):

|(u, v + c u)|² = u² + (v + c u)²

For a move straight up (v = 0, u = 1) that is 1 + c², so the move is stretched by √(1 + c²). But a move can point any way: write it as u = cos θ, v = sin θ, one unit long, and look for the θ that makes cos²θ + (sin θ + c cos θ)² largest. That expression is the stretch squared. Trying every θ (or a little algebra) finds its largest value, L², and the largest stretch itself is

L = c + √(c² + 4)2

For c = 1 that is 1.618 against √2 = 1.414. The gap is largest near c = 0.7, where L is 1.155 times √(1 + c²). (For c = 1 the largest squared stretch is 2.618, and its square root 1.618.) The flat world's swirl stretches moves in the very same way, with r the distance from the middle, which is why the figure shows the twist's numbers.

A bar turned by an angle that grows with the distance from the middle: the flat world's twist. Dots: places where the distance formula is still steeper than 1. The dashed circle is the extent, the reach the divisor is worked out for. Drag the probe (it stays inside the extent).

5Rough surfaces

A rough surface is a shape with a little added to its distance: a noise pattern, smooth hills and valleys of number that are the same every time the same point is asked about. ShadyTracer adds four layers of it, each twice as fine and half as strong as the one before (fBm), and calls it a relief. Here it is on a circle. Added as it is, it makes the formula steeper, as a warp does, and a ray marched through it (the dots are its steps) gets stuck. A march stops when the distance falls below its tolerance (chapter 2); here each step lands just past the surface, the next steps back just short of it, by the same amount, for ever, and the distance never gets small enough. The steps are too close together to tell apart here, so the plot at the right shows the distance the march saw at each of its last ones (its scale changes with the mode, to fit them): a zigzag about zero. After 300 steps it gives up, with half of them inside the shape (red).

Divided by the steepest the noise can make it, the slope bound in the readout, the march is safe and settles, the plot's last steps falling to zero: 33 steps. The bound is 1 + (amount ÷ size) × 17.2 ÷ 1.875. The noise is four layers, each half as strong as the one before and twice as fine; half the height over half the width is the same slope, so every layer is as steep as the first, 4.3 at most (a measured number, section 7), and four of them 17.2. Their strengths add up to 1 + ½ + ¼ + ⅛ = 1.875, and dividing by that turns "amount" into the first layer's share. Scaled by how tall the bumps are against how wide: wider bumps of the same height are gentler. But it pays for the noise at every step, even out where the noise cannot possibly matter, and its steps are a fraction of what they could be.

The noise can move the surface by at most a known amount, its reach. So further than twice that from the plain circle (outside the dashed band), the circle's own distance, less the reach, is a safe distance: no noise, no division. Twice, not once: just past the reach that distance is tiny, and a ray grazing the band would crawl along it in tiny steps; from twice the reach out, every step is at least the reach long (the code's comment says so). Only points inside the band pay for the noise. The same ray: 22 steps, most of them near the surface, where the plot's scale makes them look like zero. The reach is not "amount" itself but 1.386 times it, the furthest the noise can reach (section 7): the readout's 0.166 for an amount of 0.12.

A circle with a noise relief (the noise is a 3D pattern; the figure shows one flat slice of it): the bold line is its surface, the thin lines places of equal distance as the march sees them. A ray marched from the circled cross, its steps as dots, red where they are inside; the plot at the right, the distance it saw at each of its last steps. The dashed band: where the shell still asks the noise.

6How ShadyTracer does it

Most of this section's code is WGSL, the language the graphics card runs (chapter 21), kept as text inside JavaScript files. For each scene the renderer writes its shapes and joins into one program for the card, a shader (chapter 24); the functions from scene_api.js below are part of every such program, and the three from codegen.js and modifiers.js are JavaScript that writes the rest.

The joins work on a shape's normal and distance together, four numbers (a VEC4: the normal's three, then the distance w). Blending needs both normals, and a missing one means none:


    

Every shape's answer then goes into one record per point, the Hit, by fold: the nearest surface by size, and the solid the point is least deep in (section 3):


    

fold answers the march's questions, not a join's. Where two shapes overlap, the nearest surface by size can be one the point is just outside of while it is deep inside the other, so it says nothing of which side of their union the point is on. A group that is itself one shape of a join (a group cut away, say) therefore has its own shapes joined first, by a hard union, and only that is folded in. A group of shapes picks its material by the same test the join uses to pick its side, so a hole's walls wear the cutter's material:


    

A warp says what it did to space, so the distances and normals after it can be put right:


    

and the twist, written as a group's own code, calls it with one over the divisor of section 4 (warp multiplies the distances by what it is given):


    

The relief, with its shell and its division, is code of the same kind:


    

One more piece: a relief inside a blend is not divided until after the blend (section 8 says why):


  

7Tricks and trade-offs

Each is stamped with how it is known; point at a stamp to see what it means.

Noise only near the surface. The relief's shell (section 5) asks the noise only within twice its reach of the plain surface. Its reach is proved, not measured, in noise.js (): 1.386 times the strength. A reach that were only measured could cut the relief off wherever the noise went further, and the comment says so.

A measured bound, a quarter the steps. One layer of the noise can be proved never to slope more than 1.6 × (1 + 5.625√3) = 17.2 (the same number as section 5's four measured layers, by chance). A dense search for its steepest found 3.437 (test/noise.test.mjs, 2026-09-29), and the relief divides by that times 1.25, 4.3: steps four times as long as the proof allows. A later, longer search found 3.854, so the margin for one layer alone is 12%, not 25%. A measurement is not a proof, and noise.js says so beside the number: .

The twist's divisor is too small for some shapes. Section 4's gap is real in 3D too. Measured by this tutorial's test (tutorial/test/tutorial.test.mjs, in node, in 64-bit numbers, on a copy of the modifier's arithmetic): a thin plate tilted 45° at the edge of a twist's extent (rate 0.8, extent 1) still has a slope of 1.14 after the twist's division, so a step of 0.9 × the distance can be about 3% longer than the true distance. A plain box in a twist stays under 1 (0.98). A step 3% too long near a surface usually lands just inside it and steps back; it could jump a sheet thinner than that. The swept shapes in shapes.js use the same √(1 + c²), and there it is exact: their slices have no slope along the path. The figure's exact divisor, up to 15% larger, would cost a twist up to 13% shorter steps; this tutorial does not change the engine, so the gap is reported, not fixed.

Copies for the price of one. The repeat modifier folds space into one cell, p − spacing × round(p / spacing), and asks one shape there: endless copies, one distance each step. Its comment calls it "the cheapest thing here", and says the catch: the shape must fit inside its cell, or the distance is wrong near the cell borders.

Copies round an axis. The circular array puts copies round the group's centre instead, and it does not fold. Folding would turn the point into one slice of the circle and ask one copy, with the same catch, made worse because a slice narrows to nothing at the centre: a shape across the centre would come out as a wedge, and a copy crossing a slice's edge would lose its tip. So it asks every copy and keeps the smallest distance, a union: copies that overlap merge into one shape. That costs one evaluation of the shape per copy. Its reach buys the speed back for copies that stay near their own slice: it asks only the nearest few, and gives the rest a distance that is safely too small.

Every modifier says what it costs. A group's code runs at every step of every ray, so each modifier's first comment says what it adds per step: a sine and a cosine for the twist, four noises for the relief, nothing for round and shell (their code is in builder/modifiers.js; the twist's and the relief's are in section 6).

8What went wrong before

  • A hard join was not a number. ShadyTracer's shape library has its own smooth joins (), and the scene's joins do not use them, for two reasons the comment above csgNormal gives: they divide by k, so a hard join, k = 0, gives NaN ("not a number", what 0/0 gives); and they make the mixed normal one unit long even when both are zero, which is NaN again. A NaN normal fails the test fold holds every surface to (does it face the ray?), and the surface silently disappears.
  • The warped fillet of section 2: the comment's own example is a sphere scooped out of a box, the same mix of nothing and the sphere's normal.
  • White dots on a black die. A group used to wear its first shape's material everywhere, and switched only for a union's nearer shape. So the dots cut into the die, by charcoal-coloured spheres, had white walls. Since version 0.57.2 every part of the surface wears the material of the shape that makes it there, by the join's own test (the combine excerpt).
  • A relief divided too early. Divide each shape's distance by c before a blend, and the blend comes out c times too wide: smin(x/c, y/c, k) is exactly smin(x, y, k c) / c. So a relief inside a blend goes in undivided, and the top of the blend divides once, by the steepest relief below it (foldRelief).
  • A line round a cut. "The steepest relief below it" is the steepest near the point: a relief farther from its own surface than its band is skipped, and adds nothing. The band is twice the relief's height plus 5/4 of the blend, so the wider the blend, the farther out it reaches. Cut a sphere of steep knockdown stucco from a box of gentle white plaster, and on the plaster the divisor was the stucco's, 4.67, within the stucco's band, and the plaster's, 1.11, beyond it. The surface stayed where it was, since 0 divided by anything is 0. But a normal is worked out from the distances at points a small step apart (chapter 4), and two of them either side of the band's edge came back divided differently: a distance of 0.05 read 0.05 / 1.11 = 0.045 on one side and 0.05 / 4.67 = 0.011 on the other. Their difference was the jump, not the surface's slope, and the normal turned by up to 86°: a thin dark line round the hole, farther out the wider the blend. Dividing every distance by one number does not change which way the arrow points, so since version 0.71.1, when the renderer asks for the distances a normal is made from, the group does not divide (NORMAL_LOOK, declared in the excerpt in section 6). A group inside a group with code of its own still divides then: that code (a rounding, say) may use the distance away from the surface, where the division changes the shape.
  • Bumps 1/k too big. A relief sized in millimetres needs the true scale of the space it is in. Under a twist, the scale ShadyTracer kept included the twist's factor k (one over its divisor), and the bumps came out 1/k times too big; it now keeps the factors apart ().

9Try it

  • In the first figure, choose Box minus circle and drag the circle until it cuts the box in two. Then blend it.
  • In the fillet figure, choose Before the fix and move the blend from 0.05 to 1. Where do the arrows go wrong first?
  • In the swirl, set the turn to 0.6 and compare the two divisors' steepest slopes. Then 1.6. Where is the gap largest?
  • In the relief figure, make the size larger with the amount fixed. Why does the slope bound fall? Then drag the ray's start up and down with Not divided: how often does it get stuck?
  • In ShadyTracer, open File › Projects…, add a copy of the example Combining shapes, select a group in the Scene tree, and look at its joins in the Properties panel.

Words from this chapter