A ground plane, a sphere, a box and a torus melted together with a smooth minimum, plus one sphere orbiting overhead and melting through the rest of the scene as it goes. There is no geometry at all — the whole thing is one fragment shader per backend, sphere-traced pixel by pixel. This is the demo I use to show students that rendering doesn't have to mean triangles.
main.py— OpenGL, drawing the classic no-VBO fullscreen triangle from ScreenTriRayMarchingWebGPU.py— WebGPU, the same trick with a@builtin(vertex_index)triangle (see OITransparency's composite pass)
Both shaders — shaders/RayMarchFragment.glsl and RayMarch.wgsl — are line-for-line transcriptions of each other, and both are transcriptions of sdf_maths.py, a numpy-only reference implementation unit tested in tests/. Same function names in all three: sd_sphere, sd_box, sd_torus, sd_plane, smooth_min, scene. If you change the scene in one, change it in all three or the two renderers will visibly disagree.
Rather than rasterising triangles, each pixel fires a ray from the camera and walks it forward. The scene isn't a mesh, it's a distance field: scene(p) returns how far p is from the nearest surface, negative if you're inside something. That distance is always a safe step size — nothing in the scene is closer than that, in any direction — so you march by exactly that far, evaluate again, and repeat:
float travelled = 0.0;
for (int i = 0; i < MAX_STEPS; ++i) {
vec3 p = camPos + rayDir * travelled;
float d = scene(p, time, smoothK);
if (d < EPSILON) break; // close enough: call it a hit
travelled += d;
if (travelled > FAR) break; // gave up
}100 steps, epsilon 1e-3, far plane 40 units — past that the ray is considered to have escaped into the sky. The surface normal falls out of the same field for free: nudge p a tiny amount along each axis and see how the distance changes (a central-difference gradient), no vertex normals required.
Compound shapes come from combining distance fields with min. A hard min gives a sharp seam where two shapes meet; smooth_min (Inigo Quilez's polynomial smin) rounds it into a fillet, which is what makes the orbiting sphere look like it's melting through the rest of the scene rather than just intersecting it. +/- widen or narrow that blend radius live.
Shadows and ambient occlusion are the same trick again, aimed differently: a soft shadow is a second march, this time from the surface towards the light, tracking how close it grazes other geometry along the way; AO is five short taps along the normal checking whether the field is "more full" nearby than empty space would predict.
| Key | Action |
|---|---|
S |
toggle soft shadows |
O |
toggle ambient occlusion |
N |
visualise surface normals |
I |
visualise the iteration count as a heat map — blue is cheap, red is near MAX_STEPS, and it's the best single picture for explaining why ray marching cost depends on the view and the scene |
+ / - |
widen / narrow the smooth-min blend radius |
Space |
pause / resume the orbiting sphere |
| LMB / RMB / wheel | rotate / pan / zoom, Esc quits |
The camera uses the usual PyNGL mouse orbit (spin_x_face/spin_y_face for rotate, model_position for pan and zoom), but there's no model matrix to apply it to — instead main.py and RayMarchingWebGPU.py turn that state into a camera position and an orthonormal forward/right/up basis on the CPU, and the shader only has to combine that basis with each pixel's screen-space offset and field of view to get a ray direction.
The two shaders are close enough to diff directly. The differences that remain are backend syntax, not the ray marcher itself:
- WGSL has no
#version/#define— constants useconstinstead of GLSL's#version 410 corepreamble andconstqualifiers (same keyword, just consistently used for everything in WGSL). - Uniforms arrive differently: GLSL gets a flat list of
uniformscalars/vectors set individually from Python; WGSL gets oneParamsuniform buffer whose numpy dtype mirrors the struct layout by hand (eachvec3padded out to 16 bytes by thef32that follows it). - Loop syntax (
for (int i = 0; ...)vsfor (var i = 0; ...)) andbooluniforms (GLSLinttoggle vs WGSLu32) are the only other differences — the SDF functions and thescene()composition are identical statement for statement.
uv run pytest RayMarchingSDF/testsCovers exact distances (a sphere's surface is zero, its centre is negative), the two smooth_min properties that make it a smooth minimum (never above the hard min, and converging to it once the two inputs are far enough apart), and that estimate_normal recovers a sphere's radial normal from its distance field alone.
- Inigo Quilez, Distance Functions — the primitive SDFs and
smooth_minused here. - Inigo Quilez, Ray Marching and Signed Distance Fields — the march loop, normals-from-gradient and soft shadow technique.
- The Book of Shaders — Signed Distance Functions — a gentler introduction if the above two are a lot to take in at once.
- ScreenTri, OITransparency — the fullscreen-triangle tricks this demo's "geometry" is built from.
