☰
现代OpenGL光照实战:冯氏模型、法线矩阵与多光源渲染
2026/10/8 15:51:59 网站建设 项目流程

1. 从“画得出三角形”到“看得见立体感”:光照是这道分水岭

我猜你现在正处于这个状态:跟着教程画出了一个彩色三角形,把纹理贴到立方体上转了转,模型加载也跑通了——但总觉得画面“假”。棱是棱、角是角,每个面颜色都一样平,没有任何明暗变化,像工业图纸而不是真实物体。

别急着怀疑自己操作有问题。你缺的不是代码,而是光照。

《从零开始学 OpenGL:现代图形渲染实战》走到第07篇,前面几篇我们把渲染管线的底子打完了:顶点数据怎么送进去、着色器怎么编译链接、纹理怎么采样、矩阵怎么把三维坐标压到屏幕上。但说句实在话,没有光照的三维场景就是“三视图”,有了光照才是“渲染”。光照是让双眼产生立体感的根本来源——物体为什么看起来有体积?因为每个面的朝向不同,受光强度不同,眼睛接收到的亮度不同,大脑才能重建出“这是一个三维物体”的感觉。

这篇文章就专门解决这个问题:现代OpenGL里的基础光照到底怎么实现。我会从最底层的数学原理讲起,然后手写一个完整的冯氏光照模型(Phong Lighting Model),包含环境光、漫反射、镜面高光三个分量,再做平行光、点光源、聚光三种经典光源的实现。全部用现代OpenGL的可编程管线,核心代码可以直接复制跑起来,同时每一步都会解释“为什么这么做”。

适合谁来读?已经会画三角形、会贴纹理、会做简单的MVP矩阵变换,但对光照一头雾水的朋友。不需要更高级的数学基础,高中向量知识就够用,我会把推导过程掰开揉碎。

2. 从固定管线到可编程管线:为什么现代OpenGL“没有”光照API

很多入门教材或者旧博客会提到glLight*、glMaterial*这类函数——那是固定管线的时代,显卡内部把光照计算写死了,你只需要调几个参数告诉它“灯在哪儿、灯泡什么颜色、材质反光系数多少”,GPU就按内置公式算给你看。

现代OpenGL把这套全删了。核心原因有两个:一是固定管线能算的只有那一种光照模型,想换算法、想扩展效果你毫无办法;二是那套内置实现本身效率也不高,每个顶点都要执行完整的光照函数,可控性太差。现代图形技术的思路是“给你一个通用计算框架,算法你自己写”。GPU提供的是极度高效的并行计算能力,而光照公式——不管多复杂——本质都是一堆向量点积、归一化、插值运算,正好是GPU最喜欢干的活。

所以你要转变一个认知:光照不是“调用一个API”,而是“在着色器里写一段数学公式”。你写的片元着色器或顶点着色器,就是在GPU上执行的光照计算程序。

这个转变对新手来说往往是最难的一关,因为它把“图形学是纯经验学科”的幻觉打破了——你得懂一点点线性代数才能继续往下走。不过别慌,我接下来用的数学工具就三样:向量点积、向量归一化、向量减法。只要这三样思想搞明白,剩下全是套路。

先明确一下我们要实现的目标输出:一个真实感不错的茶壶、立方体或模型,表面上不同区域呈现不同的亮暗、高光、阴影过渡,并且当你移动光源或旋转物体时,光照效果实时跟着变化。

3. 冯氏光照模型:现代图形学一切光照的“地基”

所谓冯氏光照模型,是1975年裴祥风(Bui Tuong Phong)提出的一个经验模型。它不是物理上完全正确的光照模拟(真正的物理模拟那是PBR那套东西),而是用三个分量近似人眼对物体的感知,计算量小、效果直观,直到今天依然是绝大多数实时渲染的起点。

三个分量分别是:

  • 环境光(Ambient):模拟场景中无处不在的间接光照。现实中即使背光的地方也不是纯黑的,因为光线会经过墙壁、地面反复弹射。实时渲染不可能真的追踪每一条反弹光线,所以用一个常量近似:给物体一个很低的“基础亮度”,保证暗面不至于死黑。
  • 漫反射(Diffuse):模拟光线打到粗糙表面后向四面八方均匀反射的部分。这是物体呈现“固有色”的主要来源,也是让人感觉到立体感的关键。它只和“表面法线方向”与“光线方向”的夹角有关。
  • 镜面高光(Specular):模拟光滑表面上的亮点,比如陶瓷茶杯上的那道亮斑、汽车漆面上的反光。它依赖于“视线方向”与“反射光线方向”的接近程度。

这三个分量加在一起,就是经典Phong模型:

最终颜色 = 环境光 + 漫反射 + 镜面高光

落实到着色器代码里,每一行都是这个公式的展开。我们接下来逐个拆解。

3.1 环境光:让暗面别死黑就行

环境光最简单,没有任何方向性,就是乘一个因子:

float ambientStrength = 0.1; // 环境光强度,通常取比较小的值 vec3 ambient = ambientStrength * lightColor;

最终物体颜色就是ambient * objectColor。说白了就是给物体一个全方向的微弱底色。你把它设成0,场景暗面就是纯黑,虽然物理上“没错”但观感很糟糕,因为人眼习惯了现实中永远存在的环境反弹光。实践经验上,0.05到0.15之间是比较舒服的范围——太亮会让整个画面发灰,失去对比度。

3.2 漫反射:法线和光线的夹角就是一切

漫反射的物理直觉是:光照射到粗糙表面,入射能量被表面吸收一部分、反射一部分,反射方向均匀散布,所以无论从哪个方向看,亮度都一样。

关键在于:表面法线与光线方向的夹角决定了表面接收到的光通量。正对光线的表面最亮,侧对光线的表面逐渐变暗,背对光线的表面直接为零。

这个“亮暗程度”用数学表达就是法线向量和光源方向向量的点积:

float diff = max(dot(normal, lightDir), 0.0);

点积等于两个向量夹角的余弦值乘以各自长度(都归一化后就只有余弦值了)。余弦值在夹角0度时为1,90度时为0,超过90度为负——负值说明光从背面照过来,物体自己挡住了光,所以要max(..., 0.0)截断。为什么要截断?因为一个表面不可能产生“负亮度”,背面没被光照到就是没被照到。

这里要特别注意:所有参与点积的方向向量,必须是归一化后的单位向量。不然算出来的结果不只是夹角关系,还掺进了向量长度的干扰,画面完全错乱。新手十个里有八个第一次写光照就是栽在忘了normalize()上。

漫反射公式完整版:

vec3 norm = normalize(N); vec3 lightDir = normalize(lightPos - fragPos); float diff = max(dot(norm, lightDir), 0.0); vec3 diffuse = diff * lightColor;

这里lightPos - fragPos计算的是从片元指向光源的方向向量。为什么不是反过来?因为点积只关心“法线和光方向之间的夹角”,光线从哪边来就用从片元指向光源的向量,这是约定俗成的方向习惯,后面写镜面反射时你会看到这个方向定义直接影响反射向量计算,所以统一从片元出发。

3.3 镜面高光:还要考虑你的眼睛在看哪

漫反射跟观察者位置无关,但高光明显是“你所处位置不同,看到的亮点位置就不同”。镜面反射的物理直觉是:光线打到光滑表面,会按照“入射角等于出射角”的规律朝特定方向反射,你的视线恰好和反射光线重合时,就能看到非常亮的点,偏离越多越暗。

计算分为两步:先根据光线方向和法线求反射向量,再求反射向量和视线方向的点积:

vec3 viewDir = normalize(viewPos - fragPos); // 片元指向眼睛 vec3 reflectDir = reflect(-lightDir, norm); // reflect函数要求入射光指向表面 float spec = pow(max(dot(viewDir, reflectDir), 0.0), shininess); vec3 specular = spec * specularStrength * lightColor;

这里的shininess(高光反光度)是一个经验参数,控制高光斑点的集中程度。数值越小高光面积越大越模糊,接近漫反射;数值越大高光点越小越锐利,像抛光的金属。实用参考:塑料材质用32左右,陶瓷用64到128,镜面金属可以上到256。你自己运行程序时试着把值从1改到256,观察高光从“一片亮斑”变成“一个小白点”,这个动态过程比任何文字解释都直观。

pow(..., shininess)的作用是把点积结果进行非线性压缩。点积算出来的余弦值在0到1之间,乘以大于1的指数后,稍偏离反射方向的值会迅速趋近0。这正好模拟了“只有视线非常接近反射方向才能看到高光”的效果。

至此,你把三个分量相加,再乘上物体自身颜色,就是冯氏光照的最终公式了。先别急着往后翻,我建议你现在就停下来,用纸笔推一遍这三个公式——不需要复杂推导,就是把每个变量的含义、方向定义、点积顺序理清楚。等你在纸上写明白了,代码写起来就不会出那种“画面全黑但不知怎么回事”的问题。

4. 第一个完整实跑:逐行实现的漫反射场景

理论归理论,代码跑起来才算数。这里我给出一个最小可跑的漫反射场景实现。之所以先不做高光,是为了让你把最难理解的部分——法线数据管理和法线矩阵——先吃透,高光是在这个基础上加几行公式的事。

4.1 准备带法线数据的顶点结构

漫反射计算必须知道每个片元的法线方向。如果你的模型数据里没有法线字段,那第一步就是给顶点加入法线。以立方体为例,每个面有独立的法线方向(这个立方体是6个面各自朝向,所以部分顶点即使坐标相同也要拆开,顶点属性不能复用):

struct Vertex { glm::vec3 position; glm::vec3 normal; }; // 立方体前脸,法线朝正Z方向 Vertex vertices[] = { // 位置 法线 { {-0.5f, -0.5f, 0.5f}, { 0.0f, 0.0f, 1.0f} }, { { 0.5f, -0.5f, 0.5f}, { 0.0f, 0.0f, 1.0f} }, { { 0.5f, 0.5f, 0.5f}, { 0.0f, 0.0f, 1.0f} }, { { 0.5f, 0.5f, 0.5f}, { 0.0f, 0.0f, 1.0f} }, { {-0.5f, 0.5f, 0.5f}, { 0.0f, 0.0f, 1.0f} }, { {-0.5f, -0.5f, 0.5f}, { 0.0f, 0.0f, 1.0f} }, };

如果你加载的是OBJ模型,Assimp已经帮你把法线读到mesh->mNormals里了,直接按顺序填充即可。如果是自己生成的地形、球体、环面等形状,需要手动计算每个顶点的法线——一般是取相邻面的法线平均。手写计算法线的部分网上教程很多,我这篇重点在光照公式,默认你已经有能带出法线的模型数据。

VAO和VBO的设置跟之前一样,只是要多取一个属性槽:

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)0); glEnableVertexAttribArray(0); glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, normal)); glEnableVertexAttribArray(1);

顶点着色器这边定义好输入输出,把法线传到片元着色器阶段:

#version 330 core layout (location = 0) in vec3 aPos; layout (location = 1) in vec3 aNormal; uniform mat4 model; uniform mat4 view; uniform mat4 projection; out vec3 FragPos; out vec3 Normal; void main() { gl_Position = projection * view * model * vec4(aPos, 1.0); FragPos = vec3(model * vec4(aPos, 1.0)); Normal = aNormal; }

这里有两个细节我想单独说清楚。

第一,FragPos为什么要单独传?因为光照计算要在世界空间进行。我们眼睛是站在世界空间看物体的,光源坐标也是定义在世界空间的,所以片元的世界坐标得算出来。如果你直接在裁剪空间做光照,光源位置和视图方向之间的关系会变得一团乱麻。

第二,法线到底应该在哪里传?很多教程在这一步就藏了个大坑——顶点着色器里直接把模型空间的法线传到片元着色器,看起来没问题,因为物体不缩放时确实没问题。但一旦模型矩阵里带非等比缩放(比如长方体拉伸成扁盒子),法线方向就不对了,这个坑我放在下一节专门讲,现在先按“较轻”的版本实现,把Normal = aNormal;传下去,下一节再升级成法线矩阵。

4.2 片元着色器:实现漫反射公式

片元着色器这一步就是把上面的漫反射公式翻译成GLSL:

#version 330 core in vec3 FragPos; in vec3 Normal; out vec4 FragColor; uniform vec3 objectColor; uniform vec3 lightColor; uniform vec3 lightPos; uniform vec3 viewPos; void main() { // 环境光 float ambientStrength = 0.1; vec3 ambient = ambientStrength * lightColor; // 漫反射 vec3 norm = normalize(Normal); vec3 lightDir = normalize(lightPos - FragPos); float diff = max(dot(norm, lightDir), 0.0); vec3 diffuse = diff * lightColor; vec3 result = (ambient + diffuse) * objectColor; FragColor = vec4(result, 1.0); }

注意viewPos虽然这节没用上,但下一个版本会用到高光计算,uniform先留好。objectColor是物体的基色,lightColor是光源发出的颜色,这是两个可以随时调的外部参数,渲染循环里用glUniform3f传进去即可。光源位置lightPos当然也要传,它决定了当前帧的光照方向。

编译运行后,你应该看到一个亮的立方体:朝着光源的面亮,背着光源的面暗,中间有条过渡带。如果你把立方体转起来,亮暗面跟着实时变化——恭喜,漫反射光照已经跑通了。

4.3 一个常见报错的排查思路

写光照代码最容易碰见的报错是failed to initialize graphics backend for opengl。这行报错的字面意思是“无法初始化图形后端”,通常跟代码逻辑无关,而是你的环境不支持或者上下文创建失败。

排查思路按顺序来:

  1. 先确认你的OpenGL上下文确实创建成功了。GLFW的报错没那么直观,如果你用了glfwCreateWindow后没有检查返回值就后续渲染,或者因为你偷偷用了更高版本的Core Profile导致环境不支持,就会在后续某个调用里莫名其妙崩掉。建议在初始化后立刻打印glGetString(GL_VERSION),确保至少是3.3以上版本。
  2. 检查VAO是否绑定。很多光照代码会新增第二个VAO(比如画光源小方块),如果第二个VAO没绑定就调用glDrawArrays,驱动会直接报上下文错误。
  3. 检查着色器编译日志。光照代码里新增了很多uniform,如果你着色器里引用了不存在的uniform变量,或者某个in变量在顶点和片元着色器之间名称不一致导致链接失败,GLSL编译器不会有运行时错误,但画面全黑或者没有任何效果。习惯性在编译完每个着色器后打印日志,能帮你省下大量瞎猜的时间。

如果以上都查过还是不行,那就是环境问题了——虚拟机、远程桌面或者某些旧显卡驱动就是不支持现代OpenGL,换个物理机的图形环境试试。

5. 法线矩阵:这一次,模型矩阵真的不能直接用了

上一节的代码里,我是直接把模型空间的法线aNormal传到片元着色器的。这个写法对“不缩放”和“等比缩放”的模型是没问题的——因为法线的方向没有被改变。但是一旦你的模型矩阵里有非等比缩放,比如把立方体沿Y轴拉伸成长方体,你就会看到光照效果完全错乱:应该亮的面暗了,该暗的面亮了一小块。

为什么?因为法线不是“位置坐标”,它是“方向向量”。用模型矩阵直接乘方向向量,缩放分量会被一同作用。假设一个顶点的法线指向正X方向(1, 0, 0),模型在X方向缩放了2倍——坐标乘完变成了(2, 0, 0),方向没变,没问题。但假设Y方向缩放2倍,法线是(1, 1, 0),这个方向的延伸表面是被拉伸过的,原本垂直于表面的法线经过缩放后不再垂直于变形后的表面——法线“歪了”。

而光照计算最依赖的就是法线与表面的垂直关系。法线歪了,漫反射的点积结果完全失去意义。

解决办法是用法线矩阵对法线进行变换。严格的说法是:对于非等比缩放矩阵M,变换法线的正确矩阵是M的逆转置矩阵的左上角3x3部分。推导很简单:设表面切向量为T,变换后为MT,法线为N,变换后为N',垂直关系要求N'·(MT) = 0。由此可以推出N' = (M^{-1})^T N。这个推导不一定要求你背下来,但你得知道“为什么不能用model矩阵直接乘”以及“用逆转置矩阵”。

在GLSL里有两种实现方式。

一种是把逆转置矩阵在CPU端算好,当作uniform传入。GLM提供了现成函数:

glm::mat3 normalMatrix = glm::mat3(glm::transpose(glm::inverse(model))); glUniformMatrix3fv(glGetUniformLocation(shader.ID, "normalMatrix"), 1, GL_FALSE, glm::value_ptr(normalMatrix));

顶点着色器里:

uniform mat3 normalMatrix; ... Normal = normalMatrix * aNormal;

另一种是在着色器里直接写矩阵运算,每次都要做一次求逆,效率较低。如果物体没有缩放,model矩阵的左上角3x3就是正交矩阵,恰好可以直接用;如果只有等比缩放,除以缩放系数也能还原法线方向。但最省事最稳妥的方案就是CPU端算好逆转置传进去。

需要注意的一点是:在片元着色器里拿到法线后,一定要做一次 normalize。即使CPU端传来的法线已经归一化,经过插值后长度一定会发生变化(插值会让向量长度线性变化,方向也会偏移,这就是为什么法线插值不能直接当作单位向量用)。不做normalize的话,点积结果会偏离实际余弦值,整个光照亮度会乱飘。

我自己的习惯是:新写法线相关代码一律在片元着色器里先norm = normalize(Normal),宁可多归一化一次也绝不省这一步。

6. 补上镜面高光:完成整棵Phong光照树

到这一步,我们开始把代码升级成完整的冯氏模型。漫反射我们已经有了,环境光就一行事,剩下就是加上镜面高光。

片元着色器升级为:

#version 330 core in vec3 FragPos; in vec3 Normal; out vec4 FragColor; uniform vec3 objectColor; uniform vec3 lightColor; uniform vec3 lightPos; uniform vec3 viewPos; uniform float shininess; void main() { // 环境光 float ambientStrength = 0.1; vec3 ambient = ambientStrength * lightColor; // 漫反射 vec3 norm = normalize(Normal); vec3 lightDir = normalize(lightPos - FragPos); float diff = max(dot(norm, lightDir), 0.0); vec3 diffuse = diff * lightColor; // 镜面高光 float specularStrength = 0.5; vec3 viewDir = normalize(viewPos - FragPos); vec3 reflectDir = reflect(-lightDir, norm); float spec = pow(max(dot(viewDir, reflectDir), 0.0), shininess); vec3 specular = specularStrength * spec * lightColor; vec3 result = (ambient + diffuse + specular) * objectColor; FragColor = vec4(result, 1.0); }

viewPos就是相机位置。如果你在渲染循环里维护了一个相机类,那它就是这个相机位置的坐标。我常在Linux下跑这套示例,有时随手用glm::vec3(0.0f, 0.0f, 3.0f)这种写死的观察位置做测试,但正式的项目里一定是跟着相机实时传。

关于reflect函数有个小坑,我专门拿出来说。GLSL的reflect(I, N)计算的是以N为法线的反射向量,但这里的输入向量I的方向是从片元指向光源。而我们之前定义的lightDir是lightPos - FragPos,已经是从片元指向光源了。那为什么代码里又要-lightDir呢?

因为reflect的语义是“入射方向指向交点”,如果lightDir已经是从片元向光源的方向,那对reflect来说这就是反的。所以必须取负号,变成从光源向片元的方向传入,reflect算出来的反射方向才是从片元向外指向观察方向的。这一步方向搞反,高光位置会跑到背面去,画面出现诡异的“透光亮点”。

这里我们做的是Blinn-Phong 优化还是Phone 原版?上面代码用的是原版Phong:先求反射向量再和视线点积。这个写法直观,但有一个已知问题:当视线方向与反射方向夹角超过90度时,高光会骤变成零,边缘处可能出现不自然的断层。优化的做法 Blinn-Phong 不直接求反射向量,而是求“半程向量”:就是光线方向和视线方向的平均。“光线从光源出发朝片元飞,视线从相机出发朝片元飞,两个方向取平均得到的方向”就是半程向量,然后让半程向量和法线点积。因为半程向量和法线夹角永远小于90度,避免了断层问题,而且还省了一次reflect运算。代码变成:

vec3 halfwayDir = normalize(lightDir + viewDir); float spec = pow(max(dot(norm, halfwayDir), 0.0), shininess);

多数现代引擎实际用的是Blinn-Phong。我建议你把两个版本都实现一遍,对比同一个场景下的渲染结果,这样你对两种模型的差异会有更深的体感。

7. 从一盏灯到一个光场环境:平行光、点光源和多光源叠加

搞定了单个光源,我们要面对一个更现实的问题:场景里通常不只一盏灯。Ambient在场景里是全部环境光加总,但漫反射和高光必须逐光源算一遍然后累加。这也是为什么实际项目中的着色器要写一个光源循环。

在动手写循环之前,先分清光源的三种类型——它们的光方向计算方式完全不同:

  • 平行光:比如太阳光,到达场景中任意一点的方向都近似相同。所以光方向只看“光源位置”和“场景原点”的向量关系,跟片元位置无关。光源可以用一个lightDirection直接传给着色器,这样计算量小,整个场景的阴影方向一致。常见用vec3(-0.2f, -1.0f, -0.3f)这类方向向量表示。
  • 点光源:比如灯泡,光从某个点向四面八方辐射。我们在前面的代码里用的就是这个。点光源衰减公式是attenuation = 1.0 / (constant + linear * distance + quadratic * distance * distance)。距离越远越暗,平方项模拟光的能量按距离平方衰减的物理规律。distance就是光到片元的距离,三个系数按场景尺度调节,小场景用常数更多,大场景线性项和平方项权重加大。
  • 聚光源:比如手电筒、舞台聚光灯。从某个点向一个主轴方向发射,锥形范围内可见。判断片元是否在锥内,要看片元到光源的方向和聚光主轴方向之间的夹角是否小于最大角度。可以用点积加余弦阈值来实现。

多光源叠加的代码结构一般是这样:把每种光源属性定义成结构体,用数组或uniform数组接收,然后在片元着色器里对每个光源调用一个计算函数,返回该光源贡献的(ambient + diffuse + specular),最后全部相加再乘物体颜色。

如果你场景里同时有太阳、路灯、闪光灯,着色器能轻松管理十来个uniform数组,但传大量数组参数时会占用uniform空间,所以实际项目会优先考虑用UBO(Uniform Buffer Object)统一管理,把光源数据一次性绑定给所有着色器程序。这里先把循环逻辑跑通,性能优化是后话。

另外提一句,纹理和光照怎么配合。很多读者到这一步会问:objectColor是固定颜色,那我的纹理贴图呢?答案是:把objectColor替换成纹理采样结果texture(material.diffuse, TexCoords)。光照公式里的* objectColor变成* textureColor即可。法线贴图则是更高级的话题,等光照基础扎实了再上去。

8. 光照参数调试与被问烂的“全黑”问题

我自己带过不少朋友入门,每次到光照这章,收到最多的提问就是“为什么我的画面全黑”“为什么我的立方体是一个纯色块”“为什么转动视角时高光一卡一卡的”。总结下来就三类:

第一类是法线数据缺失。这个最常见。加载模型后忘了检查有没有法线数组,或者手写顶点时法线漏填成(0,0,0)。注意normalize(0,0,0)的结果是未定义的,GLSL里它是vec3(0),点积结果永远是0,所以除了环境光以外所有分量都是零,画面看起来就是一个非常暗的纯色块。再本质一点,你可以在渲染前用glDrawArrays(GL_TRIANGLES, ...)之前先开一次调试模式,或者在着色器里把Normal直接当颜色输出,比如FragColor = vec4(Normal, 1.0),这样你能直观看到每个面的法线值。如果输出结果是整片一色或者渐变缺失,那说明输入数据就不对。

第二类是方向向量没归一化。原理前面说了,点积是余弦但不是余弦,长度参与运算后结果是实数大斜坡,画面会出现亮度不均匀的斑点。检查所有normalize有没有漏掉。

第三类是shininess调成0了。shininess为0时pow(anything, 0)永远是1,等于说所有表面都在发光,画面上所有物体变成一个亮坨,没有颜色层次。副作用虽然不叫“黑”,但看起来同样很怪。把shininess初始值从32开始,别从0开始加。

调参这块我有两个笨但有效的建议。第一,把环境光强度先设成0,单独观察diffuse和specular的分量,看清每个分量对结果的贡献。第二,光源位置设置成glm::vec3 lightPos(1.2f, 1.0f, 2.0f)这类偏离中心的位置,而不是放在相机正前方,这样让立方体多个面的亮度差异更明显,更容易看出光照逻辑有没有问题。

最后说说性能。基础光照里顶点数量不多的话,把所有计算放在片元着色器里(逐片元光照,也叫Phong着色)没问题,效果好。但如果你有个几十万顶点的大模型,还在每个片元一次normalize、两次dot、一次pow,那开销略大。此时你可以把光照计算搬到顶点着色器里做,算完再插值到片元,这叫高洛德着色(Gouraud Shading),性能好但高光往往会被插值糊得没一块亮点。独立光源数量和分辨率上来之后,应该考虑延迟着色(Deferred Shading)或前向着色加光照图等优化方案——但那已经是后续篇章的内容了。

写完这些,希望你运行第一个带光照的场景时感受到的不只是“亮起来了”,而是能隐约看到“为什么会亮、为什么这里亮那里暗”的底层脉络。顺着这层理解,再去啃PBR、阴影映射、法线贴图,会发现所有的进阶话题都在你已有的坐标系里转来转去,只是把公式做得更精细、更逼近物理。下一篇我准备写阴影贴图——没有阴影的灯是漂浮的灯,加了阴影,场景才真正落地。

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

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

立即咨询