从重心坐标到位移贴图:手把手实现渲染管线中的着色与纹理映射
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换成了半程向量HH 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 * scale5.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 浮点精度问题比你想象的严重在计算重心坐标和插值时浮点误差会累积。我遇到过三角形边缘出现黑边的情况就是因为边界像素的重心坐标出现了微小的负数导致插值结果异常。解决办法是在判断点在三角形内时加一个epsilonif (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会让人调很久。如果你把这次作业的每个环节都亲手实现了一遍我建议你不要急着提交。回头看看每个函数的输入输出想想有没有更优雅的组织方式有没有可以复用的代码结构。这种重构的过程比单纯跑通作业更能提升工程能力。我自己就是在重构着色器代码的时候才真正理解了渲染管线里数据是怎么流动的。