GAMES101作业3实战:Blinn-Phong光照、纹理采样与贴图实现

发布时间:2026/10/14 4:41:09
GAMES101作业3实战:Blinn-Phong光照、纹理采样与贴图实现
做图形学学习的人大概都绕不开GAMES101这门课。它把从建模到渲染的一整条管线拆成一个个小作业逼着你亲手实现一遍“三角形是怎么变成图像的”。作业3是在作业2光栅化器基础上做一次全面升级不再往三角形上涂纯色而是用真正的Blinn-Phong光照模型、纹理采样、法线扰动和位移偏移把这些三角形变成一个带材质的立体模型。这篇记录是我完成作业3的过程复盘包含坐标空间的梳理、四种shader的实现细节、纹理采样的坑以及凹凸贴图和位移贴图的对比适合正在写作业3的人也适合想搞明白“片段着色器里到底在用什么坐标算光照”的读者。1. 作业3的完整任务拆解1.1 从纯色填充到可编程着色作业2结束时光栅化器已经能画出一个带深度缓存的彩色三角形三角形内部的颜色要么是固定值要么是顶点颜色插值。作业3把目标从“画三角形”换成了“渲染一个带材质的真实感模型”加载一个包含法线、纹理坐标的obj模型在屏幕上把它渲染出来表面颜色不再来自顶点而是来自光照计算和纹理采样。这个变化本质上是把渲染管线里的“着色”环节从固定管线切换到了可编程的片段着色器概念。框架里每个shader都是一个输入输出明确的函数输入是三角形内部某个片元的插值信息包括位置、法线、uv输出是一个RGB颜色写入framebuffer。理解了这一点后面各种shader的编写就有了统一框架。与我而言这个作业最大的门槛不在代码量而在“坐标空间错位”。顶点从模型空间到屏幕坐标经历了多次矩阵变换shader里拿来算光照的位置和法线如果不搞清楚它们到底在哪个坐标系渲染结果就会非常诡异。这一点后面会专门展开。1.2 需要实现的功能清单作业3要求实现的着色方式通常不止一种根据我看到的课程要求至少包含以下几个法线着色normal shading把法线xyz映射成RGB直观检查模型法线是否连续、方向是否正确Blinn-Phong着色blinn-phong环境光加漫反射加高光的经典组合在一个点光源下渲染模型纹理着色texture shading在Blinn-Phong基础上把漫反射颜色替换成纹理采样结果凹凸贴图bump mapping不改变几何位置只扰动法线方向让表面看起来有起伏位移贴图displacement mapping沿着法线方向真实移动片元位置再计算光照让模型的轮廓也发生变化。前三个是必做核心后两个属于进阶内容但强烈建议全部做完。因为bump和displacement才是这轮作业真正展示“贴图不只是换颜色”的关键。1.3 框架提前做好的事作业框架事实上已经实现了obj模型解析、纹理图像读取、MVP矩阵计算、重心坐标插值和深度测试。已经不需要从头写光栅化主流程我只需要动手补两块一是各种shader函数也就是从插值后的payload算出颜色二是Texture类的getColor纹理采样函数以及可能需要的双线性插值。搞清楚框架里哪些变量在CPU端已经预先变换到view space哪些变量还没变换是避免破坏的关键。框架里加载的顶点法线通常在传入光栅化器前就已经乘过model-view矩阵得到view space法线位置同理。所以shader里拿到的view_pos和normal都属于观察空间这个前提一旦弄反后面一切光照公式都会失效。2. 坐标系与着色输入一切bug的源头2.1 MVP变换把顶点送到了哪里一个模型顶点从本地坐标变成屏幕像素要经历四步model矩阵把物体放进世界场景view矩阵把世界翻转到以相机为原点、看向-z方向的观察空间projection矩阵把可见范围挤压到[-1,1]的裁剪立方体之后做透视除法再映射到屏幕坐标。作业1和作业2已经把矩阵封装好作业3只需要复用。但有一个很多人最初没想透的点裁剪和光栅化用的是裁剪空间和屏幕空间坐标而光照计算用的是观察空间坐标。框架里MVP变换后的点在光栅化阶段负责三角形遍历和深度测试同时额外保留一份view space坐标专门给shader使用。所以你写shader时看到的view_pos并不是屏幕像素坐标而是相机空间下的坐标。2.2 为什么选择view space做光照光照需要三个关键方向从着色点到光源的方向l、从着色点到视点的方向v、以及表面法线n。如果在模型空间做相机方向需要做逆变换如果直接拿屏幕空间坐标做透视除法引入的非线性会毁掉向量方向。view space下相机固定在原点视线方向就是-p光源位置也能预先转换到view space计算量小、物理直觉清晰这就是作业框架默认采用view space的原因。这里有个隐藏约束position和normal必须处于同一坐标空间。位置是view space法线就必须是view space位置是world space法线就得是world space。我见过不少同学只把坐标换了但法线还留在模型空间结果光照明暗完全无法解释。2.3 payload里到底装了什么打开框架代码能看到fragment_shader_payload结构体就是shader的入参通常包含view_pos当前片元的观察空间坐标已经由重心坐标插值normal观察空间下的法线同样插值过tex_coords模型自带的uv坐标范围通常为[0,1]color顶点颜色obj没有指定时是白色texture指向纹理对象的指针可能是自定义uniform。写shader第一步就是用normal shader验证这些变量。我在第一次写Blinn-Phong时把view_pos当成屏幕坐标用它去和光照位置做减法渲染出来整个模型光影随机平移最后才发现坐标系没对齐。建议拿到框架后先输出一个最简单的normal shader模型颜色分布正常了再往下写光照。2.4 法线变换不是简单乘矩阵法线是方向向量和位置不同。位置在仿射变换下直接乘矩阵即可但法线如果直接用model-view矩阵变换遇到非均匀缩放会让法线不再垂直于表面。正确做法是用model-view矩阵的逆转置矩阵来处理法线。作业框架一般在CPU端已经把view space法线算好并存在顶点数组里主流程不需要我们再处理。但如果你想扩展自定义几何体或者修改矩阵逻辑这个坑值得记住。排查光照异常时可以先用法线着色器输出法线颜色如果法线球面上的颜色分布不连续、不对称说明法线变换出问题了。另一个容易忽略的点是法线插值后长度不再是1shader里必须重新normalized否则点积结果会偏大或偏小导致高光范围异常。这也是为什么每个shader第一行几乎都是payload.normal.normalized()。3. Blinn-Phong光照模型的实战实现3.1 三个分量分别模拟什么Blinn-Phong把物体表面颜色拆成三部分相加。环境光是一个常量Ka乘以环境光强度模拟各处反弹过来的叙述光目的是让背光面不至于完全死黑。漫反射遵循兰伯定律只与光线入射方向和法线的夹角有关并随距离增加而衰减。高光分量模拟镜面反射只在视线接近反射方向时显著。很多刚入门的人会问光源强度为什么是500而不是1因为模型距离尺度很小直接给1时漫反射项被距离平方除完就几乎为零整个模型黑乎乎一片。作业里光源位置在(10,10,10)强度给500r²大约几百最终漫反射亮度才在0到几之间视觉上刚好合理。理解这个数量级能帮你判断该把光源强度调到多少才合适。3.2 半程向量到底比Phong强在哪Phong模型用反射向量R和视线向量V的夹角算高光Blinn-Phong改成了光源方向L与视线方向V的中间向量H再用N·H的p次幂作为高光系数。H的计算只用一次加法一次归一化开销低于反射向量视觉差异又很小所以成了实时渲染事实标准。作业公式里要求H必须先归一化。这里有个常见错误有些人把(L V)直接拿去和N做点乘忘记归一化高光形状和强度就会完全不对。我建议每步都写成独立变量L归一化、V归一化、H再归一化后续排查也更舒服。3.3 参考代码与参数选择我最终写出来的Blinn-Phong模板长这样Eigen::Vector3f blinn_phong_shader(const fragment_shader_payload payload) { Eigen::Vector3f ka(0.005f, 0.005f, 0.005f); Eigen::Vector3f kd payload.color; Eigen::Vector3f ks(0.7937f, 0.7937f, 0.7937f); float p 150.f; Eigen::Vector3f light_pos(10, 10, 10); Eigen::Vector3f light_intensity(500, 500, 500); Eigen::Vector3f eye_pos(0, 0, 10); Eigen::Vector3f n payload.normal.normalized(); Eigen::Vector3f v (eye_pos - payload.view_pos).normalized(); Eigen::Vector3f l (light_pos - payload.view_pos).normalized(); Eigen::Vector3f h (l v).normalized(); float r2 (light_pos - payload.view_pos).squaredNorm(); Eigen::Vector3f ambient ka.cwiseProduct(Eigen::Vector3f(10, 10, 10)); Eigen::Vector3f diffuse kd.cwiseProduct(light_intensity / r2) * std::max(0.0f, n.dot(l)); Eigen::Vector3f specular ks.cwiseProduct(light_intensity / r2) * std::pow(std::max(0.0f, n.dot(h)), p); return ambient diffuse specular; }参数上环境分量不要给大0.005足够漫反射分量代表真实颜色给1.0高光分量0.7左右会有光泽感但不至于塑料感。p如果是150高光会非常集中明亮想要柔和高光可以降到32或64看起来更像卡通材质这个完全看个人审美。3.4 空间一致性检查Blinn-Phong写完之后最值得做的验证动作是把eye_pos、light_pos都换成不同的位置观察模型高光移动方向是否符合直觉。如果高光不动大概率是view_pos或eye_pos没有参与计算只是写死在某个奇怪的地方。另一个经典坑是v的方向。从片元指向相机是(eye_pos - view_pos)从相机指向片元是(view_pos - eye_pos)。Blinn-Phong里h l v这个v必须是“从片元指向相机”或者与l统一约定为从点指向光源。如果你不小心把v写反了高光会往光源反方向偏移看起来像错误的镜面光。遇到这种情况先打印n·l、n·v、n·h的值很快能定位。4. 纹理映射从uv坐标到屏幕颜色4.1 uv坐标的插值与来源obj模型里每个顶点通常带有一组uv坐标表示它对应纹理图像上的位置。三角形内部每个片元的uv是光栅化时用重心坐标对三个顶点的uv插值得到的。框架已经替我们做好了插值shader里直接使用纹理坐标即可。纹理本身是一个二维数组每个像素有颜色值。把uv转成像素索引的基本公式是image_x u * widthimage_y v * height然后取整访问。要注意图像坐标系的y向下而uv坐标的v向上所以很多模型在读取时会出现上下颠倒的情况。如果渲染结果纹理倒置优先检查getColor里是否需要对v做一次1 - v的翻转。4.2 getColor的正确打开方式一个基础但健壮的getColor至少要处理三件事uv范围环绕、像素坐标取整、越界安全访问。我写作业时用了这样的模板Eigen::Vector3f Texture::getColor(float u, float v) { if (u 0) u 1 - std::fmod(-u, 1.0f); if (v 0) v 1 - std::fmod(-v, 1.0f); u std::fmod(u, 1.0f); v std::fmod(v, 1.0f); int x static_castint(u * width); int y static_castint(v * height); x std::min(x, width - 1); y std::min(y, height - 1); int idx (y * width x) * channels; return Eigen::Vector3f(image_data[idx], image_data[idx 1], image_data[idx 2]); }这里有个极易踩的坑返回的RGB数值是0到255。后续参与光照之前一定要除以255否则漫反射系数会巨大整个贴图区域过曝成纯白。我第一次写纹理着色时输出就是一片刺眼的白色排查到后来发现是忘了归一化。另一个常见坑是图像通道顺序。很多图像库默认加载顺序是RGB但也有的用BGR或者带Alpha通道。如果纹理颜色整体发蓝发红就检查一下channels是不是3、通道顺序是否和图像库一致。4.3 最近邻到双线性插值的提升最近邻采样在纹理放大时会出现明显马赛克边缘锯齿严重。双线性插值在采样点周围四个像素间做两次线性插值让颜色过渡平滑。作业里的纹理尺寸通常不大换成双线性采样后视觉提升非常明显建议无论是否要求都自己实现一遍。实现关键是把uv精确映射到像素中心的连续坐标而不是直接取整。连续坐标的小数部分就是混合权重。以下是我写的简化版本Eigen::Vector3f Texture::getColorBilinear(float u, float v) { float x u * width - 0.5f; float y v * height - 0.5f; int x0 std::max(0, (int)std::floor(x)); int y0 std::max(0, (int)std::floor(y)); int x1 std::min(x0 1, width - 1); int y1 std::min(y0 1, height - 1); float sx x - x0; float sy y - y0; Eigen::Vector3f c00 getPixel(x0, y0); Eigen::Vector3f c10 getPixel(x1, y0); Eigen::Vector3f c01 getPixel(x0, y1); Eigen::Vector3f c11 getPixel(x1, y1); Eigen::Vector3f c0 c00 * (1 - sx) c10 * sx; Eigen::Vector3f c1 c01 * (1 - sx) c11 * sx; return c0 * (1 - sy) c1 * sy; }边界处x1和y1必须clamp否则数组越界会直接崩溃。这里getPixel是简化版本质上就是按索引取image_data数组。4.4 透视校正插值的进阶话题屏幕空间里的重心坐标插值uv在极端透视下会让纹理扭动原因是透视除法破坏了线性插值在屏幕空间的条件。严格的做法是做透视校正插值对每个顶点存储u/w、v/w和1/w插值后再除以插值的1/w。作业3一般不强制要求模型三角形小直接用重心插值造成的变形肉眼难察觉。但知道这个概念能让你在后续做更复杂渲染时不至于踩坑。我建议作业完成后手动把透视校正插值加进光栅化器在侧视角观察纹理边缘是否更稳定这是一个性价比很高的扩展练习。5. 凹凸贴图与位移贴图两种骗过眼睛的方案5.1 一个只改法线一个真动位置凹凸贴图和位移贴图都依赖一张高度贴图但用途完全不同。凹凸贴图只扰动法线方向让光照在表面产生起伏明暗模型轮廓依然光滑位移贴图除了扰动法线还会沿法线方向把片元位置整体挪动一段距离让模型轮廓发生真实变化。用生活经验类比凹凸贴图是在石膏表面画阴影欺骗眼睛位移贴图是真的用雕塑刀把表面削出立体感。作业里两者代码大部分共享唯一的本质区别是位移贴图多了几行“偏移view_pos”的代码。理解这个区别是这轮作业最有价值的收获。5.2 切线空间与TBN矩阵法线贴图里的扰动值定义在切线空间需要乘一个矩阵转到view space才能和payload.normal在同一坐标系下使用。切线空间由三个基向量构成T大致沿纹理u方向、B大致沿v方向、N原法线。把T、B、N排成3x3矩阵就是TBN矩阵。作业框架给出过一种近似构造法不需要预计算每顶点的切线直接从法线N现场推导T选一个辅助方向通常让T在xoz平面内与N正交公式里会看到sqrt(x²z²)计算B N × T把T、B、N按列排成矩阵。这个近似在N接近某些轴方向时可能出现除零但作业模型一般没问题。更正规的做法是积累三角形两条边的差分用uv和位置差值构造真实切线基。学习阶段先把近似跑通再去理解标准做法。5.3 扰动法线的计算与符号核心思想是用高度贴图在u和v方向的差分构造切线空间中的扰动法线。假设表面高度函数h(u,v)在某个方向升高法线会向低处倾斜所以切线方向的分量取负。作业里常见简写为dU kh * kn * (h(u 1/w, v) - h(u, v))dV kh * kn * (h(u, v 1/h) - h(u, v))切线空间的扰动法线 normalize(-dU, -dV, 1)这里的h是高度如果贴图是彩色图就用RGB的模长近似灰度。kh、kn是两个系数控制扰动强度。最后用TBN矩阵乘这个向量得到view space法线。我踩过的坑是dU、dV没取负号结果凹凸效果变成反向凹陷。检测方法很简单把扰动后的法线着色器单独输出成颜色图视觉上凹凸方向与贴图明暗是否一致不一致就翻转符号直到看起来合理。5.4 位移贴图的改进位移贴图在bump基础上加了真实偏移。设当前片元view space位置为p法线为n高度为h则偏移后的位置为p p kn * n * h(u, v)偏移之后视线方向、光源方向都要用p重新计算否则光影与位置不匹配看起来会很假。同时扰动后的法线也参与光照。做完这一步模型轮廓才会出现真正的凹凸而不是只靠明暗骗人。位移贴图最容易翻车的地方是kn数值太大。kn默认0.1模型尺度小所以刚好如果调到0.5以上偏移量可能超过模型厚度造成三角形穿插和破面。另一个隐患是偏移后的片元可能跑到相机后面导致插值结果异常这时需要显式丢弃无效片元或者减小kn。5.5 着色器效果横向对比四种着色器形成一条递进链法线着色验证几何Blinn-Phong建立光照框架纹理着色把材质颜色换进来凹凸/位移贴图再在几何细节上做文章。我用表格总结了它们的关键区别着色器颜色来源是否改法线是否改位置主要用途normal法线映射到RGB否否验证法线空间与插值blinn-phong光照模型计算否否理解光照模型texture纹理采样结果否否材质纹理映射bump光照 扰动法线是否表面细节视觉欺骗displacement光照 扰动法线 偏移位置是是表面真实形变调试时我习惯先用normal shader确认UV和法线没问题再看Blinn-Phong是否出现明显高光接着加纹理最后加扰动。这样每次引入一个变量出错范围会大大缩小。6. 常见问题排查与调优心得6.1 高频现象速查表我把自己在作业3过程中遇到的典型问题整理成了一张表很多问题看起来现象相似但根源可能完全不同现象可能原因排查方法模型全黑法线方向反、n·l被max成0、纹理颜色未除以255换normal shader看法线打印n·l值高光位置不对半程向量未归一化、v方向反、eye_pos与view_pos不在同一空间检查l、v、h的公式与坐标空间纹理颠倒uv的v方向与图像y方向不一致在getColor里做v1-v纹理过曝采样值0-255未归一化到0-1除以255后再乘kd凹凸效果倒置dU/dV符号反了检查扰动法线负号位移破面kn过大、偏移后片元跑到相机后面调小kn显式丢弃无效片元三角边缘黑边纹理采样越界返回0getColor补充边界clamp与环绕法线颜色混乱法线未归一化或view space法线错误单独渲染normal分布排查的原则是“一次只改一个变量”不要同时调好几个系数否则你永远不会知道是哪个影响了结果。6.2 实用调试流程我推荐的调试顺序是先在窗口显示normal shader确认模型法线看起来均匀且不撕裂然后切到blinn-phong固定光照参数观察模型不同面的明暗是否合理接着加纹理用一张高饱和度的简单贴图而不是照片这样能清楚看到纹理走向最后才做bump和displacement。如果某一步崩了就回退一步缩孤问题。另外强烈建议给自己预留一个伪彩调试点。把view_pos.x映射到红色分量把normal.y映射到绿色分量能一眼看出插值量是否连续、范围是否符合预期。这个方法比盯着白盒子调试直观得多我在调bump贴图时靠它快速判断出扰动后的法线是否出现大范围跳变。6.3 性能与编码习惯纹理采样函数会被调用极多次如果每次都做复杂的模运算渲染会明显卡顿。getColor里的fmod和clamp可以写精简一些或者只在uv真正越界时才做边界处理对预览速度有帮助。另外Eigen的Vector3f临时变量尽量少构造能复用的就复用硬刷帧率时影响不可忽视。编码规范上把ka、kd、ks、p、kh、kn、light位置集中放在文件开头做成参数区方便来回切实验。我见过很多同学为了调高光把代码里的数字改成不可读的魔法数最后完全不敢动。参数常量化之后复现一个满意效果只是改两个值的事。6.4 做完之后可以继续玩的方向作业3完成后有几个方向值得扩展。一是换自定义贴图找一张石头纹理把灰度图当高度图传进去对比bump和displacement的视觉差异。二是主动修改光源位置与颜色观察冷暖光对模型的影响这会让你对光照模型的强度量级产生直觉。三是把透视校正插值补进光栅化器在侧视角下观察纹理是否更稳定。四是尝试把整个光照计算从view space改到world space多做一次变换空间概念会更扎实。我个人在实际操作中的体会是bump和displacement的差值不是视觉上“谁更立体”的区别而是你在一帧渲染里允许自己“欺骗”到什么程度。想明白这一点后面学基于物理的着色时看待法线贴图和高度图的方式都会不一样。最后再分享一个小技巧当你对某个shader的效果毫无头绪时直接把它输出成伪彩色图其实比盯着公式推手册更有效色块的分布会告诉你法线方向、位置范围到底发生了什么。