ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

从重心坐标到位移贴图:手把手实现渲染管线中的着色与纹理映射

从重心坐标到位移贴图:手把手实现渲染管线中的着色与纹理映射

1. 从"画三角形"到"画活一个模型":这次作业到底在练什么

很多人第一次接触渲染管线的时候,都会有一种错觉:不就是把三维点投影到屏幕上,然后填个颜色吗?我当初也是这么想的,直到真正动手去实现一个带纹理、带光照、带位移的着色流程,才发现自己把问题想得太简单了。这次作业的核心,就是让你亲手把渲染管线里最关键的几个环节串起来——从光栅化之后的插值,到逐像素的着色计算,再到纹理采样和位移贴图。它不是一个"调库跑通"的任务,而是一个"你得知道每个像素的颜色到底是怎么算出来的"的任务。

如果你正在学图形学入门课程,或者想搞清楚GPU里那些着色器到底在干什么,这篇内容会非常适合你。我会按照一个实际动手实现者的视角,把整个流程拆开讲:为什么需要重心坐标插值、Blinn-Phong和Phong到底差在哪、纹理采样时那些让人抓狂的接缝和摩尔纹是怎么来的、位移贴图为什么必须配合足够细的网格。中间会穿插我自己踩过的坑,以及一些文档里不会写但实际写代码时一定会遇到的问题。

先说结论:这次作业表面上是在实现几个着色模型,本质上是在训练一种思维——把"屏幕上每个像素的颜色"这件事,拆解成一系列可以逐像素计算的数学表达式。你理解了这条链路,后面不管是写PBR还是搞后处理,思路都是通的。

2. 重心坐标插值:为什么你的纹理总是"歪"的

2.1 插值的本质是"用三个顶点猜中间的值"

光栅化阶段我们拿到的是三角形三个顶点的屏幕坐标,但屏幕上的像素是离散的,一个三角形覆盖的像素远多于三个顶点。那么问题来了:三角形内部某个像素对应的纹理坐标、法线、颜色,从哪来?

答案就是重心坐标(Barycentric Coordinates)。对于三角形内的任意一点P,都可以表示成三个顶点的加权和:

P = α * A + β * B + γ * C,其中 α + β + γ = 1

这三个权重α、β、γ就是重心坐标。它的几何意义是:α等于P点与B、C组成的子三角形面积占整个三角形面积的比例。这个性质非常关键,因为它意味着重心坐标是线性的——三角形内部任何属性都可以用同样的权重去插值。

我一开始犯过一个错误:直接用屏幕空间的像素坐标去线性插值纹理坐标。结果就是纹理在透视投影下明显扭曲,近处的纹理被压缩,远处的被拉伸。原因在于,屏幕空间是经过透视除法之后的非线性空间,直接线性插值会丢失深度信息。

2.2 透视校正插值:那个容易被忽略的1/z

正确的做法是透视校正插值(Perspective-Correct Interpolation)。核心思路是:在屏幕空间做线性插值之前,先把属性除以深度z,插值完成后再乘回来。

具体来说,对于属性V(比如纹理坐标u、v),正确的插值公式是:

V_correct = (α * V_A/z_A + β * V_B/z_B + γ * V_C/z_C) / (α/z_A + β/z_B + γ/z_C)

这里的z是顶点在相机空间的深度。我第一次看到这个公式的时候觉得莫名其妙,后来想明白了:透视投影的本质是"近大远小",深度z越大,物体在屏幕上越小,那么它在屏幕空间占据的权重就应该越小。除以z正好起到了这个"按深度加权"的作用。

提示:很多教程在讲插值时只讲线性插值,导致初学者写出来的纹理在斜面上明显不对。如果你发现自己的纹理在倾斜表面上出现"果冻感"的扭曲,八成就是漏了透视校正。

2.3 实际代码里怎么组织插值数据

在实际写代码时,我习惯把需要插值的属性打包成一个结构体,比如:

struct VertexAttributes { Eigen::Vector3f worldPos; Eigen::Vector3f normal; Eigen::Vector2f texCoord; Eigen::Vector3f color; };

然后在光栅化循环里,对每个像素计算重心坐标,再对每个属性做透视校正插值。这里有个性能上的小技巧:重心坐标的计算可以增量式进行,不需要每个像素都重新算一遍面积。不过对于作业规模来说,直接算也不会慢,先保证正确性更重要。

还有一个容易踩的坑:重心坐标的符号判断。判断像素是否在三角形内,通常用叉积或者面积符号。如果三个重心坐标都非负,说明点在三角形内。但浮点误差可能导致边界像素被漏掉或重复,实际实现时通常加一个极小的epsilon容差。

3. Blinn-Phong与Phong:一个高光项引发的"血案"

3.1 两种模型的公式差异到底在哪

Phong着色模型的高光项是:

specular = ks * (R · V)^n

其中R是反射方向,V是视线方向。而Blinn-Phong把R换成了半程向量H:

H = normalize(L + V) specular = ks * (N · H)^n

看起来只是换了个向量,但实际效果和计算成本都有差别。Phong需要计算反射向量R,而Blinn-Phong只需要一次加法和一次归一化,计算量更小。更重要的是,当视线方向与反射方向夹角超过90度时,Phong的高光会突然消失(因为R·V变成负数),而Blinn-Phong的N·H在同样情况下仍然可能为正,高光过渡更平滑。

我实测下来,Blinn-Phong的高光形状更"圆润",Phong的高光在边缘处会有明显的截断感。这也是为什么现代渲染管线基本都用Blinn-Phong或者它的变体。

3.2 高光指数n到底怎么调

高光指数n控制高光的"锐利程度"。n越大,高光越集中、越像镜面;n越小,高光越分散、越像塑料。

我一般会这样选:

  • 金属材质:n取100~500,高光非常集中
  • 塑料材质:n取20~80,高光有一定扩散
  • 粗糙表面:n取5~20,高光几乎铺满整个表面

但这里有个坑:n的取值和光照强度是耦合的。如果你把n调得很大,高光区域变小,但亮度会变得非常高,容易出现"过曝"的白点。实际调参时,我通常先把n定下来,再调整ks(高光系数)来控制整体亮度。

3.3 环境光、漫反射、高光的叠加顺序

三个分量的叠加顺序其实不影响最终结果(加法满足交换律),但理解每个分量的物理意义很重要:

  • 环境光(Ambient):模拟间接光照,通常是一个常数乘以物体颜色。它保证了背光面不会全黑。
  • 漫反射(Diffuse):遵循Lambert余弦定律,与法线和光线方向的夹角余弦成正比。
  • 高光(Specular):模拟镜面反射,与视线和反射方向的夹角有关。

我见过有人把环境光设得太大,导致整个场景"发灰",失去对比度。经验值是环境光系数取0.1~0.2就够了,主要靠漫反射和高光来塑造立体感。

注意:法线一定要归一化。我踩过一次坑,插值后的法线长度不再是1,导致光照计算出现奇怪的暗斑。每次插值完法线,记得重新normalize。

4. 纹理采样:从"马赛克"到"接缝"的全套坑

4.1 纹理坐标的映射与wrap模式

纹理坐标通常在[0,1]范围内,但实际计算中可能超出这个范围。这时候就需要wrap模式:

  • Repeat:取小数部分,纹理平铺。适合地板、墙壁等重复图案。
  • Clamp:截断到[0,1],边缘像素拉伸。适合单张图片贴图。
  • Mirror:镜像重复,避免接缝。

我在做地板纹理时用了Repeat模式,结果发现纹理在远处出现了严重的摩尔纹。这是因为远处一个像素覆盖了多个纹理像素,采样频率跟不上。解决办法是Mipmap。

4.2 双线性插值与Mipmap

最邻近采样(Nearest)会直接取最近的纹理像素,放大时出现明显马赛克。双线性插值(Bilinear)会取周围四个像素做加权平均,过渡更平滑。

但双线性插值解决不了远处的高频走样。Mipmap通过预先生成一系列逐级缩小的纹理,根据像素在屏幕上的覆盖范围选择合适的层级。层级计算公式是:

LOD = log2(max(|du/dx|, |dv/dx|, |du/dy|, |dv/dy|))

实际实现时,我通常用双线性插值加上Mipmap(也就是三线性插值),效果已经足够好。如果追求更高质量,可以用各向异性过滤,但计算量会大不少。

4.3 纹理接缝:那个让人抓狂的"裂缝"

做纹理映射时,最让人头疼的就是接缝问题。尤其是用球面坐标或者柱面坐标映射时,在纹理的边界处会出现明显的裂缝。

根本原因是:纹理坐标在接缝处发生了跳变。比如球面的经度从0到2π,在接缝处从1突然变回0,插值的时候就会出问题。

解决办法有几种:

  • 在接缝处复制一圈顶点,让纹理坐标连续
  • 使用立方体贴图(Cube Map),从根本上避免球面映射的接缝
  • 在着色器里手动处理wrap,但比较麻烦

我当时的做法是在模型导出时就把接缝处的顶点拆开,虽然增加了顶点数,但省去了着色器里的特殊处理。

5. 位移贴图:为什么你的模型"炸"了

5.1 位移贴图与法线贴图的本质区别

法线贴图(Normal Map)只改变光照计算时的法线方向,不改变几何形状。位移贴图(Displacement Map)则真正移动顶点的位置。

这意味着位移贴图需要足够细分的网格。如果你的模型只有几百个三角形,位移贴图基本看不出效果,甚至会让模型出现尖锐的锯齿。我一开始用一个低模做位移,结果模型表面像被狗啃过一样。

正确的做法是:先用曲面细分或者Tessellation把网格加密,再应用位移。位移的方向通常是沿法线方向:

newPos = oldPos + normal * displacement * scale

5.2 位移贴图的采样与法线重计算

位移之后,法线也变了。如果还用原来的法线做光照,会出现明显的错误。所以位移之后需要重新计算法线。

最简单的方法是用有限差分:对位移后的网格,取相邻顶点的位置差,叉积得到新法线。但这样计算量比较大。另一种方法是在着色器里用位移贴图的梯度来扰动法线,近似效果也不错。

我实测下来,如果位移幅度不大(比如小于0.1),直接用原法线也能接受。但如果位移幅度大,必须重算法线,否则光照会非常"平"。

5.3 位移贴图的性能代价

位移贴图不是免费的。它增加了顶点着色器的计算量,而且需要更高的网格密度。在移动端或者性能受限的场景,通常用法线贴图来"假装"凹凸感,而不是真的位移。

我的经验是:只在近处物体上用位移贴图,远处物体用法线贴图。这样既能保证近处的细节,又不会让整体性能崩掉。

6. 贴图着色器的组织方式:从"一坨代码"到"可维护"

6.1 着色器代码的结构化

一开始我把所有逻辑都塞在一个片元着色器里,结果代码又长又乱,改一个参数要翻半天。后来我把它拆成了几个函数:

vec3 computeAmbient(vec3 ka, vec3 color); vec3 computeDiffuse(vec3 kd, vec3 color, vec3 N, vec3 L); vec3 computeSpecular(vec3 ks, vec3 color, vec3 N, vec3 L, vec3 V, float n); vec3 sampleTexture(sampler2D tex, vec2 uv);

这样每个函数职责单一,调试的时候也容易定位问题。比如高光不对,我就只检查computeSpecular,不用在一大段代码里找。

6.2 参数传递与uniform管理

着色器里的uniform变量是CPU和GPU之间的桥梁。我习惯把材质相关的参数打包成一个结构体,减少uniform的数量:

struct Material { Eigen::Vector3f ka, kd, ks; float shininess; int textureType; };

然后在渲染循环里统一上传。这样比一个个传uniform要清晰得多,也方便做材质切换。

6.3 调试着色器的实用技巧

着色器调试比CPU代码麻烦得多,因为不能直接打印变量。我常用的几个技巧:

  • 颜色可视化:把法线、纹理坐标、深度等中间结果直接输出成颜色,肉眼检查是否正确。
  • 分步验证:先只输出环境光,确认基础颜色对;再加上漫反射,确认光照方向对;最后加高光。
  • 边界测试:把纹理坐标放大10倍,看接缝和wrap模式是否正确。

这些方法看起来笨,但实际非常有效。我靠"颜色可视化"这一个技巧,就定位了好几次法线插值错误的问题。

7. 那些文档里不会写的实操心得

7.1 浮点精度问题比你想象的严重

在计算重心坐标和插值时,浮点误差会累积。我遇到过三角形边缘出现"黑边"的情况,就是因为边界像素的重心坐标出现了微小的负数,导致插值结果异常。

解决办法是在判断"点在三角形内"时加一个epsilon:

if (alpha >= -eps && beta >= -eps && gamma >= -eps) { ... }

eps取1e-5左右比较合适。太小了没效果,太大了会导致三角形"膨胀"。

7.2 纹理坐标的y轴方向

不同工具和API对纹理坐标的y轴方向定义不同。有的原点在左下角,有的在左上角。我因为这个原因,纹理上下颠倒了好几次。

经验是:在导入纹理时统一翻转y轴,或者在着色器里做一次uv.y = 1.0 - uv.y。关键是保持一致性,不要一会儿翻一会儿不翻。

7.3 法线贴图的切线空间

如果用法线贴图,需要构建切线空间(TBN矩阵)。这里有个坑:切线空间的handedness。不同工具生成的切线空间可能是左手系或右手系,导致法线贴图的光照方向反了。

我的做法是:在导入模型时检查切线空间的符号,必要时在着色器里翻转副切线(bitangent)的方向。这个问题在法线贴图看起来"凹凸相反"时尤其明显。

7.4 性能优化的几个实用点

  • 提前剔除:在光栅化之前,先做视锥剔除和背面剔除,减少无效计算。
  • 减少分支:着色器里的if-else会降低GPU的并行效率,能用step()、mix()等函数替代就替代。
  • 纹理压缩:如果纹理很大,考虑用压缩格式,减少带宽占用。

这些优化在作业规模下可能看不出差别,但养成习惯对以后做实际项目很有帮助。

8. 从这次作业延伸出去:还能怎么玩

实现完基础的Blinn-Phong和纹理映射之后,其实还有很多可以扩展的方向。比如把Blinn-Phong换成Cook-Torrance模型,引入微表面理论,效果会更接近真实材质。或者把位移贴图换成视差贴图(Parallax Mapping),用更少的性能代价获得类似的凹凸感。

再进一步,可以尝试基于物理的渲染(PBR),把材质参数从"颜色+高光"换成"反照率+金属度+粗糙度",这样材质的表现力会强很多。我后来把作业里的材质系统改成了PBR,虽然公式复杂了不少,但金属和塑料的区分度明显提升。

还有一个有意思的方向是阴影。这次作业没有涉及阴影,但加上阴影贴图(Shadow Map)之后,场景的立体感会立刻上一个台阶。阴影贴图的原理不复杂:从光源视角渲染一遍深度,然后在主渲染时比较深度。但实际实现时,阴影偏移(Shadow Bias)和阴影锯齿(Shadow Acne)会让人调很久。

如果你把这次作业的每个环节都亲手实现了一遍,我建议你不要急着提交。回头看看每个函数的输入输出,想想有没有更优雅的组织方式,有没有可以复用的代码结构。这种"重构"的过程,比单纯跑通作业更能提升工程能力。我自己就是在重构着色器代码的时候,才真正理解了渲染管线里数据是怎么流动的。

RELATED READING

延伸阅读

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