法线与 Lambert 余弦定律
光照的第一条规则——正对着光的面亮、侧着的面暗——背后的数学就是 Lambert 余弦定律。
法线是什么
法线(normal)是表面某点处垂直于表面的单位向量 n,∥n∥=1。它描述”这个点朝哪边”。光照、阴影、反射都要先知道法线。
每个三角形有一个面法线,由两条边的叉乘得到(左手系顺时针绕序):
n=normalize((V1−V0)×(V2−V0))
但每像素光照用的是逐顶点法线插值得到的平滑法线(见 urp1 B14 透视校正插值),让三角形拼成的面看起来光滑、看不到棱角。
Lambert 余弦定律
设入射光方向(从表面指向光源)为 l,法线 n,两都是单位向量。表面单位面积接收到的辐射度按两者夹角的余弦衰减:
irradiance∝max(0, n⋅l)
- n⋅l=1:光正对表面(直射),最亮。
- n⋅l=0:光擦边掠过,照不到。
- n⋅l<0:光从背面来,对该面无贡献——所以用 max(0,⋅) 钳到 0。
几何直觉:同样一束光柱,斜着照摊开的面积变大、单位面积分到的能量按余弦减少。
为什么法线贴图能伪造凹凸
真实表面(砖墙、布料)有微小凹凸,每个细小面的法线朝向各不相同。如果只靠几何法线(一整面墙一个法线),墙会被照得平平一片、没有立体感。
法线贴图(normal map)是一张 RGB 图,存的是逐像素的扰动法线:R/G/B 通道编码切线空间下法线的 x/y/z。采样后用它替代(或混合)几何法线去算 Lambert 余弦,于是没有真正几何凹凸的墙面也能显出砖缝和颗粒的明暗。
解码方式(切线空间到 [−1,1]):
ntangent=sampled_rgb×2−1
再用 TBN 矩阵(tangent-bitangent-normal)变换到世界空间。TBN 由顶点的 tangent、normal 算出,所以导出器必须把 tangent 抓全(见 urp1 B06)。
易错点
- l 方向约定。很多教程写光照时 l 是”从光源指向表面”,那 n⋅l 要取负或反向。统一约定(这里用”从表面指向光源”),全链路一致即可。
- 法线插值后必须归一化。透视校正插值会改变法线长度,点积前忘了
normalize 会让斜面偏亮。
- 法线贴图是切线空间的,不经过 TBN 变换直接当世界法线用,画面会乱。
- 钳到 0:max(0,n⋅l) 漏掉,背面会出现”负亮度”,与其它项相加导致颜色错乱。
参考
直接光与 PBR BRDF
URP 的标准 Lit 材质用基于物理的渲染(Physically Based Rendering, PBR)。一盏直接光打到表面的反射光,由漫反射和高光两部分按金属度混合。
反射率方程(直接光单光源简化)
Lo=(kdfdiffuse+ksfspecular)⋅Elight
其中 Elight=colorlight⋅intensity⋅max(0,n⋅l) 是表面从这盏光接收到的辐照度(Lambert 余弦见 B03)。
漫反射项(Lambertian)
理想漫反射表面把光均匀散射到上半球:
fdiffuse=πalbedo
π1 是能量守恒归一化:上半球立体角为 2π,理想漫反射 BRDF 在半球上积分应为 albedo,所以 BRDF 本身是 albedo/π。这个 π 的来历是 B07 bug 的关键。
高光项(Cook-Torrance 微表面)
金属和粗糙表面的镜面反射用微表面模型:
fspecular=4(n⋅l)(n⋅v)DFG
三个分量:
| 项 | 物理含义 | URP 默认模型 |
|---|
| D(Normal Distribution) | 微表面法线分布,决定高光斑点大小 | GGX / Trowbridge-Reitz |
| F(Fresnel) | 入射角越大反射越强 | Schlick 近似 |
| G(Geometry shadowing) | 微表面互相遮挡 | Smith + GGX |
三个都由粗糙度(roughness)控制:粗糙度高,D 分布变平、高光变大变糊;粗糙度低,D 集中、高光锐利。
金属没有漫反射(吸收了所有进入表面的光再以镜面反射出来),非金属的高光很弱。金属度 m 决定混合:
- m=1(金属):kd=0,反射光全部走高光项,反射颜色用 albedo 染色。
- m=0(电介质):高光走 Fresnel 的 4% 介电常数默认值(白色高光),albedo 进漫反射项。
- 中间值在两者间插值,URP 推荐二值化倾向以避免”塑料感”。
这正是”金属和粗糙表面的反光不一样”的技术原因——同一束光下,金属走的高光项是非金属的几倍。
直接光 vs 间接光
这里讲的是直接光:从光源直线打到表面、再反射进眼睛,只弹一次。环境光(天空的间接漫射)见 B06、阴影(光源被别的物体挡住)见 B10。完整的 PBR 还应包括间接高光(环境反射)、IBL(image-based lighting)等,URP Lit 默认开直接光 + SH 间接漫反射 + 简单反射。
易错点
- albedo 和 baseColor 不是一回事。金属的反射颜色用 albedo,非金属的漫反射用 albedo,二者在同一通道里、由 metallic 区分用途,容易混淆。
- π1 是 BRDF 的,不是辐照度的。辐照度 n⋅l 已经是余弦衰减,别再多除 π(这正是 B07 的 bug:把归一化因子错用到环境光)。
- roughness 是感知线性,specular 是物理线性。贴图存的 roughness 常做平方再送进 GGX,URP 在 shader 里
roughness *= roughness,写 CPU 复刻别漏。
- Fresnel 在掠射角趋近 1,意味着所有材质在极斜视角下都像镜子——别把 F 钳成 0。
参考
球谐函数:用 27 个数存整片天空
天空不同方向光照颜色不同(顶上偏蓝、地平线偏白)。要”把每个方向有多少光”存下来,直接存全景图太浪费。Unity 用**球谐函数(Spherical Harmonics, SH)**压缩。
球谐是什么
球谐函数 Ylm(d) 是定义在球面方向 d 上的一组正交基函数(类似把信号分解成正弦波,只不过定义在球面上)。它的”频率”由阶数 l=0,1,2,… 控制,每个阶有 2l+1 个 m∈{−l,…,l}。
光照计算里常用的三阶(l=0,1,2)共 1+3+5=9 个基函数。它们能很好地捕捉低频光照(缓慢变化的天空色),高频细节(小亮点光源)捕捉不到——但对漫反射环境光足够,因为漫反射本身就是低通的。
为什么是 27 个数
每个基函数对应一个三通道(R/G/B)系数,所以 9×3=27 个数。Unity 的 SphericalHarmonicsL2 类就是这 27 个 float。
存储的是入射辐射度场在每个基函数上的投影系数 ciRGB,预计算一次。运行时重建某个方向 d 的入射光:
L(d)≈∑i=08ciYi(d)
9 个系数相加,就得到那个方向的近似入射光颜色。相比存一张 1024×512 的 HDR 全景图(几 MB),27 个 float 只要 108 字节。
漫反射环境光的球谐卷积
更妙的是:理想漫反射表面对入射光做半球积分(乘 cosθ),这个积分在球谐域里是对角缩放——每个 SH 系数乘一个预计算的常数(基函数的余弦卷积核)。Unity 直接存的就是卷积后的系数(称”irradiance SH”或”漫反射 SH”),所以渲染时和方向基函数做点乘、再乘 albedo/π 就是环境光贡献。
Unity 怎么用
- 环境光(Window → Rendering → Lighting → Environment):天球材质烘焙或预计算,结果存成
RenderSettings.ambientProbe(一个 SH9)。
- URP shader:在片元里调
SampleSH(worldNormal),传入世界法线,返回这个法线朝向收到的环境光颜色。
- 导出:导出器从 Unity 的 RenderSettings.ambientProbe 把这 9 个三通道 SH 系数抓出来写进 JSON。
易错点
- SH 是低频近似,存不下太阳这个”小而极亮”的方向光。所以 Unity 把平行光单独当直接光算(见 B04),SH 只负责天空的低频漫射。把太阳也卷进 SH 会丢失高光方向感。
- 9 个系数 vs 27 个数。说”L2 球谐”指 9 个基函数;说”27 个 SH 系数”指 RGB 各 9 个。Unity API 用前者概念、内存里是后者。
- 法线方向必须是世界空间。SampleSH 接受的是世界法线,本地法线不经 M 矩阵变换会算错。
- 重建公式带预乘系数:Unity 的
SphericalHarmonicsL2 存的是已卷积过余弦的系数,直接和基函数点乘就是辐照度,别再乘 cosθ——那个余弦已经在卷积核里了。
参考
真实 bug:环境光暗了约 π 倍
这是 PBR 实现里非常典型的一类错误:把漫反射 BRDF 的 π1 归一化项,错误地同时用到了直接光和环境光路径上。直接光那边有别处补回来,环境光没有,等于把环境光平白除暗了约 π≈3.14 倍。
π1 从哪来
理想漫反射 BRDF(见 B04):
fdiffuse=πalbedo
为什么除 π?反射率方程要求 BRDF 在上半球的积分等于 albedo(能量守恒,全部入射光被均匀散射出来):
∫Ωfdiffuse(n⋅l)dωl=albedo
半球上 cosθ 的积分 ∫Ωcosθdω=π,所以 BRDF 必须是 albedo/π 才能消掉这个 π。
直接光路径为什么”自我修复”
直接光只来自有限几盏灯,每盏算辐照度 Ei=colori⋅intensity⋅max(0,n⋅l),然后:
Ldirect=fdiffuse⋅∑iEi=πalbedo∑iEi
这里的 π1 和光强归一化的另一个约定互相抵消。Unity/UE 的光照单位体系里,方向光强度(lux)已经在定义时隐含了 π 的归一化(Candela/lux 是辐射度量学单位,不是裸 RGB),shader 里即便不显式除 π,调光强时也会被补偿。所以漏掉或重复 π1 在直接光里经常看不出来——亮度被另一个因子悄悄对冲了。
环境光路径为什么”塌掉”
环境光(球谐,见 B06)走的是不同的代数。Unity 存的 SphericalHarmonicsL2 已经卷积过余弦核(cosθ 的半球积分),所以 SampleSH(n) 返回的本身就是辐照度 E(单位 albedo 下的反射光),不需要再除 π:
Lambient=albedo⋅SampleSH(n)(已含 π1 卷积核)
如果代码里把这个共享的”归一化亮度系数”(实际就是 π1)再乘一次到环境光:
Lambientbug=πalbedo⋅SampleSH(n)(多除了一个 π)
环境光就被多除以 π≈3.14,画面里只剩 32% 的环境光强度。背光的白墙本来该被天空照出冷青色,现在只到 1/3,几乎发黑发灰——这正是该 bug 的可视症状。
怎么排查出来
CPU 实现里”打印中间值”就是关键:在每个像素把 SampleSH(n) 的返回值、π1 是否被乘、最终 Lambient 全打出来。直接看到”环境光值比直接光暗一个数量级”,再对照 Unity 的中间值(Unity 也能在 FrameDebugger 抓),就发现 π 被用了两遍。GPU shader 这种 bug 几乎没法直接 debug,正是 CPU 复刻的价值。
易错点(普适)
- π1 只属于漫反射 BRDF,不是全局”亮度系数”。直接光的辐照度已经有 cos 衰减、不要再除 π;环境光的 SH 卷积核已含 π1、不要再除 π。每条路径单独审视它该不该带 π。
- IBL(基于图像的光照)的预卷积通常也已经在卷积时把 π 摊进去,shader 里同样不该重复。
- 方向光的单位:lux/candela 和裸”颜色 × 强度”差 π,混用单位制必然有路径对不上。
- 修法:把那个被共享的归一化系数从环境光路径拿掉,环境光就恢复了——把共享的归一化系数从环境光路径移除即可。
参考
阴影:shadow map 两遍法
阴影 = “光源和我之间有没有东西挡着”。最主流的实时算法 shadow map(阴影贴图)用两遍渲染实现。
两遍流程
第一遍(从光源视角):把相机搬到光源位置渲一遍场景,只渲深度(不写颜色)。产出的就是 shadow map(阴影贴图)——一张深度图,记录”从光源看出去,每个方向上最近的物体有多远”。
第二遍(正常视角上色):对每个像素 P,把它从世界坐标变换到光源的裁剪空间,得到 (u,v,zlight)。用 (u,v) 在 shadow map 里查到 zstored(光源到最近遮挡物的距离),比较:
inShadow={truefalsezlight>zstored+bias(中间有更近的东西挡着)otherwise
在影子里则该光源对该像素无贡献。
为什么要两遍
直接判断”线段 P→light 是否穿过任何三角形”也能判阴影(光线追踪思路),但每像素对每三角形都要做几何求交,O(像素×三角形),实时跑不动。shadow map 把”光源到最近物体的距离”提前烘成图,第二遍只查一次纹理,复杂度 O(像素),是 GPU 友好的。
投影矩阵决定取景框
光源视角的投影矩阵把”光源能看到什么”框出来:
- 平行光(太阳):所有光线同向,用正交投影,取景框是个长方体,套住整个场景(见 B11)。
- 点光源/射灯:光线从一点发散,用透视投影,取景框是个锥形(见 B13)。
两种投影都产生一张深度图,查的方法相同——区别只是怎么把世界点变换到光的裁剪空间、以及深度是正交深度还是透视距离。
shadow map 的固有精度问题
- 分辨率有限:一张 1024×1024 的阴影图覆盖整个场景,每格代表的物理面积可能很大,影子边缘出现走样(锯齿、块状)。
- 深度量化:浮点深度有量化误差,加上透视投影非线性,远处精度差,引发 acne(见 B14)。
工程上的缓解:PCF 软边(B12)、级联阴影 CSM(B11)、VSM/ESM 等变体、reversed-Z(见 urp1 B15)。
易错点
- 比较方向:zlight 是”当前像素到光源的距离”,zstored 是”光源到最近物体的距离”。挡住时 zlight>zstored。比反了所有像素都变阴影。
- 必须把世界点用光的 ViewProjection 变换,不能拿相机空间的 z 去和 shadow map 比——两者坐标系不同。
- bias 必不可少:不加 bias 几乎一定出现 acne(B14)。
- shadow map 是从光源渲染的,光照衰减/距离裁剪要和实际光源范围匹配,否则阴影边界出现亮边。
参考
正交取景框与级联阴影(CSM)
太阳是平行光,所有光线同向——它的”取景框”必须用正交投影,把整个场景套进一个长方体。
正交投影 vs 透视投影
| 透视 | 正交 |
|---|
| 光线 | 从一点发散 | 全部平行 |
| 近大远小 | 有 | 没有 |
| 投影矩阵 w 分量 | =zview | ≡1 |
| 深度 | 真实距离 | 沿轴的固定方向距离 |
正交投影矩阵把视图空间直接线性映射到 [−1,1]3(再按 NDC 范围缩放),没有除以 w 的步骤。所以 shadow map 里存的就是”沿太阳方向到物体表面的距离”,第二遍比较也是直接距离比较,简单直观。
取景框怎么套
正交框的六个面(left/right/top/bottom/near/far)必须包住所有会投影阴影、所有会被照到的物体。两步:
- 算”从光源方向看,场景的 AABB”——把所有物体世界 AABB 旋转到光的视图空间,取并集。
- 留一点边距(padding),避免框边上的物体阴影被裁切。
框越大、同样的 shadow map 分辨率,每格覆盖的物理面积越大,影子越糊。所以取景框要尽可能紧贴有效范围。
阴影分辨率 vs 场景大小
场景越大,每个格子覆盖的范围越大,影子边缘就越糊——这是直接的几何关系:
单格物理尺寸=shadow map 分辨率取景框边长
1024 阴影图覆盖 10 米场景 → 单格约 1cm,边缘清晰;同样分辨率覆盖 1km → 单格约 1m,影子锯齿严重。
级联阴影(CSM)
大场景的解决:级联阴影贴图(Cascaded Shadow Maps, CSM)。把相机视锥按距离切成几段(近、中、远),每段单独用紧贴的正交框渲一张 shadow map。近处用高精度小框、远处用低精度大框:
- 近处(玩家脚下)阴影清晰
- 远处(地平线)阴影粗糙但人眼看不出
URP 的 Universal Render Pipeline Asset → Shadows → Cascades 就是切 1/2/4 级。本工地场景足够小,单张阴影图就够;真正的大场景(开放世界)必须级联。
易错点
- 取景框套住的不只是受光面:背光物体也会投影到别人身上,AABB 必须包住会遮挡和被遮挡的全部几何。
- CSM 切换处会有接缝(seam),URP 用 blend 过渡;自己实现不 blend 会出现一道硬线。
- 正交框中心要对齐光的方向:光视图矩阵的 z 轴 = 光的方向,框沿这个轴有 near/far。对错轴阴影整体偏向一边。
- shadow map 的 reversed-Z:URP 现代管线对阴影图也用反向 Z 提升精度,比较符号也要配套反过来。
参考
PCF:硬边变软边
只查 shadow map 一个采样点,结果是二值(在影子里 / 不在),边缘硬、带锯齿。真实的影子边缘是柔和过渡。PCF(Percentage-Closer Filtering) 是最经典的软阴影做法。
为什么单采样是硬边
shadow map 的每个纹素(texel)覆盖一小块物理区域。位于阴影边缘的像素,它对应的 shadow map 纹素可能整块在阴影里、或整块在外,没有过渡——边界是非黑即白的台阶。纹素越大(低分辨率或大场景),这个台阶越宽,锯齿越明显。
PCF 的思路
不只查一个点,而是查它周围一小片(比如 3×3、5×5 个采样点),看其中有几个在影子里,按比例算明暗:
shadow = 0
for 每个采样偏移 (du, dv) in 核(如 [-1,0,1]×[-1,0,1]):
z_stored = sampleShadowMap(u+du, v+dv)
if z_light > z_stored + bias:
shadow += 1 # 这个采样在影子里
shadowFactor = shadow / 采样数 # [0,1],0 全亮,1 全暗
最终光照乘以 1−shadowFactor(或 1 - shadow)。9 核有 10 级亮度(0~9),5×5 核 26 级——边缘出现柔和的灰度过渡。
“PC” 的关键:对每个采样分别比较,再平均
PCF 全名 Percentage-Closer Filtering,重点是”先比较、后平均”,不是”先平均深度、再比较”:
- 错误:把 9 个 zstored 平均成一个值,再和 zlight 比一次。这会模糊阴影几何,远处的物体阴影会”飘”。
- 正确:每个采样点和 zlight 单独比较得到 0/1,再平均这些 0/1。
硬件 shadow sampler(如 SamplerComparisonState 在 D3D,sampler2DShadow 在 GL)在硬件里直接做这个”比较后平均”,单条指令完成 PCF。
核大小与质量
| 核 | 阴影边缘 | 性能 |
|---|
| 1×1(无 PCF) | 硬边、锯齿 | 最快 |
| 3×3(9 采样) | 略软 | 中 |
| 5×5(25 采样) | 明显软 | 慢 |
| 大核 + 旋转泊松盘采样 | 柔和、噪声少 | 工程级 |
核越大边缘越柔,但远处阴影也会被”糊开”——并不是越大越好。PCF 软边是”分辨率不足的补偿”,本质是模糊,和真实半影(光源有面积)不是一回事。要真实半影得用 PCSS(PCF + 估计遮挡距离调整核大小)。
易错点
- 先比较后平均,顺序反了结果完全不同(见上)。
- 采样位置最好对齐到 shadow map 纹素中心:随机偏移会导致噪声。工程做法是用旋转的泊松盘抖动 + 多帧累积。
- bias 在每个采样都要带(见 B14),否则核内的非边缘采样也会冒 acne。
- PCF 不是物理半影:它让边缘模糊,但不会让远处阴影变软(CSM 内的远处依然有硬影子被 PCF 糊),不要和真实光源面积混淆。
参考
点光源阴影:透视取景框
射灯(spot light)从一点向外发散,它的”取景框”不是正交的长方体,而是一个张开的锥形——用透视投影。其余流程和方向光阴影完全一致。
锥形的参数
射灯由位置 plight、方向 d、内/外半角(θinner,θouter)、射程 r 定义。它的取景框 = 一个透视投影,参数:
- 视野角:用外半角 θouter 决定(注意 Unity 的 spot angle 是全角,要除以 2 得到半角再转 fov)。
- 宽高比:1(锥形圆截面,但透视投影按矩形算,shader 里再用余弦衰减裁圆)。
- near/far:near 给个小正数(如 0.1),far = 射程 r。
光视图矩阵
和相机视图矩阵同样的构造(见 urp1 B11),只是”相机”是光源:用 plight 当位置、d 当 forward,构造 Vlight。然后 VPlight=Pspot⋅Vlight 把世界点变换到光的裁剪空间。
第二遍着色时,把像素世界坐标用 VPlight 变换、除以 w、映射到 [0,1] 的 UV,在 shadow map 查 zstored,和 zlight 比——和 B10 完全一样。
衰减:圆截面 + 距离
锥形取景框本身是方的,但射灯只照亮圆截面内和射程内的点。所以在阴影比较之外,还要算光照衰减:
attenuation=内外角之间平滑过渡angleAtten(θ)⋅距离衰减distAtten(r)
- angleAtten:用 cosθouterd⋅l 在内外角之间插值(URP 用 smoothstep),锥外为 0。
- distAtten:通常用 (ar2+br+c)21 之类的反平方型衰减,远处快速归零。
在阴影里的像素还要乘以 0(这盏光不照亮它)。
与平行光的对比
| 方向光阴影 | 射灯阴影 |
|---|
| 投影 | 正交 | 透视 |
| 取景框 | 长方体,套住整个场景 | 锥形,套住灯照射范围 |
| shadow map z | 沿光轴距离 | 透视深度(要除 w) |
| 比较 | zlight>zstored | 同左,但 zlight 是透视深度 |
| UV 查询 | 简单线性映射 | 透视除法后再映射 |
代码上区别就是换一个投影矩阵:从正交变透视,其它 shadow map 流程复用。
真正的点光源(全方位)
上面的讨论只覆盖了射灯(锥形)。真正的全方位点光源(点灯泡)需要 6 个面的”立方体阴影”(cube shadow map):从光源向上下左右前后各渲一张深度图。Unity 的 Point Light 用 cubemap shadow。本场景射灯是锥形所以一张就够。
易错点
- spot angle 是全角不是半角。Unity API 给的是顶角,转 fov 半角要除以 2。
- 透视 shadow map 的非线性深度:远处精度差,射灯远端的阴影更容易 acne(见 B14),需要更大 bias 或 reversed-Z。
- UV 映射要带透视除法:u=wxclip⋅21+21,漏掉除 w 阴影会错位。
- 锥外不参与光照也要 early-out,省一次 shadow map 查询。
参考
阴影痤疮与 depth bias
shadow acne 的典型现象:表面冒出一片细密的黑斑,像是”自己挡住了自己”。这是 shadow mapping 最经典的 artifacts,叫 shadow acne(阴影痤疮),修法是加 depth bias(深度偏移)。
成因:自阴影的数值误差
shadow map 的深度 zstored 来自第一遍从光源视角渲染、经过量化(浮点精度 + 纹素采样)的深度。第二遍把像素变换到光空间得到 zlight,比较 zlight>zstored 判阴影。
问题是:zstored 是从光视角采样纹素得到的离散值,而 zlight 是该像素自己的真实深度。同一个表面、同一块区域,两遍渲染的浮点结果不可能 bit 级一致——量化误差让某些像素的 zlight 比 zstored 略大一点点,于是被判成”被自己挡住”,出现黑斑。
为什么是”条纹状”或”细密”?因为 shadow map 纹素边界处的量化跳变最明显,斑纹沿纹素边界呈 Moiré 状。
修法一:depth bias
把比较的 zlight 往光的方向”抬一点点”(即减去一个小常数):
inShadow=(zlight−bias)>zstored
bias 让像素”看起来离光更近”,消除自阴影误判。bias 取值通常在 0.0001∼0.01 量级,和场景尺度、shadow map 分辨率、深度范围有关——和 smallpt 的 eps 同理,是经验性的权衡。
URP 在 Light/Shadow 设置里把 bias 拆成两个:
- Depth Bias:沿光轴方向的常数偏移。
- Normal Bias:沿表面法线把采样点往前推一小段(不是改深度,是改采样位置)。法线偏移对斜面更稳健,因为它跟随表面斜率自适应。
通常两个一起用,互相补偿。
bias 的副作用:light leaking / peter-panning
bias 太大会让阴影”脱离”物体底部——物体像悬浮在地面(小飞侠 Peter Pan 的影子分离),叫 peter-panning 或 light leaking(光漏到本该被挡的位置)。所以 bias 调试是个权衡:
- 太小 → acne 黑斑
- 太大 → 阴影脱离、漏光
修法二:正面剔除渲染 shadow map
更彻底的工程做法:第一遍渲 shadow map 时把背面剔除改成正面剔除(Cull Front),只渲物体的背面写入深度。这样物体表面的深度等于它背面的深度,自然比正面的 zlight 大一点(隔着整个物体的厚度),等效于一个”自动 bias”,且跟着物体厚度自适应。代价是薄物体(叶子、布料)阴影会消失(没背面可渲)。URP/D3D 把这个开关叫 “Two-Sided” 或配合 depth bias 使用。
为什么 acne 集中在远处
透视投影的非线性深度(见 urp1 B11)让远处 zndc 变化极慢、量化精度差。在远处的表面上,zlight 和 zstored 的浮点差相对更大,acne 更严重。所以 bias 通常随距离或斜率(slope-scaled bias)变化——斜面更易 acne,URP 支持 slopeScaledDepthBias。
易错点
- bias 单位要和深度单位匹配。如果 shadow map 存的是 NDC [0,1],bias 是 0.001 量级;如果是真实距离(米),bias 是厘米量级。混用坐标系必然调不对。
- bias 调到没 acne 但出现 peter-panning,往往需要配合 normal bias 或正面剔除。
- bias 不能”一劳永逸”:场景尺度、shadow map 分辨率、CSM 级数变化都要重调。
- bias 不解决”阴影边界锯齿”,那要靠 PCF(B12)或更高分辨率。
参考
雾:远处褪向雾色
雾(fog)的规则很朴素:离相机越远,越多地混入”雾色”。近处几乎不受影响,最远处几乎只剩雾色。
雾的混合公式
设像素原本(光照后)的颜色 Cscene,雾色 Cfog,雾因子 f∈[0,1](0 = 全雾,1 = 无雾),混合:
Cfinal=lerp(Cfog, Cscene, f)=f⋅Cscene+(1−f)⋅Cfog
雾的”浓淡”全由 f 随距离 d 怎么衰减决定。Unity 提供三种衰减模式:
三种雾衰减
| 模式 | 公式 | 特征 |
|---|
| Linear(线性) | f=dend−dstartdend−d | 近 dstart 内无雾,远 dend 外全雾,中间线性 |
| Exponential(指数) | f=e−ρd | 平滑衰减,需要浓度 ρ |
| Exponential squared(指数平方) | f=e−(ρd)2 | 更快归零,雾更”集中” |
URP 的指数雾参数叫 density(ρ),线性雾参数叫 start/end。导出器把这些从 Unity 的 RenderSettings.fog* 抓出来写进 JSON,CPU 复刻严格用同一公式,所以能对上 Unity。
距离 d 用哪个
两种选择:
- 距离雾(distance fog):d=∥P−camera∥,欧氏距离,绕相机成球形衰减。URP 默认。
- 高度雾(height fog):d 由像素的 y 坐标决定,模拟低洼处的雾/瘴气,常和距离雾组合。
URP 的雾还分 Fog(传统雾,距离驱动)和 Volumetric Fog(基于 froxel 体积光照,能算光轴、雾中阴影),后者复杂得多。本项目用的是简单距离雾。
在哪个空间做
雾在线性 HDR 颜色之后、色调映射之前做,还是之后做?这是常见混淆点。Unity 传统的 fog(RenderSettings.fog)在 tonemapping 之前、在 LDR(sRGB gamma)空间算——这是历史包袱,让雾色和场景色都先转 sRGB 再混合。URP 的现代管线鼓励雾在线性空间做,再统一 tonemap。两种顺序会给出略不同的雾色饱和度,必须和 Unity 设定一致才能对账。
易错点
- 雾因子 f 的定义方向:有的代码 f 是”无雾比例”(这里这么用),有的是”有雾比例”。lerp 写反了画面全雾或没雾。
- 距离用视图空间还是世界空间:必须和 Unity 一致。Unity 的 fog 距离基于相机空间 z(视图深度),不是欧氏距离——视野角内不同方向的同距离点,雾浓淡一致。
- 指数雾的浓度单位:ρ 的尺度依赖 d 的单位(米 vs NDC),导出时单位要对齐。
- 雾色在 sRGB 还是 linear:传统 fog 在 gamma 空间混合,会把雾边染出非预期的色阶。
参考
色调映射:ACES
光照算出来的亮度是线性 HDR(high dynamic range),数值可能远超 1。屏幕只能显示 [0,1]。直接截断会让亮处一片死白,丢失层次。色调映射(tone mapping) 用一条平滑 S 曲线把过亮柔和地压回可显示范围。
为什么不能直接 clamp
直接 min(L,1) 是硬截断:所有 L≥1 的像素都变成 1(纯白),云、反光、天空这些本该有层次的区域塌成一个白块。tone mapping 用渐近趋近 1 的曲线代替硬截断:值越大越接近 1 但永远不到 1,保留了亮度梯度。
ACES 曲线
URP 默认用的色调映射是 ACES(Academy Color Encoding System),电影工业标准。它的近似多项式版(Narkowicz 2015):
ACES(x)=x(2.43x+0.59)+0.14x(2.51x+0.03)
x 是线性 HDR 输入,输出映射到约 [0,1]。
特性:
- x=0 → 输出 0(暗部保留)
- x≈0.5 → 输出约 0.4(中间调略压暗,增加对比)
- x→∞ → 输出趋近 2.51/2.43≈1.03(亮部渐近 1)
- 整体形状是 S 曲线:暗部和亮部都被压缩、中调对比提高
ACES 是全局色调映射——同一个函数应用到所有像素,不考虑邻域(不像局部 tone mapping / exposure fusion)。简单、确定性、可复现,是实时渲染的事实标准。
完整的成像链路
从光照到屏幕,颜色经过:
线性 HDR 光照
→ 色调映射 (ACES) # HDR → [0,1] 压缩
→ gamma / sRGB OETF # linear → sRGB 编码 (约 ^1/2.2)
→ 8-bit 帧缓冲
sRGB 编码(gamma)和 tone mapping 是两件不同的事:
- tone mapping:把 HDR 动态范围压到 LDR [0,1],作用在线性空间。
- gamma/sRGB 编码:把线性 [0,1] 用约 γ≈1/2.2 的幂函数编码,匹配显示器的非线性响应,输出 8-bit。
顺序不能反:必须先 tonemap 再编码。Unity/URP 的 PostProcessing Stack 默认就是这条链。
易错点
- ACES 输入要是线性 HDR。把 sRGB 编码后的值喂进 ACES 会偏暗、偏饱和。
- 应用范围:URP 把 tone mapping 应用到所有不透明和透明光照物体,但不应用到未照明的天空盒(见 B18)——这是为了让天空颜色和 Unity 一致。
- ACES 不是唯一的 tone mapper:还有 Reinhard、Unreal 的 Filmic、Khronos PBR Neutral 等,曲线形状不同,亮部/中调表现不同。URP 在 Post-Processing → Tonemapping 里可切换。
- gamma 编码可能由硬件自动做:渲染到 sRGB swapchain 时,输出编码隐式做。CPU 实现要明确写
pow(c, 1/2.2),否则画面偏暗。
参考
天空盒:绕过 ACES
没有几何体的像素露出来的是天空盒。这里有个关键细节:天空故意绕过 ACES 色调映射,直接输出。其它物体走 ACES。
天空盒是什么
天空盒(skybox)是渲染在最远的背景。Unity 经典做法是六个面围成一个”盒子”或一个天球,相机总在中心,看到的是天球的内壁。本项目天空是三段渐变:顶上偏深蓝、地平线发白、下方暗下去——这种渐变可以用一个 shader 按世界方向(视线方向)算出,不需要真实贴图。
为什么天空要绕过 tone mapping
天空的颜色(顶部的深蓝、地平线的白)是已经被人调好的最终显示色——艺术家在 Unity 里调天空材质时,看到的就是最终屏幕色。如果再过一道 ACES,天空会被进一步压暗、变色,和 Unity 不一致。
所以渲染顺序是:
1. 先画天空(背景),不走 tone mapping,直接写 sRGB 帧缓冲
2. 再画 3D 物体(HDR 光照),走 ACES + gamma
3. 深度测试保证 3D 物体遮挡天空
天空在 step 1 已经是 LDR + sRGB,3D 物体在 step 2 是 HDR→tonemap→sRGB。两者最终在同一个 sRGB 帧缓冲里合到一起。
“skybox 不参与光照计算”的另一面
天空盒本身通常不接收光照(它是自发光的背景),不参与深度写入(深度填最远值,让 3D 物体总能遮住它)。所以阴影、雾对天空的处理也不同:
- 雾:传统 fog 默认不影响 skybox(fog 在物体光照阶段算,天空在那之前已画)。要让天空有雾需要专门的 skybox fog shader。
- 阴影:天空不投阴影也不接收阴影(它在无穷远)。
- 环境光:天空作为光源通过 SH(B06)照亮物体,但天空盒本身不被照亮——它是光源,不是被照物。
易错点
- 天空画在深度最远处且关 zwrite:否则会挡住所有 3D 物体。Unity 默认 skybox shader
ZWrite Off、Queue = Background。
- 绕过 tone mapping 的实现:URP 里靠渲染顺序 + 输出空间实现,不是单个开关。CPU 复刻要在管线里明确分支:天空走”sRGB 直接输出”路径,物体走”HDR→tonemap→sRGB”路径。
- 天空方向用相机方向还是世界方向:渐变天空按视线方向(view dir)采样,相机转动时地平线跟着转——这是对的。若按固定世界坐标会”贴”在世界里、视角变化不对。
- skybox 颜色空间:调色时艺术家在 sRGB 显示器上调,所以天空贴图/渐变值默认是 sRGB 编码,采样时按 sRGB→linear 处理(如果天空要参与后续线性合成),或不转(如果直接输出)——本项目直接输出,不转。
参考
对账:像素差与误差度量
肉眼说”挺像”不够,要拿数字衡量 CPU 复刻和 Unity 截图的差距。做法:两图逐像素相减,画成误差热力图。
逐像素差
设 CPU 复刻图为 A、Unity 截图为 B(都先转线性 RGB,避免 sRGB 编码差异干扰)。逐像素差:
Δi=Ai−Bi
直接显示 Δ 会因为 RGB 三通道有正有负不好看,工程上用绝对误差或误差模长:
∣Δi∣=∥Ai−Bi∥,或∣Δi∣=31∑c∣Aic−Bic∣
把这个标量映射到颜色——蓝色表示接近 0(一致)、红色表示大(差距大)——就是误差热力图。大面积蓝色说明整体吻合,红色集中在某区域(如工具密集的接触阴影)指向剩余误差源。
全图聚合:MSE / PSNR
单个像素差不便于比较,聚合到全图常用:
均方误差(MSE):
MSE=N1∑i∥Ai−Bi∥2
峰值信噪比(PSNR):
PSNR=10log10(MSEMAX2)
MAX 是像素最大值(8-bit 图为 255 或归一化后 1.0)。PSNR 单位 dB,越大越像:
- 30 dB 以上:人眼几乎看不出差别
- 40 dB 以上:近乎像素级一致
- 20 dB 以下:明显能看出差异
PSNR 是图像压缩/复现领域的标准度量,用它能把”像不像”变成一个可比较的数字,便于跨迭代版本追踪复刻精度。
误差源典型分布
本项目的剩余红色集中在工具最密处的接触阴影——这部分阴影由多盏灯的小角度遮挡 + 自阴影叠加而成,对 shadow map 分辨率、bias、PCF 核大小都敏感,参数稍偏就出明显差异。大面积的 diffuse 表面、天空、远墙都对得上(蓝),说明 PBR 主路径正确。
像素对齐的前提
要逐像素相减,两图必须像素级对齐:
- 分辨率一致:同样宽高。
- 渲染状态一致:同一相机参数、同一时刻(动态场景要同步帧)。
- 颜色空间一致:都先转线性或都 sRGB。CPU 复刻输出若已 sRGB 编码、Unity 截图也是 sRGB,相减才公平;不一致会把 gamma 误差混进结果。
任何一项不齐,误差图会显示成全图规律的条纹/偏色,掩盖真实差距。
易错点
- 比较要在同一空间做:HDR→tonemap→sRGB 的输出 vs Unity 的 LDR 截图,必须走完同样链路(见 urp2 B17)。中间任一阶段比较都会得到误导。
- PSNR 对少量大误差敏感:一两块红区拉低全图 PSNR 很多,但视觉上整体可能很接近。看误差热力图比单看 PSNR 数字更有信息。
- 动态场景要帧同步:若有动画/时间相关效果(噪点、抖动),同一帧截图才有意义。MSAA、TAA 的样本差异也会让 PSNR 下降但视觉无差。
- 天空盒区域:本项目天空绕过 ACES(见 B18),相减时这部分要确认两边都走了同样路径,否则天空区域的”误差”反映的是路径不一致而非真实差距。
参考