Graded Gyroid — a lattice ray-marched from a distance field, no mesh

Raw WebGL2, no three.js — the lattice is a graded gyroid TPMS defined as a signed distance function, sphere-traced per pixel in a single fragment shader. One inline , 31.8 KB of source. No mesh, no model file anywhere.

The geometry submitted to the GPU is one full-screen triangle from gl_VertexID, no vertex buffer. Wrapped gl.drawArrays to count calls: 7 per frame — 1 for the SDF scene pass, 6 for post (bright-pass, a two-level separable Gaussian blur, then a composite doing ACES tonemap, per-channel chromatic aberration, vignette, grain).

Cell size grades radially off one smoothstep on the frequency term — dense at the core, open at the skin, no second field. The part that took the most iteration wasn’t the raymarch, it was getting the surface to read as metal instead of plastic: dispersion runs three slightly offset refraction paths through the same field, one per color channel.

Numbers, measured just now on this machine (RTX 3050, 1250x1276 canvas, DPR 1):

  • 60 fps steady, vsync-capped
  • ~65 KB total transferred for the page: 15 KB gzip for HTML+CSS+JS+shaders together, ~50 KB for two subsetted variable fonts, zero images, zero model files
  • 3 quality tiers (56 / 80 / 88 raymarch steps for low/mid/high), picked automatically: first a probe for a software-rasterizer fallback, then touch + screen size

Where it breaks: antialiasing is off on purpose, leaning on DPR and the post pass instead — push DPR down and the lattice edges alias visibly. No WebGL2 falls back to a static panel; only tested that on evergreen browsers. Running a raymarcher flat-out at 60fps draws real power and I haven’t capped the frame rate or measured battery impact.

Happy to get into the distance field or the tiering logic if anyone’s curious.

2 Likes

The surface features remind me of this demo:

https://boytchev.github.io/tsl-textures/examples/normal-map/index.html

image

2 Likes

That’s a sharper catch than it looks, and I think the resemblance is structural rather than visual — we’re running the same operation at two different points in the pipeline.

Your test() takes the shading point, offsets it by eps = 0.001 along the tangent and the bitangent, evaluates the displaced position three times and crosses the two differences. That is a normal read off the finite differences of a scalar field. Mine does exactly the same thing — four taps in a tetrahedron instead of three in a plane, 0.0016 instead of 0.001 — and the field happens to be dot(sin(q), cos(q.yzx)) rather than folded noise. So the “surface features” you’re recognising aren’t the gyroid, they’re the fingerprint of a field-derived normal. Any implicit function sampled that way gets the same slightly waxy, self-consistent highlight roll-off, because the normal is exact to the field instead of interpolated across a triangle.

The two part company at the silhouette. Yours is a sphere’s outline with a very convincing skin on it, and that is by far the better trade for most work: one pass over the surface, WebGPU, computeTangents() and you’re done, and it runs on anything. Mine has to pay for the volume — 88 sphere-tracing steps per pixel on the high tier — and what that buys is narrow: the outline itself is the lattice, you get parallax down the channels as you orbit, refraction paths that actually travel through the solid, and a section plane that can cut the thing open. Everything else your version already has for a fraction of the cost.

Which gives an honest test between the two, if anyone needs one: orbit it and watch the silhouette. A displaced shell can’t show you its interior. That is the only thing the extra cost buys, so if a piece doesn’t need the interior, the shell is the right answer and the raymarcher is vanity.

One more thing your demo made me notice. Mine is raw WebGL2 with a hand-written fragment shader, so I’d been reading TSL as a different world, and it plainly isn’t — the whole field is four lines. dot(sin(q), cos(q.yzx)) for the gyroid, mix(1.38, 0.84, smoothstep(...)) on the frequency to grade the cell size radially, abs(g) - w for the wall thickness, then a divide by the frequency to keep it Lipschitz so the marcher doesn’t overshoot. Graded TPMS is a genuinely useful thing to have in a texture library — lattices in that family show up constantly in print and in structural work, and the graded version is the one nobody has to hand. If it would sit well in tsl-textures, say the word and I’ll write it as a proper node with the parameters exposed, test it against your examples, and open a PR — no strings, it’s a good library and I’ve been reading it this morning either way.