Raymarching实战从零构建一个会呼吸的3D球体最近几年实时图形学领域的一个技术分支正悄然改变着我们对“建模”和“渲染”的传统认知。它不再依赖复杂的多边形网格而是用几行看似简单的数学函数就能在屏幕上召唤出形态万千、动态变幻的复杂世界。这就是Raymarching一种基于光线步进算法的渲染技术。对于刚接触GLSL或WebGL的开发者来说它像是一把打开新世界大门的钥匙——门槛看似不低但一旦入门其创造力和表现力会让你惊叹不已。今天我们不谈枯燥的理论推导直接动手。我将带你从零开始用GLSL写一个完整的、可交互的3D球体渲染程序。你会亲手实现GetDist、GetNormal、RayMarch等核心函数并最终看到一个具有光影、甚至简单动画效果的球体在你的屏幕上“活”过来。整个过程就像在代码中雕刻每一步都清晰可见充满乐趣。1. 环境准备与基础概念在开始敲代码之前我们需要一个能运行GLSL的“画布”。对于初学者我最推荐的方式是使用Shadertoy或GLSL Sandbox这类在线编辑器。它们省去了配置本地WebGL环境的繁琐打开网页就能写代码、看效果实时反馈极佳。当然如果你习惯本地开发也可以创建一个简单的HTML文件嵌入一个canvas标签并通过WebGL 2.0上下文来运行我们的着色器。无论选择哪种方式核心的战场都在片段着色器Fragment Shader里。在Raymarching的世界里没有顶点没有模型文件一切视觉元素都由这段运行在GPU上的代码定义。我们的目标就是为屏幕上的每一个像素即一个片段计算出正确的颜色。Raymarching的核心思想可以类比为“盲人摸象”。想象你站在一个完全黑暗的房间相机位置想知道面前物体的形状。你伸出手发射一条光线每次向前摸索一小段距离步进并询问“我碰到东西了吗” 如果没碰到就再向前一步如果碰到了就记录下总共走了多远并根据触碰点的信息比如法线方向来计算它应该是什么颜色。这个“摸索”的过程就是光线步进。注意Raymarching与更传统的光线追踪Ray Tracing关键区别在于“求交”方式。光线追踪需要求解复杂的解析方程来确定光线与物体的精确交点而Raymarching通过迭代逼近更适合渲染由距离场Signed Distance Field, SDF定义的隐式表面。为了开始我们的旅程先建立一个最基础的着色器框架// 这是Shadertoy风格的主函数入口 void mainImage( out vec4 fragColor, in vec2 fragCoord ) { // 1. 将像素坐标归一化到[-1, 1]或[0, 1]的范围并修正宽高比 vec2 uv (fragCoord * 2.0 - iResolution.xy) / iResolution.y; // 2. 定义相机位置ro: ray origin和观察方向rd: ray direction vec3 ro vec3(0.0, 1.0, 3.0); // 相机放在(0, 1, 3)的位置 vec3 rd normalize(vec3(uv, -1.0)); // 观察方向指向屏幕内 // 3. 调用RayMarching函数得到光线与场景相交的距离 float d rayMarch(ro, rd); // 4. 初始化颜色为背景色例如深蓝色 vec3 col vec3(0.1, 0.2, 0.4); // 5. 如果光线击中了物体d 最大距离 if(d 100.0) { // 计算击中点的世界坐标 vec3 p ro rd * d; // 计算该点的光照颜色 col calculateColor(p); } // 6. 输出最终颜色 fragColor vec4(col, 1.0); }这段代码勾勒出了整个渲染管线的骨架。接下来我们要逐一填充血肉定义场景、实现步进、计算法线、添加光照。2. 构建场景定义距离场SDF场景中的一切物体在Raymarching中都是由距离场函数来描述的。距离场函数GetDist(vec3 p)接受一个三维空间点p作为输入返回该点到场景中所有物体表面的最短有符号距离。有符号点在物体外部时距离为正内部时为负。这让我们能轻松判断内外关系。最短距离场景中可能有多个物体函数总是返回最近的那个距离。让我们从最简单的物体开始一个球体和一个无限延伸的地平面。球体的SDF极其简洁。对于一个球心在center半径为radius的球体点p到其表面的距离就是length(p - center) - radius。无限平面的SDF更简单。对于一个在y 0处的水平面点p到它的距离就是p.y因为距离就是y坐标值。现在我们把它们组合进GetDist函数// 定义两个物体的参数 #define SPHERE_RADIUS 1.0 #define SPHERE_CENTER vec3(0.0, 1.0, 0.0) #define PLANE_HEIGHT 0.0 float sdSphere(vec3 p, vec3 center, float radius) { return length(p - center) - radius; } float sdPlane(vec3 p, float height) { return p.y - height; // 平面方程为 y height } float GetDist(vec3 p) { float sphereDist sdSphere(p, SPHERE_CENTER, SPHERE_RADIUS); float planeDist sdPlane(p, PLANE_HEIGHT); // 场景是球体和平面的“并集”我们取最短距离 float dist min(sphereDist, planeDist); return dist; }这里用min操作实现了物体的并集Union。SDF的强大之处在于通过min并、max交、abs差等简单操作就能组合出极其复杂的形状。例如max(dist, -sdSphere(p, anotherCenter, anotherRadius))可以实现一个“挖洞”的效果。为了让你对SDF的组合能力有个直观感受下表列举了几种基本操作及其视觉效果操作类型GLSL代码示例视觉效果描述并集 (Union)min(d1, d2)两个物体合并在一起取外形轮廓。交集 (Intersection)max(d1, d2)只保留两个物体重叠的部分。差集 (Subtraction)max(d1, -d2)从第一个物体中“减去”第二个物体。平滑并集smoothMin(d1, d2, k)两个物体边缘平滑地融合在一起k控制融合范围。有了场景的距离场描述我们终于可以开始让光线“走”起来了。3. 实现光线步进RayMarch引擎RayMarch函数是算法的心脏。它接收光线起点ro和单位化的方向rd沿着光线方向一步步前进直到触及物体表面或走得太远。其过程可以概括为以下几步初始化行进深度depth为 0。开始循环例如最多100步 a. 计算当前点位置pos ro depth * rd。 b. 调用GetDist(pos)得到当前位置到场景的最近距离d。 c. 将距离d累加到depth上depth d。这是关键因为d是当前位置到物体表面的安全距离意味着在以当前位置为圆心、d为半径的球体内不可能有物体表面。因此我们可以安全地向前跳跃d的距离而不会错过任何物体。判断终止条件如果d小于一个很小的阈值如0.001我们认为已经足够接近表面视为“命中”。如果depth超过了预设的最大距离如100.0认为光线逃逸到了无穷远视为“未命中”。返回最终的depth命中距离或一个表示未命中的大数。代码实现如下#define MAX_STEPS 100 // 最大步数防止无限循环 #define MAX_DIST 100.0 // 最大探测距离 #define SURF_DIST 0.001 // 表面判定阈值 float rayMarch(vec3 ro, vec3 rd) { float depth 0.0; // 从起点开始的行进总距离 for(int i 0; i MAX_STEPS; i) { // 计算当前探测点的世界坐标 vec3 p ro depth * rd; // 查询当前点到场景的最近距离 float d GetDist(p); // 安全地向前步进这段距离 depth d; // 终止条件判断 if(d SURF_DIST) { // 非常接近表面视为击中 return depth; } if(depth MAX_DIST) { // 走得太远视为未击中 break; } } // 循环结束仍未击中返回一个大于MAX_DIST的值 return MAX_DIST 1.0; }这个循环就是“步进”的过程。效率优化的一个关键点在于场景越简单GetDist计算越快或者光线命中/未命中越快循环次数少整体渲染就越快。在复杂场景中优化GetDist函数和采用更智能的步进策略如自适应步长至关重要。4. 计算光照与表面细节当rayMarch函数返回一个有效的距离后我们就得到了光线与物体的交点p。接下来要让这个点看起来像一个真实的表面需要做两件事计算表面法线和应用光照模型。4.1 获取表面法线法线是垂直于物体表面的向量是计算所有光照效果漫反射、高光等的基础。在SDF的世界里计算法线有一种非常优雅且高效的方法利用梯度。某一点的距离场函数值变化最快的方向就是该点法线的方向。我们可以通过在该点附近进行多次采样来近似这个梯度。vec3 GetNormal(vec3 p) { // 一个很小的偏移量用于计算梯度 float eps 0.001; // 分别对x, y, z方向进行微小扰动计算距离场的变化 float d GetDist(p); float dx GetDist(p - vec3(eps, 0, 0)) - d; float dy GetDist(p - vec3(0, eps, 0)) - d; float dz GetDist(p - vec3(0, 0, eps)) - d; // 梯度向量 (dx, dy, dz) 近似于法线方向归一化后得到单位法线 vec3 n vec3(dx, dy, dz); return normalize(n); }这里eps的值需要权衡太小会受数值精度影响太大会降低法线精度。0.001是一个在大多数场景下都表现良好的经验值。4.2 实现基础光照有了法线我们就可以实现光照。我们从最简单的漫反射Lambertian开始。漫反射的光强只取决于光线方向与表面法线夹角的余弦值。// 一个简单的定向光光源 vec3 lightDir normalize(vec3(1.0, 2.0, -1.0)); // 光从右上前方来 vec3 lightCol vec3(1.0, 0.9, 0.8); // 暖白色光 float GetDiffuse(vec3 p) { vec3 n GetNormal(p); // 获取交点法线 float diff dot(n, lightDir); // 计算点积 diff max(diff, 0.0); // 确保非负背面不受光 return diff; }但这还不够真实物体没有阴影。在Raymarching中添加软阴影是一项经典且效果出众的技巧。其原理是从表面点p向光源方向发射另一条光线阴影光线检查这条光线是否被遮挡。float GetSoftShadow(vec3 p, vec3 lightDir) { float shadow 1.0; float t 0.02; // 从表面稍微偏移一点开始避免自阴影 float maxDist length(lightPos - p); // 到光源的距离 for(int i 0; i MAX_SHADOW_STEPS; i) { float dist GetDist(p lightDir * t); if(dist SURF_DIST) { // 完全被遮挡 return 0.0; } // 利用距离场信息计算软阴影距离物体越近阴影越深 shadow min(shadow, 32.0 * dist / t); t dist; // 同样采用安全步进 if(t maxDist) break; } return clamp(shadow, 0.0, 1.0); }现在整合光照和阴影我们的calculateColor函数可以这样写vec3 calculateColor(vec3 p) { vec3 n GetNormal(p); float diff GetDiffuse(p); float shadow GetSoftShadow(p n * SURF_DIST * 2.0, lightDir); // 从表面上方一点发射阴影光线 // 基础颜色例如给球体一个颜色地面为灰色 vec3 objCol vec3(0.8, 0.4, 0.2); // 球体为橙色 if(p.y 0.1) { // 简单判断是否为地面 objCol vec3(0.7); // 地面为灰色 } // 最终颜色 物体本色 * 漫反射强度 * 阴影强度 环境光 vec3 col objCol * diff * shadow; col objCol * 0.1; // 添加一点环境光让背光面不是全黑 return col; }5. 添加交互与动态效果静态的球体已经成型但Raymarching的魅力在于动态和交互的便捷性。让我们赋予场景一些生命。5.1 让球体动起来修改球体的SDF让它的中心位置随时间变化。在Shadertoy中全局变量iTime提供了运行时间秒。// 在GetDist函数中修改球体中心的计算 float GetDist(vec3 p) { // 让球心在x和z方向上做圆周运动 float time iTime; vec3 movingCenter SPHERE_CENTER vec3(sin(time)*0.5, 0.0, cos(time)*0.5); float sphereDist sdSphere(p, movingCenter, SPHERE_RADIUS); float planeDist sdPlane(p, PLANE_HEIGHT); return min(sphereDist, planeDist); }5.2 实现简单的相机旋转我们也可以让相机围绕场景旋转提供更全面的观察视角。修改主函数中的ro相机原点和rd观察方向的计算。void mainImage( out vec4 fragColor, in vec2 fragCoord ) { vec2 uv (fragCoord * 2.0 - iResolution.xy) / iResolution.y; // 相机绕y轴旋转 float camRadius 5.0; float camAngle iTime * 0.5; // 旋转速度 vec3 ro vec3(sin(camAngle)*camRadius, 2.0, cos(camAngle)*camRadius); vec3 lookat vec3(0.0, 1.0, 0.0); // 始终看向场景中心 // 计算相机坐标系的前、右、上三个轴 vec3 forward normalize(lookat - ro); vec3 right normalize(cross(vec3(0.0, 1.0, 0.0), forward)); vec3 up cross(forward, right); // 根据uv和相机坐标系计算每个像素的光线方向 vec3 rd normalize(uv.x * right uv.y * up forward * 2.0); // 2.0是“焦距”因子 // ... 后续的rayMarch和着色代码保持不变 float d rayMarch(ro, rd); vec3 col vec3(0.1, 0.2, 0.4); if(d MAX_DIST) { vec3 p ro rd * d; col calculateColor(p); } fragColor vec4(col, 1.0); }5.3 尝试更多SDF形状掌握了球体和平面的SDF后你可以轻松地将它们替换或组合成其他形状。这里提供几个常见基本形状的SDF函数你可以直接拿来用在GetDist中做实验// 立方体 (中心在c各半轴长为b) float sdBox(vec3 p, vec3 c, vec3 b) { vec3 q abs(p - c) - b; return length(max(q, 0.0)) min(max(q.x, max(q.y, q.z)), 0.0); } // 圆环 (中心在c大半径R管半径r) float sdTorus(vec3 p, vec3 c, vec2 t) { p - c; vec2 q vec2(length(p.xz) - t.x, p.y); return length(q) - t.y; } // 圆柱体 (端点a到b半径r) float sdCappedCylinder(vec3 p, vec3 a, vec3 b, float r) { vec3 ba b - a; vec3 pa p - a; float baba dot(ba,ba); float paba dot(pa,ba); float x length(pa*baba - ba*paba) - r*baba; float y abs(paba - baba*0.5) - baba*0.5; float x2 x*x; float y2 y*y*baba; float d (max(x,y)0.0) ? -min(x2,y2) : (((x0.0)?x2:0.0)((y0.0)?y2:0.0)); return sign(d)*sqrt(abs(d))/baba; }把这些函数加入你的工具箱通过min,max,abs等操作进行组合你就能创造出无限可能的几何世界。我第一次用SDF做出一个带洞的、平滑过渡的扭曲圆柱时那种纯粹由数学公式生成复杂模型的成就感是传统建模软件无法给予的。最后把以上所有代码片段按逻辑组装起来你就得到了一个完整的、动态的Raymarching 3D场景。点击运行看着那个由你亲手用代码定义和照亮的球体在虚拟空间中优雅运动光影随之变幻——这就是图形编程最原始的乐趣。