MAKE · WRITE · SHARE

Shader-RayMarch

光线步进(Ray Marching)是我在 Shader 里最喜欢的玩法:不用模型、不用贴图,几行距离函数就能在现场“长”出一个世界。

这篇把自己这些年踩过的点整理一遍:从为什么要“走”而不是“算”,到距离场的性质、软阴影/AO、体积云、分形,再到 URP 里怎么把它塞进管线。

上面这个曼德球(Mandelbulb)没有网格、没有顶点,整张图是一个像素一个像素步进出来的。

先分清三件事

方式 怎么得到像素 擅长 不擅长
光栅化 把三角形投影到屏幕,填像素 快、有硬件支持、适合海量网格 透明、体积、精确反射/折射
光线追踪 射线与图元求交,解方程 精确、物理正确 求交函数要解析可解
光线步进 射线按“距离场”一步步往前挪,撞到就停 体积、分形、变形表面、程序化场景 每像素成本高,没有专用硬件

关键差别在于:前两者都要“算出交点在哪”,而光线步进只要求你能回答一个问题——从这个点出发,到最近表面还有多远。

一旦这个问题有答案,任何形状都能渲染。分形没有解析解、云没有边界、地形只有高度图,这些用光栅化或求交都很别扭,但步进完全不受影响。

从求交到步进

传统的 ray tracing 对每个图元解一次方程,比如球就是解一个二次方程,解出来就是交点。这要求形状可解,且求交代码要逐个图元手写。

光线步进换了个思路:不求解,只前进。

起点是相机,方向是像素对应的射线。每一步都问一次“最近表面有多远”,得到一个距离 d,然后直接前进 d。理由是:以当前点为中心、d 为半径的球里不可能有任何表面,这段路是绝对安全的,可以放心跳过。

这个方法叫球体追踪(Sphere Tracing),John C. Hart 在 1996 年提出。它的效率完全取决于距离函数的质量:距离估得准,几大步就能到;估得保守,就得慢慢挪。

1
2
3
4
5
6
7
8
9
10
11
// 最朴素的步进
float rayMarch(float3 ro, float3 rd) {
float t = 0.0;
for (int i = 0; i < 128; i++) {
float d = map(ro + rd * t); // 到最近表面的距离
if (d < 0.001) return t; // 撞上了
t += d; // 安全跳过去
if (t > 200.0) break; // 跑远了,当天空
}
return -1.0;
}

map(p) 就是场景的完整描述——几何、材质、甚至动画,全在里面。这也是为什么步进的场景写起来像在“编程”而不是“建模”。

距离场:SDF

SDF(Signed Distance Field)是一个标量函数:点在表面外为正,表面内为负,绝对值是到最近表面的距离。

1
2
3
4
5
6
7
8
9
10
11
12
float sdSphere(float3 p, float r)  { return length(p) - r; }
float sdPlane(float3 p, float3 n, float h) { return dot(p, n) - h; }

float sdBox(float3 p, float3 b) {
float3 q = abs(p) - b;
return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}

float sdTorus(float3 p, float2 t) { // t.x 主半径, t.y 环半径
float2 q = float2(length(p.xz) - t.x, p.y);
return length(q) - t.y;
}

这几个函数可以随便平移、旋转、缩放拼出绝大多数硬表面物体,而且不需要任何资源,几十字节的代码就是一个模型。

SDF 最重要的一条性质是 Lipschitz 连续:

1
|sdf(a) - sdf(b)| <= |a - b|

翻译过来就是“距离函数变化得不会比空间距离更快”,所以用 sdf(p) 当步长永远跨不过表面,这是球体追踪成立的前提。

但反过来,如果距离函数不精确(比如被噪声位移、非均匀缩放、或者分形的距离估计),这个上界就破了,直接等长前进会穿模。工程上的做法有两条:

  • 步长乘一个安全系数,常见 0.8 ~ 0.9;
  • 用梯度归一化:d = sdf(p) / length(grad(sdf(p))),把过估的量拉回等比距离,代价是每步多算几次 map。

组合:并、交、差、平滑

有了距离,布尔运算就是几个 min/max:

1
2
3
float opUnion(float a, float b)     { return min(a, b); }
float opIntersect(float a, float b) { return max(a, b); }
float opSubtract(float a, float b) { return max(-a, b); } // a 减去 b

平滑并集(smooth union)是步进的独门绝技——光栅化里两个网格要融合得靠建模软件烘焙,步进里改一个参数就行:

1
2
3
4
float opSmoothUnion(float a, float b, float k) {
float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
return lerp(b, a, h) - k * h * (1.0 - h);
}

k 越大,过渡越“黏”,可以做出融化的金属、水滴粘连、黏土质感。

注意平滑运算的结果不再是精确距离(过渡区域会被低估),所以这时候必须配合前面说的安全系数,否则接缝处会穿。

步进循环的三个参数

看似简单的循环,实际调参全在这三个地方:

1
2
3
4
5
6
7
8
9
10
11
12
float rayMarch(float3 ro, float3 rd, float tmax, int maxSteps, float epsFactor) {
float t = 0.0;
for (int i = 0; i < maxSteps; i++) {
float3 p = ro + rd * t;
float d = map(p);
// 命中阈值随距离放大:远处不需要那么精细
if (d < epsFactor * t) return t;
t += d * 0.9;
if (t > tmax) break;
}
return -1.0;
}
  • eps(命中阈值):太小 → 表面永远“差一点”,步数烧光;太大 → 表面浮起、边缘出现台阶和空心。经验值是 0.0005 ~ 0.002,并且最好和 t 挂钩,远处放宽。
  • maxSteps:直接决定最坏情况耗时。桌面 128~256,移动端多半只能给 48~64,超出就靠提前退出和包围盒裁剪。
  • tmax(最远距离):必须给,否则一条射向空处的光线会把循环跑满。

还有一个隐蔽的坑:擦边射线。射线几乎平行于表面掠过去时,每步 d 都很小,步数会在局部暴涨。这类像素占比不高,但会让最坏耗时翻倍,所以移动端常配合“半分辨率 + 上采样”来控制。

法线:有限差分

步进只给出“撞到哪”,法线得自己求。最常用的是四点采样(四面体技巧),比六点省一半:

1
2
3
4
5
6
7
8
float3 calcNormal(float3 p) {
const float2 e = float2(0.001, -0.001);
return normalize(
e.xyy * map(p + e.xyy) +
e.yyx * map(p + e.yyx) +
e.yxy * map(p + e.yxy) +
e.xxx * map(p + e.xxx));
}

采样间距要和场景尺度匹配:固定 0.001 在公里级地形上会抖,在毫米级物体上又太粗。可用 eps * t 动态缩放。

软阴影与 AO

步进的另一个好处是:阴影不需要 shadow map,直接从表面上朝光源再步进一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
// k 控制软硬:k 越大越软
float softShadow(float3 ro, float3 rd, float tmin, float tmax, float k) {
float res = 1.0;
float t = tmin;
for (int i = 0; i < 48; i++) {
float h = map(ro + rd * t);
if (h < 0.001) return 0.0; // 被挡住
res = min(res, k * h / t); // 记录最窄的缝,决定半影
t += clamp(h, 0.01, 0.1);
if (t > tmax) break;
}
return clamp(res, 0.0, 1.0);
}

h / t 的几何含义是“这条光线在遮挡物边缘上张开的角”,取整条路径上的最小值,就是半影的浓淡。这个技巧叫 soft shadow by SDF,比 PCF 便宜得多,而且没有分辨率问题。

环境光遮蔽(AO)同理,沿法线方向采样几次距离,越近的遮挡贡献越大:

1
2
3
4
5
6
7
8
9
10
float calcAO(float3 p, float3 n) {
float occ = 0.0, sca = 1.0;
for (int i = 0; i < 5; i++) {
float h = 0.01 + 0.12 * float(i) / 4.0;
float d = map(p + n * h);
occ += (h - d) * sca;
sca *= 0.9;
}
return clamp(1.0 - 1.5 * occ, 0.0, 1.0);
}

转折处变暗、缝隙变脏,这些“GI 的廉价近似”让步进场景不至于塑料感。

体积:从表面到积分

前面都是“撞到表面为止”。如果把 map 理解成密度场,步进就变成沿射线做积分——云、雾、烟、光柱都归这一类。

思路是:定步长前进,每一步累加光照贡献,同时按 Beer-Lambert 定律衰减透射率:

1
2
3
4
5
6
7
8
9
10
11
12
13
float transmittance = 1.0;
float3 color = 0.0;
for (int i = 0; i < 64; i++) {
float3 p = ro + rd * t;
float density = densityField(p);
if (density > 0.001) {
float3 lightEnergy = sunLight * phase(rd, lightDir); // 相函数
color += transmittance * density * lightEnergy * stepSize;
transmittance *= exp(-density * absorption * stepSize);
if (transmittance < 0.01) break; // 已经不透光了
}
t += stepSize;
}

几个实践点:

  • 步长决定质量:定步长会出现层状条纹(banding),常见的解法是每像素随机偏移起点,再靠 TAA 或蓝噪声在时间上抹平。
  • 相函数(Henyey-Greenstein 或最简单的 pow(saturate(dot(rd, L)), 8))决定云是“薄纱”还是“边缘发亮”。前向散射强的云,逆光时会有一圈亮边。
  • 需要光源可见性:湿云要好看,每步还得朝太阳再步进几次算阴影(也就是所谓的 light march),这是云渲染里最贵的一块,通常用低分辨率 shadow map 或半分辨率 pass 顶替。
  • 和场景混合:体积在前、硬表面在后时,用场景深度裁剪 tmax;硬表面在前就先画完再叠体积。

分形:距离估计的进阶

分形是步进的主场,因为它没有“表面”这种东西——只有一个迭代规则。以 Mandelbulb 为例,距离估计(Distance Estimator)长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
float mandelbulbDE(float3 pos, float power) {
float3 z = pos;
float dr = 1.0, r = 0.0;
for (int i = 0; i < 12; i++) {
r = length(z);
if (r > 2.0) break;
// 球坐标下把角度乘 power
float theta = acos(z.z / r) * power;
float phi = atan2(z.y, z.x) * power;
float zr = pow(r, power);
dr = pow(r, power - 1.0) * power * dr + 1.0;
z = zr * float3(sin(theta) * cos(phi), sin(theta) * sin(phi), cos(theta)) + pos;
}
return 0.5 * log(max(r, 1e-6)) * r / dr;
}

这类距离估计不是精确距离,只是下界,所以步长要乘 0.5 ~ 0.9;迭代次数越多结构越细,但也越容易在细节处步进失效,出现“融化”的噪点。

同样的套路还能做无限重复(domain repetition)、扭曲、Mandelbox、KIFS——本质都是“把点变换进一个基本空间,用基本 SDF 估计距离”。

地形与高度场

高度图也能步进,而且思路和 SDF 不太一样:没有解析距离,只能“按高度差约束步长”。

1
2
3
4
5
6
7
8
9
// 伪代码:按地形高度约束前进
float t = 0.0;
for (int i = 0; i < 128; i++) {
float3 p = ro + rd * t;
float terrainH = sampleHeight(p.xz);
float rayH = p.y;
if (rayH < terrainH) return t; // 钻进地面,命中
t += max((rayH - terrainH) * 0.5, minStep); // 保守步长
}

这里的关键是 minStep:当地面和射线几乎平行时,高度差很小,步长会退化成 0,必须给一个下限,否则循环原地打转。地形步进的成本通常比 SDF 高,因为每步都要采样高度图(采样带宽比算术贵)。

在 URP 里怎么跑

步进在引擎里一般是全屏 Pass:拿相机的逆视图投影矩阵,为每个像素重建射线,然后算,最后和场景深度混合。

1
2
3
4
5
6
7
8
9
10
11
// 最小可用的 Renderer Feature:一次全屏 Blit
public override void Execute(ScriptableRenderContext context, ref RenderingData data) {
var cmd = CommandBufferPool.Get("RayMarch");
var camera = data.cameraData.camera;

cmd.SetGlobalMatrix("_CameraInvViewProjection",
(GL.GetGPUProjectionMatrix(camera.projectionMatrix, true) * camera.worldToCameraMatrix).inverse);
cmd.Blit(m_Source, m_Target, m_Material);
context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
}
1
2
3
4
5
6
7
// 顶点着色器给全屏三角形,片元里重建射线
float3 ReconstructRay(float2 uv, out float3 origin) {
float4 ndc = float4(uv * 2.0 - 1.0, 0.0, 1.0);
float4 world = mul(_CameraInvViewProjection, ndc);
origin = _WorldSpaceCameraPos;
return normalize(world.xyz / world.w - origin);
}

几个落地经验:

  • UV 翻转:Blit 在不同平台上 UV 的 Y 方向会反,GL.GetGPUProjectionMatrix 的 renderIntoTexture 参数要和实际一致,否则整屏上下颠倒或左右镜像。
  • 和深度混合:读 _CameraDepthTexture 还原场景深度,作为 tmax,这样步进结果才能正确被墙体遮挡;只做“叠加式”的雾/体积可以不做深度测试,但云这种要。
  • 分辨率:整屏全分辨率跑 128 步基本只能跑在桌面上。移动端常规做法是半分辨率 + 双边上采样 + 抖动。
  • 不用改管线也行:Built-in 里 OnRenderImage 挂 Blit 就能跑,URP 里没有这个钩子,必须走 Renderer Feature;如果只是想在场景里放“一个步进的物体”,也可以用 Graphics.DrawProcedural 或一张透明面片写深度。

常见坑与优化

  • 表面黑点/漏光:eps 小于步长导致的“穿过去”,加速系数反而会更糟;先检查是不是距离函数不精确。
  • 远处锯齿和闪烁:步进天然没有抗锯齿,靠 TAA、dither 抖动初相位、或超采样。静态图可以用累积帧。
  • 边缘发灰(光晕):eps 和背景混合造成的,命中后可用 smoothstep 做一点透明度,或者接受一点点软边。
  • 性能墙:最贵的三件事——每步的 map 调用次数、软阴影/体积的嵌套循环、过高分辨率。优先级排序通常是:降分辨率 > 砍阴影步数 > 简化 map > 降主步数。
  • 精度:half 在长距离步进上会明显劣化(表面抖动、命中失败),距离累计用 float。
  • 最大步数不能省:GPU 上层叠循环里的动态退出依赖编译器展开,maxSteps 必须是常量,否则在部分驱动上会退化成极慢的动态循环。

参考

图片来源