第 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) | 朝右上(同时往右和往上) |
1.4 最容易搞混的一点:方向 ≠ 位置
这是必须分清的地方,很多初学者在这里卡住。
- 位置回答的是:东西在哪(一个具体的点)
- 方向回答的是:朝哪边(一个指向)
所以:同一个方向,可以画在空间里任何一个位置。判断两个方向是不是同一个,只看箭头朝向,不看箭头画在哪。记住这句话,第二部分讲法线时马上会用到。
1.5 箭头的「长度」,和「单位方向」
箭头除了有方向,还有长度。长度的计算(不用背,知道有这回事就行):
长度 = √(x² + y² + z²)
即:三个数字各自平方,加起来,再开根号。
| 方向 | 计算 | 长度 |
|---|---|---|
(1, 0, 0) | √(1+0+0) = √1 | 1 |
(1, 1, 0) | √(1+1+0) = √2 | 1.414 |
(2, 0, 0) | √4 | 2 |
(3, 4, 0) | √(9+16) = √25 | 5 |
看到区别了吗:(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,算出来的亮度就被「箭头画了多长」污染了,而亮度只应该跟「方向之间的夹角」有关。
第一部分自测点开看答案
- 在三维空间里,一个方向用几个数字表示?
(0, 0, -1)表示朝哪个方向?- 「位置」和「方向」的区别是什么?
(0, 5, 0)归一化之后是多少?(答案:(0, 1, 0))
如果第 4 题答不上来,回到 1.6 再看一遍,别往下走。
02法线到底是什么
2.1 一句话定义
法线 = 垂直于某个表面的方向。
就这一句话。它是一个方向(所以第一部分讲的都是铺垫),并且这个方向有一个要求:垂直于表面。
2.2 先搞清楚「垂直」是什么
垂直就是:两个方向之间的夹角是 90 度。生活里的例子:墙角两条交接的线、十字架、田字格。垂直也可以理解为「正对着」:一根柱子正对着地面插进去,柱子和地面就是垂直的。
2.3 用日常物品理解法线
法线这个概念,其实就是回答一个问题:「这个面,朝哪边?」
| 东西 | 这个面朝哪边 | 它的法线 |
|---|---|---|
| 你面前的桌面 | 朝上 | 竖直朝上的方向 |
| 你左边的墙面 | 朝房间里面(朝你) | 水平指向房间内的方向 |
| 你的手机屏幕 | 朝着你的脸 | 从屏幕垂直射向你的方向 |
| 你脚下的地板 | 朝上 | 竖直朝上的方向 |
| 地球表面(你站的地方) | 朝天上 | 你「头顶正上方」的方向 |
一句话总结:法线就是「这个面正对着的那个方向」。
2.4 一个面,到底有几根法线?
(1) 对于一个平面来说,法线的「方向」只有一个。因为垂直于这个平面的方向,只有那么一个朝向(朝外的那一边)。
(2) 那平面上的每一个点,都可以画一根法线。既然方向都一样,那么:平面上任何一个点,画出来的法线,方向全都相同。
(3) 那「朝里的那一边」算不算?严格数学上,平面有两侧,朝里和朝外都与平面垂直。但图形学里我们约定:法线一律取「朝外」的那个方向。
为什么要约定朝外?因为第五部分讲光照时会看到:法线和光方向的夹角决定了亮不亮。如果方向取反,亮暗就完全反了。所以必须统一规定。
2.5 曲面呢?—— 每一点的朝向都不一样
想象一个足球:球顶那个点的法线竖直朝上,球的右侧那个点法线水平朝右,球的底部那个点法线竖直朝下。
这也是为什么第五部分讲光照时,一个球看起来会有明暗渐变 —— 因为每个点朝向不同,接收到的光不一样。
第二部分自测点开看答案
- 用一句话说,法线是什么?
- 「垂直」是什么意思?
- 一个平面,法线有几个方向?
- 球面顶部的法线朝向哪?球面右侧呢?
- 为什么法线要约定「朝外」?(提示:想想如果取反会怎样)
03几何上怎么「画」法线
这一部分完全是动手操作。拿张纸、一支笔,跟着画。不需要电脑。
3.1 画一根法线的四个步骤
- 在这个面上任意挑一个点。(比如挑正中间)
- 从这个点出发,画一根箭头。
- 让箭头和这个面成 90 度(垂直)。关键理解:如果这个面是「朝向你的」,那么垂直于这个面的方向,就是「从纸面朝你射出来」的方向。
- 箭头指向「面的外侧」(对这个面来说,外侧就是朝向你的那边)。
画图时,我们用 ⊙ 这个符号表示「从纸面朝你射出来」的箭头:
3.2 画错的样子(对照一下)
- 错的画法 1:箭头没垂直,是斜的。斜箭头不是法线,因为它和面不是 90 度。
- 错的画法 2:箭头垂直,但指向了「里面」。如果你把这个面想象成一块木板,法线必须从朝着你的那一面射出来,而不是从背面射出去。这是 2.4 里说的「约定朝外」。
判断诀窍:法线和面永远是 90 度;法线指向物体外面。
第三部分自测点开看答案
- 拿一张纸,画一个三角形,在它上面画一根法线。
- 画完后自检:你画的箭头,和这个三角形的面,夹角是 90 度吗?箭头指向外面吗?
04顶点法线:36 根怎么来的
4.1 先搞懂:计算机是怎么存一个 3D 模型的
我们人看图,看到的是「面」。但计算机要精确存储,它存的是一些点,以及「哪些点连成一个面」。
- 这些点,叫顶点。
- 面由顶点围成。计算机里最小的面,叫三角形。
因为:三个点一定在同一个平面上,四个点不一定。如果允许用四个点组成一个面,那个面可能扭曲、翘起来,我们没法给它定义一个统一的法线。三角形永远不会翘,所以最省事。
一个立方体,可以拆成 12 个三角形(6 个面,每个面切成 2 个三角形)。
4.2 面法线(硬边风格)
面法线:法线「挂在面(三角形)上」。一个三角形有 3 个顶点,如果我们给这 3 个顶点都存同一个方向 —— 也就是这个三角形自己的朝向 —— 那么三角形内部任意一点,法线都是这一个方向。
效果:这个三角形整块亮度均匀,像一个平面一样。相邻三角形之间法线不同,所以会看到一条硬棱。这正好是立方体需要的效果。
4.3 顶点法线(光滑风格)
顶点法线:法线「挂在顶点上」,而不是挂在面上。一个顶点可能被好几个三角形共用。顶点法线取的是:这个顶点周围所有面的法线的平均值(平均完再归一化)。
效果:相邻三角形之间的法线是连续过渡的,没有硬棱,看起来光滑。
| 面法线 | 顶点法线 | |
|---|---|---|
| 法线挂在哪 | 挂在三角形的面上 | 挂在顶点上 |
| 取值 | 这个三角形自己的朝向 | 周围所有面法线的平均值 |
| 效果 | 硬棱角 | 光滑过渡 |
| 适合 | 立方体、箱子、建筑 | 球、人脸、圆管 |
4.4 ★ 一个模型上到底有多少根法线?
法线的数量 = 你「传给渲染器」的顶点数量。注意 —— 不是几何上「看起来有几个角」的数量。
用立方体举例(我们的项目就是它)
肉眼看法:立方体有 8 个角,所以「有 8 个顶点」。但立方体有 6 个面,每个面的朝向都不同 → 6 个不同的法线方向。
矛盾来了:立方体每个角,都被 3 个面共用。比如「右上前」那个角,同时属于右面、上面、前面。如果这个角只有一根法线,那这根法线没法同时是「朝右」「朝上」「朝前」三个方向 —— 一根箭头只能指一个方向。
所以:为了让每个面都有自己的朝向,我们把立方体「拆开」,让每个面用独立的顶点,每个顶点带自己面的法线。
立方体有 6 个面
每个面切成 2 个三角形
每个三角形有 3 个顶点
总顶点数 = 6 × 2 × 3 = 36 个顶点
总法线数 = 36 根(每个顶点一根,各指向所属面的方向)
这就是我们项目里 vertices[36] 这个数组的来历。它不是「立方体有 36 个角」,而是「为了让 6 个面各有朝向,把顶点拆成了 36 份」。
vertices[36] 里的下标 i(18~23)。4.5 顺带解释上次那个 bug 的根源
我们项目里有个 Vertex 结构体,长这样:
Vertex {
位置(position)
法线(normal)
}
内存里,它是交替排列的:位置、法线、位置、法线、位置、法线……而渲染器需要的是一连串纯位置。上次直接把整个 Vertex 数组塞给渲染器,渲染器就把「法线」当成了「位置」来读 —— 所以画面只剩零散三角面。这就是那个 bug 的根,跟法线的存储方式直接相关。
4.6 顶点法线具体怎么算出来?
面法线:拿这个三角形的两条边,做个「叉积」运算,就得到垂直于这两条边的方向 —— 也就是这个面的法线。(叉积的具体推导本文不展开,你只要知道:它输入两条边、输出一个垂直于这两条边的方向。这是工具,不是魔法。)
顶点法线三步:
步骤 1:算出这个顶点周围的每一个面的面法线
步骤 2:把这些方向加起来
步骤 3:把结果归一化(长度压成 1)
对于我们这个立方体项目:因为每个面是独立的、法线方向是 6 个已知方向之一(朝 ±x、±y、±z),我们直接手写了法线,没走「计算平均」这一步。等你以后加载真实模型(比如一个人物模型),就得用上面这三步算。
第四部分自测点开看答案
- 为什么计算机里最小单位是三角形?
- 面法线和顶点法线,分别挂在哪?效果分别是?
- 立方体几何上有 8 个角,为什么我们用了 36 个顶点、36 根法线?
- 顶点法线怎么算出来(三步)?
第 3 题是重点。如果答不上来,重读 4.4 —— 这是本次教程最核心的一个知识点。
05法线与光照(公式逐步推导)
前面都在铺垫。这一部分回答你最核心的疑问:为什么法线能决定一个面亮不亮?那个公式凭什么长那样?
5.1 先搞懂:什么叫「一个面亮」?
光照计算要算的,其实是一个简单的问题:这个面,接收到了多少光?接收到的光多 → 亮;接收到的光少 → 暗。那什么决定「接收到多少光」呢?做一个实验就知道。
5.2 光照的基本知识:先把骨架搭起来
在动手推公式之前,先给你一张「地图」。不然公式推出来了,你也不知道它在整个光照体系里站在哪一格。
(1) 光源有哪几种?
现实里的光五花八门,计算机把它们归成常见的四种。区别只有一个:光线长什么样。
| 类型 | 光线长什么样 | 有没有「位置」 | 现实里对应什么 |
|---|---|---|---|
| 点光源 | 从一个点向四面八方发散,像刺猬身上的刺 | 有,就是一个点 | 灯泡、蜡烛、台灯 |
| 平行光(也叫方向光) | 所有光线互相平行,像梳子的齿 | 没有,只有一个方向 | 太阳 |
| 聚光灯 | 从一个点发散,但只朝一个锥形的范围 | 有 | 手电筒、舞台追光 |
| 环境光 | 没有方向,从四面八方均匀地糊上来 | 都没有 | 阴天的天光、房间里的漫射光 |
怎么记:点光源的「球状发散」是因为它有位置,光线从那个位置往外跑;太阳离得太远,光线到达地球时早就几乎平行了,所以我们干脆当它「没有位置,只有一个方向」。
点光源离得越远越暗,这个现象叫「衰减」。我们项目的代码没有做这一步,所以立方体的亮度不随距离变化。
(2) 一个面看起来的亮度,其实是「几块光」叠出来的
真实世界里物体为什么是这个亮度?计算机不会真的去模拟每一颗光子,它用几个分量相加来近似。最常见的是三块:
| 分量 | 中文 | 它负责什么 | 日常感受 |
|---|---|---|---|
| ambient | 环境光 | 四面八方糊上来的底光,没有方向,均匀地给每个面一点亮度 | 阴天不开灯,屋里照样看得见东西 |
| diffuse | 漫反射 | 光照在粗糙表面上,向各个方向乱弹射回来 | 水泥墙、纸、布料、桌面 |
| specular | 高光(镜面反射) | 光照在光滑表面上,集中地反射出一片亮斑 | 金属、瓷杯、水面上的那个亮点 |
一个面的最终亮度 ≈ ambient + diffuse + specular(三块加起来)。
ambient + diffuse。第三块 specular(高光)要额外算「眼睛的方向」「反射的方向」,是下一步的内容,本文不讲。
所以下面的推导只围绕 diffuse 展开 —— 因为它才是「法线决定亮度」这件事最纯粹的体现。
(3) 光的方向 L,到底是怎么算出来的?
不管哪种光源,光照公式里都只用到一样东西:「从这个顶点看,光从哪个方向来」,记作 L。但 L 的算法,取决于光源类型 —— 这就是为什么我们要先认识光源:
| 光源类型 | L 怎么算 | 为什么 |
|---|---|---|
| 点光源 | L = 归一化(光源位置 - 顶点位置) | 光从一个点来,所以从顶点「指向光源」就是光的方向 |
| 平行光 | L = 直接就是那个固定方向 | 没有位置,方向处处相同,不用算 |
L 必须是「顶点 → 光源」,写反了光照会整体反相。点光源的 L 必须是「顶点 → 光源」,不能写反成「光源 → 顶点」。写反了,光照会整体反过来 —— 该亮的面变黑,该黑的面变亮。
(4) 那我们项目用的是哪种?
点光源。代码里把一个点存成光源位置(变量名 light_pos),然后每个顶点各自算「从自己指向那个点」的方向:用光源位置减去顶点位置,再归一化。这个计算在 main.cpp 里,写在光照循环之前。
不过有个有意思的小知识:我们项目里,点光源和平行光看起来几乎没区别。因为光源离立方体大约 5 个单位,立方体才 2 个单位大 —— 光「走」了这么远,到达时各个顶点的方向早就散得几乎平行了。只有光源贴近物体、或者物体很大的时候,两者的区别才看得出来。
5.3 🔦 手电筒实验(这是全部推导的起点)
拿一个手电筒,照一面墙,做三次:正对着照 → 最亮;斜着照 → 变暗;几乎平行照 → 几乎不亮。
为什么?—— 关键洞察
手电筒发出的光的总量,三次是一样多的(同一个手电筒嘛)。区别在于:这一点光,被摊到了多大的面积上。
- 情况 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°
| 两个方向的夹角 | 点积的结果 |
|---|---|
| 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 越高,明暗差被压缩得越厉害,面与面之间就越没有对比,看起来越「平」、越糊。我们项目里就踩过这个坑:ambient 从 0.3 降到 0.2,棱角的明暗差才够明显。
这就是「保底」和「对比」的取舍:保底太高 → 安全但平淡;保底太低 → 立体但有全黑风险。0.2~0.3 是常用区间。
第五部分自测点开看答案
- 光源常见的四种类型是什么?点光源和平行光,光线分别长什么样?
- 一个面的亮度,是由哪几块光叠出来的?我们项目做了哪几块?
- 手电筒正照墙面和斜照墙面,为什么亮度不同?
- 点积的三种关键取值是多少?分别代表什么夹角?
- 为什么点积外面要套一个
max(0, ...)?(提示:想想光从背面来) - 为什么需要 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 × intensity | compute_lambert() 里算 r/g/b 的地方 |
| ambient | compute_lambert() 的 ambient 参数(本项目 0.2) |
Σ全文总结:一条线串起所有概念
- 法线是什么、为什么它是「方向」不是「位置」
- 一个面有几根法线、曲面为什么不一样
- 几何上怎么画法线
- 面法线和顶点法线的区别
- 立方体为什么是 36 根法线而不是 8 根
- 光源有哪四种、点光源和平行光的光线形态有什么不同
- 一个面的亮度由哪几块光组成,我们项目做了哪几块
- 光照公式每一部分为什么这么写,而不是死记
- ambient 的双刃剑性质