Nabil Graphics & front-end engineering

Prism · WebGPU renderer

A physically-based spectral ray tracer, written from scratch against raw WebGPU.

No three.js. No framework. No build step. One 805-line HTML file that intersects a 65-facet gem as 65 half-spaces and traces light at individual wavelengths rather than as RGB, and still runs at interactive frame rates.

65facets, derived 400–700 nmsampled per ray 0dependencies 9bugs found & fixed
A 65-facet brilliant cut rendered by the engine: a pale faceted stone seen from slightly above, its crown and pavilion visible against a flat near-black field.
Fig. 1: an actual frame, exported from the engine at 1400×979. Not a photo, and not photorealistic. See what's not done for why.
01

Three decisions that make this hard

Rendering a shiny stone does not take much: every engine ships an environment map and a torus knot tutorial. Three choices here do take work, and they are the project.

1. Spectral, not RGB

A ray is not tinted red, green and blue. It carries a wavelength sampled from 400–700 nm, and the stone's refractive index is computed from that wavelength every single time. The coloured flashes a diamond throws are dispersion, and dispersion only exists if you compute it.

2. A gem as 65 half-spaces, not a triangle mesh

The stone is not a mesh. It is the intersection of 65 planes. A ray misses the solid unless it is inside every half-space, which means one ordered loop over 65 planes replaces a mesh traversal, a BVH, and every floating-point normal you would otherwise interpolate. It is exact, and it fits in a uniform buffer.

3. From scratch

Buffers, bind groups, uniform layouts, the render pipeline, WGSL, the readback path for PNG export, all of it against the raw API. Nothing sits between the code and the API, which was the reason to do it.

The facet geometry

The facet rings are not eyeballed. A facet that runs down to the girdle circle sits at plane distance sin(a) for tilt a; the table height follows from the crown angle and the table radius. The resulting proportions come out at the textbook figures for a 34.5° / 40.75° round brilliant without being tuned: a 53% table, ~16% crown height, ~43% pavilion depth, ~59% total depth.

Every facet in the stone: 65 planes, generated from seven parameters.
RingCountAngle from girdle plane
Table1flat
Star facets822°
Bezel (kite) facets834.5°
Upper girdle facets1642.5°
Girdle band8vertical
Lower girdle facets1642.5°
Pavilion mains840.75°
Total65

The optics

  • Fresnel. At each surface the code splits the ray by angle. Diamond reflects 17.2% of light straight back at normal incidence (a sixth of it), which is exactly why it out-sparkles glass.
  • Cauchy dispersion. n(λ) = 2.38 + 0.0115/λ², giving a refractive-index spread of about 0.048 across the visible range.
  • Total internal reflection. Past the critical angle (24.4° for diamond into air) the ray does not leave, so light entering the pavilion stays inside.
  • Colour. Each wavelength is converted to RGB and weighted by luminance, which keeps the colour tied to the spectrum.

Interactive, then still

Interact at 6 wavelengths with no anti-aliasing; once the view has been still for 600 ms the engine re-draws itself once at 4× AA and 16 wavelengths, then stops submitting work entirely until something changes. A refined frame costs about 140 ms, so repeating it would stutter permanently and keep the GPU busy on a page nobody is touching.

02

What I measured

Timed on the GPU itself rather than with a frame counter. The first bug below is what happens when you get that wrong.

65-facet brilliant at 912×638 unless stated. Milliseconds are the median of five runs.
Configurationms/framefps
Brilliant, 6 wavelengths, 3 bounces3628
Prism preset, same size1568
Brilliant, 2000×14007314
20 wavelengths, 6 bounces, 2× AA, the top end2484

Read the spread, not the digit

Five consecutive runs of identical settings gave 44.5, 35.5, 36.3, 36.7 and 36.3 ms. An earlier reading of the same configuration was 21 ms. A browser GPU is not reproducible to two digits, so I quote medians and expect ±30%. A benchmark table without a spread is a table I would not trust.

03

Three bugs in my benchmark

Every one of these produced a plausible number from a broken renderer, and every one was caught by checking the output rather than the speed.

  1. The benchmark that measured nothing

    The renderer's resolution control is a fraction of the canvas. I called it with res: 912, meaning "912 pixels wide". It read that as "912 times the canvas", asked the GPU for a 1.2-million-pixel-wide texture, failed validation, and then poisoned every subsequent GPU call with invalid due to a previous error.

    Through all of it, the benchmark reported a confident 63.5 fps, because a pipeline that submits nothing is extremely fast. I only caught it by opening the console.

    An invalid pipeline benchmarks beautifully, so check the output as well as the speed.
  2. The facets that were mathematically present and invisible

    The 16 upper-girdle facets were generated correctly and showed up nowhere. Two facet cones that both touch the girdle circle are nested: the shallower cone is always further out, so it hides the steeper one completely. Sixteen facets quietly became zero.

    Geometry can be correct and still not be visible. Count facets in the pixels as well as in the array.
  3. I guessed a distance instead of deriving it

    I set the star facet plane distance by eye. The resulting cone sat below the table and swallowed the entire crown; the first "brilliant" rendered as a smooth dome with a pinhead table. The star plane has to pass through the midpoint of a table edge; derived rather than guessed, the star triangles appeared immediately.

    Facet geometry has to be derived.

The full list runs to nine, including a probe that dispatched 65,536 workgroups against a 65,535 limit and reported a number anyway, a ray-vs-polyhedron test that computed only the entry slab so every ray reported a hit and the stone filled the screen, a preset that was quietly building a prism out of diamond's refractive index, and one where my own arithmetic was wrong and the code was right. All of them are written up in the project's verdict document.

04

What's not done

This is a correct engine that has not yet been art-directed, and the gap is specific enough to name.

  • It is not photorealistic. The environment is an analytic function, not an HDRI. Without sharp bright-against-dark structure in the surroundings, the fire stays subtle: you can compute dispersion perfectly and still have nothing to refract.
  • Nothing reads as coloured stone. Every material is a pure dielectric with a Cauchy index and no absorption, so a sapphire is impossible.
  • The tone map is raw Reinhard, which flattens exactly the highlights a gem should flash with.
  • The interactive loop cannot be tested headlessly. Browsers throttle requestAnimationFrame to zero in a background tab (measured: 0 frames in 1.5 seconds), so everything above is driven through an API instead. I added a one-frame test hook for that reason.
05

Also shipped: The Directory

A second, smaller piece: a searchable directory of independent jewellers who take on custom work. Static HTML, no dependencies, no build step, no server, and it runs from a local file.

It is also the lead list for the commercial version of the renderer, which is the main reason I built it.

06

Work with me

I work on graphics and performance-critical front-end code: WebGPU, WebGL, rendering, simulation and profiling. Open to contract work and to roles.