17 软粒子:用场景深度淡化烟雾交界
前置知识:第 04—06 章的深度测试与写入、第 14 章的烟片贴图,以及第 16 章的自定义着色器。
本章示例:硬边与软边对照 · 源码。
预期画面:左右两片相同颜色、轮廓与高度的烟雾接近地面和方块。左侧只做普通深度测试,交界处被硬裁;右侧在靠近不透明表面时逐渐变淡。调烟片高度和淡化距离,或切换到线性场景深度预览。
先看现象:图片边缘柔和,交界仍可能很硬
一张烟团图片可以有柔和的 Alpha 外轮廓,但当烟片插入地面,GPU 的深度测试仍逐片元判定“在地面前/后”。测试失败的片元完全不画,交线可能很整齐,暴露烟片是一张平面。右侧的软粒子(soft particle)在片元快碰到已有不透明表面时额外降低 Alpha,让烟雾渐渐融进地面或方块。它不改变烟片的位置,也不是把物体遮挡剔除掉。
页面的两片烟雾有相同形状、颜色、世界尺寸和高度。左侧 uSoftness=0,只依赖默认帧缓冲的深度测试;右侧 uSoftness 由滑杆控制,除了同样的深度测试,还采样不透明场景深度。把淡化距离调大,右侧更早变淡;把烟片抬高,交界淡化区域通常减少。勾选深度预览,地面与方块有灰度、天空为最远深度,且看不到烟片:这明确显示了深度纹理的内容范围。
1. 两次绘制分别承担什么
本例使用同一台透视相机,按如下顺序渲染:
不透明场景 ──渲染到 WebGLRenderTarget──> depthTexture
不透明场景 ──渲染到屏幕───────────> 屏幕颜色 + 屏幕深度
烟片 ──采样 depthTexture───────> 与屏幕已有深度测试、混合
第一步建立带 DepthTexture 的 WebGLRenderTarget,只渲染地面、网格和两个方块;烟片位于另一个 scene,不参加这个预通道。第二步把不透明场景画到默认帧缓冲,形成最终背景及正常的深度测试基准。第三步画透明烟片。示例设 renderer.autoClear=false 并在切换目标时显式 renderer.clear();否则第二个 scene 的渲染可能把上一遍颜色和深度清掉。
const depthTarget = new THREE.WebGLRenderTarget( 1, 1, {
depthBuffer: true,
stencilBuffer: false
} );
depthTarget.depthTexture = new THREE.DepthTexture( 1, 1, THREE.UnsignedIntType );
renderer.setRenderTarget( depthTarget );
renderer.clear( true, true, true );
renderer.render( opaqueScene, camera );
renderer.setRenderTarget( null );
renderer.clear( true, true, true );
renderer.render( opaqueScene, camera );
renderer.render( particleScene, camera );
本教学页为了流程清楚,把不透明场景绘制了两次。正式项目可考虑深度专用预通道、复用场景颜色目标或集成后处理,但必须保证读到的深度对应同一相机、同一帧、同一屏幕像素,且不能一边写一边采样同一附件。
2. 屏幕 UV 与像素比必须对齐
片元着色器用 gl_FragCoord.xy 取得当前片元在绘制缓冲中的物理像素坐标,再除以目标的物理宽高来采样深度纹理:
vec2 screenUv = gl_FragCoord.xy / uResolution;
float sceneDepth = texture2D( tDepth, screenUv ).r;
示例先按画布 CSS 尺寸调用 renderer.setSize(width,height,false),然后用 renderer.getDrawingBufferSize(drawingSize) 获取乘过渲染像素比的实际尺寸;depthTarget.setSize(drawingSize.x,drawingSize.y) 和 uResolution 使用同一数值。页面会显示深度纹理尺寸与像素比。若一边用 CSS 像素、一边用物理像素,烟雾会采到错位的地面或方块深度,交界出现偏移或整片消失。
本例的 DepthTexture 是渲染目标自动附着的深度纹理,默认 flipY=false,与 gl_FragCoord 的窗口坐标方向相匹配。若改用其他深度拷贝或后处理链,坐标方向与视口位置需重新核对;不能把“纹理 v 总要翻转”当通则。
3. 把非线性深度转到同一距离空间
sceneDepth 和 gl_FragCoord.z 都是透视投影后 [0,1] 范围的窗口深度,不是世界单位距离。离相机近的深度值占用的范围更大,直接相减后乘一个世界单位的“淡化距离”没有明确意义。着色器用 r174 的 packing chunk 中 perspectiveDepthToViewZ 分别转换:
float sceneDistance = - perspectiveDepthToViewZ( sceneDepth, uNear, uFar );
float particleDistance = - perspectiveDepthToViewZ( gl_FragCoord.z, uNear, uFar );
float gap = sceneDistance - particleDistance;
float fade = smoothstep( 0.0, uSoftness, gap );
alpha *= fade;
视空间前方物体的 viewZ 是负数,取负后得到沿相机视线方向的正距离。gap>0 表示不透明表面在烟片之后;gap≈0 表示两者接近;gap<0 表示烟片在该表面之后,正常屏幕深度测试通常已挡住它。uSoftness 因而可理解为视空间深度方向的世界单位距离,不等于地面上的水平距离,也不等于像素距离。若改正交相机,需要改用 orthographicDepthToViewZ,并保持近远裁剪面一致。
场景深度为 1 的天空像素对应远裁剪面,gap 很大,烟片不因附近地面而变淡。深度预览使用同一线性化函数,只是把正距离映射成灰度:近处较亮、远处较暗。它不是直接显示原始非线性深度值。
4. 与 depthTest、depthWrite 和透明排序的关系
烟片材质仍设 depthTest:true,所以已经在不透明物体后面的片元由正常深度测试挡掉;软粒子公式处理的是快接近几何表面的可见片元。材质设 depthWrite:false,避免这两片半透明烟互相写入硬深度;Alpha 依普通混合进入屏幕颜色。左侧和右侧各是一个带程序化柔边的平面网格,随相机朝向设置 quaternion。此处柔边轮廓来自片元公式,软交界来自场景深度,两者作用位置不同。
本例的深度纹理只包含明确列入 opaqueScene 的物体。透明窗、另一团烟或玻璃如果没有专门写入预通道,不会自动参与软交界计算。即使透明物体渲染在屏幕上,烟片采到的仍可能是其后方的不透明表面。把透明物体强行写入预通道会改变合成语义,并可能制造错误遮挡,必须按效果分层设计。renderOrder 也不能替代这个深度差公式,更不能对一个 Points 对象内部逐点排序。
5. 软交界的局限与参数实验
软粒子是屏幕空间近似。只有已经渲染进深度纹理的表面能被感知;深度突变处可能产生局部光晕或淡化带;单张烟片仍没有真实厚度,也不会模拟烟和方块之间的流体作用。uSoftness 太小仍像硬边,太大会让烟在远离交点时就显著消失。近远裁剪面过宽、深度格式精度不足、深度采样错位也会影响稳定性。
调参数时先固定相机与高度,只改淡化距离,看右侧亮度如何变化;再固定淡化距离,升降烟片,观察硬边位置与软区宽度。切到深度预览,确认地面与方块确实在纹理中;若预览只有单一颜色,应先查深度通道、目标尺寸及渲染顺序,再查淡化公式。本例动态 resize 会重新设置渲染目标尺寸,避免窗口改变后 UV 与像素错位。
常见问题
为什么把 depthWrite:false 设置好,交界仍硬? 这只禁止烟片改写深度缓冲,不会让通过/失败的深度测试变成渐变。要做软交界仍需场景深度差与 Alpha 衰减。
为什么只采样 texture2D(tDepth,gl_FragCoord.xy) 不行? 纹理采样 UV 要归一化到 [0,1];gl_FragCoord.xy 是物理像素坐标,必须除以实际绘制缓冲尺寸。
为什么两个原始深度值相差 0.01 看起来不对应固定距离? 透视深度非线性。先把两者转成视空间距离,再用相同单位比较。近远裁剪面变动后也应同步更新 uniform。
完成第 17 章练习。继续第 18 章的七种特效配方与第 19 章的群体行为,分别看怎样组合渲染规则,以及邻居依赖怎样改变状态更新。参考:DepthTexture、WebGLRenderTarget、ShaderMaterial。