TSL 体积云与光线步进 (Raymarching)
这不是贴图,也不是模型。这是一个纯粹用数学在立方体里‘算’出来的体积云。本章我们用 TSL 把复杂的光线步进(Raymarching)算法拆成搭积木,一步步在盒子里雕刻出流动的星云。
本示例核心使用了 Loop、 normalize、 positionWorld、 cameraPosition、 select、 frontFacing、 Var、 lengthSq、 smoothstep、 rotate、 max、 mx_fractal_noise_float、 time、 hash、 screenUV、 mix、 pow等 TSL 节点来实现体积渲染。
1. 核心概念:什么是 Raymarching?
很多人听到 Raymarching(光线步进)就头大。简单来说,就是我们从相机发射无数条光线,让光线在空间里“一步一步走”(Loop),沿途不断采样空间的密度。最后把采到的密度累加起来,就得到了体积感。我们要做的就是四步:
- 造个盒子(限制体积范围)。
- 发射光线(确定方向和起点)。
- 雕刻形状(用数学定义空间里哪里有东西)。
- 加上噪点(让形状看起来像云雾)。
2. 舞台搭建与性能优化
体积云是“显卡杀手”。在 R3F 里搭建场景时,有几个关键点必须注意,不然帧率会暴跌。
- WebGPU 优先:初始化 WebGPURenderer,这是玩 TSL 的前提。记得 await r.init()。
- 色彩空间:设置 r.outputColorSpace = THREE.SRGBColorSpace,不然颜色会发灰,这点很容易忽略。
- DPR 限制(关键):Canvas 的 dpr 强制锁定在 [1, 1.5]。在高分屏(如 Retina)上跑满分辨率性能消耗巨大。牺牲一点点清晰度换取流畅帧率,这交易绝对值得。
const TslCloud = () => {
return (
<Canvas
// ... style (全屏黑色)
dpr={[1, 1.5]} // 性能关键:限制分辨率,防止高分屏卡顿
camera={{ position: [0, 0, 3], fov: 50 }}
gl={async (props) => {
const r = new WebGPURenderer(props)
await r.init() // WebGPU 必须显式初始化
r.outputColorSpace = THREE.SRGBColorSpace // 保证颜色不发灰,很容易忽略!
return r
}}
>
<OrbitControls />
<FoggyBox />
</Canvas>
)
}3. 材质骨架与“鱼缸”
我们先放一个 2x2x2 的盒子,当作“鱼缸”,云雾会被限制在这里面。构建材质时,有一个性能陷阱和两个显示要点。
- 性能陷阱(必须避免):必须用 useMemo 包裹材质构建函数。TSL 构建节点图很贵,如果写在渲染循环里,React 每次重绘都会重建整个着色器!要确保只在初始化时“编译”一次。
- DoubleSide:材质 side 必须是 DoubleSide,不然你钻进盒子里就看不见了。
- DepthWrite:depthWrite 必须是 false。透明物体开启深度写入会导致奇怪的遮挡关系。
const FoggyBox = () => {
// 性能关键:确保复杂的材质只“编译”一次
const material = useMemo(() => buildFoggyMaterial(), [])
return (
<mesh material={material}>
{/* 2x2x2 的“鱼缸”,限制云雾范围 */}
<boxGeometry args={[2, 2, 2]} />
</mesh>
)
}
const buildFoggyMaterial = () => {
// ... TSL 逻辑 (稍后实现)
const mat = new MeshBasicNodeMaterial({
transparent: true, // 透明必须开启
side: THREE.DoubleSide, // 关键:保证钻进盒子里也能看见
depthWrite: false, // 关键:透明物体关闭深度写入,避免遮挡错误
})
// mat.colorNode = rayMarchMain()
return mat
}4. 光线步进:发射与循环
进入核心数学区。我们要在 TSL 函数(Fn)里实现光线步进的主循环。别怕,我们一步步来。
- 算方向 rd:公式很简单,用目标点(positionWorld)减去相机位置(cameraPosition),再标准化(normalize)。
- 定起点 rayOrigin:这里有个聪明的逻辑,用 select(frontFacing, ...) 判断。如果你在盒子外面(看正面),起点是盒子表面;一旦钻进盒子里(看背面),起点必须立刻切换到相机位置,不然云就消失了。
- 准备变量(新手死穴):可变变量必须用 Var 包裹!坐标 p 和密度 densityAccum 都要用 Var,否则 Shader 会把它们当常量,后面没法修改。
- 启动循环:Loop 12 次(采样次数)。核心动作是 p.addAssign(...):让探针 p 沿着 rd 方向,每次向前挪动 0.25。这就叫“光线步进”。
const rayMarchMain = Fn(() => { // TSL 主逻辑包裹器
// 1. 算方向 rd
const rd = normalize(positionWorld.sub(cameraPosition)) // 光线方向:目标点 - 相机位置,再标准化
// 2. 定起点 rayOrigin
const rayOrigin = select(frontFacing, positionWorld, cameraPosition) // 光线起点:根据相机在盒子内外智能选择 (frontFacing)
// (Dithering 稍后加入)
const startPos = rayOrigin
// 3. 准备变量
const p = Var(startPos) // TSL 陷阱:可变变量必须用 Var 包裹!(探针坐标)
const densityAccum = Var(float(0.0)) // 累积总密度 (Var)
// (pillarAccum 稍后加入)
// 4. 启动循环
Loop({ start: 0, end: 12 }, () => { // 核心:光线步进循环(12次采样)
// ... (在循环中采样形状)
p.addAssign(rd.mul(0.25)) // 步进:沿光线方向前进 0.25
})
// ...
})5. 几何雕刻:球体与光柱
现在我们在循环里用数学公式来定义形状。这里我们放一个球体和一根倾斜的光柱。
- 球体(性能技巧):用 lengthSq(p) 算距离平方,而不是 length。因为开方运算很贵,能省一点是一点。用 smoothstep(1.0, 0.0, ...) 反向映射,距离越近密度越高。
- 光柱(移动坐标系):我们不移动物体,而是移动坐标系。用 sub 偏移、rotate 旋转坐标轴——就像把整个空间歪过来,决定光柱角度。
- 光柱(形状与衰减):用 XZ 轴距离定义柱体。关键是用 abs(p.y) 算高度衰减(heightFade),让光柱在顶部和底部慢慢消失。这样才像聚焦的能量,而不是生硬的棍子。
- 融合:用 max(shapeSphere, shapeBeam) 把两个形状合起来,得到 totalShape。记得更新累加对象!
Loop({ start: 0, end: 12 }, () => {
// 1. 球体
const distSphereSq = lengthSq(p) // 距离平方(比 length 快,不开方,性能技巧)
const shapeSphere = smoothstep(1.0, 0.0, distSphereSq) // 球体密度(反向 smoothstep:越近越密)
// 2. 光柱(移动坐标系)
const pLocal = sub(p, vec3(0.1, -0.15, 0.35)) // 移动坐标系:偏移
const pRotated = rotate(pLocal, vec3(0.4, 0, -0.6)) // 旋转坐标系:旋转(决定光柱角度)
// 3. 光柱(形状与衰减)
const distAxisSq = lengthSq(vec2(pRotated.x, pRotated.z)) // 到光柱中心轴的距离(XZ平面,无限长柱子)
const heightFade = smoothstep(0.9, 0.0, abs(pRotated.y)) // 高度衰减:让光柱两端自然消失 (Y轴)
const shapeBeam = smoothstep(0.04, 0.0, distAxisSq)
.mul(heightFade)
.mul(7.5) // 光柱密度(乘上衰减和强度 7.5)
// 4. 融合
const totalShape = max(shapeSphere, shapeBeam) // 融合球体和光柱(取最大值)
// (暂时累加纯几何形状)
densityAccum.addAssign(totalShape.mul(0.2))
p.addAssign(rd.mul(0.25))
})这时候看预览,光柱确实穿插进去了。但效果像个模糊的白色石膏块,生硬且单调,还没有“气体流动”的灵魂。
6. 注入灵魂:分形噪声与流动
现在的云太像塑料了,我们需要噪点来打散它。
- 动画:用 time 制造流动位移 animOffset。
- 采样噪声:用 p.add(animOffset) 作为坐标,调用 mx_fractal_noise_float。参数 1.7 决定了云朵的“蓬松度”。
- 侵蚀形状(关键时刻):先把噪点归一化到 0-1。然后用它去“侵蚀”原本的几何体(cloudDensity = totalShape.mul(normalizedNoise))。
- 累加:把带噪点的密度累加起来。
// Loop 外部:
const animOffset = vec3(0.5, 0, 0).mul(time) // 基于时间的动画偏移,让云流动起来
// Loop 内部:
Loop({ ... }, () => {
// ... (totalShape 计算)
// 1. 采样噪声
const noiseCoord = p.add(animOffset).mul(1.7) // 带动画的噪声采样坐标(1.7控制蓬松度)
const noise = mx_fractal_noise_float(noiseCoord, 2, 2.0, 0.5) // 分形噪声
const normalizedNoise = noise.mul(0.5).add(0.5) // 归一化到 0-1 范围
// 2. 侵蚀形状
const cloudDensity = totalShape.mul(normalizedNoise) // 注入灵魂的关键:用噪声“侵蚀”几何形状
// 3. 累加
densityAccum.addAssign(cloudDensity.mul(0.2)) // 累加密度(0.2是浓度系数)
p.addAssign(rd.mul(0.25))
})
就是这一行代码!原本死板的几何体瞬间破碎了,变成了不断流动的、有着丰富内部细节的真实体积云。这就是 Math Shader 的魅力!
7. 免费优化:Dithering 消除条纹
大家仔细看画面,可能会发现难看的“年轮”状条纹。这是因为为了性能,我们只采样了 12 次,导致步进的间隙太大,露馅了。有没有不花钱的优化办法?有,这就是 Dithering(抖动)。
原理很简单:利用 hash(screenUV) 算出一个随机数 dither。然后打乱每个像素的出发点 startPos,偏移量最大不超过一步(0.25)。
// 在 Loop 外部,修改起点定义:
// 基于屏幕 UV 的随机噪声,用于 Dithering
const dither = hash(screenUV)
// 光线起点:根据相机在盒子内外智能选择 (frontFacing)
const rayOrigin = select(frontFacing, positionWorld, cameraPosition)
// 加入 Dithering 后的最终起点 (打乱出发点)
const startPos = rayOrigin.add(rd.mul(0.25).mul(dither))
const p = Var(startPos) // TSL 陷阱:可变变量必须用 Var 包裹!(探针坐标)看!瞬间消除!虽然采样数还是 12,但因为每个像素的起点都不同,误差被转化为了杂色,人眼会自动把它平滑掉。这就是图形学里用低成本换高画质的终极奥义。
8. 调色:深邃星云与能量感
开始上色。我们利用累积的密度 d 和光柱的占比 pFactor 来混合颜色,制造通透感和高级感。
- 基调(冷色):用密度 d 做调色盘,混合淡灰蓝和深海蓝。注意:finalRGB 必须用 let 声明,不能用 const!
- 能量感(通透感):用 pow(d, 4.0) 给最厚实的地方加光。我们不要耀眼的白光。4 次方会把高光限制在最核心区域,让它看起来像是内部蕴含着能量。
- 光柱分离(救命细节):在循环中单独统计光柱密度 pillarAccum。计算占比时,分母必须写成 max(..., 0.001)。千万别除以 0,否则 Shader 会算出 NaN,导致画面出现黑色破洞!
- 光柱上色(冷暖对比):把光柱密度转为因子 pFactor(蒙版),用 mix 叠加上清新的薄荷绿。这种对比让 Shader 看起来不单调。
// --- Loop 外部 (初始化) ---
const pillarAccum = Var(float(0.0)) // 单独累积光柱密度 (Var,用于调色)
// --- Loop 内部 (光柱分离) ---
// 光柱在总密度中的占比
const beamRatio = shapeBeam.div(max(totalShape, 0.001)) // 救命细节:防止除以零!避免 NaN 导致的黑色破洞
// 累加光柱部分密度
pillarAccum.addAssign(cloudDensity.mul(0.22).mul(beamRatio))
// --- Loop 结束后 ---
const d = smoothstep(0.0, 1.0, densityAccum) // 最终总密度(归一化)
const pFactor = smoothstep(0.0, 1.0, pillarAccum) // 光柱颜色混合因子 (蒙版)
// 1. 基调
let finalRGB = mix(color("#aabcee"), color("#2f8ac6"), d) // 基调:根据密度混合深蓝与淡蓝 (注意用 let)
// 2. 光柱上色
finalRGB = mix(finalRGB, color("#9ad6b4"), pFactor) // 叠加光柱颜色(薄荷绿,冷暖对比)
// 3. 能量感
finalRGB = finalRGB.add(vec3(pow(d, 4.0).mul(0.8))) // 能量感:用 pow(d, 4) 制造核心高光和通透感
return vec4(finalRGB, d) // 最终输出:颜色 + 透明度
9. 总结与回顾
恭喜你完成了这个复杂的体积云效果!将前面步骤的代码组合起来,你就能得到最终流动的星云。回顾一下我们实现的关键点:
- 性能优先:通过限制 DPR 和使用 useMemo 保证了流畅运行。
- 光线步进:利用 Loop 和 Var 实现了核心的 Raymarching 逻辑,并用 select 智能选择起点。
- 数学建模:通过 smoothstep、lengthSq 和坐标旋转定义了复杂的几何形状。
- 注入灵魂:使用 mx_fractal_noise_float 打破了生硬的几何感,并通过 time 实现流动。
- 画质提升:利用 Dithering (hash) 免费消除了低采样率带来的条纹。
- 艺术调色:通过分离密度和使用 pow、mix 实现了富有层次感的星云色彩。