Raymarching 是一种完全在片元着色器中渲染三维几何的技术——不需要顶点缓冲、不需要三角形、不需要网格。你只需要一个数学函数描述场景的形状,GPU 就能为屏幕上的每个像素发射一条射线,沿着距离场一步步"摸索"到表面。本文将从 SDF 有符号距离场的数学定义出发,一路推到软阴影、平滑布尔运算和完整的光照管线。

引言:Raymarching 与光栅化的根本区别

传统光栅化管线的核心是三角形——你把几何体离散成三角面片,顶点着色器变换坐标,光栅器填充像素。这条路高效成熟,但它有一个根本限制:几何体必须是显式的、预先定义的网格。

Raymarching 走了完全相反的路。它不从几何体出发,而是从射线出发——对屏幕上的每个像素,构造一条从相机出发的视线,然后沿着这条线"步进",直到撞上某个表面。判断是否撞上的依据不是一个三角面片,而是一个连续的标量场:有符号距离场(Signed Distance Field, SDF)

核心差异

光栅化回答的问题是"这个三角形覆盖了哪些像素";Raymarching 回答的问题是"从这个像素出发的射线,在多远处碰到了表面"。前者是正向投影,后者是逆向追踪。

这种范式带来了几个光栅化难以企及的优势:

代价是性能——每个像素都要执行完整的射线步进循环,计算量远高于光栅化的一次插值。但 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 的距离,都保证不会穿过表面。这条性质是球体追踪算法安全性的数学保证,没有它,射线步进就会"飞过"薄壁结构。

Lipschitz 条件

形式化地说,SDF 满足 |f(p) - f(q)| ≤ |p - q|。即函数值的变化速率不会超过距离的变化速率。这就是为什么我们可以用 d = map(p) 的返回值直接作为安全步长——沿射线方向走 d 不会撞墙。

常见几何体的 SDF 公式

下面是四个最基本的 SDF 原语。几乎所有复杂场景都是它们的组合与变形。

GLSL
// 球体:到球心的距离减去半径
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
查询 d = map(p)
p += rd × d
d < ε ?
命中表面

核心循环可以用几行伪代码表达:

GLSL
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 次采样就得到三维法线:

GLSL
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.yxyvec3(0, 0.001, 0)e.yyxvec3(0, 0, 0.001)——分别采样三个轴的正负偏移,差分后归一化即得法线。ε 的选择很关键:太大则法线模糊,太小则浮点误差主导。通常 0.0005 ~ 0.002 是合理区间。

法线精度直接影响光照的锐利度。如果表面出现"波纹"状的着色异常,首先检查 calcNormal 的 ε 是否过小导致浮点噪声放大。

光照与阴影:免费的软效果

SDF 的距离信息不仅用于步进,还天然编码了遮挡关系——这使得软阴影和环境光遮蔽几乎不需要额外的射线投射,只需在已有的距离查询上稍作改写。

软阴影:基于 penumbra 估计

硬阴影只需判断"光源是否被遮挡"——发射一条从表面到光源的射线,命中任何物体即全黑。但真实世界的阴影有半影区——光源被部分遮挡时,阴影是柔和过渡的。

Inigo Quilez 提出了一种优雅的近似:在朝光源步进的过程中,记录沿途最小的 k × d / t 比值。其中 d 是到最近遮挡物的距离,t 是沿阴影射线的行进距离。这个比值越小说明射线越接近擦过某个遮挡物——半影越宽,阴影越淡。

GLSL
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 的近似方法沿法线方向采样若干点,利用距离场的"凹陷程度"估计遮蔽强度:

GLSL
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 的过渡带:

GLSL
// 多项式平滑并集
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 退化为标准 mink 越大过渡区越宽、融合越柔和。指数版本的过渡更圆润但计算量稍大,适合需要有机感的水滴、软体生物等场景。

平滑交集与平滑差集

有了 smin,平滑交集和平滑差集可以通过取反推导出来——交集是 smax(a, b),而 smax 恰好是 -smin(-a, -b)

GLSL
// 平滑交集 = -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 工具箱。通过组合 sminsmaxsminSub,你可以在片元着色器中雕刻出极其复杂的有机几何——从融化的金属到生长的菌丝,全靠几个数学函数组合而成。

代码实战:完整片元着色器

下面是一个完整可运行的 GLSL 片元着色器,整合了前面所有概念——SDF 原语、平滑布尔运算、球体追踪、法线计算、软阴影和环境光遮蔽。你可以将它挂载到覆盖全屏的四边形上,配合相机矩阵 uniform 即可渲染出完整的 3D 场景。

GLSL — Fragment Shader
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,因为远距离的像素本来就不需要精细几何:

GLSL
// 距离自适应的命中阈值
float surfEps = 0.001 * (1.0 + t * 0.02);
if (d < surfEps) break;

2. 包围盒检测

在进入完整 map() 之前,先用一个简单的轴对齐包围盒(AABB)相交测试快速剔除完全不在场景中的射线。这一步计算成本极低(一次 box SDF),但能跳过整个昂贵的步进循环。

GLSL
// 包围盒快速剔除
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 并非默认。如果距离场在远处出现"层状"断裂或表面闪烁,几乎可以确定是浮点精度不足。解决方案:将 rayMarchmap 声明为 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 的天花板很高,但地基就是本文覆盖的这些内容。