ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

隐式曲面与显式曲面:SDF/CSG几何表示及光线步进实战解析

隐式曲面与显式曲面:SDF/CSG几何表示及光线步进实战解析

先聊个实际场景。你从网上随便找一个三维模型文件,打开一看是几十万甚至上百万的三角形网格,顶点坐标密密麻麻排了十几万行。模型倒是有鼻子有眼,就是加载慢、内存大、光线求交还得建加速结构。反过来,如果我只有一句话:半径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(x+h)-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场景配合硬阴影时,别只做一次光线步进回看光源,要做一个"软阴影"版本——从表面点向光源方向走若干步,记录步进过程中最小距离与步长的比值,用这个比值弱化阴影边缘。实测下来阴影柔和自然很多,性能开销只有几倍步进,完全划得来。当年我第一次把软阴影加上去之后,模型瞬间就从"教学例程"变成了"有质感的东西",你可以试一下这个效果。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进