22 三维 SDF 与 Ray Marching
前置知识:第 18 章的有符号距离、第 19 章的矩形 SDF、第 20 章的组合与距离精度、第 21 章的颜色与法线方向。
本章示例:三维距离场与逐步前进,完整源码。
学完本章能做什么
你将能从三维 SDF 生成球、盒体与地面,给每个片元建立一条射线,按距离场的返回值向前走到表面;还能根据三维距离梯度估计世界空间法线,解释命中阈值、步数上限与阴影射线各自带来的画质和成本变化。
先看现象
示例默认显示球与盒体的硬并集。转动“相机绕 Y 轴”会看到不同的立体侧面;切换“世界空间法线 RGB”后,朝向不同的面呈现不同颜色;“步数热图”把已命中片元的查询次数可视化。把最大步数降到 8,部分射线来不及抵达表面,画面会露出背景。调大命中阈值,轮廓定位变粗。平滑并集的连接处会真实改变由距离场定义的表面,而不是仅模糊颜色。
本例的 Three.js 网格只是覆盖画面的 PlaneGeometry(2,2,1,1)。它有 1 × 1 个分段;球、盒体、地面和它们的明暗都在片元 shader 的虚拟三维场景中算出,没有相应的球或盒体网格。Three.js 的正交相机负责把全屏平面画出来;片元代码中的 ro、rd 才是逐像素射线使用的虚拟相机。以下三维位置、半径、距离和前进步长都以同一套虚拟世界单位计量。
1. 从二维距离扩展到三维
圆的 length(p)-r 把 vec2 换成 vec3 就是球:
// 片元阶段;p 为相对球心的三维世界空间坐标,r > 0。
float sdSphere( vec3 p, float r ) {
return length( p ) - r;
}
在球心 d=-r,表面 d=0,外部 d>0。p 可以先减一个三维中心位置来移动球。盒体沿用第 19 章矩形的“角外算长度、内部取最近面”思路,只是增加 z 轴:
// 片元阶段;p 为相对盒体中心的世界空间坐标,b 为正的半尺寸。
float sdBox( vec3 p, vec3 b ) {
vec3 q = abs( p ) - b;
return length( max( q, 0.0 ) ) +
min( max( q.x, max( q.y, q.z ) ), 0.0 );
}
例如 b=(0.5,0.4,0.3),中心距离为 -0.3;角外点 (0.6,0.5,0.4) 的距离为 √(0.1²+0.1²+0.1²)。水平地面 y=-0.72 则可写成 p.y+0.72:地面上方为正,下方为负。示例的 mapObject(p) 返回当前选中形状的距离,mapScene(p)=min(mapObject(p),p.y+0.72) 再把它和地面合在一起。硬并集的零边界与符号符合第 20 章规则;其内部距离幅值仍可能受隐藏边界影响。本例从外部发射主射线,重点使用的是外部的安全步长。
2. 每个片元先生成一条射线
射线由起点 ro 和单位方向 rd 定义,沿途的点是 ro+t*rd,其中 t>=0。rd 必须归一化,这样 t 才和距离场用同一种长度单位。示例从 UV 得到以画面中心为原点的屏幕坐标,并用绘制区域的宽高比修正横向尺度:
// 片元阶段;screen 是屏幕平面坐标,ro/target 为虚拟世界空间坐标。
vec2 screen = vUv * 2.0 - 1.0;
screen.x *= uAspect;
vec3 forward = normalize( target - ro );
vec3 right = normalize( cross( forward, vec3( 0.0, 1.0, 0.0 ) ) );
vec3 up = cross( right, forward );
vec3 rd = normalize( 1.9 * forward + screen.x * right + screen.y * up );
forward 指向观察目标,right、up 构成与它垂直的方向。中心片元的 screen=(0,0),因此沿 forward 看;边缘片元添加横向或竖向分量。1.9 控制视野宽窄,越大画面中的物体越小。这里的 uAspect 来自 viewport.clientWidth/clientHeight,不是整张网页的比例,也不是第 7 章图片纹理的比例。示例的 JavaScript 用角度控件计算 ro=(4.1*sin(angle),1.55,4.1*cos(angle));角度先由度转成弧度,target 固定在场景附近。若相机正好沿世界上方向看,叉积会退化,本例的轨道高度避免了这种情况。
3. 用距离作为前进步长
对精确的外部 SDF,在当前点沿任意方向前进 d,不会跨过最近的表面:半径为 d 的空球内没有表面。这就是常说的 sphere tracing;“ray marching”是更宽泛的沿射线逐步采样,既可以采用这样的距离步长,也可以采用固定步长。示例每次在 ro+rd*t 查询场,并把返回的 d 加到 t:
// 片元阶段;ro/rd 属于虚拟世界空间,rd 已归一化。
float t = 0.0;
for ( int i = 0; i < 96; i++ ) {
if ( i >= uStepLimit ) break;
float d = mapScene( ro + rd * t );
if ( d < uHitEpsilon ) { /* 命中:保存 t 与步数 */ break; }
t += d;
if ( t > MAX_DISTANCE ) break;
}
用一个可手算的球验证:半径 1、球心原点,ro=(0,0,3)、rd=(0,0,-1)。第一次查询得 d=2,所以跳到 t=2;此时点在球面 (0,0,1),第二次查询得 d=0,判为命中。固定加 2 在其他方向未必安全;当前点的场值才是这次前进的依据。
完整示例的 march 返回 (t, 查询次数, 是否命中)。三种停止条件用途不同:d<uHitEpsilon 表示足够靠近表面;t>MAX_DISTANCE 表示超出可见范围;达到 uStepLimit 表示计算预算用完。后二者返回未命中,显示背景。阈值不是对象大小:设得太大,会在表面前提前命中,轮廓膨胀或接触位置偏移;设得太小,更多射线可能耗尽步数,还可能暴露浮点精度和细节误差。示例允许 0.001~0.020,并把循环的编译上限固定为 96 次,控件只改变实际可用次数。
这个步长逻辑依赖距离不高估到最近表面的距离。球、轴对齐盒体和地面在外部有精确 SDF;硬并集对位于两个形状外的点取最近者。任意噪声位移、非等比缩放或自定义组合可能只给出近似场;若估计值偏大,射线可能一步跨过薄表面。平滑并集在示例中形成新的场,不能因为表达式名字含 SDF 就默认它在所有用途和区域都等同精确欧氏距离。调试时先从单一精确形状和较大的步数预算开始,再加入组合与细节。
4. 在命中点估计三维法线
命中后的位置是 p=ro+rd*t。沿世界空间 x、y、z 分别在点两侧采样,得到场的中心差分梯度:
// 片元阶段;p 为虚拟世界空间的命中点,e 为同单位的小距离。
float e = 0.002;
vec3 n = normalize( vec3(
mapScene( p + vec3( e, 0.0, 0.0 ) ) - mapScene( p - vec3( e, 0.0, 0.0 ) ),
mapScene( p + vec3( 0.0, e, 0.0 ) ) - mapScene( p - vec3( 0.0, e, 0.0 ) ),
mapScene( p + vec3( 0.0, 0.0, e ) ) - mapScene( p - vec3( 0.0, 0.0, e ) )
) );
对负内正外的场,梯度通常指向物体外侧。共同的 2e 因子在归一化后抵消,所以示例省去。这个 n 是虚拟场景世界空间的三维法线;它与第 21 章平面边缘明暗所用的二维梯度不同,也不是第 15 章位移网格的 Three.js 顶点法线。法线查看模式用 n*0.5+0.5 映射到 RGB:世界 +x、+y、+z 分别增加红、绿、蓝通道。盒体的平面颜色较均匀,球面逐渐变化;硬并集的接缝或盒角附近不可微,法线可能快速变化。
示例用 max(dot(n,lightDir),0) 得到简单漫反射,并加少量高光。lightDir 与 n 都在同一虚拟世界空间。中心差分额外查询场六次;把 e 设得太大,会抹平细节,太小则可能受数值精度影响。命中阈值与法线差分间距解决不同问题,不应混作一个参数。
5. 阴影是另一条射线
在明暗模式中,示例从命中点沿光线方向再次查询 mapObject。起点先沿法线偏移 0.01,避免立即命中自身;若光线途中距离物体小于 0.002,把直射光乘以 0.38,得到简化的硬阴影。阴影只测试物体,地面本身不充当遮挡者。偏移不足会在表面形成斑点,偏移过大则会让接触阴影与物体分离。
第二条射线最多再查询 40 次,因此默认明暗模式比法线或步数模式更费片元计算。步数热图显示的是主射线命中前的查询次数,未命中的片元仍显示背景;它不包含法线与阴影的额外采样。降低渲染分辨率、减少复杂场查询、使用合适的最大距离和迭代上限,都比盲目把步数推高更有效。
6. 与 Three.js 场景的边界
当前球、盒体和地面只存在于片元程序的虚拟场景。输出是全屏平面上的颜色,gl_FragColor 的 alpha 始终为 1,并使用 #include <colorspace_fragment> 把线性 RGB 转到渲染器的输出色彩空间。示例内部的遮挡来自主射线最近命中;阴影来自第二条距离场射线。它们不会自动写入 Three.js 场景中这些虚拟表面的真实深度,也不会直接接收或投射 Three.js 的普通灯光和阴影。若要与场景网格混合、交互或正确处理深度,需要额外设计深度、坐标变换和材质集成,不能只把全屏颜色贴在平面上。
常见问题
- **画面横向被压扁:**射线屏幕坐标忘记用绘制区域的宽高比修正;调整网页布局后还要更新
uAspect。 - **球始终不出现:**检查
rd是否归一化、forward是否指向目标,以及t>MAX_DISTANCE或步数上限是否过早终止。 - **表面有缺口或薄层被跳过:**距离估计可能高估最近表面,或步数预算不足;先切换精确球体并看步数热图。
- 形状轮廓鼓胀:
uHitEpsilon太大,射线还未到零边界就被判为命中。 - **法线颜色不对或光照反了:**检查负内正外的符号、中心差分的采样顺序,并确认法线和光方向在同一世界空间。
- **阴影有斑点或悬空:**调整阴影射线起点的法线偏移,并确认它大于二次射线的命中阈值。
- **期待球体参与 Three.js 网格遮挡:**本例只有全屏平面进入 Three.js 几何与深度流程;三维表面来自片元计算。
本章小结
三维 SDF 将每个虚拟世界点映射为有符号距离。每个片元先生成世界空间射线,再按场值前进到零边界;命中点的三维梯度给法线,第二条射线可测试阴影。命中阈值、最大距离与步数上限分别控制定位精度、观察范围和计算预算。将场值当作安全步长前,必须核对它是否会高估到表面的距离。