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 可视化。这个习惯让我在后续加复杂效果时省了大量时间。如果你刚开始学强烈建议也这么做——它不会让你的画面更好看但会让你少熬很多夜。