TSL Volumetric Clouds & Raymarching
This is not a texture, nor a model. It’s a volumetric cloud computed purely with math inside a cube. In this chapter we use TSL to break the complex Raymarching algorithm into simple building blocks, sculpting a flowing nebula inside a box step by step.
This example is built around Loop、 normalize、 positionWorld、 cameraPosition、 select、 frontFacing、 Var、 lengthSq、 smoothstep、 rotate、 max、 mx_fractal_noise_float、 time、 hash、 screenUV、 mix、 powand other TSL nodes to implement volumetric rendering.
1. Core concept: What is Raymarching?
Raymarching sounds intimidating to many people. In short, we shoot a bunch of rays from the camera and let them “walk” through space step by step (a loop), sampling density along the way. Summing up that density gives us a volumetric look. What we need are four steps:
- Create a box (limit the volume region).
- Shoot rays (define direction and origin).
- Sculpt shapes (use math to define where matter exists in space).
- Add noise (make the shapes look like clouds or fog).
2. Stage setup & performance tuning
Volumetric clouds are “GPU killers”. When setting up the scene in R3F, there are several key points you must respect, or the framerate will tank.
- WebGPU first: initialize WebGPURenderer – this is a prerequisite for using TSL. Don’t forget await r.init().
- Color space: set r.outputColorSpace = THREE.SRGBColorSpace, otherwise colors will look washed out. This is easy to miss.
- DPR cap (critical): clamp the Canvas dpr to [1, 1.5]. Running at full resolution on high-dpi screens (like Retina) is extremely expensive. Sacrificing a bit of sharpness for a stable framerate is absolutely worth it.
const TslCloud = () => {
return (
<Canvas
// ... style (全屏黑色)
dpr={[1, 1.5]} // Perf-critical: limit resolution to avoid lag on hi-dpi screens
camera={{ position: [0, 0, 3], fov: 50 }}
gl={async (props) => {
const r = new WebGPURenderer(props)
await r.init() // WebGPU must be initialized explicitly
r.outputColorSpace = THREE.SRGBColorSpace // Keeps colors from looking washed out – easy to forget!
return r
}}
>
<OrbitControls />
<FoggyBox />
</Canvas>
)
}3. Material scaffold & the “fish tank”
We first place a 2x2x2 box as our “fish tank” – the cloud is constrained inside it. When building the material there is one performance trap and two rendering gotchas to watch out for.
- Performance trap (must avoid): wrap the material construction in useMemo. Building a TSL node graph is expensive; if you do it in render, React will rebuild the whole shader on every re-render. Make sure it only “compiles” once during init.
- DoubleSide: the material side must be DoubleSide, otherwise once you move inside the box everything disappears.
- DepthWrite: depthWrite must be false. Writing depth for transparent objects causes weird occlusion issues.
const FoggyBox = () => {
// Perf-critical: make sure the complex material only “compiles” once
const material = useMemo(() => buildFoggyMaterial(), [])
return (
<mesh material={material}>
{/* 2x2x2 “fish tank” box that bounds the cloud */}
<boxGeometry args={[2, 2, 2]} />
</mesh>
)
}
const buildFoggyMaterial = () => {
// ... TSL 逻辑 (稍后实现)
const mat = new MeshBasicNodeMaterial({
transparent: true, // Transparency must be enabled
side: THREE.DoubleSide, // Important: still visible when the camera goes inside the box
depthWrite: false, // Important: disable depthWrite for transparency to avoid occlusion bugs
})
// mat.colorNode = rayMarchMain()
return mat
}4. Raymarching: shooting and marching
Now we enter the core math. We’ll implement the main raymarching loop inside a TSL function (Fn). Don’t worry, we’ll do it step by step.
- Compute direction rd: target point (positionWorld) minus camera position (cameraPosition), then normalize.
- Choose rayOrigin: here’s a neat trick – use select(frontFacing, ...) to branch. If you’re outside the box (front faces), the origin is the box surface; once you go inside (back faces), the origin must immediately switch to the camera position, otherwise the cloud disappears.
- Prepare variables (classic newbie pitfall): mutable values must be wrapped in Var. Both the probe position p and the density accumulator densityAccum should be Var; otherwise the shader treats them as constants and you can’t update them in the loop.
- Start the loop: run Loop 12 times (sample count). The core operation is p.addAssign(...): move the probe p along rd by 0.25 each step. That’s the “march” in raymarching.
const rayMarchMain = Fn(() => { // TSL main logic wrapper function
// 1. 算方向 rd
const rd = normalize(positionWorld.sub(cameraPosition)) // Ray direction: target point − camera position, then normalize
// 2. 定起点 rayOrigin
const rayOrigin = select(frontFacing, positionWorld, cameraPosition) // Ray origin: smartly switches based on whether the camera is inside or outside the box (frontFacing)
// (Dithering 稍后加入)
const startPos = rayOrigin
// 3. 准备变量
const p = Var(startPos) // TSL pitfall: mutable variables must be wrapped in Var (probe position)
const densityAccum = Var(float(0.0)) // Accumulated total density (Var)
// (pillarAccum 稍后加入)
// 4. 启动循环
Loop({ start: 0, end: 12 }, () => { // Core: raymarch loop (12 samples)
// ... (在循环中采样形状)
p.addAssign(rd.mul(0.25)) // March step: advance 0.25 along ray direction
})
// ...
})5. Geometric sculpting: sphere & light beam
Inside the loop we now use math formulas to define shapes. Here we’ll add a sphere and a tilted light beam.
- Sphere (perf trick): use lengthSq(p) to compute squared distance instead of length. Square roots are expensive, so skip them when you can. Then use smoothstep(1.0, 0.0, ...) in reverse so density increases as distance decreases.
- Beam (moving the coordinate system): we don’t move the object, we move the coordinates. Use sub to offset and rotate to tilt the axes—as if you slant the whole space—to control the beam angle.
- Beam (shape & falloff): define the cylinder by distance in the XZ plane. The key is heightFade using abs(p.y) so the beam fades out near the top and bottom. That makes it feel like focused energy instead of a rigid stick.
- Combine: use max(shapeSphere, shapeBeam) to merge them into totalShape. Remember to update whatever you accumulate with this.
Loop({ start: 0, end: 12 }, () => {
// 1. 球体
const distSphereSq = lengthSq(p) // Squared distance (faster than length, no sqrt, perf trick)
const shapeSphere = smoothstep(1.0, 0.0, distSphereSq) // Sphere density (reverse smoothstep: closer → denser)
// 2. 光柱(移动坐标系)
const pLocal = sub(p, vec3(0.1, -0.15, 0.35)) // Local coordinates: offset from world position
const pRotated = rotate(pLocal, vec3(0.4, 0, -0.6)) // Rotated coordinates: rotates axes (controls beam angle)
// 3. 光柱(形状与衰减)
const distAxisSq = lengthSq(vec2(pRotated.x, pRotated.z)) // Distance to beam axis (XZ plane, infinitely long cylinder)
const heightFade = smoothstep(0.9, 0.0, abs(pRotated.y)) // Height fade: lets the beam naturally vanish at both ends (Y axis)
const shapeBeam = smoothstep(0.04, 0.0, distAxisSq)
.mul(heightFade)
.mul(7.5) // Beam density (includes height fade and intensity 7.5)
// 4. 融合
const totalShape = max(shapeSphere, shapeBeam) // Combined sphere + beam shape (max)
// (暂时累加纯几何形状)
densityAccum.addAssign(totalShape.mul(0.2))
p.addAssign(rd.mul(0.25))
})At this point the preview shows the beam cutting through the sphere, but the result still looks like a blurry white plaster lump—stiff and monotonous, with no feeling of gaseous flow yet.
6. Adding soul: fractal noise & flow
Right now the cloud looks plasticky. We need noise to break it up.
- Animation: use time to create a flow offset animOffset.
- Sample noise: call mx_fractal_noise_float with p.add(animOffset) as the coordinate. The 1.7 parameter controls how “fluffy” the cloud is.
- Erode shapes (critical moment): first normalize the noise to 0–1, then use it to erode the original geometry (cloudDensity = totalShape.mul(normalizedNoise)).
- Accumulate: add this noisy density into the accumulator.
// Loop 外部:
const animOffset = vec3(0.5, 0, 0).mul(time) // Time-based offset that makes the cloud flow
// Loop 内部:
Loop({ ... }, () => {
// ... (totalShape 计算)
// 1. 采样噪声
const noiseCoord = p.add(animOffset).mul(1.7) // Animated noise coordinates (1.7 controls fluffiness)
const noise = mx_fractal_noise_float(noiseCoord, 2, 2.0, 0.5) // Fractal noise
const normalizedNoise = noise.mul(0.5).add(0.5) // Noise normalized to 0–1
// 2. 侵蚀形状
const cloudDensity = totalShape.mul(normalizedNoise) // Key “soul” step: use noise to erode the geometry
// 3. 累加
densityAccum.addAssign(cloudDensity.mul(0.2)) // Add to density accumulator (0.2 is the density factor)
p.addAssign(rd.mul(0.25))
})
That single line changes everything: the rigid geometry breaks apart and becomes a living volumetric cloud, full of internal detail and motion. That’s the charm of math shaders.
7. Free optimization: Dithering away the banding
If you look closely, you may notice ugly “tree-ring” bands. That’s because for performance we only sample 12 times, so the march steps are wide and the gaps show. Can we fix this for free? Yes—using Dithering.
The idea is simple: compute a random dither value from hash(screenUV), then jitter each pixel’s starting point startPos by up to one step (0.25).
// 在 Loop 外部,修改起点定义:
// Random noise based on screen UV for Dithering
const dither = hash(screenUV)
// Ray origin: smartly switches based on whether the camera is inside or outside the box (frontFacing)
const rayOrigin = select(frontFacing, positionWorld, cameraPosition)
// Final start position after Dithering (jittered origin)
const startPos = rayOrigin.add(rd.mul(0.25).mul(dither))
const p = Var(startPos) // TSL pitfall: mutable variables must be wrapped in Var (probe position)See? The bands vanish instantly. The sample count is still 12, but because each pixel starts at a different position, the error turns into fine noise that your eyes automatically smooth out. This is a classic graphics trick: trading tiny cost for big quality gain.
8. Color grading: deep nebula & energy
Now we color it. We use the accumulated density d and the pillar ratio pFactor to mix colors and create depth and a premium look.
- Base tone (cool): use density d as a palette to blend a pale gray-blue with a deep ocean blue. Note: finalRGB must be declared with let, not const.
- Energy (translucency): use pow(d, 4.0) to light up the densest areas. We don’t want blinding white; raising to the fourth power confines highlights to the core so it feels like energy glowing from within.
- Separating the beam (lifesaver detail): track pillar density pillarAccum separately in the loop. When computing its ratio, write the denominator as max(..., 0.001). Never divide by zero, or the shader will produce NaNs and you’ll get black holes in the image.
- Coloring the beam (warm–cool contrast): convert the pillar density into a pFactor mask and use mix to blend in a fresh mint green. This contrast keeps the shader from feeling flat.
// --- Loop 外部 (初始化) ---
const pillarAccum = Var(float(0.0)) // Beam-only density accumulator (Var, used for coloring)
// --- Loop 内部 (光柱分离) ---
// Beam share within total density
const beamRatio = shapeBeam.div(max(totalShape, 0.001)) // Lifesaver detail: avoid division by zero and NaN-induced black holes
// Accumulate beam-only density
pillarAccum.addAssign(cloudDensity.mul(0.22).mul(beamRatio))
// --- Loop 结束后 ---
const d = smoothstep(0.0, 1.0, densityAccum) // Final total density (normalized)
const pFactor = smoothstep(0.0, 1.0, pillarAccum) // Beam color mix factor (mask)
// 1. 基调
let finalRGB = mix(color("#aabcee"), color("#2f8ac6"), d) // Base mix: blend dark and light blue by density (remember to use let)
// 2. 光柱上色
finalRGB = mix(finalRGB, color("#9ad6b4"), pFactor) // Beam color mix (mint green, warm–cool contrast)
// 3. 能量感
finalRGB = finalRGB.add(vec3(pow(d, 4.0).mul(0.8))) // Energy glow: pow(d, 4) for core highlights and translucency
return vec4(finalRGB, d) // Final output: color + alpha
9. Summary & recap
Congrats on finishing this complex volumetric cloud! Once you stitch all the previous steps together, you get the final flowing nebula. Let’s recap the key ideas:
- Performance first: we capped DPR and used useMemo to keep the effect running smoothly.
- Raymarching: we implemented the core logic with Loop and Var, and used select to choose the ray origin intelligently.
- Math modeling: we used smoothstep, lengthSq and coordinate rotation to define complex geometry.
- Adding soul: we used mx_fractal_noise_float to break up rigid geometry and time to make it flow.
- Quality boost: we used Dithering (hash) to remove low-sample banding essentially for free.
- Artful grading: we separated densities and used pow and mix to build layered nebula colors.