在传统光线追踪里,我们通过解方程直接算出光线和几何体相交的精确坐标。但在某些场景中(比如云雾等体积数据,或者极其复杂的数学隐式曲面),方程根本解不出来。 Ray Marching 的核心思想是:既然算不出交点,那就沿着光线的方向,每次往前走一小步,每走一步,就停下来“四处张望”一下,检查当前所在的位置是否满足了某种条件(比如进入了物体内部,或者积累了足够的雾气浓度)。 如果没有,就继续往前走,直到命中目标或超出视距。
Ray Marching 实现步骤
实现一个最基础的、固定步长的 Ray Marching,分为以下四个步骤:
1. 射线生成
这一步是一切基于光线的渲染的基础。从相机位置向屏幕的每一个像素发射一条射线。
float3 ro = float3(0.0, 0.0, -5.0);
float3 rd = normalize(float3(uv.x, uv.y, 1.0));- ro (Ray Origin): 射线的起点(即相机位置)。
- rd (Ray Direction): 射线的方向(标准化向量)。
2. 定义空间属性
我们需要一个函数来描述 3D 空间中任意一点的属性。在最通用的情况下,这个属性可以是一个布尔值(是否在物体内部)或一个密度值(当前位置云的厚度)。
// 检查点 p 是否在一个半径为 1 的球体内部
bool isInsideObject(float3 p) {
return (p.x*p.x + p.y*p.y + p.z*p.z) < 1.0;
}3. 核心循环:步进
这是通用 Ray Marching 的运作方式:设定一个固定的步长 $\Delta t$,沿着方向 rd 不断累加并测试。
#define MAX_STEPS 100 // 最大步进次数
#define STEP_SIZE 0.1 // 每次盲目向前迈进的固定步长
float rayMarch(float3 ro, float3 rd) {
float t = 0.0; // 光线走过的总距离
bool hit = false;
for(int i = 0; i < MAX_STEPS; i++) {
float3 p = ro + rd * t; // 计算当前光线到达的探测点
if(isInsideObject(p)) {
hit = true; // 发现进入了物体!
break; // 停止步进
}
t += STEP_SIZE; // 盲目向前走一步
}
}4. 结算与着色
如果光线检测到碰撞,就可以根据当前碰撞点进行着色;如果是渲染体积云,则在这个循环中不断累加颜色的不透明度(Alpha)。
if (hit) {
// 进行着色
}Ray Marching 的痛点
这种“固定步长”的通用解法,在实际工程中会遇到非常致命的矛盾:
- 痛点 1:步长过大导致“穿模”与漏检
如果
stepSize设置得比较大(为了跑得快),光线很可能会在一步之间,直接跨过了一个较薄的物体,导致屏幕上该物体完全消失。 - 痛点 2:步长过小导致性能崩盘
如果为了不漏掉细节,把
stepSize设置得极小,在广袤的空旷空间中,光线需要执行成千上万次for循环,仅仅是为了探测“空气”,这会让 GPU 性能瞬间崩溃(帧率跌至个位数)。 - 痛点 3:交点不精确
当检测到
hit = true时,光线其实已经陷入物体内部了,拿到的交点p不是精确的表面,这会导致光照计算(如法线和阴影)出现严重的锯齿和阶梯状瑕疵。
利用抖动掩盖固定步长的瑕疵
为每条光线的初始步进位置加一点微小的、基于屏幕像素坐标的随机偏移(Noise/Dither),配合 TAA 后,能在极低的迭代次数下得到平滑的体积效果。
用二分查找修正相交面
当前点陷入物体内部时,不要直接用当前点,可以退回上一步,然后在“上一步”和“当前点”之间进行几次二分查找,逼近真正的表面。这样能以很小的性能开销,极大提高硬表面的渲染质量。
基于 SDF 的 Ray Marching
为了解决上述步长的痛点,图形学界引入了 SDF(Signed Distance Field,有向距离场) 和 Sphere Tracing(球体追踪) 算法。 不再让场景返回“是否在内部”,而是让场景返回当前点距离最近表面的最短距离,一旦有了这个距离,光线就不需要“盲走”固定步长了。
场景建模:编写 SDF
所有的几何体都不由顶点组成,而是由数学公式定义。我们需要一个全局的 map(float3 p) 函数,输入一个空间点,返回该点到场景的最短距离。
float sdSphere(float3 p, float s) {
return length(p) - s; // 球体的 SDF
}
float map(float3 p) {
float sphereDist = sdSphere(p - float3(0, 1, 0), 1.0);
float planeDist = p.y; // 地面的 SDF
return min(sphereDist, planeDist); // 用 min() 组合物体
}核心循环:步进
光线以 SDF 返回的安全距离为半径,向前“步进”,只要步长等于 SDF 的返回值,光线就绝对不会穿透物体。
#define MAX_STEPS 100 // 最大步进次数
#define MAX_DIST 100.0 // 最大距离
#define SURF_DIST 0.001 // 最小距离阈值
float rayMarch(float3 ro, float3 rd) {
float dO = 0.0; // 射线已行进的总距离
for(int i = 0; i < MAX_STEPS; i++) {
float3 p = ro + rd * dO; // 当前探测点 $p = ro + rd \cdot t$
float dS = map(p); // 探测点到场景的最近距离
dO += dS; // 向前迈进
// 如果距离小于阈值(击中表面),或超出最大距离(未击中),则跳出循环
if(dS < SURF_DIST || dO > MAX_DIST) break;
}
return dO;
}法线计算
没有多边形,如何获取表面法线来计算光照?答案是利用 SDF 的梯度。通过在击中点的 X、Y、Z 方向上做极其微小的偏移(有限差分法),观察距离场的变化速率,即可得到法线向量。
float3 getNormal(float3 p) {
float d = map(p);
float2 e = float2(0.001, 0); // 极小偏移量
float3 n = d - float3(
map(p - e.xyy),
map(p - e.yxy),
map(p - e.yyx)
);
return normalize(n);
}场景太大导致精度丢失
当物体距离相机非常远时,浮点数精度下降,导致画面出现水波纹或表面粗糙的现象。
让阈值随着光线行进的距离 dO 成正比增加。即:距离相机越远的物体,判定其击中表面的容错率就越大。