第 09 步 · 软渲染器

法线与光照:从「方向」两个字开始

本文假设你什么都不知道 —— 不知道法线、不知道顶点、不知道点积、不知道光照公式,都是正常的起点。

这是 SoftRenderer 系列的第 9 步(原理篇 · 零基础图解)—— 从最基础的概念开始讲,不预设任何前置知识。

怎么读这一篇
  • 每一个新词第一次出现时,都会先解释清楚它是什么意思,再往下用。不会出现「众所周知」「很简单」「显而易见」。
  • 全文分六部分,必须按顺序读,因为后面的内容依赖前面刚讲过的。每部分末尾都有一组自测题(点开折叠区可见),答不上来就说明那一节没讲透 —— 告诉我,我重写那一节。

01先搞懂「方向」

1.1 为什么先讲方向?因为法线本质就是一个方向

法线不是什么神秘的东西,它只是一个方向。所以我们要先搞清楚:在数学和计算机里,「方向」是怎么被表示出来的。

1.2 日常里的「方向」

如果有人问你「朝哪边走能出门」,你可能会说「往前走」「往左拐」「往回走」。这些都是方向。方向回答的问题是:朝哪边。

1.3 把方向翻译成三个数字

计算机不会理解「往前」。所以我们需要用一组数字来表示方向。在三维空间里(我们生活的空间就是三维的:有左右、上下、前后),一个方向用三个数字表示:

方向 = (x, y, z)

这三个数字的意思是:从你站的地方出发,沿着 x 轴移动 x 这么多,沿着 y 轴移动 y 这么多,沿着 z 轴移动 z 这么多,你走到的那个方向,就是你要的方向。

假设 x 轴是向右(+x 右、−x 左),y 轴是向上(+y 上、−y 下),z 轴是向前(+z 前、−z 后):

三个数字意思
(1, 0, 0)朝右(右移 1,上下不动,前后不动)
(0, 1, 0)朝上
(0, 0, 1)朝前
(-1, 0, 0)朝左
(0, 0, -1)朝后
(1, 1, 0)朝右上(同时往右和往上)
x (右)y (上)z (前)原点(0.6, 0.8, 0)一组三个数字 = 一个方向
图 1:一组三个数字 = 一个方向。箭头从原点出发,指向 (0.6, 0.8, 0)。

1.4 最容易搞混的一点:方向 ≠ 位置

这是必须分清的地方,很多初学者在这里卡住。

  • 位置回答的是:东西在哪(一个具体的点)
  • 方向回答的是:朝哪边(一个指向)
起点 A起点 B方向完全相同两处起点不同,但箭头都朝上 —— 方向与画在哪里无关
图 2:同一个方向,可以画在空间里任何一个位置。判断两个方向是否相同,只看箭头朝向。

所以:同一个方向,可以画在空间里任何一个位置。判断两个方向是不是同一个,只看箭头朝向,不看箭头画在哪。记住这句话,第二部分讲法线时马上会用到。

1.5 箭头的「长度」,和「单位方向」

箭头除了有方向,还有长度。长度的计算(不用背,知道有这回事就行):

长度 = √(x² + y² + z²)

即:三个数字各自平方,加起来,再开根号。

方向计算长度
(1, 0, 0)√(1+0+0) = √11
(1, 1, 0)√(1+1+0) = √21.414
(2, 0, 0)√42
(3, 4, 0)√(9+16) = √255

看到区别了吗:(1,0,0) 的方向是朝右、长度是 1;(2,0,0) 的方向也是朝右、长度是 2。长度是 1 的方向,叫单位方向(也叫单位向量)。

1.6 「归一化」:把任何方向变成单位方向

把一个方向的长度强行变成 1,这个操作叫归一化。做法很简单:三个数字都除以原来的长度。

把 (3, 4, 0) 归一化:
原来的长度 = 5
归一化后 = (3/5, 4/5, 0/5) = (0.6, 0.8, 0)

验证长度:√(0.6² + 0.8² + 0²) = √(0.36 + 0.64) = √1 = 1 ✓

方向没变(还是朝右上那个斜向),长度变成了 1。

1.7 为什么非要归一化?

你现在只需要知道:光照计算里,法线和光方向都必须是单位方向(长度为 1)。

原因在第五部分会详细推导。先给一句结论:因为后面用来算亮度的那个公式,会把两个方向的「长度」也乘进去。如果不把长度压成 1,算出来的亮度就被「箭头画了多长」污染了,而亮度只应该跟「方向之间的夹角」有关。

第一部分自测点开看答案
  1. 在三维空间里,一个方向用几个数字表示?
  2. (0, 0, -1) 表示朝哪个方向?
  3. 「位置」和「方向」的区别是什么?
  4. (0, 5, 0) 归一化之后是多少?(答案:(0, 1, 0))

如果第 4 题答不上来,回到 1.6 再看一遍,别往下走。

02法线到底是什么

2.1 一句话定义

法线 = 垂直于某个表面的方向。

就这一句话。它是一个方向(所以第一部分讲的都是铺垫),并且这个方向有一个要求:垂直于表面。

2.2 先搞清楚「垂直」是什么

垂直就是:两个方向之间的夹角是 90 度。生活里的例子:墙角两条交接的线、十字架、田字格。垂直也可以理解为「正对着」:一根柱子正对着地面插进去,柱子和地面就是垂直的。

2.3 用日常物品理解法线

法线这个概念,其实就是回答一个问题:「这个面,朝哪边?」

东西这个面朝哪边它的法线
你面前的桌面朝上竖直朝上的方向
你左边的墙面朝房间里面(朝你)水平指向房间内的方向
你的手机屏幕朝着你的脸从屏幕垂直射向你的方向
你脚下的地板朝上竖直朝上的方向
地球表面(你站的地方)朝天上你「头顶正上方」的方向

一句话总结:法线就是「这个面正对着的那个方向」。

面法线90°面朝上 → 法线就朝上(垂直 + 指向外侧)朝里:不取
图 3:平面法线的侧视图。法线与面成 90°,且指向外侧(朝里的那一边不取)。

2.4 一个面,到底有几根法线?

(1) 对于一个平面来说,法线的「方向」只有一个。因为垂直于这个平面的方向,只有那么一个朝向(朝外的那一边)。

(2) 那平面上的每一个点,都可以画一根法线。既然方向都一样,那么:平面上任何一个点,画出来的法线,方向全都相同。

正视图:面朝你点 A点 B点 C⊙ = 箭头垂直纸面、朝你射出侧视图:面变成一条线点 A点 B点 C三个点的法线,方向完全一样
图 4:左为正视图(⊙ 表示箭头垂直纸面朝你射出),右为侧视图。三个点的法线方向完全一样。

(3) 那「朝里的那一边」算不算?严格数学上,平面有两侧,朝里和朝外都与平面垂直。但图形学里我们约定:法线一律取「朝外」的那个方向。

为什么要约定朝外?因为第五部分讲光照时会看到:法线和光方向的夹角决定了亮不亮。如果方向取反,亮暗就完全反了。所以必须统一规定。

2.5 曲面呢?—— 每一点的朝向都不一样

想象一个足球:球顶那个点的法线竖直朝上,球的右侧那个点法线水平朝右,球的底部那个点法线竖直朝下。

球顶:朝上球右:朝右球底:朝下球左:朝左曲面:每个点都有自己的法线方向
图 5:曲面上的每一个点都有自己专属的法线方向,各不相同。

这也是为什么第五部分讲光照时,一个球看起来会有明暗渐变 —— 因为每个点朝向不同,接收到的光不一样。

第二部分自测点开看答案
  1. 用一句话说,法线是什么?
  2. 「垂直」是什么意思?
  3. 一个平面,法线有几个方向?
  4. 球面顶部的法线朝向哪?球面右侧呢?
  5. 为什么法线要约定「朝外」?(提示:想想如果取反会怎样)

03几何上怎么「画」法线

这一部分完全是动手操作。拿张纸、一支笔,跟着画。不需要电脑。

3.1 画一根法线的四个步骤

  1. 在这个面上任意挑一个点。(比如挑正中间)
  2. 从这个点出发,画一根箭头。
  3. 让箭头和这个面成 90 度(垂直)。关键理解:如果这个面是「朝向你的」,那么垂直于这个面的方向,就是「从纸面朝你射出来」的方向。
  4. 箭头指向「面的外侧」(对这个面来说,外侧就是朝向你的那边)。

画图时,我们用 ⊙ 这个符号表示「从纸面朝你射出来」的箭头:

① 面上任选一个点正视图② 从该点画垂直箭头侧视图③ 箭头指向面外侧正视图⊙ = 垂直于纸面、朝你射出的箭头(这就是正视图里的法线画法)判断标准:箭头和面永远成 90°,且指向外面
图 6:画法线三步走。正视图里,垂直射出的法线画成 ⊙;侧视图里,能直接看到 90° 关系。

3.2 画错的样子(对照一下)

  • 错的画法 1:箭头没垂直,是斜的。斜箭头不是法线,因为它和面不是 90 度。
  • 错的画法 2:箭头垂直,但指向了「里面」。如果你把这个面想象成一块木板,法线必须从朝着你的那一面射出来,而不是从背面射出去。这是 2.4 里说的「约定朝外」。

判断诀窍:法线和面永远是 90 度;法线指向物体外面。

第三部分自测点开看答案
  1. 拿一张纸,画一个三角形,在它上面画一根法线。
  2. 画完后自检:你画的箭头,和这个三角形的面,夹角是 90 度吗?箭头指向外面吗?

04顶点法线:36 根怎么来的

4.1 先搞懂:计算机是怎么存一个 3D 模型的

我们人看图,看到的是「面」。但计算机要精确存储,它存的是一些点,以及「哪些点连成一个面」。

  • 这些点,叫顶点。
  • 面由顶点围成。计算机里最小的面,叫三角形。
为什么最小单位是三角形?

因为:三个点一定在同一个平面上,四个点不一定。如果允许用四个点组成一个面,那个面可能扭曲、翘起来,我们没法给它定义一个统一的法线。三角形永远不会翘,所以最省事。

一个立方体,可以拆成 12 个三角形(6 个面,每个面切成 2 个三角形)。

4.2 面法线(硬边风格)

面法线:法线「挂在面(三角形)上」。一个三角形有 3 个顶点,如果我们给这 3 个顶点都存同一个方向 —— 也就是这个三角形自己的朝向 —— 那么三角形内部任意一点,法线都是这一个方向。

效果:这个三角形整块亮度均匀,像一个平面一样。相邻三角形之间法线不同,所以会看到一条硬棱。这正好是立方体需要的效果。

4.3 顶点法线(光滑风格)

顶点法线:法线「挂在顶点上」,而不是挂在面上。一个顶点可能被好几个三角形共用。顶点法线取的是:这个顶点周围所有面的法线的平均值(平均完再归一化)。

效果:相邻三角形之间的法线是连续过渡的,没有硬棱,看起来光滑。

面法线 → 硬棱角顶点法线 → 光滑过渡每块三角形一个亮度,交界处是硬边法线连续变化,明暗平滑,看不出三角片
图 7:同一束光下,面法线 → 每块一个亮度、硬棱;顶点法线 → 明暗连续、光滑。
面法线顶点法线
法线挂在哪挂在三角形的面上挂在顶点上
取值这个三角形自己的朝向周围所有面法线的平均值
效果硬棱角光滑过渡
适合立方体、箱子、建筑球、人脸、圆管

4.4 ★ 一个模型上到底有多少根法线?

先说结论

法线的数量 = 你「传给渲染器」的顶点数量。注意 —— 不是几何上「看起来有几个角」的数量。

用立方体举例(我们的项目就是它)

肉眼看法:立方体有 8 个角,所以「有 8 个顶点」。但立方体有 6 个面,每个面的朝向都不同 → 6 个不同的法线方向。

矛盾来了:立方体每个角,都被 3 个面共用。比如「右上前」那个角,同时属于右面、上面、前面。如果这个角只有一根法线,那这根法线没法同时是「朝右」「朝上」「朝前」三个方向 —— 一根箭头只能指一个方向。

所以:为了让每个面都有自己的朝向,我们把立方体「拆开」,让每个面用独立的顶点,每个顶点带自己面的法线。

立方体有 6 个面
每个面切成 2 个三角形
每个三角形有 3 个顶点

总顶点数 = 6 × 2 × 3 = 36 个顶点
总法线数 = 36 根(每个顶点一根,各指向所属面的方向)

这就是我们项目里 vertices[36] 这个数组的来历。它不是「立方体有 36 个角」,而是「为了让 6 个面各有朝向,把顶点拆成了 36 份」。

远面 z=-1 · 红近面 z=+1 · 绿底面 y=-1 · 蓝顶面 y=+1 · 黄左面 x=-1 · 紫右面 x=+1 · 青立方体 6 个面 → 6 个不同的法线方向虚线箭头 = 背面(被挡住)的法线,同样存在
图 8:立方体 6 个面 = 6 个不同的法线方向。虚线箭头是被挡住的背面法线,它们同样存在。
18192021222336 根法线,全部画在这一颗立方体上把立方体转歪一点(绕 Y 28°、绕 X 16°),6 个方向的法线就散开了,不再互相压住。6 个面 → 6 个方向 → 36 根实线 = 3 个可见面,共 18 根虚线 = 3 个背面,同样 18 根远面 (0, 0, -1) i=0~5近面 (0, 0, +1) i=6~11底面 (0, -1, 0) i=12~17顶面 (0, +1, 0) i=18~23左面 (-1, 0, 0) i=24~29右面 (+1, 0, 0) i=30~35为什么一个面是 6 根?面 = 2 个三角形 × 3 顶点 = 6 个渲染顶点4 个角为什么变 6 根?共用边两端的 2 个角各被两个三角形用到→ 2 + 1 + 2 + 1 = 6同一个角的 2 根并排画出(错开约 30px),否则同方向的箭头会完全重合。小数字 = 顶面 6 根的下标 i(18~23)
图 9(核心图):36 根法线,全部画在这一颗立方体上。把立方体转歪一点(绕 Y 28°、绕 X 16°),6 个面的法线方向就在屏幕上散开了。实线 = 3 个可见面(18 根),虚线 = 3 个背面(同样 18 根)。六个面各自 6 根,都落在该面的 4 个角上,共用边两端的 2 个角各有 2 根 —— 这两根并排错开约 30px 画出,否则同方向的箭头会完全重合。虚线旁的小数字 = 顶面 6 根在 vertices[36] 里的下标 i(18~23)。
几何角:8 个立方体确实只有 8 个角一个面 = 2 三角形 × 3 顶点 = 6①②4 个角,但 2 个角被两个三角形共用(左下、右上各属于 2 个三角形)6 面 × 6 = 36 个渲染顶点
图 10:左边 8 个几何角,右边一个面拆成 2 个三角形 = 6 个渲染顶点。6 面 × 6 = 36。
8
几何角(肉眼看)
6
面数 → 6 种朝向
12
三角形(每面 2 个)
36
渲染顶点 = 法线根数

4.5 顺带解释上次那个 bug 的根源

我们项目里有个 Vertex 结构体,长这样:

Vertex {
    位置(position)
    法线(normal)
}

内存里,它是交替排列的:位置、法线、位置、法线、位置、法线……而渲染器需要的是一连串纯位置。上次直接把整个 Vertex 数组塞给渲染器,渲染器就把「法线」当成了「位置」来读 —— 所以画面只剩零散三角面。这就是那个 bug 的根,跟法线的存储方式直接相关。

4.6 顶点法线具体怎么算出来?

面法线:拿这个三角形的两条边,做个「叉积」运算,就得到垂直于这两条边的方向 —— 也就是这个面的法线。(叉积的具体推导本文不展开,你只要知道:它输入两条边、输出一个垂直于这两条边的方向。这是工具,不是魔法。)

顶点法线三步:

步骤 1:算出这个顶点周围的每一个面的面法线
步骤 2:把这些方向加起来
步骤 3:把结果归一化(长度压成 1)
法线 B (0,1,0)法线 A (1,0,0)平均后的顶点法线顶点步骤:① 把周围所有面的法线加起来 ② 把结果归一化(本例两个面:A+B = (1,1,0) → 归一化 = (0.707,0.707,0),正好是角平分线)顶点挂在顶点上,被几个面共用,就取这几个面法线的平均
图 11:顶点法线 = 相邻面法线相加再归一化(二维示例:A+B = (1,1,0) → 归一化 = (0.707,0.707,0),正好是角平分线)。

对于我们这个立方体项目:因为每个面是独立的、法线方向是 6 个已知方向之一(朝 ±x、±y、±z),我们直接手写了法线,没走「计算平均」这一步。等你以后加载真实模型(比如一个人物模型),就得用上面这三步算。

第四部分自测点开看答案
  1. 为什么计算机里最小单位是三角形?
  2. 面法线和顶点法线,分别挂在哪?效果分别是?
  3. 立方体几何上有 8 个角,为什么我们用了 36 个顶点、36 根法线?
  4. 顶点法线怎么算出来(三步)?

第 3 题是重点。如果答不上来,重读 4.4 —— 这是本次教程最核心的一个知识点。

05法线与光照(公式逐步推导)

前面都在铺垫。这一部分回答你最核心的疑问:为什么法线能决定一个面亮不亮?那个公式凭什么长那样?

5.1 先搞懂:什么叫「一个面亮」?

光照计算要算的,其实是一个简单的问题:这个面,接收到了多少光?接收到的光多 → 亮;接收到的光少 → 暗。那什么决定「接收到多少光」呢?做一个实验就知道。

5.2 光照的基本知识:先把骨架搭起来

在动手推公式之前,先给你一张「地图」。不然公式推出来了,你也不知道它在整个光照体系里站在哪一格。

(1) 光源有哪几种?

现实里的光五花八门,计算机把它们归成常见的四种。区别只有一个:光线长什么样。

点光源有位置 · 光线从一点发散平行光(方向光)无位置 · 光线互相平行聚光灯有位置 · 只朝一个锥形范围环境光无方向 · 四面八方均匀糊上来我们项目用的是【点光源】:把光源存成一个点,每个顶点各自算「指向它」的方向
图 12:四种光源的光线形态。点光源的「球状发散」和太阳的「平行光」是最容易混的两个。
类型光线长什么样有没有「位置」现实里对应什么
点光源从一个点向四面八方发散,像刺猬身上的刺有,就是一个点灯泡、蜡烛、台灯
平行光(也叫方向光)所有光线互相平行,像梳子的齿没有,只有一个方向太阳
聚光灯从一个点发散,但只朝一个锥形的范围有手电筒、舞台追光
环境光没有方向,从四面八方均匀地糊上来都没有阴天的天光、房间里的漫射光

怎么记:点光源的「球状发散」是因为它有位置,光线从那个位置往外跑;太阳离得太远,光线到达地球时早就几乎平行了,所以我们干脆当它「没有位置,只有一个方向」。

补充:距离衰减

点光源离得越远越暗,这个现象叫「衰减」。我们项目的代码没有做这一步,所以立方体的亮度不随距离变化。

(2) 一个面看起来的亮度,其实是「几块光」叠出来的

真实世界里物体为什么是这个亮度?计算机不会真的去模拟每一颗光子,它用几个分量相加来近似。最常见的是三块:

分量中文它负责什么日常感受
ambient环境光四面八方糊上来的底光,没有方向,均匀地给每个面一点亮度阴天不开灯,屋里照样看得见东西
diffuse漫反射光照在粗糙表面上,向各个方向乱弹射回来水泥墙、纸、布料、桌面
specular高光(镜面反射)光照在光滑表面上,集中地反射出一片亮斑金属、瓷杯、水面上的那个亮点

一个面的最终亮度 ≈ ambient + diffuse + specular(三块加起来)。

我们项目只做了前两块

ambient + diffuse。第三块 specular(高光)要额外算「眼睛的方向」「反射的方向」,是下一步的内容,本文不讲。

所以下面的推导只围绕 diffuse 展开 —— 因为它才是「法线决定亮度」这件事最纯粹的体现。

(3) 光的方向 L,到底是怎么算出来的?

不管哪种光源,光照公式里都只用到一样东西:「从这个顶点看,光从哪个方向来」,记作 L。但 L 的算法,取决于光源类型 —— 这就是为什么我们要先认识光源:

光源类型L 怎么算为什么
点光源L = 归一化(光源位置 - 顶点位置)光从一个点来,所以从顶点「指向光源」就是光的方向
平行光L = 直接就是那个固定方向没有位置,方向处处相同,不用算
✅ 正确:L = 顶点 → 光源❌ 写反:L = 光源 → 顶点光源顶点dot(N, L) 为正 → 面被照亮光源顶点dot 全反号 → 亮暗颠倒
图 13:易错点 —— L 必须是「顶点 → 光源」,写反了光照会整体反相。
易错点

点光源的 L 必须是「顶点 → 光源」,不能写反成「光源 → 顶点」。写反了,光照会整体反过来 —— 该亮的面变黑,该黑的面变亮。

(4) 那我们项目用的是哪种?

点光源。代码里把一个点存成光源位置(变量名 light_pos),然后每个顶点各自算「从自己指向那个点」的方向:用光源位置减去顶点位置,再归一化。这个计算在 main.cpp 里,写在光照循环之前。

不过有个有意思的小知识:我们项目里,点光源和平行光看起来几乎没区别。因为光源离立方体大约 5 个单位,立方体才 2 个单位大 —— 光「走」了这么远,到达时各个顶点的方向早就散得几乎平行了。只有光源贴近物体、或者物体很大的时候,两者的区别才看得出来。

5.3 🔦 手电筒实验(这是全部推导的起点)

拿一个手电筒,照一面墙,做三次:正对着照 → 最亮;斜着照 → 变暗;几乎平行照 → 几乎不亮。

A. 正对着照光斑小、最亮B. 斜着照光斑被拉长、变暗C. 几乎平行照几乎没有光斑几乎照不到、不亮同一个手电筒、同样多的光 —— 差别只在于「被摊到了多大面积上」
图 14:手电筒三组实验。同样的光,正照时光斑小、亮;斜照时光斑被拉长、暗。

为什么?—— 关键洞察

手电筒发出的光的总量,三次是一样多的(同一个手电筒嘛)。区别在于:这一点光,被摊到了多大的面积上。

  • 情况 A:光是「正面撞上」墙的 —— 总量相同的光,集中洒在一个小圆里 → 每个点分到的多 → 亮
  • 情况 B:光是「斜着擦上」墙的 —— 总量相同的光,被拉长摊在一个大椭圆里 → 每个点分到的少 → 暗

打个比方:同样一把面粉,撒在一个小盘子里 → 厚厚一层;撒在一张大桌子上 → 薄薄一层,几乎看不见。光的「亮度」就像面粉的「厚度」:光的总量除以摊开的面积。

而摊开多大面积,完全由「光的方向」和「墙的朝向(法线)」的夹角决定:

  • 夹角 = 0 度(正照):摊开的面积最小 → 最亮
  • 夹角 = 90 度(平行照):摊开的面积无限大 → 不亮

所以:亮度跟「光方向和法线的夹角」直接相关。

5.4 把「夹角」翻译成数字:点积

但计算机不会算「夹角」,它只会算加减乘除。所以我们需要一个办法:给两个方向,算出一个数字,这个数字能代表「它们夹角多大」。这个办法叫点积。

两个方向 a = (ax, ay, az) 和 b = (bx, by, bz),点积写成 a · b,算法是:

a · b = ax × bx  +  ay × by  +  az × bz

就是:对应位置相乘,再把三个乘积加起来。

举几个例子,看看它输出什么:

【同向】a = (1,0,0) 朝右,b = (1,0,0) 朝右
        a · b = 1×1 + 0×0 + 0×0 = 1        → 夹角 0°

【垂直】a = (1,0,0) 朝右,b = (0,1,0) 朝上
        a · b = 1×0 + 0×1 + 0×0 = 0        → 夹角 90°

【反向】a = ( 1,0,0) 朝右,b = (-1,0,0) 朝左
        a · b = 1×(-1) + 0×0 + 0×0 = -1    → 夹角 180°
完全同向ab点积 = 1夹角 0°互相垂直ab点积 = 0夹角 90°完全相反ab点积 = -1夹角 180°单位向量的点积 = 「两个方向有多同向」的分数
图 15:点积的三种关键取值 —— 1(同向)、0(垂直)、−1(反向)。
两个方向的夹角点积的结果
0 度(完全相同)1
90 度(垂直)0
180 度(完全相反)−1
0~90 度之间0 到 1 之间的数
90~180 度之间0 到 −1 之间的数

点积,就是一个「两个方向有多同向」的分数。完全同向 = 满分 1;垂直 = 0 分;完全反向 = −1。

这正是我们要的!光正照 → 光方向和法线同向(夹角 0)→ 点积 = 1 → 最亮;光垂直擦过 → 光方向和法线垂直(夹角 90)→ 点积 = 0 → 不亮。点积的大小,正好就是「亮度系数」。

补充说明(不必深究)

严格的数学结论是「单位向量的点积 = 夹角的余弦」,需要余弦定理才能证明,本文不展开。你只需要掌握上面三个关键取值(1 / 0 / −1),足够理解光照了。

为什么必须归一化?现在能解释了

点积还有一层:如果两个方向长度不是 1,点积的结果会同时包含「长度」和「夹角」两个信息,混在一起。

举例:(2,0,0) · (1,0,0) = 2。方向明明完全一样(都朝右),点积却不是 1,而是 2 —— 因为第一个方向长度是 2。

亮度只应该由夹角决定,不该由「箭头画了多长」决定。所以法线和光方向都先归一化(长度压成 1),这样点积就只剩下夹角信息,干净。

5.5 完整推出 Lambert 光照公式

零件清单

名字是什么从哪来
N(法线)这个点「朝哪边」,单位方向第四部分,我们存在顶点里的
L(光方向)「光从哪来」,单位方向从顶点指向光源的方向
dot(N, L)N 和 L 有多同向5.4 的点积
base_color这个物体本来的颜色我们自己定的(比如红、绿)

第 1 步:算出「这个点接收到多少光」。

diffuse = dot(N, L)

点积 = 1 → 光正照 → 满额接收
点积 = 0 → 光垂直擦过 → 一点没接到

第 2 步:处理「光从背后照过来」的情况。

夹角超过 90 度时,dot(N, L) 变成负数。但物理上,光从背面来,这个面根本照不到,亮度应该是 0,不能是负数(负数亮度没有意义,还会把颜色算成垃圾值)。所以:负数一律按 0 处理。用一个「取最大值」的操作:

diffuse = max(0, dot(N, L))

max(0, x) 的意思是:如果 x 比 0 大就用 x,否则用 0。

光照正面来:dot = 0.8  → 取 0.8 ✓
光照背面来:dot = -0.6 → 取 0   ✓

第 3 步:用这个系数去调颜色。

color = base_color × diffuse

base_color 是红色 (230, 90, 90)
如果 diffuse = 1(满亮) → 颜色不变,就是 (230, 90, 90)
如果 diffuse = 0.5(半亮)→ 颜色变暗一半 → (115, 45, 45)
如果 diffuse = 0(全暗)  → 变成 (0, 0, 0),纯黑
现在公式长这样(但还没完)
color = base_color × max(0, dot(N, L))

问题:如果光从背面照,颜色会变成纯黑 (0,0,0)。纯黑意味着和黑色背景完全融为一体 —— 你会看不到这个物体的轮廓,它像凭空消失了一块。

这在我们的项目里真实发生过:相机转到背光侧时,整个立方体变成一坨灰,棱角全无。

5.6 加上「保底亮度」:ambient

解决办法:不要让背光面变成纯黑,给它一个保底亮度,叫 ambient。我们希望:正面受光 → 最亮(= 1);背光 → 不完全黑,而是剩下一个保底值(比如 0.2)。所以亮度系数应该从 ambient 起步,往 1 爬:

intensity = ambient + (1 - ambient) × diffuse
部分作用
ambient起点。哪怕 diffuse = 0(背光),也至少有 ambient 这么多亮度
(1 - ambient)剩余的可变空间。如果保底占 0.2,那剩下的 0.8 留给「受光程度」来分配
× diffuse受光程度决定「在剩下的空间里能拿到多少」

验证一下这个式子:

  • 情况 1:diffuse = 0(背光)→ intensity = 0.2 + 0.8 × 0 = 0.2 ✓ 保底不黑
  • 情况 2:diffuse = 1(正照)→ intensity = 0.2 + 0.8 × 1 = 1.0 ✓ 最亮
  • 情况 3:diffuse = 0.5(半受光)→ intensity = 0.2 + 0.8 × 0.5 = 0.6

注意:无论 diffuse 是多少,intensity 永远落在 [0.2, 1.0] 之间,绝不会超过 1。这就是为什么用 (1 - ambient) 而不是随便一个数 —— 保证不会算溢出。

5.7 最终公式(完整版)

diffuse   = max(0, dot(N, L))                     ← 5.3~5.5 推导
intensity = ambient + (1 - ambient) × diffuse     ← 5.6 推导
color     = base_color × intensity                ← 5.4~5.5 推导
符号含义取值范围
N顶点法线(单位方向)—
L从顶点指向光源的方向(单位方向)—
diffuse「这个面接收到多少光」0 ~ 1
ambient保底亮度,防止背光面全黑0 ~ 1(我们取 0.2)
intensity最终亮度系数ambient ~ 1
base_color物体本来的颜色—
color最终算出来的颜色—

这个公式叫 Lambert 漫反射模型,名字不用记,你只要知道它是怎么一步步推出来的就够了。

5.8 为什么 ambient 不能太高?

你可能想:既然 ambient 是「保底亮度」,那调高一点不是更好、更不容易全黑吗?不行。

diffuse 的差距,只占最后 (1 - ambient) 这么多:

  • 如果 ambient = 0.2:受光面和背光面的差距,最多能拉开 0.8
  • 如果 ambient = 0.8:差距最多只能拉开 0.2
ambient = 0.2正受光1.00斜 45°0.60背光0.20三块差异 1.00 → 0.20对比强烈,棱角清晰ambient = 0.6正受光1.00斜 45°0.80背光0.60三块差异 1.00 → 0.60对比被压缩,发平、发糊intensity = ambient + (1 − ambient) × diffuse
图 16:ambient 从 0.2 提到 0.6,三块面的差异从 1.00→0.20 被压缩成 1.00→0.60,棱角对比明显变糊。

ambient 越高,明暗差被压缩得越厉害,面与面之间就越没有对比,看起来越「平」、越糊。我们项目里就踩过这个坑:ambient 从 0.3 降到 0.2,棱角的明暗差才够明显。

这就是「保底」和「对比」的取舍:保底太高 → 安全但平淡;保底太低 → 立体但有全黑风险。0.2~0.3 是常用区间。

第五部分自测点开看答案
  1. 光源常见的四种类型是什么?点光源和平行光,光线分别长什么样?
  2. 一个面的亮度,是由哪几块光叠出来的?我们项目做了哪几块?
  3. 手电筒正照墙面和斜照墙面,为什么亮度不同?
  4. 点积的三种关键取值是多少?分别代表什么夹角?
  5. 为什么点积外面要套一个 max(0, ...)?(提示:想想光从背面来)
  6. 为什么需要 ambient?为什么它不能太高?

第 5、6 题是本部分的灵魂。答不上来就回头重读 5.5 和 5.6。

06这些概念在代码里对应哪里

本部分是「索引」,不重复原理。

本文讲的概念在项目里对应
顶点、位置Vertex 结构体里的 position
法线、顶点法线Vertex 结构体里的 normal(存的就是单位向量)
立方体 6 个面的方向vertices[36] 里写的 Vec3(0,0,-1)、Vec3(0,0,1) 等等
36 个渲染顶点vertices[36] 这个数组的长度
方向归一化normalize() 这个函数
点积Vec3::dot(),或代码里写的 a.dot(b)
dot(N, L)compute_lambert() 里的 normal.dot(light_dir)
max(0, ...)compute_lambert() 里的 std::max(0.0f, ...)
L:从顶点指向光源main.cpp 里的 normalize(light_pos - world_pos)
intensity 公式compute_lambert() 里的那两行
base_color × intensitycompute_lambert() 里算 r/g/b 的地方
ambientcompute_lambert() 的 ambient 参数(本项目 0.2)

Σ全文总结:一条线串起所有概念

① 方向 —— 用 3 个数字表示「朝哪边」
长度压成 1 → 单位方向(归一化)
▼
② 法线 —— 垂直于表面的方向
回答「这个面朝哪边」;一个平面一个方向,曲面每点各不相同
▼
③ 面法线 → 硬棱角 | 顶点法线 → 光滑过渡
顶点法线 = 相邻面法线相加再归一化
▼
④ 立方体:6 个面 → 6 种朝向 → 36 个渲染顶点 → 36 根法线
8 个几何角被拆成 36 份,因为一根箭头只能指一个方向
▼
⑤ 手电筒实验:夹角决定亮度
正照亮、斜照暗 —— 光被摊开的面积不同
▼
⑥ 点积把夹角变成数字:1 / 0 / −1
完全同向 = 1,垂直 = 0,反向 = −1
▼
⑦ diffuse = max(0, dot(N, L))
背后照过来的光要归零(否则亮度变负)
▼
⑧ intensity = ambient + (1 − ambient) × diffuse
保底亮度,防止背光面全黑;ambient 太高会压缩对比
▼
⑨ color = base_color × intensity
屏幕上:每个面按自己的朝向,显示不同亮度
如果你完整读完了这份文档,你现在应该理解的东西
  • 法线是什么、为什么它是「方向」不是「位置」
  • 一个面有几根法线、曲面为什么不一样
  • 几何上怎么画法线
  • 面法线和顶点法线的区别
  • 立方体为什么是 36 根法线而不是 8 根
  • 光源有哪四种、点光源和平行光的光线形态有什么不同
  • 一个面的亮度由哪几块光组成,我们项目做了哪几块
  • 光照公式每一部分为什么这么写,而不是死记
  • ambient 的双刃剑性质
返回渲染管线专栏 返回目录