WebGL Raymarcher
A scene built from signed distance fields(SDF). Move the cursor to relight; drag the slider to morph between two compositions.
WebGL Raymarcher — proof of concept and notes
What it is
A fragment-shader-only scene. The geometry isn’t a mesh; it’s a function. The fragment shader, run for every pixel of a fullscreen quad, “marches” a ray from the camera into the scene and asks at each step “how far is the nearest surface?” That distance is computed by a signed distance field (SDF), which returns the distance to the nearest surface as a function of position alone.
Two SDF compositions live in the shader:
- A — a sphere and a rounded box, blended together with a smooth minimum.
- B — a sphere and a torus, similarly blended.
The slider blends the two compositions’ distance fields. At slider 0 you see only A; at 1 you see only B; in between you see geometry that doesn’t exist in either, but emerges from the math of the blend.
Why mouse → light direction
The lighting term in the shader recomputes per-pixel from the light direction uniform. A mousemove handler writes the direction; every pixel re-shades. There’s no scene-graph step, no buffer update — just a uniform write and the next frame draws with the new lighting. It’s the smallest, most idiomatic interaction in shader-art, so a pretty good WebGL POC.
Soft shadows
The cheap shadow term is a secondary raymarch from the surface point toward the light. If the secondary ray hits something else before exiting the scene, the surface is shadowed (multiplied by 0.55). This is an order of magnitude cheaper than a real shadow algorithm and produces the characteristic “SDF-art” shadow look — soft-edged, slightly fuzzy. Documented here because the half-precision of shadowT > 0.0 ? 0.55 : 1.0 is a deliberate stylization, not a bug.
WebGL2 with WebGL1 fallback
The shader source is GLSL ES 100, which compiles under both WebGL2 and WebGL1 contexts. WebGL2 is preferred at runtime for the broader feature set, but the actual scene math (scalar SDF, mix(), smoothstep, clamp) is all available in 100, so the same source works either way. If neither context is available the component renders a static unsupported-message instead of failing silently.