Editor’s Word: Our celebration of the very first Three.js Convention is all in regards to the folks, concepts, and experiments that make this neighborhood so inspiring. As we speak, we’re extremely completely satisfied to shine a light-weight on Mathis Biabiany, whose expertise and curiosity have led to this stunning exploration of lit GPU tubes, TSL, and WebGPU. We’re so grateful that Mathis selected to share this improbable work with us as a part of the celebration. We hope you get pleasure from getting misplaced within the particulars as a lot as we did.
🇫🇷 The celebration continues in Paris! The very first Three.js Convention is bringing the neighborhood collectively for 2 days of talks, concepts, and connections. Use code CODROPS for 15% off and get your ticket →
The place this got here from
I draw a variety of strains. For the final whereas, my go-to has been makio-meshline, a TSL-powered meshline library by David Ronai for the WebGPU renderer that actually does nearly all the pieces you’d need: gradients, dashes, textures, sprint offsets, instancing, GPU-driven positions, even shadow casting. If you happen to’re doing line work in three.js on WebGPU, it’s pretty.
There’s one factor it may possibly’t do, and it isn’t the library’s fault. Its materials extends MeshBasicNodeMaterial, so it’s unlit by development. And when you discover that, you discover it in every single place. Your strains by no means catch a rim gentle, by no means shade throughout their width, by no means fairly really feel like they’re in the scene quite than painted on high of it.
That’s not a bug you’ll be able to patch. A meshline locations each vertex twice and pushes the pair aside in clip house, perpendicular to the road’s path on display screen. By the point the geometry exists, you’ve already left the world. There’s no floor, so there’s no regular, and there’s nothing for a light-weight to hit. The ribbon all the time faces you. That’s the entire trick.
So I had this picture caught in my head: two open arms drawn fully out of thread, fraying into unfastened strands beneath the wrists. I constructed it with meshlines first and it seemed good, however flat. I wished the threads to be issues. I wished them to catch gentle, to occlude one another correctly, to have quantity.
Meaning actual geometry. Meaning tubes. This text is in regards to the tube system that got here out of that, the arms constructed on high of it, and a genuinely humbling detour into why one line of vector maths refused to work.
Three bits I feel are price stealing:
- A tube whose geometry is by no means rebuilt. Positions and normals are computed in TSL, on high of a traditional PBR materials.
- The cross-section body drawback, and why you mathematically can’t totally win it.
- Authoring curves by strolling a mesh, so strands describe a form as a substitute of adorning it.
It’s all TSL on the WebGPU renderer, however the concepts port to plain GLSL simply high quality.
The apparent method, and why it doesn’t match
The usual reply for “line with quantity” is THREE.TubeGeometry: pattern a curve on the CPU, construct a hoop of vertices round every pattern, then add the entire thing.
Which is nice as soon as. My threads transfer each body. Rebuilding and re-uploading a couple of hundred thousand vertices per body on the CPU is strictly the work you acquire a GPU to keep away from.
So the objective grew to become easy: the triangles by no means change. Solely the place they’re.
A tube that by no means rebuilds
Right here’s the shift that makes all the pieces else fall out. What I add isn’t a tube. It’s a grid that has no thought what form it’s:
progress— how far alongside the curve this ring sits, 0 → 1angle— the place across the cross-section this vertex sits, 0 → 2π- indices stitching quads between neighbouring rings
That’s it. No significant positions (a zeroed attribute retains the pipeline completely satisfied), and no significant normals. For 90 segments × 3 radial sides, that’s a couple of hundred vertices of pure parameter house.
for (let i = 0; i <= tubularSegments; i++) {
const t = i / tubularSegments
for (let j = 0; j <= radialSegments; j++) {
progress.push(t)
angle.push((j / radialSegments) * Math.PI * 2)
}
}
The precise form exhibits up at render time. The fabric takes a curve sampler, a TSL perform that maps progress to a place, and works out all the pieces else per vertex:
// The entire contract: t in [0,1] → vec3. All the pieces else is derived.
const sampleCurve = (t) => gpuPositionNode(clamp(t, 0, 1))
this.positionNode = Fn(() => {
const P = sampleCurve(aProgress)
const Pnext = sampleCurve(aProgress.add(EPS)) // EPS = half a section
const Pprev = sampleCurve(aProgress.sub(EPS))
// Central distinction provides us the tangent, with out ever storing one.
const delta = Pnext.sub(Pprev)
const tangent = delta.div(max(delta.size(), float(1e-6)))
// Construct a body round that tangent (that is the enjoyable half, see beneath)
const N = normalize(cross(upAxis, tangent))
const B = cross(tangent, N)
// Sweep the ring. The radial path IS the floor regular.
const radial = N.mul(cos(aAngle)).add(B.mul(sin(aAngle)))
vTubeNormal.assign(radial)
return P.add(radial.mul(radius))
})()
this.normalNode = Fn(() => transformNormalToView(normalize(vTubeNormal)))()
Two issues listed below are doing the heavy lifting.
The conventional is free. The path you push a vertex away from the backbone is the floor regular there. Write it to a various, hand it to the fragment stage by way of normalNode, and also you’re accomplished. That’s what lets this journey on MeshStandardNodeMaterial as a substitute of a bespoke shader. We override positionNode and normalNode and nothing else, so lights, setting maps, roughness, metalness, and shadow passes all simply preserve working. In contrast to the meshline, we by no means left world house, so nothing needed to be faked.
The curve is a perform, not information. gpuPositionNode(t) is usually a helix in three strains of TSL, curl noise, or a spline studying from a storage buffer. Animating prices nothing on the CPU. Change a uniform and each vertex re-derives its personal place, tangent, body, and regular. Radius is a perform too: radiusFn(r, progress, angle). That seems handy you the load-in animation without spending a dime later.
One gotcha: with positions dwelling within the shader, the CPU-side bounding field is a lie. Ship it with frustumCulled = false, or the mesh will cull itself into nonexistence the primary time you orbit.
The half the place I used to be incorrect 3 times
All the pieces above labored on the primary go besides one harmless line, cross(upAxis, tangent). I wish to stroll via this correctly as a result of I bought it incorrect 3 times, and every failure taught me one thing I hadn’t seen written down.
The issue: to comb a hoop round a curve, you want two perpendicular instructions at each level, a body. TubeGeometry does this with parallel transport: stroll the curve, carry the earlier body ahead, and rotate it as little as potential at every step. That’s sequential. Body n wants body n−1, and a vertex shader has no “earlier vertex”. Each vertex is by itself, with solely the tangent to work from.
Right here’s the identical helix rendered 3 ways. That is the entire detour in a single image:

Try 1: the branchless orthonormal foundation (Duff et al., the Pixar one everyone makes use of). Quick, elegant, strong in isolation. It additionally accommodates a signal(tangent.z), which suggests the body flips at any time when a curve crosses the tangent.z = 0 aircraft. Two neighbouring rings straddling that aircraft get a 180° twist between them, and the quad connecting them pinches right into a bow-tie.
The center picture is that this, and it’s a tidy little proof: a helix’s tangent has z ∝ cos(a), which crosses zero precisely 4 occasions over its two turns. There are precisely 4 pinches, all sitting on the centre line as a result of that’s the place the crossings occur to land in house.

Try 2: mix between two reference axes. Use (0,1,0), ease over to (0,0,1) because the tangent goes vertical. Sounds wise! I even talked myself into believing the mix was protected.
It’s not. Someplace mid-blend, the blended axis itself passes straight via the tangent, at |tangent.y| ≈ 0.93 with my explicit easing, and cross(up, tangent) collapses to zero. Take a guess which band of instructions near-vertical hand strands spend all their time in.
That is the one failure that isn’t within the image above, and that’s the attention-grabbing half: it doesn’t produce a clear artifact. Duff provides you a well-behaved incorrect body. A collapsed body provides you a normalised near-zero vector, which is to say an arbitrary path, which is to say brilliant rubbish. On the arms it seemed like glitter, lots of of tiny blown-out slivers scattered via the weave. I spent an embarrassing period of time attempting to find it within the lighting and the bloom earlier than it occurred to me that the geometry was mendacity. I solely discovered the precise collapse level by writing a ten-line numerical sweep, after my geometric instinct had confidently assured me the mix couldn’t probably cross the tangent.

Try 3: choose the world axis least aligned with the tangent. This one genuinely can’t degenerate. The smallest part of a unit vector is at most 1/√3, so the cross product all the time has one thing to work with. I used to be happy with it. I checked it on the arms, the place it appears flawless, and moved on.
Then I put it on the helix (right-hand picture). Wherever two tangent elements tie, the chosen axis switches and the body snaps, and the tube breaks into segments. You possibly can see all of it the best way alongside, with the worst artifacts on the bends the place you’ll be able to watch the cross-section step sideways. It appears like a size of bamboo.
The explanation it handed on the arms is simply that the strands there are hair-thin. There aren’t sufficient pixels throughout a strand for a rotated cross-section to point out up. Which is a small lesson in its personal proper about what you take a look at on. The artifact was all the time there; my take a look at topic simply couldn’t specific it. And even the place it hides, it’s nonetheless a discontinuity within the floor parameterisation, ready to tear the primary texture mapped throughout it.
At which level the precise fact is price saying out loud: no stateless body could be steady for each potential tangent path. That’s the furry ball theorem. You possibly can’t comb a sphere flat. Any rule that maps a path to a perpendicular has to interrupt someplace on the sphere of instructions. You don’t get to take away the singularity. You solely get to decide on the place it sits and the way large it’s.
That reframes the entire thing, from “discover the appropriate method” to “put the failure someplace my content material by no means goes”. And the reply finally ends up being the only of the 4:
// One mounted reference axis. Singular ONLY the place the curve runs precisely
// parallel to it — two factors on the sphere. Not a aircraft. Not a set of seams.
const N = normalize(cross(upAxis, tangent))
const B = cross(tangent, N)
upAxis is a parameter, defaulting to +Z, as a result of strand-y content material (hair, grass, kelp, these arms) runs principally vertically. A +Y reference would park each single curve proper on high of the singularity. The arms by no means produce a wonderfully Z-aligned tangent, so the unhealthy case merely by no means renders. Helix: clear. Arms: clear. And the twist that would present up on a Z-running curve is invisible anyway on a spherical untextured tube, as a result of a rotated circle continues to be a circle.
If you happen to ever want frames secure sufficient for textures on arbitrary curves, the actual improve is computing parallel-transport frames in a compute cross. Strolling ~100 samples sequentially is nothing for a single workgroup, and you may learn them within the vertex shader. For strand-like work, one cross product is lots.
All three modes are nonetheless within the entity behind a frameMode change as a result of their failures are visible, and evaluating them facet by facet is the one sincere solution to see it. Which can also be how the image above was made.
Feeding it actual curves
Procedural curves are enjoyable, however the arms want authored ones. Every strand is 49–129 management factors in a storage buffer, and the sampler reads them with a Catmull-Rom spline:
const positionNode = Fn(([progress]) => {
const f = progress.mul(float(SEGMENTS))
const i0 = int(flooring(f))
const u = f.sub(flooring(f))
const base = instanceIndex.mul(int(SEGMENTS + 1)) // this strand's slice
const at = (ok) => factors.ingredient(base.add(clampIndex(i0.add(int(ok))))).xyz
const p0 = at(-1), p1 = at(0), p2 = at(1), p3 = at(2)
// ...customary Catmull-Rom, then wind / interplay displacement on high
})
Price flagging why it’s a spline and never a combine(): with a meshline, you’ll be able to completely get away with linear interpolation between management factors as a result of a stroke has no cross-section to deform. A tube punishes it instantly. Each management level turns into a visual side as a result of the body is constructed from the spinoff, and a polyline’s spinoff jumps at each joint. Easy positions aren’t sufficient. You want a easy spinoff.
Since instanceIndex picks which slice of the buffer to learn, one instanced draw renders all 500+ strands. Identical grid, one buffer, a distinct curve per occasion. Wind, curl, pointer interplay, and the clicking shockwave are all simply displacement added after the spline pattern. None of it touches the CPU.
Drawing arms by strolling a mesh


That’s the pair above: the identical digicam twice, displaying the plain hand mannequin the strands are walked throughout, after which what truly will get drawn. The strands are generated as soon as on the CPU by strolling that mannequin. The hole between “threads adorning a hand” and “threads describing a hand” turned out to stay fully in the way you steer the stroll.
Work on a graph, not triangles. Weld the geometry’s duplicate vertices (UV seams cut up them) and document every vertex’s neighbours. All the pieces else occurs on that graph.
A geodesic distance discipline is the compass. One Dijkstra cross out from the wrist vertices provides each vertex its distance alongside the floor from the wrist. The gradient of that discipline factors “towards the fingertips” in every single place: across the thumb, throughout the palm, over a knuckle. No world-space path can try this. Good bonus: the native maxima of the sector are the fingertips, in order that’s how the code finds them. No handbook markers on the mannequin.
Circulation strands descend the sector. Round 500 walks begin close to fingertips and step neighbour to neighbour, scoring candidates on how a lot they descend the sector, plus momentum, plus a per-strand sideways bias, plus jitter. The end result reads like tendons as a result of the sector encodes the hand’s actual topology. Then every stroll is resampled to a set level depend (the GPU buffer desires uniform strides), and those that attain the wrist preserve going. A procedural tail continues the final path, bending towards straight down with a little bit of amassed wobble. That’s the fraying cascade on the backside.
Wander strands cowl what circulate misses. Descending walks all need the identical ridges, which leaves the again of the hand naked. So a second household of ~1000 longer walks provides a protection time period. A rough occupancy grid over the floor penalises floor that earlier walks already claimed, nudging every new thread towards empty patches. They find yourself wrapping the shape like thread wound spherical a mould. Their native crowding will get baked into the buffer’s 4th channel, so the shader can skinny strands precisely the place the weave piles up. In any other case the wrist fuses right into a strong shell.
Small selections that punched above their weight
The reveal is a radius, not a fade. The load-in animation gates every strand’s radius alongside progress: forward of the sweeping entrance, the radius is just zero. No opacity, no transparency sorting, no mix price. The fabric stays totally opaque, and strands develop out of the fingertips as a substitute of fading in. That’s radiusFn incomes its preserve. The tube entity didn’t change in any respect.
Bloom reads a G-buffer, not the body. Emissive goes to its personal MRT goal, and bloom blurs solely that. The pale weave stays crisp whereas accent strands glow. About 12% of strands are accented, chosen by hashing their seed, with brilliant packets working alongside them (a sharpened sine of progress and time). One lure: with that setup, supplies that shouldn’t glow should actively write black to the emissive goal. Unlit issues that ought to glow, such because the core sprite and the mud, need to route their color into it explicitly by way of their very own mrtNode. Neither occurs by default.
Interplay lives in display screen house. Every strand level initiatives itself to NDC within the vertex shader and measures its distance to the cursor on display screen, so the comb sits below the mouse from any digicam angle. A world-space radius solely strains up from one viewpoint. The push follows pointer velocity, so threads get swept alongside a stroke quite than shoved away from a degree. And the clicking shockwave is one uniform, an age reset to 0 on click on, learn independently by three totally different shaders: the strand displacement ring, their emissive flash, and the core’s flare. They’re in sync as a result of they’re actually the identical clock.
The digicam eases frame-rate-independently. Orbit, hover parallax, and wheel zoom all chase targets with ok = 1 − e^(−damping · dt). A plain lerp issue eases visibly faster on a 120Hz display screen than on a 60Hz one. The exponential is one exp() and behaves the identical in every single place.
Numbers
- ~1,500 tube cases throughout two strand households, as much as 129 management factors every
- Tube grid: 90 tubular × 3 radial segments. Triangular cross-sections, as a result of at this thickness lighting sells roundness lengthy earlier than geometry must
- All movement within the vertex stage; per-frame CPU work is a couple of uniform writes
- CPU authoring (graph + Dijkstra + ~1,500 walks) runs as soon as at load and is cached throughout parameter tweaks
- Put up: emissive-only bloom, then SMAA. Price realizing that renderer-level MSAA quietly does nothing when you render via a submit pipeline, because the scene by no means reaches the default framebuffer
Wrapping up
If there’s one takeaway, it’s this: transfer the definition of your geometry into the shader, not simply its animation. As soon as a tube is “a grid of parameters plus a perform”, the costly a part of animated tubes disappears, and the expressive elements, radius as an animation channel, curves as buffers, normals as a by-product, all fall out of a single positionNode.
And when a method retains preventing you the best way these frames fought me, generally the theory is telling you which you can’t win in every single place. The precise craft is choosing the place to lose.









