MAKE · WRITE · SHARE

Shader-RayMarch

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

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

RayMarch:这篇把自己这些年踩过的点整理一遍:从为什么要“走”而不是“算”,到距离场的性质、软阴影/AO、体

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

1. 先分清三件事

方式 怎么得到像素 擅长 不擅长
光栅化 把三角形投影到屏幕,填像素 快、有硬件支持、适合海量网格 透明、体积、精确反射/折射
光线追踪 查询射线与场景交互 可用于反射、阴影与光传输 需要加速与采样;并不自动物理正确
光线步进 射线按“距离场”一步步往前挪,撞到就停 体积、分形、变形表面、程序化场景 每像素成本高,没有专用硬件

光线步进是沿射线推进的一类方法,也可以成为光线追踪中的求交或体积积分手段。其中球体追踪依赖距离或保守距离界;普通固定步长体积积分并不要求每步知道最近表面距离。

程序化场、体积和隐式表面常适合步进,但仍受表示精度、边界、步长保守性和预算限制,不是所有形状都能无条件稳定渲染。

2. 从求交到步进

射线查询可以使用解析求交、数值方法或距离场追踪,并结合 BVH 等加速结构;不要求所有几何都有封闭解析解。

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

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

起点是相机,方向是像素对应的射线。每一步都问一次“最近表面有多远”,得到一个距离 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) 就是场景的完整描述——几何、材质、甚至动画,全在里面。这也是为什么步进的场景写起来像在“编程”而不是“建模”。

球体追踪使用安全距离推进的二维示意,步长不能随意大于可保证的空域距离

原笔记配图:球体追踪使用安全距离推进的二维示意,步长不能随意大于可保证的空域距离。

3. 距离场:SDF

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

1
2
3
4
5
6
7
8
9
10
11
12
13
float sdSphere(float3 p, float r)  { return length(p) - r; }
// n 必须为单位法线,h 与 p 使用一致的距离单位
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) 当步长永远跨不过表面,这是球体追踪成立的前提。

非精确场并不一定破坏保守性;需要检查它是否可能高估到表面的距离。噪声位移、非均匀缩放和距离估计都可能改变距离界,应按具体构造处理:

  • 经验系数可以缓解小幅过估,但不能代替保守界的证明;
  • 若有可靠的 Lipschitz 上界,可据此约束步长。局部 f/length(grad(f)) 是一种近似修正,不保证对任意场恢复全局安全距离,不能视为普遍正确的替代。

点集构造距离场的旧实验示意,距离必须说明相对于哪些几何对象

原笔记配图:点集构造距离场的旧实验示意,距离必须说明相对于哪些几何对象。

从雪花二值形状生成距离纹理的原示例

原笔记配图:从雪花二值形状生成距离纹理的原示例;纹理中的值需要另行约定符号和尺度。

4. 组合:并、交、差、平滑

有了距离,布尔运算就是几个 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 越大,过渡越“黏”,可以做出融化的金属、水滴粘连、黏土质感。

组合:并、交、差、平滑:k 越大,过渡越“黏”,可以做出融化的金属、水滴粘连、黏土质感。

平滑组合通常不再是精确欧氏距离,但“不精确”不等于必定过估。低估会让步进更保守,过估才可能跨过表面;应分析所用组合的距离界,而不是仅凭平滑二字固定乘安全系数。

5. 步进循环的三个参数

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

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 < max(0.0001, epsFactor * t)) return t;
t += d * 0.9;
if (t > tmax) break;
}
return -1.0;
}
  • eps(命中阈值):太小 → 表面永远“差一点”,步数烧光;太大 → 表面浮起、边缘出现台阶和空心。经验值是 0.0005 ~ 0.002,并且最好和 t 挂钩,远处放宽。
  • maxSteps:限制最坏工作量。合理数值取决于每步复杂度、覆盖率与设备,不能按桌面/移动给出通用固定范围。
  • tmax(最远距离):必须给,否则一条射向空处的光线会把循环跑满。

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

6. 法线:有限差分

步进只给出“撞到哪”,法线得自己求。一种常用方法是四点采样(四面体技巧),相对六次场查询减少两次,即约三分之一:

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 动态缩放。

7. 软阴影与 AO

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

1
2
3
4
5
6
7
8
9
10
11
12
13
// 此近似中 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 的廉价近似”让步进场景不至于塑料感。

8. 体积:从表面到积分

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

体积:从表面到积分:前面都是“撞到表面为止”。如果把 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;硬表面在前就先画完再叠体积。

9. 分形:距离估计的进阶

分形是步进的主场,因为它没有“表面”这种东西——只有一个迭代规则。以 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;
}

这类距离估计通常不是精确距离,是否为可靠下界需结合具体推导。经验安全系数可缓解部分问题,但不证明不会越界;应同时限制迭代、步长和命中阈值。

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

10. 地形与高度场

高度图也能步进,而且思路和 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 防止停滞,但最小步长也可能跨过细节,不是保守求交保证。可靠实现需要结合坡度界、命中区间与细化;高度采样和解析场计算谁更贵,应按缓存、场复杂度与设备测量。

11. 在 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,这样步进结果才能正确被墙体遮挡;只做“叠加式”的雾/体积可以不做深度测试,但云这种要。
  • 分辨率:全屏步进成本取决于覆盖、每步运算与退出分布。降分辨率、引导上采样和抖动是可评估的优化,但不是所有移动场景都必须采用的固定路径。
  • 不用改管线也行:Built-in 里 OnRenderImage 挂 Blit 就能跑,URP 里没有这个钩子,必须走 Renderer Feature;如果只是想在场景里放“一个步进的物体”,也可以用 Graphics.DrawProcedural 或一张透明面片写深度。

12. 常见坑与优化

  • 表面黑点/漏光:eps 小于步长导致的“穿过去”,加速系数反而会更糟;先检查是不是距离函数不精确。
  • 远处锯齿和闪烁:步进天然没有抗锯齿,靠 TAA、dither 抖动初相位、或超采样。静态图可以用累积帧。
  • 边缘发灰(光晕):eps 和背景混合造成的,命中后可用 smoothstep 做一点透明度,或者接受一点点软边。
  • 性能墙:最贵的三件事——每步的 map 调用次数、软阴影/体积的嵌套循环、过高分辨率。优先级排序通常是:降分辨率 > 砍阴影步数 > 简化 map > 降主步数。
  • 精度:half 在长距离步进上会明显劣化(表面抖动、命中失败),距离累计用 float。
  • 最大步数需要上限:动态循环并不普遍要求编译期常量,但循环、分歧与展开策略会影响性能。使用清楚的上界并检查目标编译结果,不把常量当作所有平台的合法性前提。

13. 参考

14. 原配图与来源

15. 距离与步长的适用条件

精确 SDF 的绝对值是到最近表面的距离,而非到场景原点的距离。球体追踪示例默认从表面外开始、射线归一化且场查询提供保守距离;位于物体内部时,简单 d<eps 会立刻误判,需要单独定义穿出或双面行为。

高度差并不天然是沿射线可安全前进的距离。地形掠射角和高坡度可能使固定最小步长跨过细节,须使用相交区间、保守坡度界或细化策略。

原 SDF 笔记日期为 2021 年 9 月 6 日,RayMarch 简记为 2022 年 5 月 3 日;本文按较早记录归档,后续内容保留原更新历史。

总结

光线步进的可靠性来自场表示、保守距离与误差控制。球体追踪、固定步长表面积分与体积积分需要不同前提;优化步数之前,先验证坐标、距离尺度与命中条件。