让 2D 像素拥有 3D 打光:光照方向感知的法线估计




背景
最近刷到 @moonfell_rpg 发的一个像素艺术光照工具 Pixel Art Lighter,看效果真挺不错,遂研究之。
注意这个工具目前只有网页版,通过随网页分发的形式以 MIT 协议开源,并没有对应的 GitHub 仓库(作者回复我说因为工具和他的游戏在一个仓库,所以不方便开源)。


部分对建模和游戏引擎有过研究的朋友可能知道背后的原理:法线图。
这里快速解释下:法线图就是给每个像素记一个朝向,RGB 三个通道分别代表朝右、朝上、朝屏幕外的分量。有了朝向,就能用最简单的 Lambert 模型算出这个像素对着灯有多亮,实现动态光照的效果:

传统的法线生成方案
在研究 Pixel Art Lighter 之前,我们先看看其他的法线生成方案的原理和存在的问题,确定个基线,也好验证下 PAL 是不是真的这么强(毕竟作者说他的初衷是现有方案生成出来的法线都让人受不了)。

| 工具 | 做法 | 形态 |
|---|---|---|
| Sprite Fusion Sprite Normals | 欧氏距离场鼓起 + Oklab 亮度,再求梯度 | Rust / WASM |
| SpriteLab | 倒角距离做斜面 + 亮度 + 颜色边界压槽,模糊后 Sobel | 网页 |
| Bump It | 亮度当高度,混合轮廓斜面,遮罩模糊后 Sobel | 网页 |
| Laigter | 轮廓距离斜面 + 浮雕,默认模糊半径 6 和 10 | 桌面端,GPL-3 |
| SpriteKiln | 每个连通块按厚度做四分之一圆的鼓起 + 亮度细节,可选混入 MoGe-2 的 AI 法线 | ComfyUI 节点 |
| Normalizer | 程序化部分改自 Laigter,另一路在浏览器里跑 DeepBump 模型 | 网页 / CLI |
套路大同小异,基本都是从亮度和轮廓构造高度场,经模糊后用局部梯度计算法线。

所以问题也差不多:
- 连续法线:法线逐像素连续变化,打光后一个色块里出现渐变,像个充气气球。
- 亮度当高度:和 Pixel Art Lighter 一样,全都把亮度直接算进高度。
- 重复打光:输入本身已经画了光,换个灯位再打一遍,等于两盏灯叠在一起。
商业工具里,也调研了两个:
- Sprite Lamp 走的是另一条路:让画师把同一张图按不同灯位各画一遍,再反解出法线,本质上是光度立体。效果最准,但画师得多画好几张,我反正是画不动。。。
- SpriteLight 的介绍里写着默认模型会「读画上的明暗,而不是从亮度猜深度」,和我后面的思路是一个方向,不过闭源,看不到具体做法。
Pixel Art Lighter 是怎么做的
PAL 的核心思想是按色块处理,这是它和其他生成器最不一样的地方,一共七个步骤:

- 同色连通块:颜色完全相同、上下左右相连的像素归成一块。
- 高度场:每个像素的高度由两部分相加。一部分是离轮廓的距离,开根号后越往里越高,让整个形状鼓起来;另一部分是亮度,越亮越高。
- 模糊:默认 Object 设置下,高度场做三遍邻域平滑。
- 逐像素法线:对高度场求梯度。
- 块内取均值并量化:每个色块取法线均值,再吸附到 8 个方位 × 4 档倾斜上。一个色块只有一个朝向。
- 大块按方向分面:面积够大的色块,按法线朝上下左右分成几组,组与组之间方向差得够多,就拆成几个面。
- 描边吸收邻色:又暗又细的色块被当成描边,从里往外把相邻色块的法线传进去;最后两个像素以内的碎片并入邻居。
精髓在第 5 步。一个色块只有一个朝向,打出来的光只会在色块边界上变化,天然和像素网格对齐。
除了法线生成,PAL 还引入了光照蒙版的概念,通过额外增加一张蒙版图,越暗的块接收的光越少。

PAL 的效果比同类算法在像素场景下的确好上不少,不过依然存在一些局限:
- 亮度当高度:像素图的明暗大多是画上去的光,亮的地方是朝着灯的面,高度未必更高。后面会细说。
- 精确同色才算一块:抗锯齿、抖动和贴图纹理都会把色块打碎。作者也提醒过要用调色板干净的素材。
- 阈值按绝对像素算:默认分面强度下,色块至少要 142 个像素才拆面。32px 的小图几乎拆不出面,96px 的大图又容易拆过头。
我的方案
光照角度估计
我主要想解决的是「亮度 ≠ 高度」这个问题:大部分素材本身为了呈现体积感就会携带高光和阴影,这部分颜色的变化被错误估计成高度就会导致法线方向错误,给本来平滑的地方产生梯度感。

这时候我想到平时用的主动降噪耳机,通过提取出噪声频段然后叠加上相位相反的声波实现降噪的效果。那对于图片来说,是不是也可以通过提取出光照方向实现更精准的法线估计呢?
如果知道光从哪来,就能换一种解释:亮度主要说明这个像素朝着灯。具体分两步:
- 亮度在高度里的权重降为原来的 1 − 影响度(默认 60%)。
- 算出每个像素比周围亮多少(方框均值,半径随图大小变化),按这个差值把法线往光源方向推。
光照方向怎么获取呢?最好能够自动从图片推测,再不济就加个滑杆,毕竟一般这类工具都需要人工调参才能获得更好的效果。
自动估计
轮廓是最可靠的线索:不管画的是什么,轮廓附近的表面大致朝外。所以只看离轮廓 4 像素以内的一圈,用轮廓距离场近似出这些像素的法线,再看哪个光照方向最能解释它们的明暗。

- 按「连通块 × 色度」分组,每组减去自己的平均亮度,免得把衣服和皮肤的固有色差当成光照。
- 在 72 个方位 × 3 个仰角上,算 Lambert 预测和实际亮度的相关系数,取最高的方向。
- 拿不准就放弃:相关系数不到 0.3、和反方向拉不开差距、把图切成 4 块轮流去掉一块后方向漂移超过 45°、某个独立部件和整体方向差 75° 以上,都当作没估计出来。
- 相关系数在 0.3 到 0.45 之间时,影响度按比例打折。
允许手动设置角度
自动估计总有拿不准的时候,但大部分时候,要么我就是原图的作者,本来就知道光源的方向,要么我一看就知道光从哪边来,只给一个角度要省事得多。
对于前者可以认为是直接拿到 GT(标准答案)了,但如果我不是原图的作者,万一估计错了方向咋办?这时候就要看看这个方案的容差率了,尽可能在一定的偏差范围内都能得到不错的效果。
我测试了误差在 360 度下的所有场景,得到下面的结果:

- 偏差在 ±15° 以内,几乎和给准角度一样。
- 偏差 ±30° 时,还和自动估计差不多。
- 偏到 ±90°,就比完全不感知光照还差了。
所以,只要大致说对光从哪边来,就能拿到大部分收益,何乐而不为呢哇咔咔。
去原图光影
这主要是为了解决原图光照与后渲染光照冲突的情况。假设原图本身就携带了从左到右的光影(左亮右暗),这时候我们虽然预测出了正确的法线,但是最终着色的时候光源是从右到左的(也就是动态光源会给一个左暗右亮的效果),叠加出来往往依然是左亮右暗。

可以看到在宝箱场景上,由于原图是从左往右的打光,宝箱的右边有大量的阴影,在这样的原图上叠加从右往左的动态光照,右边依然很难被点亮。
所以我加了一步去光:用生成的法线和估计的光照方向,假设原图是「15% 环境光 + 45° 仰角的平行光」画出来的,把这部分光除掉,再打新的光。可以看到效果好了不少,明显右边亮起来了。
不过实际使用中去光可能会导致色彩幅度变得扁平,因此是个实验性功能,这块还在持续优化 ing。
Lab 色差聚类
PAL 要求相邻像素 RGB 必须逐字节完全相等才能并入同一 cluster,因此要求比较纯净的调色板,对于颜色复杂或使用了抗锯齿的场景就会分成很多小的细碎区块。
我改成在 CIELAB 里做区域生长:相邻像素色差 ΔE 不超过 6、和种子像素不超过 10,并且透明度差不到 48,就并进同一块。

评测用例里,骑士从 1090 块降到 191 块,宝箱从 1234 块降到 57 块。块变回了「面」,后面的分面和量化才有意义。
自适应分面阈值
- 分面的最小面积按前景面积缩放(以 64 × 64 为基准,最多两倍),分组的支持度阈值按色块面积的平方根算,小图也能拆出面。
- 轮廓鼓起的曲线随图变大,从平方根过渡到线性,大图的折痕方向保留得更好。
Benchmark
评测集设计
改了这么多,那必然得上评测来佐证。不过这块比较小众,找了一圈基本没有公开数据集:
- pixel-art-normal-maps 有一万四千多对,但目标法线经过量化和轮廓处理,是风格化的法线,不是真值。
- OpenGameArt 上有些素材包自带法线图,但法线也是用 Sprite DLight 自动生成的。
拿生成的法线当真值,测出来的只是「像不像另一个工具」。所以还是回到最笨的办法:用 3D 模型渲染成像素图,真值直接从模型里取。

- 8 个 KayKit 的 CC0 模型:骑士、法师、游侠、骷髅战士、宝箱、探测车、水磨坊、神龛。
- 每个模型 2 个视角、4 种原生尺寸(32、48、64、96 px),共 64 个用例。
- 正交光栅化,每个像素做深度测试,不做抗锯齿。真值法线是可见三角形内插的顶点法线,底色取自模型贴图。
- 输入从左侧打光(方位角 159°、仰角 29°),明暗分成五档,接近像素图的赛璐璐画法。
- 另外渲染右、上、下、正面四个新灯位,作为换光的目标。
然后就是指标的选取了。注意我们并不追求「生成的法线 = GT 的法线」(因为对像素场景并不匹配),所以主要是选取折痕指标和角误差。最终定下了下面的指标:
| 指标 | 衡量什么 |
|---|---|
| 折痕 AP | 把所有相邻像素对按预测的法线跳变排序,看真值里跳变 ≥ 30° 的「折痕」排得靠不靠前 |
| 折痕召回(方向一致) | 真值折痕的位置上,预测也有 ≥ 30° 的跳变,并且跳变方向一致 |
| 平均角误差 | 预测法线和真值之间的夹角 |
| 换光 RGB MAE | 用预测法线把输入换到四个新灯位,和目标图逐像素比 |
评测结果

| 方法 | 折痕 AP ↑ | 折痕召回 % ↑ | 误判折痕 % ↓ | 平均角误差 ° ↓ |
|---|---|---|---|---|
| Xprite 自动角度 | 0.520 | 14.67 | 1.19 | 37.99 |
| Xprite 手动角度(160°) | 0.522 | 15.51 | 1.20 | 37.74 |
| Sprite Fusion | 0.317 | 3.65 | 2.76 | 37.56 |
| SpriteLab | 0.290 | 0.05 | 0.04 | 38.74 |
| Bump It | 0.311 | 0.08 | 0.01 | 41.38 |
| Pixel Art Lighter 原版 | 0.287 | 4.75 | 4.42 | 40.03 |
| Laigter | 0.276 | 0.43 | 0.95 | 47.10 |
解释下这里看上去没那么好的指标:
- 误判折痕:SpriteLab 和 Bump It 几乎为 0,是因为它们基本不出折痕,本身分母就小。
- 平均角误差:由于 Xprite 和 PAL 都是追求像素场景下的打光,会对法线色块进行聚类,因此直接对比连续法线的 GT 本身就存在理论上的劣势。