Appearance
M2-C1: Why SDF Composition Matters
Taught: 2026-04-30 Milestone: Phase 1 M2 — Pedestal SDF Result: PASS (on the conversational re-teach; original compact format was rejected by the user as "test without teaching")
Why this concept matters here
M2 introduces a second SDF primitive (a box pedestal) under the existing sphere. The architectural question is: how do two SDFs combine into a single scene? This concept establishes that SDF composition is the foundational primitive of SDF rendering — the killer demo (perfume bottle on pedestal with liquid torus) is the same min() line as M2, just with more primitives. Get the mental model right and every future SDF addition is one line of code.
The walkthrough
Step 1: An SDF is a FIELD — not just a function on the surface
Most people think "SDF = formula for the surface." That's wrong. An SDF assigns a number to every point in space, including points far from any surface, including points inside surfaces.
Concrete example. A sphere of radius 1 at origin. The SDF is length(p) - 1. Pick some points:
Point p | length(p) | length(p) - 1 | Meaning |
|---|---|---|---|
(0, 0, 0) (origin, inside) | 0 | −1 | "1 unit inside the surface" |
(0.5, 0, 0) | 0.5 | −0.5 | "0.5 inside" |
(1, 0, 0) (on surface) | 1 | 0 | "at the surface" |
(2, 0, 0) (outside) | 2 | +1 | "nearest surface 1 away" |
(5, 0, 0) (far away) | 5 | +4 | "nearest surface 4 away" |
The SDF is a 3D scalar field — every point in space has a value. The surface is implicit: the set of points where the field equals zero. But the field exists everywhere.
This is the same way a fluid sim has a velocity field at every point — even where there's no fluid, the field has a value (zero). The field is a complete description of space.
Step 2: The march algorithm doesn't render the surface — it samples the field
When you march a ray, you're not "drawing the surface." You're sampling the field at successive points along the ray, using the field's value to decide your next step.
At position p along the ray:
d = scene_sdf(p) ← read the field value
if d < ε: HIT ← we've reached zero (the implicit surface)
else: advance by d ← the field tells us how far we can safely stepThe algorithm has zero knowledge of "what's a sphere" or "what's a box." It just calls scene_sdf(p) and reads a number. This is the architectural payoff coming.
Step 3: Two ways to compose multiple primitives
Now you have a sphere AND a pedestal. Two questions you could ask:
Approach A — One combined field, march it once. Build a new function: scene_sdf(p) = min(d_sphere(p), d_pedestal(p)). At every point in space, return the distance to whichever surface is closer. The combined field is itself a valid SDF. March it like before — the march algorithm is literally unchanged.
Approach B — March each primitive separately, take whichever hits first. March the sphere as if it's the only thing in the scene. Get t_sphere. March the pedestal separately. Get t_pedestal. Pick the smaller t. Use that primitive's normal/material for shading.
Both sound reasonable. Approach B is what you'd do with a triangle ray-tracer (test against each mesh separately, take the closest hit). And it's tempting because it lets you reuse single-primitive code.
Step 4: Concretely, what does Approach B break?
Three problems, in order of badness.
Problem 1: Cost scales with N (number of primitives).
Approach A: march once, regardless of how many primitives. The combined SDF gives you min(d_a, d_b, d_c, ...) at each step — but you sample it ONCE per march iteration, advance by that value, repeat. 64 march steps total for the whole scene.
Approach B: march N times. 64 steps × 5 primitives = 320 march iterations, each doing pipeline work, normal computation, etc. You scale linearly with primitive count. The killer demo has 3+ primitives. Bigger scenes will have more.
Problem 2: At the SEAM where two surfaces touch, Approach B steps wrong.
Imagine your sphere sitting tangent on top of the pedestal — they meet at exactly one circle (the contact line). Now imagine a ray approaching this seam from above.
In Approach A, at every point along the ray, min(d_sphere, d_pedestal) returns "distance to whichever is nearer." The march can never overshoot, because it always uses the most conservative (smallest) safe-step distance.
In Approach B, marching sphere alone, the algorithm only knows about the sphere. At a point near the seam, the sphere's distance value might be 0.5 — so the algorithm steps 0.5 forward. But that step might cross into the pedestal's interior because the sphere SDF doesn't know the pedestal exists. You miss the actual surface intersection.
You can patch this by limiting the step size — but now you're crawling through space taking tiny steps because each primitive can't trust the others. You've lost the whole reason ray marching is fast.
Problem 3: Smooth-min blending is geometrically impossible in Approach B.
This is the architectural killer. A smooth-min combines two SDFs into a NEW surface that doesn't belong to either primitive:
smin(a, b, k) = a smoothly-melted blend of two surfaces, with blend radius kIf you smin(d_sphere, d_pedestal, 0.2) instead of min, the sphere doesn't sit on the pedestal — it melts into the pedestal with a curved fillet at the contact line. That fillet is a brand-new surface that exists in neither the sphere's parametric form nor the box's. It only exists in the combined field.
Approach B has no concept of this. There's no "march the sphere" or "march the pedestal" anymore — the surface is the smin'd field, full stop. Approach A handles it for free: just change min to smin and re-run. No other change.
This is what makes SDFs a unique geometric representation. You cannot get this kind of seamless blending from triangle CSG without remeshing the result. With SDFs, blending is a one-line operator on the field.
Step 5: What this means for the the renderer architecture
The killer demo (perfume bottle + pedestal + liquid torus) needs:
- Composition (3 primitives → 1 image)
- Smooth blending where the liquid meets the bottle
- Material identification per primitive
- Fast rendering even as you add more primitives later
Approach A gives you all four for the cost of one scene_sdf(p) function. Approach B chokes on every one of them.
M2 isn't proving "we can render two things." M2 is proving the composition primitive itself works — the min() line that becomes min(min(min(...))) as the scene grows. Once that's in, every future SDF primitive is essentially free to add.
The mental model in one sentence
An SDF is a field over all of space; composition is just arithmetic on those fields; the march algorithm doesn't care how many primitives are in the scene because it only sees one combined scene_sdf(p) function.
Explain-back question
Someone proposes: "Why don't we just keep each primitive's SDF separate, march each one independently, and pick whichever hit comes first along the ray? Same result as
min, right?"Tell them, in your own words: (1) what specifically breaks at the SEAM where two surfaces meet, and (2) why
sminblending makes the alternative approach not just inefficient but impossible.
User's answer
- when two surfaces meet, if we go with approach b, we might overshoot and go inside the other primitive and never get the seam. we might overcome this by doing smaller t increments at corners, but that is too expensive and wasteful.
- with smin() we basically have a new shape and with that no individual primitives exist anymore. so it is impossible to smooth-min blending with approach b!
Judgment
PASS. Got both:
- Seam mechanism — overshoot into another primitive's interior because each primitive's SDF doesn't know about the others; correctly identified the "smaller steps" patch and called it expensive/wasteful
- smin architectural impossibility — smin creates a new surface that doesn't belong to either primitive, so "march each separately" has no individual surfaces to march
Showed WHY both are problems, not just WHAT breaks.