☰
GAMES101作业3实战:软光栅Shader实现与常见坑点全解析
2026/10/4 1:08:21 网站建设 项目流程

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进行,但纹理坐标是模型自带的UV,Bump 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 space,eye_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光照下的颜色。我的关键代码结构大概是这样:

  1. 取出payload里的normal,用normalMatrix变换并归一化。
  2. 把light.position和eye_pos变换到view space。
  3. 计算方向光:dir2light,dir2eye,half_vector。
  4. 计算ambient、diffuse、specular三项。
  5. 颜色 = 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_shader:TBN矩阵是重灾区

这两个Shader是作业3的进阶难点。Bump Mapping的思路是:在切线空间里根据高度图扰动用高度图求出的法线,再利用TBN矩阵把它转换回view space参与光照。Displacement Mapping更暴烈,直接在view space里沿扰动后的法线平移顶点,再重新做光照。

框架里的代码注释已经给了比较明确的步骤提示,核心是计算TBN矩阵。计算原理是:从三角形的两条边向量和对应的UV差求出切线T和副切线B,具体公式是:

E1 = v1 - v0
E2 = v2 - v0
deltaUV1 = uv1 - uv0
deltaUV2 = uv2 - uv0
f = 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)) * width,dv类似。扰动后的法线取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,其实都建立在这些基础的插值、变换和采样思想上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询