☰
Raymarching 入门:SDF 与光线步进实现实时渲染
2026/10/17 23:09:47 网站建设 项目流程

1. 从零理解 raymarching:它到底在解决什么问题

第一次接触 raymarching 的人,多半是被 Shadertoy 上那些炫酷的实时渲染画面吸引过来的——一个纯片元着色器,没有模型文件,没有顶点数据,却能渲染出体积云、无限分形、流体般的烟雾。这背后的核心思路就是 raymarching(光线步进)。它和传统的光栅化渲染管线走的是完全不同的路:光栅化是把三角形投影到屏幕上再逐像素填充,而 raymarching 反过来,从摄像机出发,为每个像素发射一条光线,沿着光线方向一步步“试探”场景,直到碰到物体表面或者走够步数。

这个“试探”的过程就是 raymarching 的精髓。它不依赖任何显式几何体,而是用一个距离场函数(Signed Distance Function,简称 SDF)来描述场景。SDF 的定义很优雅:给定空间中任意一点,返回该点到最近表面的距离,点在表面内部返回负值,外部返回正值。有了这个函数,光线步进就有了“安全步长”——因为你知道当前点距离最近表面至少有多远,所以可以放心地往前跳这么远,而不会穿过任何物体。这就是所谓的sphere tracing,也是绝大多数 raymarching 实现采用的策略。

为什么这套方法值得学?因为它把几何、光照、材质、动画全部统一在数学表达式里。你不需要建模软件,不需要导出网格,改一个参数就能让物体变形、融合、分裂。对于做创意编程、Shader 特效、体积渲染、甚至某些科学可视化的场景来说,raymarching 提供了一种极其灵活的“用代码雕刻形状”的方式。它适合有一定编程基础、想入门图形学或者想做实时视觉特效的人,也适合已经会写 Shader 但想突破传统管线限制的开发者。哪怕你只是想在网页上放一个能交互的 3D 艺术装置,raymarching 也是性价比极高的选择。

2. 核心原理拆解:SDF、步进与法线

2.1 SDF 为什么能描述一切形状

SDF 的本质是一个标量场。以最简单的球体为例,圆心在原点、半径为 r 的球,其 SDF 就是length(p) - r。这个表达式返回的就是点 p 到球面的最短距离。立方体的 SDF 稍微复杂一点,需要用到分量取绝对值和最大值运算,但核心思想一样:用数学运算组合出距离信息。

真正强大的地方在于 SDF 的布尔运算。两个 SDF 取 min,得到的是它们的并集;取 max,得到的是交集;取 max(a, -b),得到的是 a 减去 b 的差集。这意味着你可以像搭积木一样组合形状。更妙的是smooth min(平滑最小值),它能在并集的基础上产生圆滑的过渡,这就是很多 raymarching 作品里物体“融化”“粘连”效果的来源。我个人的经验是,smooth min 的平滑系数 k 取值很关键,k 太大过渡会糊成一团,k 太小又看不出融合效果,一般从 0.1 到 0.5 之间试,具体看场景尺度。

还有一类操作叫域变换(domain transformation),比如对坐标做取模运算就能得到无限重复的阵列,对坐标做旋转就能让物体转起来。这些操作不需要改动 SDF 本身,只需要在传入 SDF 之前对 p 做处理。这种“修改输入而非修改函数”的思路,是 raymarching 灵活性的重要来源。

2.2 光线步进的步长策略与终止条件

有了 SDF,光线步进的循环逻辑就很直接了。从摄像机位置出发,沿着光线方向不断前进,每次前进的距离等于当前点的 SDF 值。如果 SDF 值小于一个很小的阈值(比如 0.001),就认为碰到了表面,停止步进;如果累计距离超过最大距离,或者步数超过上限,就认为没碰到任何东西,返回背景色。

这里有几个参数直接决定渲染质量和性能。最大步数通常设 64 到 256 之间,步数越多越容易捕捉到细节,但性能开销也越大。表面阈值决定了你判断“碰到”的精度,太小会导致光线在表面附近反复徘徊浪费步数,太大又会让物体边缘出现锯齿或穿透。最大距离则决定了可视范围,超出这个距离就当作无限远。我在实际项目里一般把阈值设在 0.0005 到 0.002 之间,配合场景尺度调整。

注意:如果 SDF 不满足 Lipschitz 连续性(简单说就是距离变化不能比实际距离还快),步进可能会穿过物体。常见于某些非精确的 SDF 或者经过剧烈域变换后的场景。遇到穿透问题时,可以把步长乘以一个小于 1 的系数(比如 0.8)来保守前进,代价是步数增加。

2.3 法线计算与光照模型

raymarching 本身只告诉你“碰到了”,但要做光照还需要法线。法线的计算用梯度法:在命中点附近,分别对 x、y、z 三个方向做微小偏移,采样 SDF 值,用中心差分求出梯度向量,归一化后就是法线。这个方法的精度取决于偏移量 epsilon 的选取,一般取和表面阈值同一量级。epsilon 太大法线会平滑失真,太小又会因为浮点精度产生噪点。

光照模型可以很简单,比如 Lambert 漫反射加一个方向光;也可以很复杂,比如加入环境光遮蔽(AO)、软阴影、菲涅尔反射。AO 的常用做法是在命中点沿法线方向采样几次 SDF,看周围有多少遮挡,遮挡越多越暗。软阴影则是从命中点向光源方向做一次短距离的 raymarching,记录最小距离与行进距离的比值,比值越小阴影越硬。这些技巧在 Shadertoy 上都有大量现成实现,但理解原理后你才能根据场景调参。

3. 实操环境搭建:从零写一个可运行的 raymarching 场景

3.1 工具选型与最小运行环境

要跑 raymarching,最轻量的方式是直接用 WebGL 或者 WebGPU 写一个全屏 quad,在片元着色器里实现。不需要任何第三方库,一个 HTML 文件加一段 GLSL 就能跑起来。如果你更习惯桌面端,用 Python 的 moderngl 或者 C++ 的 OpenGL 也可以,但配置成本更高。对于学习和快速迭代,我强烈建议从网页端开始,因为改完代码刷新就能看到效果,反馈循环最短。

下面是一个最小可运行的 WebGL 骨架,核心就是创建一个覆盖全屏的三角形,然后把 raymarching 逻辑写在片元着色器里。顶点着色器只负责传递屏幕坐标,片元着色器负责一切。

// 顶点着色器 attribute vec2 aPosition; varying vec2 vUv; void main() { vUv = aPosition * 0.5 + 0.5; gl_Position = vec4(aPosition, 0.0, 1.0); }
// 片元着色器 precision highp float; varying vec2 vUv; uniform vec2 uResolution; uniform float uTime; // SDF:一个球体 float sdSphere(vec3 p, float r) { return length(p) - r; } // 场景 SDF float map(vec3 p) { // 让球体上下浮动 vec3 spherePos = vec3(0.0, sin(uTime) * 0.3, 0.0); return sdSphere(p - spherePos, 1.0); } // 法线计算 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) )); } void main() { // 将屏幕坐标映射到 [-1, 1],并修正宽高比 vec2 uv = (vUv * 2.0 - 1.0); uv.x *= uResolution.x / uResolution.y; // 摄像机位置与光线方向 vec3 ro = vec3(0.0, 0.0, 3.0); vec3 rd = normalize(vec3(uv, -1.5)); // 光线步进 float t = 0.0; bool hit = false; for (int i = 0; i < 128; i++) { vec3 p = ro + rd * t; float d = map(p); if (d < 0.001) { hit = true; break; } t += d; if (t > 20.0) break; } // 着色 vec3 color = vec3(0.05, 0.05, 0.08); // 背景色 if (hit) { vec3 p = ro + rd * t; vec3 n = calcNormal(p); vec3 lightDir = normalize(vec3(1.0, 1.0, 1.0)); float diff = max(dot(n, lightDir), 0.0); // 简单的半兰伯特加环境光 color = vec3(0.2, 0.5, 0.9) * (diff * 0.8 + 0.2); } gl_FragColor = vec4(color, 1.0); }

这段代码虽然简单,但已经包含了 raymarching 的全部核心要素:SDF、步进循环、法线计算、光照。你可以直接把它放进任何 WebGL 模板里跑起来,看到一个上下浮动的蓝色球体。

3.2 参数调优与性能观察

跑起来之后,第一件事是观察帧率。在浏览器开发者工具里打开性能面板,或者简单地在页面上显示 FPS。如果帧率低于 60,优先检查步数是不是设得太高。对于这个单球场景,128 步其实绰绰有余,降到 64 甚至 48 都看不出区别。真正吃性能的是复杂场景和多次采样(比如软阴影、AO、反射)。

另一个容易忽略的点是精度限定符。在移动端或者某些集成显卡上,highp可能不被支持,需要降级到mediump。但mediump在做距离场计算时精度不够,会导致表面出现条纹或噪点。我的做法是在片元着色器开头用#ifdef GL_FRAGMENT_PRECISION_HIGH做条件编译,优先用highp,不支持时再降级。

实操心得:调试 raymarching 时,把步进次数临时调低到 16 左右,可以直观看到光线的“跳跃”轨迹。如果发现光线在某个区域反复小步前进,说明那里 SDF 梯度变化剧烈,可能需要检查域变换是否引入了不连续。

4. 进阶技巧:让画面从“能看”到“好看”

4.1 软阴影与环境光遮蔽的实现细节

基础光照下的球体看起来像塑料玩具,缺乏真实感。加入软阴影和 AO 后,画面立刻有了层次。软阴影的实现思路是:从命中点向光源方向步进,记录过程中最小的d / t比值(d 是 SDF 值,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.2); if (t > maxt) break; } return clamp(res, 0.0, 1.0); }

参数 k 控制阴影软硬程度,k 越大阴影越硬。我一般从 8 到 32 之间试,具体看场景尺度。AO 则是在命中点沿法线方向采样几次,每次采样距离递增,累加(采样距离 - SDF值) / 采样距离,最后归一化。采样次数 5 次左右就能有不错的效果,太多会明显掉帧。

4.2 域重复与分形:用几行代码生成无限世界

raymarching 最让人上瘾的地方是用极少的代码生成极其复杂的结构。域重复是最简单的例子:对坐标做mod运算,就能把一个物体复制成无限阵列。比如把p.xz = mod(p.xz + 2.0, 4.0) - 2.0加在 map 函数开头,球体就会在平面上无限重复。配合距离衰减或者雾效,就能做出“无限走廊”的效果。

分形则更进一步。以 Mandelbulb 为例,它通过反复迭代“球坐标变换 + 幂运算 + 平移”来生成自相似结构。每次迭代都让形状更复杂,但 SDF 的表达式可能只有十几行。这种“简单规则产生无限复杂度”的特性,让 raymarching 在生成艺术领域特别受欢迎。不过分形 SDF 通常不是精确距离场,步进时需要乘以一个小于 1 的系数来避免穿透,常见取 0.5 到 0.9。

注意:域重复和分形都会显著增加 SDF 的计算量。如果帧率下降明显,优先减少步进次数,其次考虑降低分形迭代次数。很多时候视觉上的复杂度并不需要那么高的迭代。

4.3 材质与着色:从单色到丰富表面

给物体上色最简单的方式是根据法线方向或者位置映射颜色。但要做金属、玻璃、次表面散射这些效果,就需要更细致的着色模型。一个常用的技巧是基于位置的调色板,用sin和dot组合出彩虹渐变,再根据法线与视线角度调整明暗。玻璃效果则需要做折射和反射,通常要再发射一条光线做二次 raymarching,性能开销翻倍。

我的建议是先把漫反射和软阴影做扎实,再逐步叠加效果。每加一个效果就测一次帧率,确保在目标设备上能跑满 60 帧。如果目标是移动端,反射和折射要慎用,或者用低精度的近似替代。

5. 常见问题与排查实录

5.1 画面出现条纹、噪点或穿透

这是 raymarching 新手最常遇到的问题。条纹通常是因为表面阈值设得太大,光线在表面附近反复穿越。把阈值调小到 0.0005 以下往往能解决,但要注意步数是否够用。噪点则可能是浮点精度不足,尝试把mediump换成highp,或者把摄像机拉近一点减少远距离计算。穿透问题前面提过,检查 SDF 是否满足 Lipschitz 条件,必要时给步长加一个 0.8 左右的系数。

5.2 性能突然下降的排查思路

性能问题往往不是单一原因。我习惯按以下顺序排查:先看步进次数是不是设得太高,再看 SDF 里有没有昂贵的运算(比如pow、sin、多次length),然后检查是否在循环里调用了 map 函数多次(软阴影和 AO 都会)。如果用了域重复,确认重复次数是否失控。最后看分辨率,有时候把渲染分辨率降到 0.8 倍再放大,帧率能提升 30% 以上,而视觉损失很小。

问题现象可能原因排查方法解决方向
表面条纹阈值过大或精度不足降低阈值、切换 highp阈值 0.0005,精度 highp
随机噪点浮点精度不够检查精度限定符强制 highp 或拉近摄像机
物体穿透SDF 不满足 Lipschitz检查域变换和分形步长乘 0.8 保守前进
帧率骤降步数过高或多次采样逐项注释排查降步数、降分辨率、减采样
边缘锯齿法线 epsilon 过大调小 epsilonepsilon 与阈值同量级

5.3 跨平台兼容性坑点

不同设备对 GLSL 的支持差异很大。移动端 Safari 对循环次数有严格限制,for循环的边界必须是常量,不能是 uniform 变量。某些安卓设备不支持动态索引数组。我的做法是把所有循环写成固定次数,用break提前退出,而不是用变量控制循环上限。另外,mod函数在负数上的行为在不同驱动上可能不一致,建议用p - floor(p / size) * size这种更明确的写法。

实操心得:在多个设备上测试之前,先用一个极简场景(比如单个球体)验证基础管线是否正常。如果极简场景都有问题,那多半是环境配置或精度问题,而不是 raymarching 逻辑本身。

6. 从练习到作品:我的迭代路径

我最初学 raymarching 的时候,花了整整一周才让一个球体正确显示光照。回头看,最大的弯路是试图一次性写出复杂场景,结果出了问题根本不知道是哪一层导致的。后来我改成“每次只加一个变量”的迭代方式:先让球体动起来,再加第二个球体做 smooth min,再加地面,再加阴影,再加 AO。每加一步就截图对比,确认效果符合预期再继续。

另一个让我受益匪浅的习惯是把 SDF 可视化。在 map 函数里直接返回vec3(d)作为颜色输出,可以看到距离场的分布。如果距离场在某处出现突变或者不连续,那里就是步进容易出问题的地方。这个方法帮我定位过好几次穿透和噪点的根源。

现在我做任何 raymarching 场景,都会先搭一个“调试模式”,用一个 uniform 开关切换正常渲染和 SDF 可视化。这个习惯让我在后续加复杂效果时省了大量时间。如果你刚开始学,强烈建议也这么做——它不会让你的画面更好看,但会让你少熬很多夜。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询