Games101作业3,我个人认为是整个入门阶段里最值得反复咀嚼的一份作业。它不再是作业2里让你把三角形画出来那么简单,而是真正开始接触“着色”这件事:怎么把法线、光照、纹理这些抽象概念变成屏幕上显示的像素。做完这份作业,你才算正式推开实时渲染的大门,也才理解为什么GPU管线的shader部分这么重要。
这份作业适合两类人:一类是刚学完基础图形学、想把理论落到代码里的学生,另一类是做引擎或渲染相关工作、想回头补一补光照模型细节的开发者。只要你有基本的C++和线性代数基础,能跑通作业框架,跟着思路把每个Shader补全,最终看到模型被光照点亮的那一刻,会有一种“原来如此”的踏实感。这篇内容就是把我当时踩过的坑、现在回看觉得关键的细节,以及调试方法全部分享出来。
1. 作业3整体设计与思路拆解
1.1 课程框架在做什么
这套课程的作业框架本质上是一个简化版的光栅化渲染器。它从obj模型文件中读入三角形面片,经过MVP变换、视口变换后,把顶点映射到屏幕空间,然后对三角形覆盖的每个像素执行光栅化流程。作业3在光栅化流程里加入了一个关键的抽象:shader,也就是对着色逻辑的封装。
框架里已经写好了光栅化循环,会遍历三角形内的每个像素并执行采样。你并不需要重新去造一个渲染器,而是需要实现几个不同的Fragment Shader。所谓Fragment Shader,简单理解就是“对每个像素点做颜色计算”的小函数。它接收来自顶点的属性(位置、法线、纹理坐标等),然后输出这个像素最终的颜色。
很多同学做完作业2之后,会觉得光栅化只是一个几何过程:三角形在屏幕上哪一块区域,就把颜色填进去。但作业3告诉你,事情没那么简单。填进三角形的颜色不是随便给的,而是要通过一系列光照计算得出的结果。框架把这种计算拆成了shader,让你在固定管线之外的“自定义通道”操控像素颜色,这其实就是现代引擎中材质系统的雏形。你想让一个物体看起来是木头、石头还是金属,本质上都是在改这个shader的算法和参数。
1.2 要完成的三个Shader目标
作业3明确要求实现三个方向的shader:Phong光照、纹理贴图、法线贴图(包括凹凸贴图)。这三个目标不是孤立的,而是层层递进的:
- Phong光照:让你理解点光源下物体表面的亮度由哪些因素决定,包括环境光、漫反射、高光。
- 纹理贴图:在光照基础上,把一张图像映射到物体表面,让不同区域呈现不同颜色,同时要保持透视正确。
- 法线贴图/凹凸贴图:在不修改几何形状的前提下,通过扰动法线来模拟表面的凹凸细节。
这三个目标合起来,基本上就是现在游戏里PBR材质系统出现之前,主流实时渲染材质的核心内容。理解它们,再去看现代引擎里的材质节点,会轻松很多。
我建议不要一上来就想着把三个shader都写出来然后一次跑通。更好的策略是:先保证第一个Phong shader输出的画面颜色正确,再叠加纹理,最后再做Normal Mapping。因为后面两者都依赖你前面建立的法线、光照和采样坐标体系,如果基础颜色是错误的,后面叠加再多效果也是白算。
2. 核心原理:法线变换与光照模型
2.1 法线变换为什么不能直接用模型矩阵
这是作业3里最容易翻车的地方。很多同学在顶点着色阶段,直接把顶点的法线乘以模型矩阵,然后传到片元阶段。结果在绕Z轴旋转的模型上还看着正常,一旦模型做了非等比缩放(比如X轴拉长两倍),整个光照就变得奇怪,高光错位,明暗交界线也不对。
原因在于法线是垂直于表面的向量,它描述的是方向,而不是空间里的一个点。当你对一个模型做非等比缩放时,表面切线方向的变换和法线方向并不一致。比如一个平面的法线原本是(0,1,0),如果你把X轴方向拉伸,平面在切线方向变长了,法线实际上应该做一些补偿调整,否则法线就无法继续与表面垂直。
正确的做法是使用模型矩阵逆的转置矩阵来变换法线。也就是说,对于模型变换矩阵M,法线变换矩阵应该是(M⁻¹)ᵀ。在作业框架中,通常模型矩阵就是view了?实际上作业3里顶点着色部分需要传入模型变换矩阵,具体看框架代码。我记得标准做法是:ModelMatrix的逆的转置,或者因为框架只旋转不缩放时,直接用ModelMatrix。但规范写法还是用inverse和transpose组合,避免非等比缩放出问题。
代码里可以这样写:
Eigen::Vector3f normal = payload.normal; Eigen::Matrix4f normalMatrix = model.inverse().transpose(); Eigen::Vector3f transformedNormal = (normalMatrix * Eigen::Vector4f(normal.x(), normal.y(), normal.z(), 0.0f)).head<3>();这里把法线当作方向向量,w分量设为0,防止平移影响。变换之后记得normalize,因为逆矩阵转置的操作可能会改变向量长度,光照计算前必须归一化,否则高光大小会出现异常。
顺带一提,如果你只是做旋转和平移,模型矩阵的逆的转置其实就是模型矩阵本身(不考虑平移部分)。所以很多引擎在Uniform变换时直接传normal matrix,就是为了安全性。
2.2 Phong模型的每个分量计算
Phong光照模型是把最终亮度拆成三个独立分量:环境光(Ambient)、漫反射(Diffuse)、高光(Specular)。这个模型基于一个点光源,你在着色时知道光源的位置、颜色,以及物体表面的法线、观测方向。
环境光是一个常量基底,模拟来自环境四面八方散射的光。它不需要依赖法线或光源方向,直接给一个很小的亮度值,让背光面不至于完全漆黑。漫反射遵守Lambert余弦定律:表面亮度与法线方向到光源方向夹角的余弦成正比。如果光源垂直照射表面,最亮;如果是擦着表面,则几乎没有亮度。这个余弦值通过normal与lightDir的点积获得,且要限制在非负范围,因为背光面不应该有漫反射。
高光是模拟镜面反射的亮点。Phong原始模型用的是反射光线与观察方向的夹角,而Blinn-Phong用的是半程向量的技巧。半程向量是光源方向与观察方向的中间方向,计算只需把两个方向相加再归一化。使用半程向量和法线点积,再取一个高光shininess次幂。shininess越大,高光越集中,反光斑越小。
一个完整的Phong Fragment Shader伪代码是:
// ambient float ka = 0.005; Eigen::Vector3f ambient = ka * amb_light_intensity; // diffuse float kd = 1.0; float diff = std::max(0.0f, normal.dot(lightDir)); Eigen::Vector3f diffuse = kd * diff * light_color; // specular (Blinn-Phong) float ks = 0.5f; Eigen::Vector3f halfDir = (lightDir + viewDir).normalized(); float spec = std::pow(std::max(0.0f, normal.dot(halfDir)), p); Eigen::Vector3f specular = ks * spec * light_color; return result_color = ambient + diffuse + specular;这里的系数不是固定的,需要根据场景去调。作业框架直接给了一些常数,比如ka=0.005,kd=1.0,ks=0.5,p=150。我实测下来,这些数值能让模型呈现比较明显的塑料质感。如果你想把高光调弱,可以把ks降到0.2,或者把p从150降到64。做作业没必要纠结调到物理正确,能看出光照变化就行。
2.3 半程向量的好处在哪里
Blinn-Phong用半程向量代替反射向量,最大的好处是计算量小,而且效果更柔和。反射向量需要先用入射光方向和法线做一次反射运算,公式相对复杂;半程向量只做两次向量加法加一次归一化。在几乎没有shader能力的古老显卡上,这个节省有明显意义。
从视觉效果上看,Blinn-Phong的高光形状和Phong原始模型有细微差别。使用半程向量时,高光会更偏向“圆润”一些,而且对法线的精度要求略低。作业3要求实现的是Blinn-Phong还是Phong?我记得课程作业里写的是Blinn-Phong,并且框架里已经帮你算好了viewDir和lightDir,你只需要算半程向量。这也是为什么很多代码示例里一看就是半程向量形式的原因。
3. 实操过程:从框架到完整渲染
3.1 Shader参数传递与payload结构
写shader之前,先搞清楚框架里的数据流转。每个顶点经过顶点着色(vertex shader)之后,会生成一个vertex_shader_payload结构体,里面包含视口变换后的顶点坐标、世界空间下的法线、纹理坐标,以及各种uniform变量。你的Fragment Shader就接收这样一个payload,输出颜色。
我踩过的一个典型坑是:误以为payload里的normal已经是经过变换后的世界/相机空间法线了。实际上框架里只存了原始法线,你在Fragment Shader里必须自己先做normalMatrix变换。如果你直接把payload.normal拿去和光照方向点积,结果大概率是错的。尤其当模型有旋转时,光照会像粘在模型上一样不对。
另一种常见误解是texture坐标。payload里的tex_coords是模型文件提供的纹理坐标,范围一般在0到1之间。你在纹理采样时,需要严格使用这个坐标,但要注意图片读取时的原点方向。很多同学最终贴图出现上下颠倒,就是因为图片第一行是图片的顶部,而OpenGL的纹理坐标原点在左下角,你直接读取像素行号时方向不同导致了翻转。
为了避免这类问题,建议先写一个最简单的shader:不计算光照,只根据纹理坐标做渐变颜色,比如直接用tex_coords的x值作为红色、y值作为绿色。这样能立刻确认纹理坐标是否正确传递。确认坐标没问题后,再套用Phong光照。
3.2 实现Phong Shader的关键代码与参数校准
实现Phong Shader时,我建议把光照计算拆成多个小函数,而不是塞在一个大文件里。你可以定义computeAmbient、computeDiffuse、computeSpecular三个辅助函数,最后在主函数里组合。这样调参数的时候很清楚改的是哪个分量。
一个容易忽略的细节是:作业框架里光照强度、光源位置、观察方向这些变量存储在payload里,不同版本的框架字段名可能略有出入。你需要先打开框架头文件确认字段,比如light_dir、view_dir、light_color、amb_light_intensity,别凭记忆瞎写。
我的完整实现大致如下:
Eigen::Vector3f phong_fragment_shader(const vertex_shader_payload& payload) { Eigen::Vector3f normal = payload.normal; Eigen::Vector3f view_dir = payload.view_dir; Eigen::Vector3f light_dir = payload.light_dir; Eigen::Vector3f light_color = payload.light_color; // transform normal to world space Eigen::Matrix4f inv_model = payload.model.inverse(); Eigen::Matrix3f normal_matrix = inv_model.topLeftCorner<3,3>().transpose(); Eigen::Vector3f N = (normal_matrix * normal).normalized(); Eigen::Vector3f L = light_dir.normalized(); Eigen::Vector3f V = view_dir.normalized(); Eigen::Vector3f H = (L + V).normalized(); float ambient_strength = 0.005f; Eigen::Vector3f ambient = ambient_strength * payload.amb_light_intensity; float diff = std::max(0.0f, N.dot(L)); Eigen::Vector3f diffuse = diff * light_color; float spec = std::pow(std::max(0.0f, N.dot(H)), 150.0f); Eigen::Vector3f specular = 0.5f * spec * light_color; return ambient + diffuse + specular; }调试时,先确保diffuse分量正确。怎么判断?把specular和高光去掉,如果模型是类似陶土的漫反射质感,说明diffuse和法线没问题。再把ambient提高到0.1左右,确认背光面不是死黑,最后才加上specular。这样做比一口气全开要容易定位问题。
3.3 纹理Shader与透视正确插值
纹理Shading的实现,本质上就是在Phong的基础上,把漫反射系数换成从纹理采样的颜色。框架里提供了Texture类的成员函数getColor(u, v),内部负责读取图像像素,返回RGB。你只需要把diffuse颜色换成texture.getColor(u, v)乘以光照强度。
但这里有一个非常隐蔽的质量问题:纹理坐标在三角形内部如何进行插值。作业框架的光栅化循环里,如果你使用的是普通的重心坐标插值,那么当三角形经过透视投影之后,屏幕空间中的重心坐标并不能直接对应到真实的纹理坐标。因为透视投影是线性的齐次变换,不是线性的仿射变换,屏幕空间中的线性插值在三维空间中并不线性。
为了得到正确结果,必须使用透视校正插值。作业3的框架里,很多版本已经帮你处理了这个细节,但你在写自己的rasterizer时还是需要注意。如果你发现纹理在模型的某个三角形上出现扭曲,尤其是在画面边缘的三角形上,明显感觉纹理像是被拉扯,那就是插值没有考虑透视。
简单判断方法:看模型的某个三角形,如果纹理图案在三角形内部变化速度是不均匀的,或者三角形边界处纹理有“跳跃感”,就是这个问题。作业框架一般不会要求你改光栅化内核,但理解这个概念很重要,因为很多后续渲染课程里这是基本功。
3.4 Bump/Normal Mapping的实现细节
Bump Mapping和Normal Mapping在作业3里是同一个shader的两步实现。Bump Mapping的思路是:用纹理图像的灰度值当作高度场,求出该高度场在局部坐标下的梯度,然后用梯度去扰动法线。
作业里给了一个TBN矩阵。T是切线方向,B是副切线方向,N是原始法线方向。从纹理坐标和表面坐标的偏导数可以算出T和B。实际代码里,框架可能已经提供了扰动公式,你需要读懂它。这个公式并不复杂:
n = normalize(N + (u方向和v方向上的梯度产生的扰动))核心取决于你如何从相邻像素的纹理颜色变化计算梯度。比如取纹理坐标(u,v),分别计算它与(u+1, v)、(u, v+1)的颜色差值,映射成高度增量,然后通过TBN矩阵把扰动法线变换到世界空间。
我建议大家先把Normal Mapping的文字理解清楚:它不改变几何体形状,只是给每个像素一个假法线。视觉效果是表面有凹凸感,但轮廓依然是光滑的。如果你做出来的模型轮廓能看到锯齿或者凹凸感,那才是错的,正确结果应该是表面光照变化很丰富但边缘依然是平滑弧线。
4. 常见问题与排查技巧实录
4.1 特征问题速查表
我在做这份作业时,以及后来帮别人看代码时,遇到最多的问题基本集中在下面几个,整理成表格方便对照。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 整个模型一片黑 | 法线未归一化,或法线方向反了 | 检查normalMatrix变换后是否normalize,点积前确认法线朝向光源 |
| 模型颜色正常但高光错位 | 高光计算使用了错误的半程向量或观察方向 | 确认view_dir和light_dir都已归一化,半程向量方向不要搞反 |
| 纹理贴图上下颠倒 | 图片读取时行序问题 | 在getColor中把v坐标改为1-v,或调整纹理坐标处理方向 |
| 纹理大面积扭曲 | 没有做透视校正插值,或重心坐标算错 | 检查光栅化循环里的插值方式,确认是透视校正版本 |
| 法线贴图效果很淡 | TBN矩阵计算有误,或扰动强度太低 | 检查切线方向计算,尝试调大高度图缩放系数 |
| 模型只在正面有光,背面完全黑 | 光照只处理了单面 | 在Fragment Shader中取法线绝对值,或在光栅化阶段做双面渲染 |
| 画面有强烈锯齿或闪烁 | 采样时没有做MSAA,或三角形顶点颜色差异太大 | 先开启框架自带的MSAA,确认不是几何问题 |
4.2 用可视化颜色做Debug的经验
遇到shader问题,最蠢的做法是盯着代码空想。我习惯用的第一个调试手段是把最终的颜色输出替换成某个中间量本身。比如想确认法线是否正常,就直接returnnormal,把向量值映射到0到1区间的颜色。
- 如果模型颜色像彩虹一样光滑渐变,说明法线变换和插值基本正常。
- 如果颜色在某条边上有明显突变,说明法线在三角形之间不连续,可能需要在顶点阶段做法线插值而不是直接用面法线。
想确认纹理坐标是否正常,就returnEigen::Vector3f(tex_coords.x(), tex_coords.y(), 0)。这样你会看到一张RGB渐变图,如果渐变在三角形内有跳跃,坐标传递就有问题。
想确认光照方向是否正常,可以把light_dir当作颜色输出,如果模型整体颜色统一,说明方向一致,没有诡异变化。这类可视化调试非常高效,能帮你把问题隔离到某个特定环节,而不是在几个因素之间猜来猜去。
4.3 一个容易被忽略的坑:坐标空间不统一
作业3里另一个高频问题,是shader里各个向量所在的空间不统一。顶点位置是屏幕空间,法线来自模型空间,光和观察方向又可能是世界空间。如果你把这些量混在一起点积,结果完全是错的。正确做法是让所有参与光照运算的量都处于同一个空间,一般统一到世界空间。
当你发现模型旋转后光照不正常,优先检查是不是法线还在模型空间,而光的方向在世界空间。这种错误用肉眼不容易马上看出,但把模型转90度后,光照变化会很明显地不对劲。解决办法就像前面说的,用法线矩阵把法线变换到世界空间,同时确保payload里提供的光线方向本身就在世界空间。
统一空间还有一个细节:透视投影后,顶点坐标是NDC,但法线不应该被投影变换影响。很多同学会困惑要不要对法线做MVP变换,答案是不要。法线参与光照只需要模型变换到世界空间,不参与MVP的投影部分,因为投影会破坏方向属性。
5. 实操记录:从零可见到最终效果
5.1 完成顺序建议
我建议按这个顺序来完成所有代码:
- 先把空的Fragment Shader返回纯白色,确认光栅化正常,能看到模型的白色剪影。
- 加入法线变换,返回法线颜色,确认法线方向正确。
- 实现Phong光照,观察模型的明暗变化和高光。
- 加入纹理采样,和Phong光照结果相乘,得到纹理光照效果。
- 实现Bump/Normal Mapping,在纹理基础上叠加扰动高光,观察凹凸感。
每完成一步,保存一张渲染结果。这样如果后一步出错,就可以对比上一步找到差异。我自己的经验是,很多人卡在第3步高光形状不对,就是前面法线没搞定;还有的人第4步纹理暗得看不出来,其实是phi光照里的ambient强度太低,导致纹理区域整体偏暗。你可以把环境光系数适当调到0.1,整体画面会亮很多,纹理细节也更容易观察。
5.2 参数调节的现场笔记
我当时把Phong的系数调到ka=0.05、kd=1.0、ks=0.5、p=150,模型是经典的某个拿枪的角色模型。在这个参数下,金属边缘的高光比较明显,但角色衣服的布料感稍弱。后来我把p降到64,高光变得柔和,更像皮肤质感。这说明一个道理:高光幂次不是越大越真实,要看你想要什么材质效果。
对于Bump Mapping,权重系数(即高度图的扰动强度)我试过0.05到5的范围。太小声表面几乎看不出凹凸,太大会出现明显噪点状高光,甚至出现类似水波纹的干涉。视觉上合适的值一般在0.5到1.5之间。为了避免作业里默认测试图效果太夸张,你可以先试着把权重设为0.8观察。
光照颜色方面,作业里提供的白色光源其实不太适合展示纹理颜色,因为会让画面比较平淡。我调试时会临时把光源调成暖黄色(1.0, 0.9, 0.7),再看效果好很多。等确认实现没有bug后,再换回默认光源。
5.3 最终画面检查清单
渲染出最终图片后,可以从这些角度自检:
- 模型正面和侧面是否有连贯的光照渐变,而不是生硬的块状?
- 高光是否落在面向光源的方向,不会出现在背光面?
- 纹理图案是否清晰,没有倒置或拉伸?
- 表面凹凸感在斜射光下是否明显,而不是所有方向都一样?
只要以上几点符合,你的作业基本稳了。剩下的就是细节美感问题,可以慢慢调。
6. 最后分享一个关于调试的小技巧
做完作业3之后很长一段时间,我每次写shader都会保留一个“Return AOV”的习惯,就是写几个不同的shader输出中间变量,比如法线、UV、世界坐标高度。以前调试全靠打印数字,非常痛苦。后来发现,把中间量映射成颜色直接渲染到屏幕上,是最快的定位方式。哪怕你现在只是做作业,也建议尝试这个思路,它能帮你建立“像素级直觉”。
另一个实用技巧是,每次修改参数或算法前,用Git或直接复制一份代码备份。shader调试过程中,你可能调整了十几个参数,结果越调越乱,最后想回退却发现找不到原来的状态。我吃过这个亏,后来强制自己每获得一个满意的渲染结果就存一张图并做好记录。这样一来,后续再折腾新效果时,心里始终有底。