Blog · · 5 min read
How we ray trace a diamond in a fragment shader
A diamond is a convex solid, so a fragment shader can ray trace it with nothing more than a list of planes. Each pixel sends one ray in, finds the facet it enters through, bounces up to six times inside the stone, and splits the light into red, green and blue where it leaves. Facets, total internal reflection and fire all fall out of that loop without a single texture. This is how the Wren & Halloway demo does it.
By the AnimatedWebs team
A stone is a list of planes
Every cut in the demo (round brilliant, oval, emerald) is stored as a list of planes. Each plane is a vec4: a unit normal in xyz and an offset in w, and a point is inside the stone when dot(n, p) <= w holds for every plane. The round brilliant is built from 73 of them: one table, eight star facets, eight bezels, sixteen upper halves, sixteen girdle facets, eight pavilion mains and sixteen lower halves. The crown angle is 34.5 degrees, the pavilion angle 40.8 degrees and the table is 56 percent of the girdle width.
Describing the stone as planes instead of triangles has two payoffs. The shader needs no mesh data at all, only an array of vec4 uniforms capped at 80 entries. And changing the cut means uploading a different array. The oval is the round brilliant stretched 1.38 times along x, which for planes means dividing each normal by the scale and renormalizing.
The GPU still needs triangles to decide which pixels to shade. On the CPU we intersect every triple of planes, keep the points that satisfy all the others, and hand that point cloud to three.js ConvexGeometry, inflated by 1.2 percent so the silhouette never clips the stone. For 73 planes that is 62,196 triples, which is fine once per cut change. The fragment shader then ignores the mesh surface and starts its own ray from the camera.
Entry: one pass over the planes
The camera position is moved into the stone's object space on every frame, and the ray direction is the vector from the camera to the interpolated vertex position. Finding where that ray enters and leaves a convex solid is the slab method. Planes facing the ray raise the entry distance, planes facing away lower the exit distance, and if entry ends up past exit the ray missed.
float tNear = -1e9, tFar = 1e9;
vec3 nEntry = vec3(0.0, 1.0, 0.0);
for (int i = 0; i < MAXP; i++) {
if (i >= uCount) break;
vec4 pl = uPlane[i];
float along = dot(dir, pl.xyz);
float gap = pl.w - dot(origin, pl.xyz);
if (abs(along) < 1e-6) { if (gap < 0.0) discard; continue; }
float t = gap / along;
if (along < 0.0) { if (t > tNear) { tNear = t; nEntry = pl.xyz; } }
else tFar = min(tFar, t);
}
if (tNear > tFar) discard;nEntry is the normal of the facet the ray hits first. The surface reflection comes from it directly: a Schlick Fresnel term with a base reflectance of ((n - 1) / (n + 1)) squared, where n is the index of refraction. We use 2.418 for diamond. The reflected direction samples the environment, and the rest of the light, one minus Fresnel, refracts into the stone with refract(dir, nEntry, 1.0 / n).
Bounces: where the sparkle is made
Inside, the ray travels in a straight line until it meets the nearest plane it is moving toward. That is another pass over the array, this time keeping the smallest positive distance among planes whose normal points along the ray. At that exit plane two things can happen.
- The ray refracts out. If
refract(dir, -normal, n)returns a non-zero vector, some light leaves. We add it to the pixel, weighted by the current throughput and by one minus the Fresnel term at that angle. - The ray cannot leave. When the angle is past the critical angle for n = 2.418, which is about 24.4 degrees,
refractreturns zero. Nothing is added, and the full throughput carries on as a mirror reflection. That is total internal reflection, and it is why a well cut stone looks bright from the top: light that goes in comes back out the table.
Whatever Fresnel keeps at the boundary becomes the new throughput, the ray reflects, and the loop runs again. We stop at six bounces. Light that is still inside after that adds a small, dim environment lookup in its final direction, so the stone does not go black where the budget ran out. The cost is bounded: at most 73 plane tests for the entry and 73 for each of six bounces, and only pixels under the stone's hull run it at all.
Dispersion at the exit
Fire is color separation, and it happens because the index of refraction depends slightly on wavelength. Instead of tracing three full rays, we split once, at the moment light leaves. The exit direction is computed three times with slightly different indices: n minus a spread for red, n for green and n plus the spread for blue. Each direction samples the environment, and each result contributes only its own channel. The default spread is 0.022, so red uses 2.396 and blue 2.440.
vec3 g = refract(rd, -ne, uIor);
if (dot(g, g) > 0.0) {
vec3 r = refract(rd, -ne, uIor - uSpread);
vec3 b = refract(rd, -ne, uIor + uSpread);
vec3 c = vec3(dot(r, r) > 0.0 ? env(r).r : 0.0,
env(g).g,
dot(b, b) > 0.0 ? env(b).b : 0.0);
float fe = fresnel(dot(g, ne), f0);
col += thr * (1.0 - fe) * c;
thr *= fe;
}
rd = reflect(rd, ne);This is an approximation and we say so: the entry refraction uses a single index, so the three colors only diverge at the exit, and the red or blue ray can pass or fail total internal reflection on its own. That is what produces the colored flashes at the edges of facets. For a hero section, one split is enough to read as fire.
A studio made of math
A diamond reflects its surroundings, so the surroundings matter more than the stone. We have no HDR image. The env(direction) function builds a studio from a few lines: a near-black vertical gradient, three soft boxes, one tiny hot spot and a faint ring of light low in the sky. Each soft box is a smoothstep-edged rectangle, and its color is above 1.0 on purpose so bright reflections stay bright after tone mapping.
float softBox(vec3 d, vec3 center, vec2 halfSize) {
float k = dot(d, center);
if (k <= 0.0) return 0.0;
vec3 u = normalize(cross(center, vec3(0.0, 1.0, 0.001)));
vec3 v = cross(u, center);
vec2 q = abs(vec2(dot(d, u), dot(d, v)) / k);
return smoothstep(halfSize.x, halfSize.x * 0.7, q.x)
* smoothstep(halfSize.y, halfSize.y * 0.7, q.y);
}Dividing by k keeps the box a clean rectangle. The whole studio rotates around the vertical axis by one uniform. The demo ties it to the cursor position and adds a slow drift over time, so the facets flash without the stone moving at all. Because the light is a function and not a picture, it costs no download.
What it does not do
- No absorption along the path. A tint is multiplied in once per bounce, which is good enough for a warm stone.
- Six bounces is a budget, not physics. A real stone keeps going.
See it run
Play the live Wren & Halloway demo, switch the cut, and watch the flashes change with the facet layout. The full build prompt is on the template page. If you want the same idea in a different shell, look at Gem Dispersion Jewelry and Diamond Dispersion Hero. For another physical process turned into a shader, read A year of sunlight in six seconds.

