拓冰建站拓冰建站
首页 / 资讯中心 / 正文

GAMES101作业3实战:软光栅Shader实现与常见坑点全解析

GAMES101的作业3是很多人在软光栅这条路上第一次真正意义上写Shader的一道坎。作业1和作业2你还能靠数学和几何直觉硬撑过去作业3完全不一样它要求你在一套已经封装好的光栅化流程里自己动手实现Phong光照、纹理映射、Bump Mapping和Displacement Mapping而且框架里挖的坑一个接一个。我自己在这份作业上断断续续折腾了整整一个周末渲染结果从全黑到满屏噪点再到最终能看出立体感和纹理细节中间踩过的坑比前两份作业加起来都多。这篇文章纯属个人实操记录把我在GAMES101作业3里遇到的各种问题、排查思路、以及最终能跑通的代码细节全部整理出来。不管你是在校学生做课程作业还是自学图形学补作业这篇文章应该能帮你少走很多弯路。1. 作业3到底在做什么任务拆解与难点地图1.1 从代码框架看作业3的四个Shader目标作业3的官方框架其实已经把整个渲染管线搭好了从顶点着色、三角形光栅化到深度测试都有现成实现你要做的是在main.cpp中定义的几个shader函数里填充内容phong_fragment_shader、texture_fragment_shader、bump_fragment_shader和displacement_fragment_shader。前两个是基础后两个是进阶。这四段Shader的输入都是同一个结构体fragment_shader_payload里面包含view空间下的坐标、法线、UV坐标和颜色输出是Vector3f的颜色值。目标看起来很简单但实际写起来你会发现框架只告诉你“在这里补全光照计算”却不会告诉你view space下的法线变换到底该用哪个矩阵、纹理采样出来的颜色为什么总是怪的、Bump Mapping算出来的法线方向为什么经常翻天覆地。这些恰恰是作业3真正的考点不是让你调用现成的OpenGL函数而是让你理解它们背后的数学原理。1.2 作业3的隐形难点不是代码量是概念转换我最初以为作业3就是套公式。Phong光照模型上课时听过纹理映射不就是采样tex_color吗真正动手之后才意识到作业3最大的难点在于坐标系转换。框架里传入的坐标是view space坐标光照计算要在view space进行但纹理坐标是模型自带的UVBump Mapping又需要切线空间到视图空间的变换矩阵。这三层空间关系如果不理清代码写再多也是错的。另一个隐形难点是深度插值的透视校正。作业2你只需要插值深度的倒数作业3需要在光栅化阶段把view space坐标正确传给fragment shader否则纹理和光照的颜色会在三角形内部产生明显的“游泳”现象。我一开始没有把viewspace_pos传对结果纹理在三角形边缘疯狂抖动排查了很久才发现是透视校正的权重用错了。2. 核心原理与代码实现里的关键细节2.1 法线变换矩阵为什么“模型矩阵不能直接用”作业3里有很多同学第一个翻车的地方就在法线变换。直接在Shader里写normalMatrix view * model然后把法线乘上去结果光照效果完全不对。原因很简单对于非均匀缩放法线经过模型矩阵变换后不再垂直于表面。这一点闫老师上课专门讲过法线变换应该用模型矩阵的逆转置矩阵即normalMatrix transpose(inverse(model))然后取左上3x3部分。作业3的代码里我建议把normalMatrix算在光栅化之前然后传入shader统一使用。具体做法是构造一个Matrix4f的model矩阵然后计算它的逆转置再乘以view矩阵。因为view矩阵本身是刚体变换不会破坏法线方向所以normalMatrix (view * model).inverse().transpose()取左上3x3是安全的。如果你的模型文件没有缩放直接用(view * model)的3x3左上角也能出差不多的效果但为了严谨还是要用逆转置。提示如果法线方向算反了你会在球体表面看到光照在背面亮、正面暗的诡异效果或者整个模型像被挖空了一样。这种情况下优先检查法线变换矩阵以及乘上法线之后有没有做normalize。2.2 Blinn-Phong光照模型半程向量的计算与向量归一化Blinn-Phong本身不复杂三项加起来就行环境光、漫反射、高光。真正容易出错的是向量方向。框架里的light_dir是从光源指向物体还是从物体指向光源不同参考代码写法不一样。我统一采用“从着色点指向光源”的方向也就是dir2light light.position - point然后归一化。视角方向dir2eye eye_pos - point同样归一化。半程向量h normalize(dir2light dir2eye)。高光项用pow(max(dot(normal, h), 0), shininess)计算shininess取32或者64都可以取决于你想要的高光锐利程度。还有一个非常隐蔽的点光照计算里的向量必须全部在同一个坐标系下。作业3中所有顶点坐标都已经变换到了view space所以光照位置和眼睛位置都要先变换到view space再参与计算。框架里给的light.position和eye_pos是world space坐标如果直接用亮度方向就会错乱。我当时的做法是写一个小函数把light.position用view矩阵变换到view spaceeye_pos直接按照框架给的(0,0,10)用view矩阵乘一次或者干脆在main函数里算好传进去。另一个常见问题是归一化时机。normalize太早或太晚都会导致高光面积异常。比如法线在变换之后忘了归一化半程向量忘了归一化都会让高光看起来像一团不规则的亮斑。我的经验是所有参与点乘的向量在使用之前必须normalize这是硬性要求。2.3 重心坐标与透视校正插值纹理“游泳”问题作业3的光栅化代码里已经帮你算好了重心坐标alpha、beta、gamma并且把viewspace_pos传了进来。如果你直接在fragment shader里用屏幕空间的重心坐标对UV和法线做线性插值纹理会在三角形内部发生扭曲远处看起来像在“游泳”。原因是透视投影下屏幕空间的线性插值不等于view space的线性插值。正确做法是透视校正插值。对每个顶点属性attr用公式 interpolated_attr (alpha * attr_a / w_a beta * attr_b / w_b gamma * attr_c / w_c) / (alpha / w_a beta / w_b gamma / w_c) 其中w_a、w_b、w_c是三个顶点在view space的齐次坐标w分量也就是view space的z值的相反数。在作业3框架里rasterizer.cpp的triangle函数已经准备好了viewspace_pos并且Shader里实际上可以用alpha、beta、gamma直接对向量做插值但需要注意框架可能已经做了透视校正或者需要你自己做。我自己的做法是检查框架代码是否有对viewspace_pos的透视校正如果没有就在shader外部先算好透视校正后的法线、UV再传入。注意如果你发现纹理在小三角形上看不出来问题但模型一旦转动或者靠近摄像机就出现明显扭曲大概率就是透视校正没做对。这是作业3里非常典型的现象。2.4 纹理采样与双线性插值从理论到代码texture_fragment_shader的核心就是调用Texture类的getColor(u, v)取得颜色。但这里有几个细节首先UV坐标的范围是[0,1]纹理图像的像素坐标需要乘上width和height其次stb_image在加载图片时默认不翻转Y轴所以你的UV坐标如果不做处理贴图会是上下颠倒的。我是在main函数里调用stbi_set_flip_vertically_on_load(true)来翻转这样UV原点就在左下角和OpenGL习惯一致。如果作业要求实现双线性插值Bilinear Interpolation你要在getColor里自己写插值逻辑。大致思路是给定(u,v)对应的纹理坐标找到包围它的四个像素点按距离加权混合。公式是 color lerp(lerp(c00, c10, alpha), lerp(c01, c11, alpha), beta) 其中alpha是u方向的小数部分beta是v方向的小数部分。这里容易犯的错是把alpha和beta搞反导致纹理方向歪掉。我自己还被另一个问题坑过getColor函数的返回值是[0,255]范围的颜色而shader里的颜色运算要保持在[0,1]范围最后写回framebuffer时再乘255。如果你在中间步骤没有归一化纹理颜色叠加光照后就容易溢出变成白色一片或者出现诡异的暗斑。3. 从零到一四个Shader的完整实现过程3.1 环境准备框架运行、依赖库和模型文件作业3的框架在Windows、Linux、macOS上都能跑。我是在Windows上用Visual Studio跑的编译前需要确认依赖库路径Eigen、OpenCV、stb_image。这三个库缺一不可。OpenCV这里其实只是在main函数里用来显示图片如果你嫌配置麻烦可以把它去掉改成把渲染结果保存成PPM文件再用图片查看器打开功能完全不受影响。模型文件是作业里自带的spot_triangulated.obj和spot_texture.png正常情况下放在models目录下路径别写错。如果你运行时控制台输出找不到文件大概率是工作目录不对。Visual Studio里可以右键项目属性把“工作目录”设置成项目根目录。这个问题我替无数同学排查过真的很常见。环境准备好之后先跑一遍原始代码你应该能看到一个纯色的牛头模型。如果连这个都看不到先别急着写Shader优先检查框架本身是否编译运行正常。3.2 phong_fragment_shader 的完整实现这个Shader的输出是Phong光照下的颜色。我的关键代码结构大概是这样取出payload里的normal用normalMatrix变换并归一化。把light.position和eye_pos变换到view space。计算方向光dir2lightdir2eyehalf_vector。计算ambient、diffuse、specular三项。颜色 ambient diffuse specular每一项都在[0,1]范围。最容易忽略的是环境光项。有些同学为了追求对比度把ambient设成0结果模型的背光面完全是黑色看起来像硬边卡通渲染反而不自然。我用的环境光强度是0.05左右保证暗部有一点亮度。还有一点框架里面给的颜色是Vector3f类型乘法和加法都已经重载好了但你必须确认自己是先归一化再运算还是直接在255范围里运算。我统一在0~1范围做最后乘以255写回frame buffer。验证结果完成phong_fragment_shader后你应该能看到一个有明暗变化、高光明显的牛头模型。如果看起来很平没有立体感多半是法线插值或者方向向量出了问题。3.3 texture_fragment_shader 与 UV 处理texture_fragment_shader相对简单就是把Phong光照里的漫反射项换成纹理采样颜色或者直接用纹理颜色作为输出。我建议先实现一个最简单的版本return payload.tex_color也就是采样颜色确认贴图能正确显示在模型上再做光照叠加。这里要特别注意payload里传进来的tex_coords是经过插值后的结果。如果你直接用它调用texture.getColor理论上没问题但前提是你在rasterizer阶段传入了正确的插值UV。如果你发现纹理错位——比如牛角上的纹理跑到了脸颊上——那多半是UV插值时把顶点顺序搞错了。还有一种情况是模型表面出现大量黑点或白点那通常是纹理坐标越界了getColor函数里没有做clamp采样到边界外的空像素。我给getColor补上了边界检查效果立刻正常。如果你要加双线性插值建议先用最朴素的最近邻采样调通整个流程再改采样方式。这样定位问题时能少一个变量。3.4 bump_fragment_shader 与 displacement_fragment_shaderTBN矩阵是重灾区这两个Shader是作业3的进阶难点。Bump Mapping的思路是在切线空间里根据高度图扰动用高度图求出的法线再利用TBN矩阵把它转换回view space参与光照。Displacement Mapping更暴烈直接在view space里沿扰动后的法线平移顶点再重新做光照。框架里的代码注释已经给了比较明确的步骤提示核心是计算TBN矩阵。计算原理是从三角形的两条边向量和对应的UV差求出切线T和副切线B具体公式是E1 v1 - v0E2 v2 - v0deltaUV1 uv1 - uv0deltaUV2 uv2 - uv0f 1.0 / (deltaUV1.x * deltaUV2.y - deltaUV2.x * deltaUV1.y)Tangent f * (deltaUV2.y * E1 - deltaUV1.y * E2)然后用cross(Tangent, Normal)得到Bitangent再把T、B、N组装成3x3的TBN矩阵。注意TBN矩阵的行和列方向很容易搞反你可以在做完之后渲染一个可视化法线的颜色图来检查。Bump Mapping里高度函数h(u,v)我用的就是纹理采样颜色的长度也就是亮度然后通过差分近似求导du (h(u 1.0/width, v) - h(u, v)) * widthdv类似。扰动后的法线取normalize(Vector3f(-du, -dv, 1.0))然后用TBN矩阵转回view space。这里最大的坑是算出来的扰动法线是切线空间里的如果你忘了乘TBN矩阵或者矩阵方向转反了凹凸效果会变成条纹状或者完全不对。另一个常见问题是扰动强度太大整个表面变成噪声毛刺适度调整du、dv的缩放系数能改善。Displacement Mapping则有额外的坑它是先根据高度图把顶点位置沿法线方向偏移然后再重新计算法线做光照。如果你的偏移量太大三角形会被扯得很碎甚至出现穿透。我当时把偏移量的缩放系数调得比较小视觉效果才正常。这个作业其实很考验调参能力没有绝对正确的数值只有“看起来对”的数值。3.5 别忘了透视除法与深度插值在写Shader的时候你可能只关注颜色计算忘了rasterizer.cpp里还有个深度插值。作业3的光栅化代码里深度值本身已经有了透视校正但我需要确认在输出到framebuffer时使用的深度是校正过的还是原始的。如果你发现三角形的前后遮挡关系不对远处的三角形盖住了近处的那就是深度插值或深度转换出了问题。我当时在调试displacement时遇到过一个问题顶点沿着法线移动后模型看起来像被切了一刀。原因是我在bump和displacement两个Shader里使用了不同的法线导致深度和颜色不一致。后来我统一在displacement的Shader里也做同样的TBN变换问题就消失了。这类“看起来是遮挡问题”的bug往往根源不在深度而在你插值的属性法线、位置有问题。4. 常见问题与排查技巧实录4.1 渲染全黑或偏暗从向量和数值范围排查全黑是作业3里最让人心态爆炸的问题。原因通常是以下几种法线变换错误导致法线和视线方向点乘为负所有光照项都为0。可以在shader里暂时返回法线可视化颜色比如return normal * 0.5 Vector3f(0.5)看看模型表面颜色变化是否正常。向量方向反了比如dir2light写成了从光源到着色点点乘结果全负。把光源位置和着色点的方向打印出来对比看看。颜色值范围错误。如果你在shader里得到的是0~255范围的纹理颜色然后直接乘光照最后写回frame buffer又乘255数值会爆炸或变成纯黑。建议在shader入口统一归一化。偏暗也类似多半是光照强度太低或者方向光没有达到物体表面。试着把ambient调大一点或者把diffuse系数提高一些排除数值问题再逐步调光。4.2 纹理颠倒、错位与采样颜色不对纹理颠倒的核心原因是图片Y轴原点问题。stb_image默认左上角是原点OpenGL习惯左下角所以需要翻转。如果你不希望改全局设置可以单独在getColor里对v做1 - v处理。纹理错位往往是UV插值顺序和三角形顶点顺序不匹配。检查模型文件里的纹理坐标和顶点坐标是否是同一套索引尤其注意OBJ格式里vt和v是分开的如果你的模型加载代码偷懒用了同一个索引纹理就会对不上。采样颜色不对还有可能是getColor返回的像素颜色通道顺序问题。stb_image默认是RGB但有些贴图是RGBA如果你的代码固定取RGB而贴图带了Alpha通道颜色会偏移。我直接用vec3颜色存储遇到带Alpha通道的纹理时只取前三个分量问题解决。4.3 凹凸贴图方向错乱或过度剧烈Bump Mapping效果不对先检查TBN矩阵的行列方向。我之前把TBN矩阵组装成了旋转矩阵的转置结果表面凹凸方向整体翻转看起来像石头表面变成了坑洞表面。最简单的验证方法是把TBN矩阵乘一个简单的切线空间法线(0,0,1)看看结果是不是等于原有的法线。如果不是说明矩阵方向有问题。另一个表现是凹凸效果过于剧烈整个表面到处是尖刺。这通常是差分步长太小或者扰动强度太大。我的做法是把差分步长改成一个固定的小量比如0.01而不是直接用1/width这样更容易控制强度。4.4 MSAA黑边、背面剔除与边缘异常作业3框架里有些版本提供了MSAA选项但MSAA和作业3的Shader结合之后很容易出现黑边。原因是MSAA需要为每个采样点单独存颜色和深度而你的Shader如果只计算了像素中心的属性边缘就会出现不连续。如果你追求作业效果稳定可以先把MSAA关掉所有功能调通之后再开启。开启后如果出现黑边尝试把shader输出颜色时加上一个极小值避免颜色为0导致边缘发黑。还有一个常见问题模型被部分“穿透”看起来像背面被剔除了。作业3默认没有开启背面剔除但如果你自己加了剔除逻辑注意法线方向和顶点绕序要一致。牛头模型里有些三角形的绕序可能不统一统一剔除会丢失片元。建议最开始不要做任何剔除。4.5 问题速查表我把作业3里自己踩过的坑汇总成了一张速查表方便你快速定位现象可能原因解决思路模型全黑法线方向错/方向向量未归一化/颜色范围溢出可视化法线打印方向向量统一颜色范围贴图上下颠倒图片Y轴未翻转stbi_set_flip_vertically_on_load(true)或getColor里做1-v贴图错位UV索引和顶点索引不匹配检查OBJ加载逻辑确认vt和v索引对应关系纹理抖动/游泳透视校正插值没做对使用viewspace_pos重心坐标做透视校正光照出现条纹法线变换用了模型矩阵改为逆转置矩阵并归一化Bump方向反了TBN矩阵组装错误验证TBN * (0,0,1)是否等于原法线表面毛刺严重差分步长太小/扰动强度过大固定差分步长为0.01调节扰动缩放系数物体前后遮挡错误深度插值未做透视校正检查深度写入和插值公式5. 从现象反推原因的调试规律5.1 调试三板斧逐步打印、单帧截图、分模块二分定位作业3没有现成的调试界面默认只弹出一个窗口显示渲染结果。我调试时最常用的方式是三步走第一在shader里加中间结果输出。想验证法线对不对就先把法线可视化输出成颜色想验证纹理坐标对不对就先把UV的x通道和y通道映射成红绿色输出。这样通过观察模型表面的颜色分布能很快定位是哪一步计算出了问题。第二保存中间渲染结果为图片。有时候窗口一闪而过根本来不及细看。我在main函数里把framebuffer存成PPM或BMP然后用图片查看器放大逐像素观察很多边界问题都能看出来。这个方法在调试边缘锯齿和黑边时尤其好使。第三用二分法注释代码。当你不知道是光照问题还是纹理问题时先写一个最简单的shader只输出纹理颜色确认纹理没问题再写一个只输出光照颜色确认光照没问题最后再合并。每步都用最小可运行版本验证能省下大量时间。5.2 我把作业3踩过的坑整理成了一张速查表上面那张表虽然只是最典型的几个坑但在实际调试里很多疑难杂症是多重原因叠加的。比如我曾经遇到一个“半边亮半边暗”的问题排查了一晚上最后发现是法线矩阵里忘记把view矩阵包含进去同时又没做透视校正。这种组合问题最折磨人所以我的建议是每改一个变量就输出一张渲染图用图片对比的方式判断改动方向对不对。不要指望一次把所有问题都修好图形学调试从来都是增量式的。还有一个经验是不要过度相信参考代码。网上确实有很多版本可以参考但每个人的框架代码可能改过接口都不一样。我一开始直接照抄了一份参考实现因为它的tbn矩阵是用行向量还是列向量组装跟我的框架不匹配折腾了一下午。后来我老老实实从矩阵定义出发一行一行算清楚才跑通。图形学这个领域理解原理比背代码重要得多。5.3 调试时的一个小技巧善用“调试色”如果你和我一样对数值不太敏感强烈建议在shader里用“调试色”来可视化中间变量。比如我把alpha、beta、gamma三个重心坐标分别映射到RGB三个通道输出到屏幕上就能直观看到三角形内部的重心坐标分布是否连续。如果颜色分布出现跳变说明插值权重算错了。这个技巧在透视校正的调试里尤其好用。我在不校正的情况下看到重心坐标颜色在三角形内部产生了弯曲的条纹校正之后颜色分布变成了均匀的渐变。虽然这不能直接告诉我公式应该怎么写但它能提示我当前实现方向是否正确。最后说一个关于心态的建议GAMES101作业3确实有难度但它的难度集中在理解坐标系转换和完善插值细节上。如果你卡住了不妨把每一行代码都当成“一个坐标变换加一次归一化”的组合来审视很多问题都会变得清晰。做完之后你会发现后面作业里很多看似复杂的shadow mapping、path tracing其实都建立在这些基础的插值、变换和采样思想上。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门