先聊个实际场景。你从网上随便找一个三维模型文件打开一看是几十万甚至上百万的三角形网格顶点坐标密密麻麻排了十几万行。模型倒是有鼻子有眼就是加载慢、内存大、光线求交还得建加速结构。反过来如果我只有一句话半径1、球心在原点的单位球你甚至不用写任何顶点数据实时渲染程序就能直接画出这个球。这句话的数学描述方式就是计算机图形学几何建模里常说的隐式曲面而那一大堆三角形网格则是显式曲面的典型代表。两者没有绝对的好坏但选错了表示方式后续的存储、求交、建模、渲染一条链路都会跟着别扭。这篇文章从图形学课程里几何1的内容出发把隐式曲面里的代数形式、CSG构造实体几何、距离函数、分形几何以及显式曲面里的常见表示法逐一拆开讲最后给一套可以直接上手的SDF光线步进渲染实操代码。想系统补几何表示这块知识的同学或者自己写渲染器时纠结该用哪种几何表示的开发者这篇文章应该能帮你把思路理顺。1. 先分清两件事隐式曲面的判断和显式曲面的枚举1.1 隐式曲面不告诉你点在哪只告诉你哪些点属于它隐式曲面最核心的定义就是一个函数给空间里任意一个点 (x, y, z)代入某个函数 f(x, y, z)如果结果等于0就说明这个点刚好落在曲面上结果大于0点在曲面一侧小于0点在另一侧。等于0的点组成的面就是我们看到的曲面。这个定义乍一听有点绕但它有个非常实用的推论判断一个点是否在物体内部只需要算一次函数值看正负就行。这就是为什么很多碰撞检测、物理模拟、以及程序化建模工具偏爱隐式表示。换句话说隐式曲面是一面隐形的筛子它不直接告诉你曲面长什么样而是给你一个可以随时提问的判据这个坐标点到底在不在我的体内以单位球为例隐式形式是 x² y² z² - 1 0。任意给一个点代入算一下结果大于0就是在球外小于0在球内等于0在球面上。整个过程干净利落。1.2 显式曲面直接把点列出来或者给个参数规则显式曲面走的是另一条路。常见的形式有两种第一种是参数映射比如给一个二维参数 (u, v)通过函数 P(u, v) 直接算出三维坐标点第二种就是最常用的网格/点云把成千上万个顶点坐标存下来再连成边、面。显式曲面最大的特点是可枚举你问它表面有哪些点它能给你列出一串坐标你问它这个点是不是在内部它反而要费一番功夫。举一个最直白的例子。单位球的参数形式可以写成 x cos(u)sin(v), y sin(u)sin(v), z cos(v)u 和 v 在一定范围内取遍所有值就能把整个球面上的点都生成出来。这种枚举性质的几何表示恰恰是GPU最喜欢的——渲染管线本质上就是一个逐个顶点处理的流水线给它一批顶点坐标它就流水线式地完成变换、光栅化、着色。为了帮你快速建立直觉我把两种表示的关键差异整理成了表格对比维度隐式曲面显式曲面判断点是否在内部代入函数算正负O(1)需要做射线求交、法线计算等成本高枚举表面上的点困难需要解方程求点天然支持遍历顶点列表即可描述复杂几何用CSG、SDF、分形可以覆盖很多场景需要大量顶点/面片逼近GPU渲染友好度需要额外转换或使用光线步进原生友好光栅化管线直接吃顶点典型代表代数曲面、SDF、分形三角形网格、贝塞尔曲面、点云简单说隐式擅长回答提问显式擅长展示全貌。2. 隐式曲面的四种形态从纯数学到程序化建模2.1 代数形式最朴素的隐式定义代数形式就是我们高中就能见到的那些方程平面 ax by cz d 0球面 x² y² z² - r² 0椭球面、双曲面、圆环面也都有对应的多项式方程。这类曲面的优势是精确、直观、内存占用几乎为零——一个球在代码里就是四个浮点数。但代数形式的短板也很明显真实世界的物体没有几个能用单个多项式精确描述。所以纯代数曲面在工业界用得不多它更多是作为基础零件被组合到下一节要讲的CSG体系中。不过在图形学教学里代数形式的求交推导是绕不开的基本功一条射线代入曲面方程得到一个一元二次方程解判别式就能得到交点。整个光线跟踪的入门教程几乎都是从球面求交开始讲的因为这个例子干净、可验证、性能高。2.2 CSG构造实体几何用布尔运算把简单形状组合成复杂零件CSG全称Constructive Solid Geometry思路一句话概括用并、交、差三种布尔运算把简单几何体球、柱、立方体等组合成复杂模型。这个思想非常像雕刻先拿一块长方体减去一个球得到孔洞再并上一个圆柱当凸台零件的雏形就出来了。在SDF语境下CSG的运算对应一组极其简洁的数学规则并集A ∪ B距离函数取 min(d_A, d_B)代表到两个物体中较近的那个的距离交集A ∩ B取 max(d_A, d_B)代表同时满足两个条件的区域差集A - B取 max(d_A, -d_B)注意B的距离取负号含义是在A的内部且在B的外部。我在实际写渲染器的时候最喜欢用CSG做程序化建模烟囱、齿轮、机箱外壳这些东西几行布尔代码就出来了完全不用建模软件导网格。而且CSG天然是无损的布尔运算永远能保持曲面方程的逻辑关系不像网格布尔运算那样动辄产生裂缝和退化三角形。2.3 距离函数与SDF隐式曲面里最常用的反应场距离函数Distance Function给空间中的每个点返回一个数值这个数值是该点到曲面集合的最短距离。如果带符号就叫有向距离场Signed Distance Field简称SDF点在物体内部取负值在表面取0在外部取正值。为什么SDF在渲染里这么重要因为它天然支持一种叫光线步进的渲染算法。普通光线求交要解方程而SDF的做法是从视点出发沿着光线方向一步步走每走一步都拿当前点代入SDF函数得到一个安全距离——我可以放心地朝前走这么多绝对不会穿模越界。每一步都保证不撞进物体内部一旦步进距离小于某个极小阈值就认为光线命中了表面。用SDF表述单位球只比代数形式多一点信息d length(p) - 1。前者只告诉你正负后者还告诉你离表面还有多远。多出的这个距离信息让程序能自适应地控制步长效率比盲目的固定步长高出几个量级。这个特性我在后面实操部分会重点展开。2.4 分形几何无限细节的隐式迭代分形几何和前面三种完全不同它不讲一个函数值判断内外而是靠递归迭代生成细节无限的结构。图形学里常见的分形有两种一种是静态的迭代函数系统IFS比如蕨类植物、雪花通过多次迭代应用一组仿射变换生成最终图案另一种是复平面上著名的Julia集、Mandelbrot集——每个点不断迭代复变函数 z z² c看它逃逸到无穷的速度。分形作为隐式曲面理解时它的判断逻辑变成了给定一个三维坐标点迭代若干次看它的值是否发散。发散就说明点在集合外部不发散则说明在内部。靠这个判断就能渲染出无限细致的三维分形表面。代价是迭代次数决定细节程度——迭代太少会糊迭代太多则性能爆炸。所以分形渲染里到处是精度换速度的调优技巧这一点我放到后面的问题排查部分专门讲。3. 显式曲面参数映射、网格和点云如果说隐式曲面是函数思维显式曲面就是数据思维。图形学渲染管线的底层处理器只认三角形所以绝大多数实际渲染使用的都是显式表示。这里要分清楚几个概念3.1 参数曲面用控制点拉出一张面参数曲面通过定义二维参数域通常是一个正方形区域到三维空间的映射来生成表面。最有名的例子是贝塞尔曲面和B样条曲面。一张双三次贝塞尔曲面有16个控制点修改任何一个控制点曲面形状就会朝它鼓过去。这让参数曲面具备了极强的交互编辑能力所以工业软件里建模、汽车车身设计都大量使用NURBS曲面。参数曲面的隐形优势是拓扑保持它天然是一张连续面没有裂缝、没有多边形数量过高等问题。但代价是边界处理麻烦而且把参数曲面离散成三角形网格供GPU使用本身就需要一个细分过程。3.2 点云与三角形网格最通用的显式存储点云就是一大串三维坐标点没有边、没有面。它非常适合激光扫描、深度相机这类测一堆点但说不上拓扑的数据源。缺点也很明显不知道点与点之间该不该连起来渲染出来等于一堆悬浮的点粒。所以点云通常要经过重建步骤才能变成三角形网格。三角形网格是当今渲染的主力。顶点、边、面三层结构每个面就是一个三角形。GPU能直接把整个网格喂给光栅化单元逐三角形上色。模型表面越复杂需要的三角形就越多存储和传输成本跟着上涨。这里还会碰到不同层次的细节优化网格简化、细分等都是围绕用最少的面保留最多的形这一目标。3.3 两者混用才是工业界的常态说了这么多我的核心观点是没有哪个表示法全方位碾压另一个。实际渲染管线里最常见的流程是建模阶段用SDF/CSG这种隐式方式做布尔运算和程序化细节让建模逻辑烧脑一点也没关系等几何定型之后把SDF采样成三角形网格交给光栅化渲染管线。这样做的好处是两头都占建模期灵活、跑起来快。有人可能会问那为什么渲染SDF不直接用光线步进非得转网格答案很简单光线步进在离线渲染、实时小物体、粒子特效里表现很好但面对全场景大规模实时光追光线步进要逐像素走几十上百步成本比三角形求交高不少而网格天生就是为GPU优化的。所以转换的意义在于进入渲染期的性价比。一个典型的转换方案是Marching Cubes——把空间划分成格子在格子里找零值面片输出三角网格。这个算法在医学影像重建里同样是主力。4. 实操用SDF CSG渲染一个有孔板的程序化建模Demo这部分我会给出一份可以直接抄作业的OpenGL/GLSL风格伪代码实现的是一个方块减去两个球、再并上一个球的组合体。读者可以把这段代码植入自己的实时渲染框架跑起来看效果。// 基础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); } // CSG运算 float opUnion(float d1, float d2) { return min(d1, d2); } float opSubtract(float d1, float d2) { return max(d1, -d2); } float opIntersect(float d1, float d2) { return max(d1, d2); } // 组合体SDF float sceneSDF(vec3 p) { vec3 boxCenter vec3(0.0, 0.0, 0.0); float box sdBox(p - boxCenter, vec3(1.0, 0.8, 0.8)); // 两个球体作为钻孔 float hole1 sdSphere(p - vec3(0.6, 0.0, 0.0), 0.35); float hole2 sdSphere(p - vec3(-0.6, 0.0, 0.0), 0.35); // 先减掉两个孔 float body opSubtract(box, hole1); body opSubtract(body, hole2); // 再并上一颗装饰球 float knob sdSphere(p - vec3(0.0, 0.9, 0.0), 0.3); return opUnion(body, knob); } // 光线步进主循环 float rayMarch(vec3 ro, vec3 rd) { float t 0.0; for (int i 0; i 64; i) { vec3 p ro rd * t; float d sceneSDF(p); if (d 0.0001) return t; // 击中表面 if (t 10.0) return -1.0; // 未命中 t d; // 沿安全距离前进 } return -1.0; }代码逻辑很直白sdBox是一个重要的基础SDF它利用中心到边界距离减半的思路实现。abs处理对称性max(q, 0.0)处理盒外部分min(max(...), 0.0)处理盒内凹陷部分。这个写法建议直接记下来几乎所有盒状物体都会用。opSubtract(box, hole)里的max(box, -hole)是CSG差集的标准写法含义是去A内部且不在B内部。光线步进循环最大64步、最大射程10.0这是我反复测试觉得看场景复杂度最稳妥的默认值。步数太少画面会出现条纹状漏检太多则性能扛不住。判定命中阈值为0.0001。这个阈值太小会让步进走太密太大则会让表面看起来发胖。这里0.0001配合 float 精度足够。接着补一段法线计算和简单光照的代码让最终渲染出来的模型有立体感// 用中心差分求梯度作为SDF的法线近似 vec3 calcNormal(vec3 p) { const float h 0.0001; vec2 k vec2(1.0, -1.0); return normalize( k.xyy * sceneSDF(p k.xyy * h) k.yyx * sceneSDF(p k.yyx * h) k.yxy * sceneSDF(p k.yxy * h) k.xxx * sceneSDF(p k.xxx * h) ); } // 简单的兰伯特漫反射光照 float lambert(vec3 p, vec3 lightDir) { vec3 n calcNormal(p); return max(dot(n, lightDir), 0.0); }这里calcNormal用了经典的四个交叉采样点求梯度做法效果好于单轴差分而且只需要调4次SDF函数实时渲染完全可以接受。我自己最早写的时候图省事只做了normalize(vec3(d(xh)-d(x-h), ...))的单轴版本结果法线在斜面上有可见的阶梯感。换成上面这个交叉采样后平整很多代价只有4次SDF取样几乎没有性能损失。5. 常见问题与排查技巧实录5.1 画面出现细碎条纹或镂空洞这是光线步进最经典的水土不服症状。我排查过几次根因基本逃不出两个一是步进次数太少64步限制下光线还没走完一个横向跨度就超限返回了二是命中阈值太小表面附近来回震荡始终进不了判定圈。最简单的修法就是把maxSteps提到128、epsilon调到0.001试一下。如果只是局部镂空可以检查是不是CSG差集运算写反了符号——opSubtract(d1, d2)写成min(d1, -d2)会得到完全相反的布尔结果。5.2 SDF的距离不准出现表面偏移当多个CSG操作叠在一起时距离函数不再是严格的几何最短距离而只是保守距离也就是说我们估到的值比真实值小一点。这会让光线步进前进得太慢性能变差。常见的坑是用abs(max(...))这种写法去算带圆角的SDF圆角半径加得越大距离越接近安全距离而非精确距离。解决办法是不要把距离函数当成物理精确值步进时只依赖任何d 阈值即命中的判据把阈值稍微放大一些让误判被吸收掉。5.3 分形细节闪烁、边缘锯齿分形几何里迭代次数有限导致细节层次在相机拉近时跟不上提示需求。最常见的修法是根据当前点到相机距离动态调整迭代次数近处迭代30次、远处迭代8次。比起固定高迭代次数这样既省性能又不会出现远处细节爆炸。另一个坑是分形的内/外判断在迭代结束时依赖模长阈值阈值取得太大所有点都判成外部结果画面空白取太小则是整片噪声。我一般用3.0作为模长阈值视觉效果和性能相对平衡。5.4 判断模型是否封闭水密性如果你把一个SDF转换成了网格或者直接拿到一个不保证封闭的网格想判断它内部外部千万别一个点接着一个点盲目测试。最常用的稳健方法是从该点向任意方向发一条射线统计射线与网格表面的交点个数奇数次交点在内部偶数次在外部。注意要把恰好经过顶点或边的情况做扰动处理否则计数会不稳定。我在处理半透明模型的内部体积计算时就是用这个办法配合浮点扰动效果比单纯查SDF正负更可靠。5.5 性能优化SDF场景如何加速SDF光线步进有几种经典优化实测下来效果排序大概是包围体积剔除先粗算AABB再进场景SDF、自适应步长距离越大步子越大、分层采样远处用大步近处精细。其中最容易见效的是先算一个粗AABB射线没碰到AABB就提前返回对空背景的大场景能直接省掉50%以上无效步进。6. 关于选择的一点点个人心得说到最后我梳理一下自己这些年做图形学项目和渲染器开发的实际体会。如果你就是给学生作业、Demo、小型可视化做个场景显式网格几乎永远是首选因为它是渲染器的母语生态成熟、避坑容易如果你做的是程序化建模、布尔运算、体积效果、或者想尝试用数学直接画复杂形状SDF和CSG的组合值得认真学透这个组合动手能力极强一套代码能控制形状还能做混合、圆角、噪声扰动。另一个实用技巧是在做SDF场景时永远把场景函数写成一个可复用的sceneSDF(vec3 p)纯函数方便随时换形状、做布尔组合、加重复变换。我在自己的项目里就把材质和几何分离材质只认sceneSDF返回的距离值和法线这样后续换形状、做LOD、甚至加物理模拟都只需要改一个函数。最后再分享一个小技巧当SDF场景配合硬阴影时别只做一次光线步进回看光源要做一个软阴影版本——从表面点向光源方向走若干步记录步进过程中最小距离与步长的比值用这个比值弱化阴影边缘。实测下来阴影柔和自然很多性能开销只有几倍步进完全划得来。当年我第一次把软阴影加上去之后模型瞬间就从教学例程变成了有质感的东西你可以试一下这个效果。