Skip to main content

Chapter 8: Geometry and Vector Calculus

图形程序看到的几何体,通常已经从曲线、曲面、扫描体或雕刻模型转换成三角形网格。渲染管线继续使用这些三角形、顶点属性、纹理和 shader 计算一帧图像。读完本章后,读者应能追踪一个曲面从参数定义进入网格属性,再进入 fragment shader 光照计算的路径,并能判断法线、切线、向量场和 Lambert 漫反射结果是否一致。

本章的贯穿材料是一块来自 DCC 工具的弯曲装饰面板。它的原始形状可以由 Bezier 或 NURBS 曲面表达,导入阶段把它采样成三角形网格,运行时把 POSITIONNORMALTANGENTTEXCOORD_0 传给 shader,fragment shader 再结合 normal map 和一盏方向光计算 Lambert 漫反射。这个材料覆盖本章所有对象:曲线曲面决定采样几何,法线和切线决定局部朝向,向量场和梯度解释高度与 SDF 的几何方向,最后的 dot 乘法把方向关系变成亮度。

图形学中的 vector calculus 不应脱离图像结果理解。梯度、散度和旋度在数学上属于向量分析,在渲染工程里分别对应“表面朝哪边变化最快”“流场是否产生压缩或膨胀”“局部是否旋转”。这些量最终会进入法线重建、SDF shading、流体贴图、噪声扰动和 debug 可视化。

本章建立的判断链是:先确定几何来源和采样误差,再确认顶点属性的空间和接缝规则,接着检查向量场量是否进入正确的 shader 输入,最后用一次 Lambert 计算复查方向、归一化、clamp 和能量尺度。这个顺序能把“模型看起来有棱角”“法线贴图接缝明显”“光照方向反了”“亮度异常”这类视觉问题放回可检查的数据路径。

8.1 基本曲线与曲面的数学定义

曲线和曲面在渲染管线中的工作定义是:用少量控制数据描述连续几何,再在资产导入、CPU 预处理、tessellation stage 或 compute pass 中采样成 GPU 可处理的离散点、线段或三角形。GPU 的标准光栅化路径最终处理的是 primitive,连续表达提供形状来源,采样结果提供运行时输入。

以弯曲装饰面板为例,DCC 工具中设计师可能调节少量控制点来改变边缘弧度。控制点给出形状的控制句柄,参数域给出曲线或曲面上的位置索引,采样策略决定导出的顶点数量。渲染工程需要关心三件事:采样后的 silhouette 是否稳定,法线是否连续,顶点和索引数量是否符合预算。

Bezier 曲线使用一组控制点和参数 t[0,1]t \in [0,1] 定义位置。三次 Bezier 常见形式为 B(t)=(1t)3P0+3(1t)2tP1+3(1t)t2P2+t3P3B(t)=(1-t)^3P_0+3(1-t)^2tP_1+3(1-t)t^2P_2+t^3P_3。在图形工程中,P0P_0P3P_3 通常对应端点,P1P_1P2P_2 控制进入和离开端点的方向。这个公式在 runtime shader 中也能求值,但大部分静态资产会在导入阶段预先采样,因为普通 mesh 渲染路径更容易进入 batching、LOD、碰撞和 lightmap 管线。

Bezier 曲面的思路类似,只是参数从一个 tt 扩展到 (u,v)(u,v)。一个 bicubic Bezier patch 可以看成在两个方向上同时做 Bezier 求值。它适合表达平滑工业曲面和面板轮廓。采样时,导入器会在 uuvv 方向上取网格点,再把四边形采样格拆成三角形。采样密度越高,曲面越接近连续形状,同时 vertex buffer、index buffer、法线切线生成、cache 压力和导入时间都会上升。

B-Spline 通过 knot vector 和控制点定义分段多项式曲线。它的工程价值在于局部控制:调整一个控制点通常只影响有限区间。NURBS 在 B-Spline 上加入权重,能稳定表达圆弧、圆柱和许多 CAD 曲面。NURBS 的 rational 权重会影响曲面点的位置,因此导入器需要在统一精度下完成求值,再把结果转成 mesh 数据。对实时渲染而言,NURBS 的核心问题通常是采样策略、法线一致性和与材质 UV 的对应关系。

连续性决定采样后视觉是否顺滑。C0C^0 表示位置连续,曲线或曲面片能接上;C1C^1 表示一阶导数连续,切线方向一致;C2C^2 表示二阶导数连续,曲率变化更平滑。渲染中常见的“硬边”来自有意断开的法线或切线空间,常见的“假接缝”来自几何位置接上了,但导出的 NORMALTANGENT 或 UV chart 在边界上发生跳变。

曲线曲面进入渲染管线的路径可以用一个数据流表示。这个图只覆盖静态网格导入路径;运行时 tessellation 和 procedural patch 会把采样阶段后移到 GPU。

图中的关键边界在 参数域采样法线切线生成。采样决定几何轮廓,属性生成决定局部方向。一个曲面采样得很密仍然可能出现 normal map 接缝;一个曲面采样较稀也可以通过合理的法线平滑获得可接受的局部光照。两类问题需要分开定位。

工程上判断采样质量时,先看 silhouette,再看曲率变化区,最后看材质细节。silhouette 暴露几何精度,曲率变化区暴露法线插值质量,材质细节暴露 UV 与切线空间。对本章的弯曲面板,若边缘在近景中出现明显折线,优先提高曲面采样或使用 LOD;若边缘顺滑但高光出现断裂,优先检查法线、切线和接缝拆分。

8.2 法线、切线与几何属性计算

法线是表面局部朝向,切线是沿着表面某个参数方向或 UV 方向的局部方向。渲染管线使用它们回答同一个问题:fragment shader 当前处理的表面点应该如何把纹理、光线和视线放到同一个局部坐标系中计算。没有稳定的法线和切线,曲面形状、normal map 和光照会产生相互矛盾的方向。

面法线来自三角形两条边的叉乘。给定三角形顶点 P0,P1,P2P_0,P_1,P_2,未归一化的面法线可以写成 (P1P0)×(P2P0)(P_1-P_0) \times (P_2-P_0)。这个向量的方向受顶点绕序影响,长度和三角形面积相关。归一化后,它提供 flat shading 的朝向;保留长度作为权重时,它可以参与面积加权的顶点法线累加。

顶点法线通常由相邻三角形的面法线加权平均得到。面积加权会让大三角形对顶点方向影响更大,角度加权会让顶点处的夹角成为权重。面积加权适合三角形大小相对均匀的网格,角度加权在长瘦三角形较多时更稳定。导入器需要在 smoothing group、hard edge、UV seam 和 material split 处拆分顶点,因为同一个几何位置可以拥有多组不同的属性。

切线空间由 tangent、bitangent 和 normal 构成,常写作 TBN。normal map 里的 RGB 值表达的是切线空间中的法线方向,因此 shader 需要把贴图法线从切线空间变换到 world space 或 view space。以 glTF 2.0 为例,normalTexture 描述为 tangent-space normal texture,TANGENTw 分量参与 bitangent 符号计算;规范给出的关系是 bitangent = cross(normal.xyz, tangent.xyz) * tangent.w,相关字段可在 glTF 2.0 normal texture and tangent description 中回溯。

normal map 的常见解码路径很短,但每一步都对应一个可见错误。采样值先从 [0,1][0,1] 映射到 [1,1][-1,1],再用 TBN 变换到目标空间,最后归一化。若贴图的 Y 通道约定和引擎相反,凹凸会出现上下反转;若 tangent.w 丢失,镜像 UV 区域会出现亮暗翻转;若法线变换使用了普通 model matrix,非均匀缩放会破坏方向。

下面的 shader 片段展示 TBN 和 normal map 进入光照前的最小路径。它假设 vNormalvTangentvLightDir 已经处在同一空间,vTangent.w 存储 bitangent 的符号。

vec3 N = normalize(vNormal);
vec3 T = normalize(vTangent.xyz);
vec3 B = normalize(cross(N, T) * vTangent.w);
mat3 TBN = mat3(T, B, N);

vec3 sampledNormal = texture(normalMap, vUV).xyz * 2.0 - 1.0;
vec3 shadingNormal = normalize(TBN * sampledNormal);
vec3 lightDir = normalize(vLightDir);
float ndotl = max(dot(shadingNormal, lightDir), 0.0);

这段代码的检查点是空间一致性。vNormalvTangentBvLightDir 必须在同一坐标空间中比较;sampledNormal 位于 tangent space,经过 TBN 后才进入光照空间。dot 的几何含义可以在 GLSL dot reference 中核对,maxclamp 的作用是把背光方向的负值收束到可用亮度范围,clamp 的定义可见 GLSL clamp reference

几何接缝需要按属性看待。位置相同的两个顶点,如果 UV 不同,切线空间通常也不同;如果 material 不同,normal map 约定也可能不同;如果硬边需要保留,顶点法线应分裂。把它们强行合并会让 vertex cache 看起来更省,但 shader 输入会丢失局部方向差异。导入阶段的去重规则应使用完整属性键,例如 position、normal policy、UV、material id 和 tangent sign。

debug normal 是定位方向错误的第一张图。把 world normal 映射成颜色 normal * 0.5 + 0.5 后,连续曲面应该产生连续渐变;硬边应该产生明确色块;UV seam 处若出现不符合设计的颜色跳变,问题可能来自法线拆分、切线生成或 normal map 约定。debug tangent 和 debug bitangent 能继续区分“法线错”和“切线空间错”。

8.3 向量场与梯度运算在图形学中的应用

向量场把空间中的每个点映射到一个方向和大小。图形工程使用向量场表达局部变化:高度场的坡度、SDF 的表面方向、流体速度、风场扰动、噪声梯度和粒子运动。它和普通 vertex attribute 的区别在于,向量场通常需要在位置或纹理坐标上被查询,并且查询结果会随着空间位置连续变化。

梯度是标量场变化最快的方向。给定高度场 h(x,y)h(x,y),梯度 h=(h/x,h/y)\nabla h=(\partial h/\partial x, \partial h/\partial y) 表示高度在纹理平面上的坡度。高度场法线可以由坡度构造,例如 N=normalize((h/x,h/y,1))N=normalize((-\partial h/\partial x, -\partial h/\partial y, 1))。这条关系把一张灰度高度图转换成可用于光照的方向,它也是许多 normal map 生成器的基本思想。

SDF 的梯度用于恢复隐式表面的法线。SDF 是 signed distance field,它在空间中存储点到表面的带符号距离。距离值为零的位置对应表面,梯度方向对应距离增长最快的方向。ray marching 命中 SDF 表面后,shader 通常用有限差分采样周围几个点来估计梯度,再把估计结果归一化为表面法线。这个法线随后可以进入和 mesh 法线相同的 Lambert、Phong 或 PBR 光照路径。

散度描述向量场在局部是否向外扩张或向内收缩。流体、烟雾、屏幕空间流动贴图和粒子速度场中,散度大的区域会出现源或汇的视觉倾向。实时流体模拟常把散度作为 pressure solve 的输入,用压力场修正速度,使流体在视觉上保持近似不可压缩。这里的关键点是,散度本身作为生成或修正速度场的中间证据,通常先进入模拟步骤,再通过速度、密度或颜色间接影响画面。

旋度描述向量场的局部旋转。curl noise 常用于烟雾、能量流、风场和魔法特效,因为它能生成具有旋转感的流线。对一个速度场 V(x,y,z)V(x,y,z),curl 给出局部涡旋轴和强度。shader 或 compute pass 可以把 curl 作为粒子速度扰动,使粒子沿着局部旋转结构运动。视觉结果是流动具有卷曲结构,而非单纯沿一个方向平移。

高度场、SDF 和流体例子共享一条工程路径:标量或向量纹理提供场数据,shader 在当前 fragment 或粒子位置查询场,差分或向量分析运算得到方向量,方向量继续影响法线、速度、颜色或透明度。排查这类效果时,应先固定采样坐标,再输出中间场值,接着输出梯度、散度或旋度的 debug 颜色,最后进入最终光照或运动。

对弯曲装饰面板而言,向量场最常出现于两个位置。第一,normal map 或 height map 使用纹理坐标定义局部表面扰动,gradient 决定微表面朝向。第二,面板上的能量流、灰尘流动或水痕可以由 flow map 定义局部速度方向。前者影响光照,后者影响采样位置和动画,两者都要求坐标空间和采样频率稳定。

一个可复用检查顺序是:确认场数据的定义域,例如 object space、world space、screen space 或 UV space;确认采样坐标是否和定义域匹配;确认差分步长是否和纹理尺寸或世界尺度匹配;确认输出量是否经过归一化或尺度映射;确认最终使用阶段是光照、运动、混合还是 debug 显示。这个顺序能防止把“场数据错”“采样错”“使用错”混成一个问题。

8.4 计算几何属性的高效实现

几何属性计算的输入通常是 position、index、UV、material group、smoothing policy 和 optional source normal。输出是 vertex normal、tangent、bitangent sign、bounds、邻接关系和可选曲率指标。高效实现需要同时满足两个目标:结果要和渲染语义一致,数据访问要适合 CPU cache 或 GPU 并行执行。

法线生成最常见的 CPU 路径是逐三角形遍历。每个三角形读取三个 position,计算面法线贡献,再把贡献累加到三个顶点的 accumulator。遍历结束后,对每个顶点 accumulator 归一化。这个过程简单稳定,适合导入阶段和离线构建。它的性能主要受索引访问、随机写 accumulator 和顶点拆分数量影响。

GPU 并行生成法线时,线程通常按三角形或顶点分配。按三角形分配容易计算贡献,但累加到共享顶点需要 atomic 或分组归约;按顶点分配需要邻接表,否则顶点无法快速找到相邻三角形。大规模网格的常见做法是在导入阶段预先构建邻接或直接预计算法线,把运行时 GPU 算力留给变形、粒子、culling 或 shading。动态拓扑和程序生成网格才更常把法线计算放到 compute pass。

切线生成比法线更依赖 UV。每个三角形的 position 差分和 UV 差分共同决定 tangent 方向。UV 退化、镜像、重叠和 seam 都会影响结果。由于 normal map 通常由 DCC 或烘焙工具基于某个 tangent basis 生成,运行时引擎应使用一致的 tangent basis。glTF 2.0 在 morph target tangents 的说明中提到 MikkTSpace 作为默认切线计算算法方向,这提示工程中需要把“烘焙工具”和“运行时导入器”的 tangent basis 对齐。

邻接关系服务多种几何属性。法线平滑需要知道顶点周围有哪些面;边界检测需要知道一条边被几个三角形引用;曲率估计需要观察邻域法线变化;mesh simplification 需要局部拓扑。半边结构适合编辑和复杂拓扑查询,压缩邻接表适合导入阶段批量计算,运行时 vertex/index buffer 适合提交给 GPU。数据结构的选择应跟任务频率一致:编辑频繁使用拓扑结构,渲染频繁使用线性 buffer。

缓存友好遍历要求把读取和写入变成可预测模式。CPU 上,position、normal accumulator 和 index buffer 分开存储有利于连续扫描;GPU 上,thread group 可以先处理一个 triangle block,再把贡献写入中间 buffer,随后做 reduce。对于静态资产,导入阶段还可以做 index reorder,提高 post-transform cache 命中率,并让相邻三角形在内存中更接近。

下面的表格把几何属性放到合适的计算阶段中。阶段选择比单个算法更影响工程稳定性。

属性常见输入推荐计算阶段主要证据
面法线triangle position导入阶段或动态生成阶段flat shading、backface、debug normal
顶点法线面法线、smoothing policy导入阶段曲面高光连续性、硬边位置
tangent signposition、UV、normal导入阶段,与烘焙工具对齐normal map seam、镜像 UV
boundsposition导入阶段和动态更新阶段culling、LOD、碰撞代理
邻接关系index buffer导入阶段或编辑器阶段边界、曲率、简化、法线平滑
高度场梯度height textureshader 或离线纹理处理normal map、坡度 debug
SDF 梯度SDF samplingshader 或 compute passray marching normal、接触阴影

这张表的判断逻辑是:静态且可复用的属性优先预计算,动态且依赖当前变形或场数据的属性在运行时计算。对弯曲装饰面板,原始曲面采样、法线、切线、bounds 和 LOD 可以在导入阶段完成;若面板运行时发生顶点位移,新的 shading normal 可能需要在 shader 中由位移函数或 height map gradient 重建。

导入阶段预计算还需要记录版本边界。DCC 导出器、glTF 导入器、normal map 烘焙器和引擎 tangent basis 的版本改变,都可能改变微小方向。正式项目中应把导入器版本、切线算法、坐标系转换、normal map Y 通道约定和单位缩放写进 asset pipeline 配置。这样 visual regression 出现时,可以从配置差异回溯到属性变化。

8.5 Lambert Lighting as Vector Calculation Case

Lambert 漫反射把表面法线和光照方向的夹角转换成亮度。它是最适合复查本章几何向量链的案例,因为输入少、计算短、视觉结果直接。只要 NL 的空间一致并且已归一化,dot(N,L) 就等于夹角余弦;再把负值收束到零,就得到基础漫反射比例。

Lambert 的最小计算可以写成 diffuse=albedolightColorintensitymax(dot(N,L),0)diffuse = albedo \cdot lightColor \cdot intensity \cdot max(dot(N,L),0)。这里的 N 是 shading normal,可以来自顶点法线插值,也可以来自 normal map 经过 TBN 变换后的结果;L 是从表面点指向光源或方向光反方向的单位向量;albedolightColor 应在线性颜色空间中参与乘法;intensity 是光能量尺度。

数值例子能快速检查方向。假设面板某点的 world normal 为 N=(0,0,1)N=(0,0,1),方向光向量为 L=normalize(1,1,2)L=normalize(1,1,2),则 dot(N,L)0.816dot(N,L) \approx 0.816。若 albedo 为 (0.8,0.6,0.4)(0.8,0.6,0.4),lightColor 为 (1,1,1)(1,1,1),intensity 为 22,则线性 diffuse 约为 (1.306,0.980,0.653)(1.306,0.980,0.653)。这个值可能超过 1,后续 HDR、tone mapping 或 clamp 策略会决定屏幕显示。Lambert 本身只说明局部入射角贡献。

下面的 fragment shader 片段把几何属性、normal map 和 Lambert 计算串在一起。它延续 8.2 的 TBN 约定,并额外输出一个 debug 分支。

vec3 decodeNormal(vec3 encodedNormal) {
return encodedNormal * 2.0 - 1.0;
}

vec3 applyLambert(
vec3 worldNormal,
vec4 worldTangent,
vec2 uv,
vec3 worldLightDir,
vec3 albedo,
vec3 lightColor,
float lightIntensity
) {
vec3 N = normalize(worldNormal);
vec3 T = normalize(worldTangent.xyz);
vec3 B = normalize(cross(N, T) * worldTangent.w);
mat3 TBN = mat3(T, B, N);

vec3 tangentNormal = decodeNormal(texture(normalMap, uv).xyz);
vec3 shadingNormal = normalize(TBN * tangentNormal);
vec3 L = normalize(worldLightDir);

float diffuseTerm = clamp(dot(shadingNormal, L), 0.0, 1.0);
return albedo * lightColor * lightIntensity * diffuseTerm;
}

这段代码的第一条检查线是 worldNormalworldTangentworldLightDir 的空间。若前两者在 world space,光照方向也需要在 world space。第二条检查线是 normal map 的编码约定。若贴图来自另一套 Y 通道方向,tangentNormal.y 可能需要在导入配置中翻转。第三条检查线是颜色空间。albedo 从 sRGB 纹理读取后应转换到 linear,最终写屏幕前再走输出转换或 tone mapping。

Lambert debug 可以分三张图输出。第一张输出 shadingNormal * 0.5 + 0.5,确认 normal map 和 TBN 是否连续。第二张输出 vec3(diffuseTerm),确认光照方向和 clamp 是否合理。第三张输出最终 diffuse,确认 albedo、lightColor 和 intensity 是否按预期叠加。若第一张连续、第二张异常,重点检查光向量;若第一张在 UV seam 处跳变,重点检查 tangent basis;若前两张正确但最终颜色异常,重点检查纹理颜色空间和光能量尺度。

Lambert 案例把本章的数学对象压缩成一条可复查路径:曲面采样提供 position,法线和切线提供局部坐标系,normal map 或高度场梯度提供微表面方向,dot 和 clamp 把方向关系转换成亮度。读者在分析更复杂的 Phong、Blinn-Phong、PBR 或 subsurface 模型前,应先能用这条路径解释基础漫反射图像。

最小自检任务

给定一个弯曲面板的某个 fragment,导入器提供的 world normal 为 N=(0,0,1)N=(0,0,1),world tangent 为 T=(1,0,0,1)T=(1,0,0,1),normal map 采样值为 (0.5,0.5,1.0)(0.5,0.5,1.0),方向光向量为 L=normalize(0,1,1)L=normalize(0,1,1),albedo 为 (0.7,0.7,0.7)(0.7,0.7,0.7),lightColor 为 (1,1,1)(1,1,1),intensity 为 1.51.5。请判断这个 fragment 的 Lambert 漫反射大约是多少,并说明如果相邻 UV seam 一侧突然变暗,应按什么顺序排查。

答案要点

normal map 采样值先映射到 tangent space:(0.5,0.5,1.0)×21=(0,0,1)(0.5,0.5,1.0) \times 2 - 1 = (0,0,1)。它经过 TBN 后仍然沿 world normal 方向,因此 shading normal 为 (0,0,1)(0,0,1)。光照方向 L=normalize(0,1,1)L=normalize(0,1,1),所以 dot(N,L)=1/20.707dot(N,L)=1/\sqrt{2}\approx 0.707。Lambert 比例为 0.7070.707,乘以 intensity 1.51.5 得到约 1.0611.061,再乘 albedo 得到线性 diffuse 约 (0.742,0.742,0.742)(0.742,0.742,0.742)。这个值进入 HDR 或输出转换前可以超过普通 LDR 范围。

UV seam 一侧突然变暗时,排查顺序是:先输出 debug normal,观察 seam 两侧的 shading normal 是否跳变;再输出 debug tangent 和 bitangent,检查 tangent.w、镜像 UV 和 tangent basis;接着固定 normal map 为 (0.5,0.5,1.0)(0.5,0.5,1.0),确认顶点法线自身是否连续;然后固定光照方向,输出 dot(N,L) 灰度图;最后检查 albedo 颜色空间和 light intensity。这个顺序把几何属性、切线空间、光向量和颜色能量分开验证。

本章知识点总结

  • 曲面路径:连续曲线曲面通常在导入阶段采样成三角形,再通过顶点属性进入 shader。
  • 参数域:Bezier、B-Spline 和 NURBS 都用参数域定位曲线或曲面上的点。
  • 采样误差:几何采样主要影响 silhouette、曲率区域和顶点数量预算。
  • 连续性:位置连续、切线连续和曲率连续分别对应不同层级的视觉平滑。
  • 法线来源:面法线来自三角形边叉乘,顶点法线来自相邻面贡献累加和归一化。
  • 切线空间:TBN 把 tangent-space normal map 转换到光照计算使用的坐标空间。
  • 属性接缝:位置相同的顶点可以因为 UV、材质、硬边或 tangent sign 拥有不同属性。
  • 梯度作用:梯度把高度场、SDF 或其他标量场转换成局部方向。
  • 散度旋度:散度描述局部扩张收缩,旋度描述局部旋转,它们常用于流体和流场特效。
  • 计算阶段:静态几何属性优先在导入阶段预计算,动态场数据按当前变形或采样状态运行时计算。
  • 邻接结构:邻接关系服务法线平滑、边界检测、曲率估计和拓扑处理。
  • Lambert 核心:Lambert 漫反射用 dot(N,L) 和 clamp 把方向关系转换成亮度比例。
  • 空间一致:法线、切线、贴图法线和光向量必须转换到同一空间后再参与计算。
  • 调试顺序:先看 debug normal,再看 tangent basis,再看 dot(N,L),最后检查颜色空间和能量尺度。