Raymarching 是一种完全在片元着色器中渲染三维几何的技术——不需要顶点缓冲、不需要三角形、不需要网格。你只需要一个数学函数描述场景的形状,GPU 就能为屏幕上的每个像素发射一条射线,沿着距离场一步步"摸索"到表面。本文将从 SDF 有符号距离场的数学定义出发,一路推到软阴影、平滑布尔运算和完整的光照管线。
引言:Raymarching 与光栅化的根本区别
传统光栅化管线的核心是三角形——你把几何体离散成三角面片,顶点着色器变换坐标,光栅器填充像素。这条路高效成熟,但它有一个根本限制:几何体必须是显式的、预先定义的网格。
Raymarching 走了完全相反的路。它不从几何体出发,而是从射线出发——对屏幕上的每个像素,构造一条从相机出发的视线,然后沿着这条线"步进",直到撞上某个表面。判断是否撞上的依据不是一个三角面片,而是一个连续的标量场:有符号距离场(Signed Distance Field, SDF)。
光栅化回答的问题是"这个三角形覆盖了哪些像素";Raymarching 回答的问题是"从这个像素出发的射线,在多远处碰到了表面"。前者是正向投影,后者是逆向追踪。
这种范式带来了几个光栅化难以企及的优势:
- 程序化几何:场景由数学函数定义,无需建模工具导出网格,体积无限可微。
- 精确 CSG:并集、交集、差集只是
min、max运算,天然得到无缝的布尔几何。 - 软阴影与 AO 免费:SDF 的距离值自带遮挡信息,几乎零额外成本即可计算软阴影和环境光遮蔽。
- 无限细节:距离场连续可微,放大不会出现纹理像素化,天然适合分形与位移。
代价是性能——每个像素都要执行完整的射线步进循环,计算量远高于光栅化的一次插值。但 GPU 的并行能力让这件事在中等复杂度的场景中完全可行。
SDF 有符号距离场:Raymarching 的数学基石
有符号距离场是一个从空间中的点 p 到实数的映射 f: R^3 → R,它的值满足三条性质:
|f(p)| 等于 p 到最近表面的欧几里得距离;f(p) > 0 表示 p 在物体外部;f(p) < 0 表示 p 在物体内部;f(p) = 0 就是物体的表面。
这正是它被称为"有符号"的原因——距离永远非负,但 SDF 额外编码了内外信息。一个以原点为中心、半径为 r 的球体,其 SDF 就是 length(p) - r:在球外为正,球内为负,球面上恰好为零。
为什么 SDF 是 Raymarching 的基础
SDF 的关键性质是Lipschitz 连续——它的梯度模长不超过 1。这意味着:如果当前点到表面的距离是 d,那么沿任何方向行进 d 的距离,都保证不会穿过表面。这条性质是球体追踪算法安全性的数学保证,没有它,射线步进就会"飞过"薄壁结构。
形式化地说,SDF 满足 |f(p) - f(q)| ≤ |p - q|。即函数值的变化速率不会超过距离的变化速率。这就是为什么我们可以用 d = map(p) 的返回值直接作为安全步长——沿射线方向走 d 不会撞墙。
常见几何体的 SDF 公式
下面是四个最基本的 SDF 原语。几乎所有复杂场景都是它们的组合与变形。
// 球体:到球心的距离减去半径
float sdSphere(vec3 p, float r) {
return length(p) - r;
}
// 立方体:内外两种情况分别处理
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);
}
// 平面:法线 n.xyz,原点到平面的偏移 n.w
float sdPlane(vec3 p, vec4 n) {
return dot(p, n.xyz) + n.w;
}
// 圆环:主半径 t.x,管半径 t.y
float sdTorus(vec3 p, vec2 t) {
vec2 q = vec2(length(p.xz) - t.x, p.y);
return length(q) - t.y;
}
球体 SDF 最直观——length(p) 是到原点的距离,减去半径就是到球面的有符号距离。立方体稍复杂:先用 abs(p) 把问题折叠到第一象限,再用 max(q, 0) 处理外部距离、min(max(...), 0) 处理内部距离。圆环则是在 xz 平面上先投影到环心圆,再减去管半径。
球体追踪算法:核心循环
有了 SDF,球体追踪(Sphere Tracing)的思路就呼之欲出了:从射线起点出发,每一步查询当前点到最近表面的距离 d,然后沿射线方向前进 d。因为 SDF 保证走 d 不会穿过任何表面,所以这是一个安全且自适应的过程。
ro
核心循环可以用几行伪代码表达:
float rayMarch(vec3 ro, vec3 rd) {
float t = 0.0; // 累计行进距离
for (int i = 0; i < MAX_STEPS; i++) {
vec3 p = ro + rd * t; // 当前采样点
float d = map(p); // 到最近表面的距离
if (d < SURF_DIST) break; // 足够接近 → 命中
t += d; // 安全步进
if (t > MAX_DIST) break; // 超出视距 → 放弃
}
return t;
}
为什么比线性步进高效
最朴素的射线步进是固定步长——每次前进固定的 0.1,步进 1000 次才覆盖 100 个单位。这种方式完全无视场景结构,在空旷区域浪费大量迭代。
球体追踪的步长是自适应的。在远离表面的空旷区域,map(p) 返回很大的值,射线可以一次跳跃很远;只有在接近表面时步长才收缩。这意味着在大多数像素上,射线只需要几步就能穿越漫长的空旷地带,最终在表面附近精细收敛。
对于一个半径为 1 的球体,射线从 50 个单位外出发:线性步进(步长 0.1)需要约 500 步;球体追踪第一步就能跳 49 个单位,通常 3 到 5 步即可命中。这就是 SDF 自适应步进带来的数量级提升。
法线计算:梯度近似
命中表面后,下一步是计算光照,而光照需要表面法线。SDF 的一个优雅之处在于:法线不需要额外存储或解析推导,它就是 SDF 的梯度——指向距离场增长最快的方向,恰好垂直于等距面(即表面)。
在 GLSL 中,我们用中心差分法数值计算梯度。技巧是用 vec2(ε, 0) 的 swizzle 组合,仅用 6 次采样就得到三维法线:
vec3 calcNormal(vec3 p) {
vec2 e = vec2(0.001, 0.0);
return normalize(vec3(
map(p + e.xyy) - map(p - e.xyy),
map(p + e.yxy) - map(p - e.yxy),
map(p + e.yyx) - map(p - e.yyx)
));
}
这里 e.xyy 展开为 vec3(0.001, 0, 0),e.yxy 为 vec3(0, 0.001, 0),e.yyx 为 vec3(0, 0, 0.001)——分别采样三个轴的正负偏移,差分后归一化即得法线。ε 的选择很关键:太大则法线模糊,太小则浮点误差主导。通常 0.0005 ~ 0.002 是合理区间。
法线精度直接影响光照的锐利度。如果表面出现"波纹"状的着色异常,首先检查 calcNormal 的 ε 是否过小导致浮点噪声放大。
光照与阴影:免费的软效果
SDF 的距离信息不仅用于步进,还天然编码了遮挡关系——这使得软阴影和环境光遮蔽几乎不需要额外的射线投射,只需在已有的距离查询上稍作改写。
软阴影:基于 penumbra 估计
硬阴影只需判断"光源是否被遮挡"——发射一条从表面到光源的射线,命中任何物体即全黑。但真实世界的阴影有半影区——光源被部分遮挡时,阴影是柔和过渡的。
Inigo Quilez 提出了一种优雅的近似:在朝光源步进的过程中,记录沿途最小的 k × d / t 比值。其中 d 是到最近遮挡物的距离,t 是沿阴影射线的行进距离。这个比值越小说明射线越接近擦过某个遮挡物——半影越宽,阴影越淡。
float softShadow(vec3 ro, vec3 rd, float mint, float maxt, float k) {
float res = 1.0;
float t = mint;
for (int i = 0; i < 64; i++) {
float h = map(ro + rd * t);
if (h < 0.001) return 0.0; // 完全遮挡
res = min(res, k * h / t); // penumbra 估计
t += clamp(h, 0.01, 0.25);
if (t > maxt) break;
}
return clamp(res, 0.0, 1.0);
}
参数 k 控制半影宽度——值越大阴影越锐利,值越小阴影越柔。这个算法的精妙之处在于:它复用了与主射线完全相同的 map() 函数,几乎零额外几何信息开销。
环境光遮蔽
环境光遮蔽模拟的是凹角和缝隙处环境光的衰减。基于 SDF 的近似方法沿法线方向采样若干点,利用距离场的"凹陷程度"估计遮蔽强度:
float calcAO(vec3 p, vec3 n) {
float occ = 0.0;
float sca = 1.0;
for (int i = 0; i < 5; i++) {
float hr = 0.01 + 0.12 * float(i) / 4.0;
float d = map(p + n * hr);
occ += (hr - d) * sca; // 凹陷越深遮蔽越强
sca *= 0.95; // 衰减权重
}
return clamp(1.0 - 3.0 * occ, 0.0, 1.0);
}
核心思想是:沿法线向外逐步采样,如果距离值 d 小于采样半径 hr,说明表面附近有凹陷——差值 hr - d 为正即代表遮蔽。5 次采样足够得到视觉上令人信服的接触阴影。
平滑布尔运算:无缝融合的几何
传统 CSG 的并集是 min(a, b)、交集是 max(a, b)、差集是 max(a, -b)。这些运算精确但会产生硬边——两个球体的并集在交界处有明显折痕。平滑布尔运算用一个过渡区域替代硬边界,产生像液态金属般有机融合的效果。
平滑并集 smin
最常用的是多项式平滑并集。它在两个 SDF 值接近时用混合函数取代 min,制造一个宽度为 k 的过渡带:
// 多项式平滑并集
float smin(float a, float b, float k) {
float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
return mix(b, a, h) - k * h * (1.0 - h);
}
// 指数平滑并集(更圆润的过渡)
float sminExp(float a, float b, float k) {
float res = exp(-k * a) + exp(-k * b);
return -log(res) / k;
}
当 k = 0 时,smin 退化为标准 min;k 越大过渡区越宽、融合越柔和。指数版本的过渡更圆润但计算量稍大,适合需要有机感的水滴、软体生物等场景。
平滑交集与平滑差集
有了 smin,平滑交集和平滑差集可以通过取反推导出来——交集是 smax(a, b),而 smax 恰好是 -smin(-a, -b):
// 平滑交集 = -smin(-a, -b)
float smax(float a, float b, float k) {
return -smin(-a, -b, k);
}
// 平滑差集 = smax(a, -b)
float sminSub(float a, float b, float k) {
return smax(a, -b, k);
}
这套运算构成了一个完整的平滑 CSG 工具箱。通过组合 smin、smax 和 sminSub,你可以在片元着色器中雕刻出极其复杂的有机几何——从融化的金属到生长的菌丝,全靠几个数学函数组合而成。
代码实战:完整片元着色器
下面是一个完整可运行的 GLSL 片元着色器,整合了前面所有概念——SDF 原语、平滑布尔运算、球体追踪、法线计算、软阴影和环境光遮蔽。你可以将它挂载到覆盖全屏的四边形上,配合相机矩阵 uniform 即可渲染出完整的 3D 场景。
precision highp float;
uniform vec2 uResolution;
uniform float uTime;
uniform vec3 uCameraPos;
uniform mat3 uCameraMat;
#define MAX_STEPS 128
#define MAX_DIST 100.0
#define SURF_DIST 0.001
// ─── SDF 基元 ───
float sdSphere(vec3 p, float r) {
return length(p) - r;
}
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);
}
float sdPlane(vec3 p) {
return p.y + 1.0;
}
float sdTorus(vec3 p, vec2 t) {
vec2 q = vec2(length(p.xz) - t.x, p.y);
return length(q) - t.y;
}
// ─── 平滑布尔运算 ───
float smin(float a, float b, float k) {
float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
return mix(b, a, h) - k * h * (1.0 - h);
}
// ─── 场景定义 ───
float map(vec3 p) {
float sphere = sdSphere(p - vec3(0.0, 0.0, 0.0), 1.0);
float box = sdBox(p - vec3(1.8, 0.0, 0.0), vec3(0.8));
float torus = sdTorus(p - vec3(-1.8, 0.0, 0.0), vec2(0.8, 0.25));
float plane = sdPlane(p);
// 球体与立方体平滑融合
float blended = smin(sphere, box, 0.4);
return min(min(blended, torus), plane);
}
// ─── 法线计算 ───
vec3 calcNormal(vec3 p) {
vec2 e = vec2(0.001, 0.0);
return normalize(vec3(
map(p + e.xyy) - map(p - e.xyy),
map(p + e.yxy) - map(p - e.yxy),
map(p + e.yyx) - map(p - e.yyx)
));
}
// ─── 球体追踪 ───
float rayMarch(vec3 ro, vec3 rd) {
float t = 0.0;
for (int i = 0; i < MAX_STEPS; i++) {
vec3 p = ro + rd * t;
float d = map(p);
if (d < SURF_DIST) break;
t += d;
if (t > MAX_DIST) break;
}
return t;
}
// ─── 软阴影 ───
float softShadow(vec3 ro, vec3 rd, float mint, float maxt, float k) {
float res = 1.0;
float t = mint;
for (int i = 0; i < 64; 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.25);
if (t > maxt) break;
}
return clamp(res, 0.0, 1.0);
}
// ─── 环境光遮蔽 ───
float calcAO(vec3 p, vec3 n) {
float occ = 0.0;
float sca = 1.0;
for (int i = 0; i < 5; i++) {
float hr = 0.01 + 0.12 * float(i) / 4.0;
float d = map(p + n * hr);
occ += (hr - d) * sca;
sca *= 0.95;
}
return clamp(1.0 - 3.0 * occ, 0.0, 1.0);
}
// ─── 光照渲染 ───
vec3 render(vec3 ro, vec3 rd) {
vec3 col = vec3(0.0);
float t = rayMarch(ro, rd);
if (t < MAX_DIST) {
vec3 p = ro + rd * t;
vec3 n = calcNormal(p);
vec3 lightPos = vec3(2.0, 4.0, 2.0);
vec3 lightDir = normalize(lightPos - p);
float diff = max(dot(n, lightDir), 0.0);
float spec = pow(max(dot(reflect(-lightDir, n), -rd), 0.0), 32.0);
float shadow = softShadow(p + n * 0.02, lightDir, 0.02, 25.0, 16.0);
float ao = calcAO(p, n);
vec3 baseColor = vec3(0.8, 0.4, 0.2);
vec3 ambient = baseColor * 0.15 * ao;
vec3 diffuse = baseColor * diff * shadow;
vec3 specular = vec3(1.0) * spec * shadow * 0.5;
col = ambient + diffuse + specular;
// 距离雾
col = mix(col, vec3(0.05), 1.0 - exp(-0.02 * t * t));
} else {
// 天空背景
col = mix(vec3(0.02, 0.04, 0.08), vec3(0.0), rd.y);
}
return col;
}
void main() {
vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / uResolution.y;
vec3 ro = uCameraPos;
vec3 rd = normalize(uCameraMat * vec3(uv, -1.5));
vec3 col = render(ro, rd);
// 色调映射 + Gamma 校正
col = col / (col + 1.0);
col = pow(col, vec3(1.0 / 2.2));
gl_FragColor = vec4(col, 1.0);
}
这段着色器的执行流程是:main 中构造屏幕坐标到射线方向的映射,render 调用 rayMarch 找到命中点,然后计算法线、光照、阴影和 AO,最后做色调映射和 Gamma 校正输出颜色。注意 p + n * 0.02 这一步——它把阴影射线的起点沿法线偏移,避免表面自遮蔽导致的 acne 伪影。
性能优化
Raymarching 的性能瓶颈在于每个像素都要执行 map() 函数若干次(主射线 + 法线 6 次 + 阴影 64 次 + AO 5 次),总计可达上百次。以下策略能有效控制开销。
1. 步数控制
MAX_STEPS 直接决定循环上限。对于简单场景,64 步通常足够;复杂场景可能需要 128 到 256 步。关键洞察是:大多数像素在空旷区域只需几步就结束,真正耗费步数的是接近掠射角的表面——射线几乎平行于表面,SDF 收敛极慢。
可以在 map() 中根据距离 t 降低精度要求——远处允许更大的 SURF_DIST,因为远距离的像素本来就不需要精细几何:
// 距离自适应的命中阈值
float surfEps = 0.001 * (1.0 + t * 0.02);
if (d < surfEps) break;
2. 包围盒检测
在进入完整 map() 之前,先用一个简单的轴对齐包围盒(AABB)相交测试快速剔除完全不在场景中的射线。这一步计算成本极低(一次 box SDF),但能跳过整个昂贵的步进循环。
// 包围盒快速剔除
float boundBox = sdBox(p - sceneCenter, sceneHalf);
if (boundBox > 2.0) {
t += boundBox - 1.0; // 直接跳过空旷区域
continue;
}
3. 距离场精度与梯度重用
法线计算调用 6 次 map(),软阴影调用 64 次,AO 调用 5 次——这些开销不容忽视。一种优化是降低远处像素的采样次数:软阴影的循环步数可以根据距离衰减,AO 的采样层数也可以动态调整。此外,precision mediump 在移动端可以显著降低寄存器压力,但要注意 SDF 的距离值在 mediump 下精度不足会导致远处表面断裂。
在移动端 GPU 上,highp 并非默认。如果距离场在远处出现"层状"断裂或表面闪烁,几乎可以确定是浮点精度不足。解决方案:将 rayMarch 和 map 声明为 precision highp float,或拆分距离为整数+小数两部分分别累积。
性能对比表
在同一台机器(M1 Pro / Chrome 126 / 1080p 全屏)下测试不同复杂度的 raymarching 场景,帧率差异显著:
| 场景 | MAX_STEPS | 特效 | FPS |
|---|---|---|---|
| 单球体 | 64 | 无 | 120 |
| 多几何体 | 64 | 无 | 95 |
| + 平滑布尔 | 64 | smin | 88 |
| + 软阴影 | 128 | smin + softShadow | 62 |
| + 环境光遮蔽 | 128 | 全特效 | 48 |
| 复杂 CSG 场景 | 200 | 全特效 | 31 |
可以看到,软阴影和 AO 各带来约 20-30% 的帧率下降,这是因为它们各自需要额外的射线步进循环。在性能敏感的场景中,可以按距离降级——近处全特效、远处关闭 AO 甚至软阴影,视觉差异极小但帧率提升明显。
总结
Raymarching 的核心可以浓缩为一句话:用 SDF 描述场景形状,用球体追踪找到射线与表面的交点,用梯度计算法线,用距离场顺便算出软阴影和 AO。这套范式不依赖任何网格数据,一切几何都在片元着色器中由数学函数实时雕刻。
从 SDF 原语到平滑布尔运算,从球体追踪的自适应步进到 penumbra 估计的软阴影——每个环节都建立在"距离场是连续且可微的"这一数学性质之上。理解了 Lipschitz 条件为何保证安全步进,你就能自信地设计自己的 SDF 组合,而不必担心射线穿透薄壁。
下一步可以探索的方向:域重复(domain repetition)用 mod 实现无限阵列、位移函数(displacement)用噪声给表面增加细节、体积渲染将 SDF 扩展到半透明介质。Raymarching 的天花板很高,但地基就是本文覆盖的这些内容。