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

GitHub:xprite-lighter

当前帧 · 法线图
打光
素材: Pixel_Poem · Dungeon Assets Pack
多物体场景
多物体场景
门内外场景
门内外场景
金币场景
金币场景
Xprite 和各工具生成的法线图对比
Xprite 和各工具生成的法线图对比

背景

最近刷到 @moonfell_rpg 发的一个像素艺术光照工具 Pixel Art Lighter,看效果真挺不错,遂研究之。

注意这个工具目前只有网页版,通过随网页分发的形式以 MIT 协议开源,并没有对应的 GitHub 仓库(作者回复我说因为工具和他的游戏在一个仓库,所以不方便开源)。

部分对建模和游戏引擎有过研究的朋友可能知道背后的原理:法线图。

这里快速解释下:法线图就是给每个像素记一个朝向,RGB 三个通道分别代表朝右、朝上、朝屏幕外的分量。有了朝向,就能用最简单的 Lambert 模型算出这个像素对着灯有多亮,实现动态光照的效果:

从一张像素图生成法线图,再换灯位重新打光
从一张像素图生成法线图,再换灯位重新打光

传统的法线生成方案

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

作者在评论区回复:做 PAL 是因为每次尝试的法线图都很难看
作者在评论区回复:做 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 的核心思想是按色块处理,这是它和其他生成器最不一样的地方,一共七个步骤:

Pixel Art Lighter 生成法线的七个步骤
Pixel Art Lighter 生成法线的七个步骤
  1. 同色连通块:颜色完全相同、上下左右相连的像素归成一块。
  2. 高度场:每个像素的高度由两部分相加。一部分是离轮廓的距离,开根号后越往里越高,让整个形状鼓起来;另一部分是亮度,越亮越高。
  3. 模糊:默认 Object 设置下,高度场做三遍邻域平滑。
  4. 逐像素法线:对高度场求梯度。
  5. 块内取均值并量化:每个色块取法线均值,再吸附到 8 个方位 × 4 档倾斜上。一个色块只有一个朝向。
  6. 大块按方向分面:面积够大的色块,按法线朝上下左右分成几组,组与组之间方向差得够多,就拆成几个面。
  7. 描边吸收邻色:又暗又细的色块被当成描边,从里往外把相邻色块的法线传进去;最后两个像素以内的碎片并入邻居。

精髓在第 5 步。一个色块只有一个朝向,打出来的光只会在色块边界上变化,天然和像素网格对齐。

除了法线生成,PAL 还引入了光照蒙版的概念,通过额外增加一张蒙版图,越暗的块接收的光越少。

左:原图  右:光照蒙版
左:原图 右:光照蒙版

PAL 的效果比同类算法在像素场景下的确好上不少,不过依然存在一些局限:

  • 亮度当高度:像素图的明暗大多是画上去的光,亮的地方是朝着灯的面,高度未必更高。后面会细说。
  • 精确同色才算一块:抗锯齿、抖动和贴图纹理都会把色块打碎。作者也提醒过要用调色板干净的素材。
  • 阈值按绝对像素算:默认分面强度下,色块至少要 142 个像素才拆面。32px 的小图几乎拆不出面,96px 的大图又容易拆过头。

我的方案

光照角度估计

我主要想解决的是「亮度 ≠ 高度」这个问题:大部分素材本身为了呈现体积感就会携带高光和阴影,这部分颜色的变化被错误估计成高度就会导致法线方向错误,给本来平滑的地方产生梯度感。

PAL 在法师脸上的法线:画上去的光被当成高度,朝光一侧的法线方向错误
PAL 在法师脸上的法线:画上去的光被当成高度,朝光一侧的法线方向错误

这时候我想到平时用的主动降噪耳机,通过提取出噪声频段然后叠加上相位相反的声波实现降噪的效果。那对于图片来说,是不是也可以通过提取出光照方向实现更精准的法线估计呢?

如果知道光从哪来,就能换一种解释:亮度主要说明这个像素朝着灯。具体分两步:

  1. 亮度在高度里的权重降为原来的 1 − 影响度(默认 60%)。
  2. 算出每个像素比周围亮多少(方框均值,半径随图大小变化),按这个差值把法线往光源方向推。

光照方向怎么获取呢?最好能够自动从图片推测,再不济就加个滑杆,毕竟一般这类工具都需要人工调参才能获得更好的效果。

自动估计

轮廓是最可靠的线索:不管画的是什么,轮廓附近的表面大致朝外。所以只看离轮廓 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,就并进同一块。

精确 RGB 和 Lab 色差聚类的色块数对比
精确 RGB 和 Lab 色差聚类的色块数对比

评测用例里,骑士从 1090 块降到 191 块,宝箱从 1234 块降到 57 块。块变回了「面」,后面的分面和量化才有意义。

自适应分面阈值

  • 分面的最小面积按前景面积缩放(以 64 × 64 为基准,最多两倍),分组的支持度阈值按色块面积的平方根算,小图也能拆出面。
  • 轮廓鼓起的曲线随图变大,从平方根过渡到线性,大图的折痕方向保留得更好。

Benchmark

评测集设计

改了这么多,那必然得上评测来佐证。不过这块比较小众,找了一圈基本没有公开数据集:

  • pixel-art-normal-maps 有一万四千多对,但目标法线经过量化和轮廓处理,是风格化的法线,不是真值。
  • OpenGameArt 上有些素材包自带法线图,但法线也是用 Sprite DLight 自动生成的。

拿生成的法线当真值,测出来的只是「像不像另一个工具」。所以还是回到最笨的办法:用 3D 模型渲染成像素图,真值直接从模型里取。

8 个 KayKit 模型的输入和真值法线
8 个 KayKit 模型的输入和真值法线
  • 8 个 KayKit 的 CC0 模型:骑士、法师、游侠、骷髅战士、宝箱、探测车、水磨坊、神龛。
  • 每个模型 2 个视角、4 种原生尺寸(32、48、64、96 px),共 64 个用例。
  • 正交光栅化,每个像素做深度测试,不做抗锯齿。真值法线是可见三角形内插的顶点法线,底色取自模型贴图。
  • 输入从左侧打光(方位角 159°、仰角 29°),明暗分成五档,接近像素图的赛璐璐画法。
  • 另外渲染右、上、下、正面四个新灯位,作为换光的目标。

然后就是指标的选取了。注意我们并不追求「生成的法线 = GT 的法线」(因为对像素场景并不匹配),所以主要是选取折痕指标和角误差。最终定下了下面的指标:

指标衡量什么
折痕 AP把所有相邻像素对按预测的法线跳变排序,看真值里跳变 ≥ 30° 的「折痕」排得靠不靠前
折痕召回(方向一致)真值折痕的位置上,预测也有 ≥ 30° 的跳变,并且跳变方向一致
平均角误差预测法线和真值之间的夹角
换光 RGB MAE用预测法线把输入换到四个新灯位,和目标图逐像素比

评测结果

64 个用例上的各项得分
64 个用例上的各项得分
方法折痕 AP ↑折痕召回 % ↑误判折痕 % ↓平均角误差 ° ↓
Xprite 自动角度0.52014.671.1937.99
Xprite 手动角度(160°)0.52215.511.2037.74
Sprite Fusion0.3173.652.7637.56
SpriteLab0.2900.050.0438.74
Bump It0.3110.080.0141.38
Pixel Art Lighter 原版0.2874.754.4240.03
Laigter0.2760.430.9547.10

解释下这里看上去没那么好的指标:

  • 误判折痕:SpriteLab 和 Bump It 几乎为 0,是因为它们基本不出折痕,本身分母就小。
  • 平均角误差:由于 Xprite 和 PAL 都是追求像素场景下的打光,会对法线色块进行聚类,因此直接对比连续法线的 GT 本身就存在理论上的劣势。

返回全部文章