Enhancing 3D Rendering: Exploring Lit GPU Tubes with TSL and WebGPU

Sep 07, 2026 930 views

Drawing With Light: An Exploration of Lit GPU Tubes with TSL and WebGPU

This article recounts the evolution from a simple meshline habit to a sophisticated tube renderer, highlighting the mathematical missteps that shaped the journey.

Editor’s Note: We are thrilled to mark the inaugural Three.js Conference with this work that embodies the creativity and innovation within our community. Mathis Biabiany's stunning investigation into lit GPU tubes, TSL, and WebGPU is a testament to his dedication and curiosity. We are thankful for his willingness to share this insightful project, and we hope you find the intricate details as captivating as we did.

🇫🇷 The celebration continues in Paris! Join the first-ever Three.js Conference, featuring two days of inspiring talks and networking opportunities. Use code CODROPS to receive 15% off when buying your ticket →

Origins of the Project

I've been on a journey of drawing lines, frequently using the makio-meshline library by David Ronai. This TSL-based library offers an impressive range of features from gradients to shadow casting, and is well-suited for line work in three.js on the WebGPU. However, it has one limitation: its materials are unlit by design, stemming from the way they extend MeshBasicNodeMaterial. Once you recognize this, it becomes evident that your lines lack depth; they don’t interact with scene lighting, making them feel flat and out of place.

It dawned on me that this limitation stems from the geometry itself. A meshline places vertices in a manner that they exist only in clip space, creating an illusion rather than true depth. To bring my vision to life—specifically a pair of hands drawn with fraying threads—I aimed to create a more tangible sense of volume and realism. This required employing genuine geometric structures, specifically tube-like forms. This article unveils the tube system born from this vision, detailing the hands created above it and the valuable learning moments encountered during development.

Here are three key insights that emerged throughout this process:

  1. A tube designed so its geometry remains unchanged, with positions and normals calculated dynamically in TSL using a standard PBR material.
  2. Addressing the cross-section frame problem and recognizing its inherent complexities.
  3. Constructing curves via walking a mesh, which allows strands to embody rather than merely embellish shapes.

All these applications utilize TSL on the WebGPU renderer, interestingly, the concepts can also be adapted to standard GLSL without much hassle.

Why Traditional Methods Fall Short

The common solution for rendering a "line with volume" is to use THREE.TubeGeometry. This approach samples a curve on the CPU, generating a series of vertices around each sample for rendering. While this works adequately for static models, my threads animate every frame, making the CPU's burden of rebuilding and re-uploading thousands of vertices per frame exceptionally counterproductive. Thus, my goal became clear: keep the geometry static; only adjust where it resides.

Revolutionizing Tube Geometry

The pivotal realization that ushered in other solutions was recognizing what I was actually uploading wasn’t a traditional tube but rather a grid unbound to any specific shape:

  • progress — signifying the curve's position, ranging from 0 to 1
  • angle — determining the vertex's position around the tube's cross-section, ranging from 0 to 2π
  • Indices linking the quads of adjacent rings

This framework has no intrinsic shape properties, merely essential parameters to keep the graphics pipeline operational. For a typical setup of 90 segments by three radial sides, this totals only a few hundred vertices derived purely from parameter space.

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)
  }
}

At the moment of rendering, the actual shape is revealed. The material utilizes a curve sampler, a TSL function that correlates progress to a position while computing all necessary attributes per vertex:

// The full formula: t in [0,1] → vec3. All other variables are 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 segment
  const Pprev = sampleCurve(aProgress.sub(EPS))

  // Central difference provides tangent without storing it.
  const delta   = Pnext.sub(Pprev)
  const tangent = delta.div(max(delta.length(), float(1e-6)))

  // Construct a frame around that tangent (the exciting part, detailed below)
  const N = normalize(cross(upAxis, tangent))
  const B = cross(tangent, N)

  // Sweep the ring. The radial direction is the surface normal.
  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)))()

The two main approaches that drive this system are:

The normal is inherently defined. The radial direction extending from the spine is naturally the surface normal. Simply record this in a varying variable and pass it to the fragment stage via normalNode.

The curve is defined by a function, not as static data. gpuPositionNode(t) can describe a helix, curl noise, or read from a buffer, allowing for fluid animation without taxing the CPU. Adjust any uniform, and every vertex recalculates its position, tangent, and normal without additional overhead. Interestingly, the radius is also a function, supporting dynamic animations effortlessly.

A caveat arises concerning the CPU-side bounding box, which can give misleading information since positions exist solely in the shader. Enable frustumCulled = false to avoid accidental culling when orbiting the camera.

Math Puzzles: Lessons Learned

While much of this framework operated correctly initially, a specific line—cross(upAxis, tangent)—presented several challenges that I unraveled with each attempt, yielding unexpected insights along the way.

The challenge lies in sweeping a ring around a curve, requiring two orthogonal directions at each curve point—a concept known as a frame. TubeGeometry effectively achieves this through parallel transport, updating the previous frame as the curve is traversed. However, this approach encounters complications since a vertex shader operates independently for each vertex, without access to the prior frame.

The following images reveal the various attempts I made using different methods, each illustrating the complexities associated with the tangent direction:

My first approach utilized a branchless orthonormal basis. This method is efficient and elegant but includes a sign(tangent.z) element causing a frame flip when crossing the tangent.z = 0 plane. As a result, surrounding quads connected to these rings experience a 180° twist, leading to noticeable artifacts.

The accompanying image visually demonstrates this phenomenon during a helix rendering, revealing four pinched areas resulting from the tangential crossings.

Second Attempt: Blend Between Two Reference Axes. I aimed to transition from (0,1,0) to (0,0,1) as the tangent ascended vertically. This seemed reasonable, but the blend turned out to be problematic. At a critical moment during the transition, the blended axis ran directly through the tangent, resulting in a collapsed frame leading to unpredictable direction vectors. The result was a glaring artifact in the form of vivid distortions across the mesh.

Third Attempt: Choose the World Axis at Least Aligned with Tangent. I reasoned this would avoid degeneration. Yet, this still led to frame snapping wherever the tangent direction fluctuated, visibly breaking the continuity of the tube. This issue became apparent when adjacent points in the helix produced unexpected jumps, resembling segments of bamboo.

The reason this method seemed to work on the hands was due to the thinness of the strands, which masked the discontinuity. Although it might not have caused visible artifacts on fine threads, the flaw loomed large whenever a texture was applied across the surface.

An important lesson here is that no stateless frame can achieve continuity for all potential tangent directions. This is related to the hairy ball theorem—it's impossible to manage a directional vector without encountering singularities on the sphere of directions. The focus should shift from seeking a “perfect” formula to strategically managing where potential failure points exist and mitigating their effects within acceptable limits for your graphical renderings.

Thus, the refined solution became straightforward:

// A fixed reference axis, singular only when parallel to it
const N = normalize(cross(upAxis, tangent))
const B = cross(tangent, N)

Here, upAxis defaults to +Z due to the vertical nature of strands like hair or grass. Meanwhile, a +Y orientation would leave most curves directly aligned with singularity issues. Because strands never fully align with the Z-axis, the problem simply does not manifest when rendering. The resulting clean visuals reinforce this idea.

If you require frames stable enough for textures across various curves, an upgrade involves calculating parallel-transport frames in a compute pass, allowing workgroup-level efficiency to generate sequential samples read by the vertex shader. For fine strand-like applications, a single cross-product suffices.

Incorporating Real Curves

While procedural curves are enjoyable, my project demanded authored curves. Each strand comprises between 49 and 129 control points stored in a buffer, sampled via a Catmull-Rom spline:

const positionNode = Fn(([progress]) => {
  const f  = progress.mul(float(SEGMENTS))
  const i0 = int(floor(f))
  const u  = f.sub(floor(f))

  const base = instanceIndex.mul(int(SEGMENTS + 1))   // this strand's slice
  const at = (k) => points.element(base.add(clampIndex(i0.add(int(k))))).xyz

  const p0 = at(-1), p1 = at(0), p2 = at(1), p3 = at(2)
  // ...standard Catmull-Rom, then add wind / interaction displacement on top
})

It's essential to emphasize why I favored splines over mix(). With meshlines, you may interpolate linearly between control points; however, tubes require a more sophisticated approach due to their volumetric nature. Each control point turns into a visible facet, and using smooth positions alone won’t suffice; you also require a smooth derivative for proper rendering.

Thanks to instanceIndex, I can select which segment of the buffer to draw, rendering all 500+ strands in a single instanced draw call. The geometry remains consistent, only differing in the curves they represent. Effects like wind, curls, pointer interactions, and animations can be easily applied without significant CPU overhead.

Crafting Hands Through Mesh Walking

What you see in the comparison above is two perspectives of the same scene. The left depicts the basic hand model, while the right illustrates what is rendered with thorough detail. The core difference emerged from the method of steering the walk across the underlying model, emphasizing that threads can indeed define rather than merely decorate the shape.

Start with a graph instead of traditional triangle geometry. Merge duplicate vertices and keep track of each vertex's connections. Subsequently, all operations should rely on this graph structure.

A geodesic distance field can serve as an invaluable guide. Executing a Dijkstra pass from wrist vertices allows you to calculate distances across the surface which guides the strands toward their endpoints with accuracy—something that world-coordinate calculations often fail to address. Additionally, the distance field’s local maxima conveniently identify fingertip locations without manual input.

### Closing Thoughts When reflecting on the complexities discussed, one key insight stands out: **the revolution lies in transferring the geometry's definition directly into the shader, transcending mere animation tasks.** This shift liberates developers from the traditional constraints of animated geometry, allowing them to think in terms of streamlined parameters and functions rather than clunky, hardcoded structures. With this approach, you’re not just optimizing performance; you’re also unlocking a new level of creative expression. Radial animations, curvature in buffers, and normals crafted as incidental outputs can all merge into a singular *positionNode*. Yet, it's crucial to acknowledge that not every solution will fit every scenario perfectly. As I navigated through the challenges of implementation, it became clear: the nuances of your current setup might signal constraints that are better honored than overcome. Embracing where you might need to accept trade-offs is an underestimated skill in tech—perhaps one of the most vital in mastering this new paradigm. For those of you in the trenches, **don’t be disheartened by moments of frustration; they often point to deeper insights about your project’s architecture.** The real creativity lies in blending practicality with imagination—knowing when to innovate and when to rein in aspirations for the sake of functionality. As the technology continues to evolve, keeping this balance will be essential for your success.
Source: Mathis Biabiany · tympanus.net

Comments

Sign in to comment.
No comments yet. Be the first to comment.

Related Articles

Drawing With Light: An Exploration of Lit GPU Tubes with ...