2D纸片人光照立体感革命:零成本法线贴图生成全攻略
这年头做2D游戏的团队谁还没几张纸片人呢角色好看归好看但一到光照环境下就露馅整张图平得像一张A4纸光源转来转去角色脸上身上一点明暗变化都没有跟场景里的3D物件完全是两个世界的东西。你要是也遇到过这种尴尬那这篇东西就是写给你的。先把这个“黑科技”拆明白零成本靠的是开源工具加几条命令或者干脆手搓像素法线贴图是图形学里用RGB通道存法线方向、骗光照做出立体感的标准做法纸片人就是你的2D精灵图。把这三件事串起来你就能让一张平平无奇的立绘在光照下拥有和3D模型一样的体积感、凹凸感而代价仅仅是多一张贴图运行时开销约等于零。这招最适合谁独立游戏开发者、素材画师、TA技术美术还有那种想给游戏加点质感但不想重新建模的团队。不用改管线、不用写复杂的Shader引擎自带的Sprite光照系统就能直接吃下这套方案。1. 先弄明白一张贴图是怎么骗过眼睛的1.1 法线贴图到底存了什么普通的颜色贴图存的是“这个像素是什么颜色”而法线贴图存的是“这个像素朝向哪个方向”。每个像素的RGB三个通道分别对应法线向量的X、Y、Z分量取值范围被压缩到0到1之间所以存进贴图里看起来就是一片蓝紫色的、花花绿绿的东西。当面片是平的法线全指向屏幕外也就是Z轴正方向换算成颜色就是RGBA值大约为(128, 128, 255)这正是法线贴图默认的底色的来源。像素颜色越接近这个值代表该点的表面越平坦颜色偏红说明该点法线向右偏偏绿说明向上偏偏蓝有时会更深说明朝屏幕外偏得更多。光照计算时引擎读取这张贴图用里面存储的法线方向替换原本平面统一的法线方向然后跟光源方向做点积运算得出该像素的受光强度。于是明明是一个平面却因为每个像素的“朝向”不同产生了不同的明暗这就是人们常说的“视觉欺骗”。1.2 2D角色用这招比3D模型更划算3D模型本身自带顶点法线法线贴图只是锦上添花增加皮肤毛孔、砖缝之类的细节。但2D精灵完全相反它原本连法线都没有全靠一张图骗人你给它配上法线贴图等于给它从无到有造了一个立体身份视觉效果的变化是天翻地覆的。面数成本3D模型增加细节需要提高网格密度而2D精灵加法线贴图三角形数量一个都不变该多少个面还是多少个面性能开销只是多采样一张纹理。制作成本3D法线贴图通常需要高模烘焙要建高模、展UV、烘焙一套流程下来人力成本很高。而2D精灵生成法线贴图只需要处理一张图甚至可以让程序自动算真正做到零成本起步。适配成本引擎自带的2D光照系统比如Unity的2D URP或者Godot的2D光照都原生支持把法线贴图应用到Sprite上不需要额外写任何渲染代码。2. 零成本生成法线贴图的三条路线我实际用下来生成2D精灵的法线贴图主要有三条路可走它们之间是互补关系不是替代关系。根据素材类型不同选的路也不同。2.1 路线一免费插件对画师最友好这里重点推荐开源工具Laigter它是我目前见过最贴合2D角色需求的免费法线贴图生成器。它内置了一个专门的Normal Map生成算法会根据图片的颜色亮度、透明度、边缘信息来推算凹凸感。操作逻辑非常简单打开软件、拖入精灵图、直接出结果不需要你懂任何贴图原理。右边有滑块控制参数主要调两个值——Height Intensity高度强度和Smoothness平滑度。前者决定凹凸的猛烈程度默认值1.0只是起步想效果明显一般加到1.5左右后者决定细节的锐利程度数值越低越能保留原画的边缘转折。它最实用的一个功能是支持按透明度分离图层。角色立绘通常有头发、皮肤、衣服、装饰这些部位的“凸起高度”天然不同头发应该比脸高装饰物应该比衣服凸。Laigter允许你根据透明通道分区域设置高度偏移让生成的法线贴图不再是“全图统一凹凸”而是“不同区域不同高度”这比插件无脑生成的效果高一个档次。另一方面塞一堆滑块自动生成的让位之后你还可以在它内置的笔刷面板里手动修补法线方向比如给鼻梁画一笔偏红的高光引导线就能让鼻子看起来真的在发光。2.2 路线二免费图像处理软件滤镜对老手最稳妥如果你用的工具是GIMP有现成的法线贴图插件可以装装完之后生成也就是点几个按钮的事。它的原理是先从原图提取高度图再对高度图做Sobel算子卷积运算求得X方向和Y方向的梯度结合Z方向固定为1最终形成法线向量。这个流程的好处是你可以高度控制最终结果。比如在PS风格的工作流里你可以直接把原图去色变成灰度图手动用加深减淡工具涂抹出你要的高度关系亮部视为凸起暗部视为凹陷然后再交给滤镜生成法线贴图。这种“先画高度图再转法线图”的做法比任何算法都更贴合你的创作意图。命令行工具也是一个选择特别是做批量处理的时候。有一款名为NormalMap的免费命令行工具支持在批处理脚本里调用参数一次处理几十张角色序列帧。虽然它主要针对照片或3D资产但对于素材规整的2D项目效果也说得过去。下面这段命令是我常用的一套参数已经反复调过多次效果稳定normalmap -i character.png -o character_n.png -s 1 -a 8 -f 0含义解读-s是缩放强度1.0是保守值不会让凹凸感太夸张-a是抗锯齿级别8倍抗锯齿能避免边缘出现狗牙-f是格式0表示输出PNG。如果你自己试的时候发现凹凸感不足把-s从1.0往上加到1.5超过2.0基本就会出现明显的光照脏斑了。2.3 路线三纯数学算法自动生成对程序最省事如果你是一个偏代码的开发者或者你想在游戏启动时动态生成法线贴图那么用代码实时计算是最高效的方案。这里会用到Sobel算子它在图像处理中的地位就跟加减乘除在数学里的地位差不多专门用来测量像素周围的明暗变化率。核心逻辑是先把原图转成灰度图作为高度图然后对于每个像素计算它在X方向和Y方向的梯度。梯度大的地方说明高度变化剧烈是边缘凹槽梯度小的地方说明表面平坦是光滑平面。把这两个梯度值映射成法线的X和Y分量Z分量则通过向量归一化保证长度固定为1就得到了一组合法的法线数据。写代码的时候倒是用不着去翻图形学教材我直接把可用的代码贴出来顺手做了注释说明。3. 从零手写一个法线贴图生成器附完整代码3.1 选型为什么用JavaScript而非C如果你只是做一次性的离线生成完全可以用Python或者Node.js脚本跑完就扔。但为了让这套方法可以直接嵌进游戏里我这里选择用JavaScript加Canvas API来做游戏运行时也可以直接复用这段逻辑。Canvas的ImageData对象提供了对像素级数据的直接访问非常适合这种逐像素操作。而且浏览器和游戏引擎中对它的支持都很完善随便一个现代游戏引擎都内置了JavaScript运行时。3.2 完整实现代码第一步读取图像像素async function loadImageToCanvas(src) { const img new Image(); img.src src; await img.decode(); const canvas document.createElement(canvas); canvas.width img.width; canvas.height img.height; const ctx canvas.getContext(2d); ctx.drawImage(img, 0, 0); const imageData ctx.getImageData(0, 0, canvas.width, canvas.height); return { canvas, imageData, width: img.width, height: img.height }; }第二步转灰度图作为高度图这一步输出的是每个像素的亮度值也就是高度值。亮度越高代表该点越凸出。如果你的原图是带透明通道的精灵图建议用Alpha值做为主高度数据RGB值做辅助不然透明区域会生成一堆没意义的高度信息这在处理角色立绘时尤其重要因为角色的边缘往往就是半透明的抗锯齿像素全按亮度转会生成一圈奇怪的白色包边。function toHeightMap(imageData) { const { data, width, height } imageData; const heights new Float32Array(width * height); for (let i 0; i width * height; i) { const r data[i * 4]; const g data[i * 4 1]; const b data[i * 4 2]; const a data[i * 4 3]; // 如果Alpha通道接近0说明是透明区域高度直接设为0 if (a 10) { heights[i] 0; } else { // 灰度值 0.299R 0.587G 0.114B这是标准亮度公式 heights[i] (0.299 * r 0.587 * g 0.114 * b) / 255; } } return heights; }第三步Sobel算子计算法线这是全流程的核心。Sobel算子用两个3x3的卷积核分别计算水平和垂直方向的梯度。水平梯度可以理解为“这个点左边比右边亮多少”垂直梯度类似。这两个值一起决定了法线在X和Y方向上各偏了多少。function generateNormalMap(heights, width, height, strength 1.0) { const normalData new Uint8ClampedArray(width * height * 4); // Sobel核 const kernelX [-1, 0, 1, -2, 0, 2, -1, 0, 1]; const kernelY [-1, -2, -1, 0, 0, 0, 1, 2, 1]; for (let y 0; y height; y) { for (let x 0; x width; x) { let gx 0; let gy 0; // 遍历3x3邻域 for (let ky -1; ky 1; ky) { for (let kx -1; kx 1; kx) { // 处理边界超出图像范围就取边缘像素值保持法线连续 const px Math.min(width - 1, Math.max(0, x kx)); const py Math.min(height - 1, Math.max(0, y ky)); const idx py * width px; const kernelIdx (ky 1) * 3 (kx 1); gx heights[idx] * kernelX[kernelIdx]; gy heights[idx] * kernelY[kernelIdx]; } } // 梯度乘强度系数 gx * strength; gy * strength; // 法线向量Z方向固定为1XYZ做归一化 const nx gx; const ny gy; const nz 1.0; const length Math.sqrt(nx * nx ny * ny nz * nz); // 把法线从[-1,1]映射到[0,255]的颜色空间 const normalX Math.round((nx / length 1) * 127.5); const normalY Math.round((ny / length 1) * 127.5); const normalZ Math.round((nz / length 1) * 127.5); const idx (y * width x) * 4; normalData[idx] normalX; normalData[idx 1] normalY; normalData[idx 2] normalZ; normalData[idx 3] 255; } } return new ImageData(normalData, width, height); }第四步把结果输出为PNG文件async function processImage(src) { const { canvas, imageData, width, height } await loadImageToCanvas(src); const heights toHeightMap(imageData); const normalImageData generateNormalMap(heights, width, height, 1.5); const outCanvas document.createElement(canvas); outCanvas.width width; outCanvas.height height; outCanvas.getContext(2d).putImageData(normalImageData, 0, 0); // 导出为Blob然后下载 outCanvas.toBlob(blob { const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download character_normal.png; a.click(); URL.revokeObjectURL(url); }); }3.3 关键参数怎么调才不翻车Strength强度参数控制凹凸幅度的强度它直接乘到梯度上。强度太低法线几乎全指向屏幕外光照下角色依然是个平板强度太高梯度值会过大归一化之后法线方向变化剧烈光照下会出现一坨坨黑色斑块俗称“油腻感”。一般来说角色立绘这种面积光滑的素材强度1.0到1.5之间比较安全。像素画风格的素材要更低一些建议0.8起步因为像素画的颗粒感本身就带着孔洞如果强度过大孔洞会被错误放大成深坑角色看起来像得了天花。Alpha通道的处理这个坑我必须单拎出来说。很多人直接拿带透明的PNG去做灰度转换结果透明区域因为RGB通道里还有残留颜色信息生成了大块错误法线最后光照一打角色边缘一圈全是奇怪的亮线。正确做法如上文代码所示透明区域直接归零强制法线指向Z轴方向。UV边界连续性关系到法线贴图在平铺素材上的表现比如地面砖块这种需要四方连续的贴图。如果你生成的法线贴图在接缝处有明显的断层光照下会出现一条明显的分割线这是因为左右边缘的梯度计算使用了补边策略而补边策略默认拿边缘像素填充导致梯度不连续。如果需要做四方连续素材你需要在计算时把图像的左右边缘卷绕起来让左边缘的邻域拿右边缘的像素来算。4. 在Unity里让纸片人“活”过来4.1 导入设置最容易被忽略的一环法线贴图生成出来只是第一步真正让它生效还需要在引擎里正确配置。Unity是目前国内2D开发者用得最顺手的引擎之一我就以它为例。把法线贴图拖进工程之后选中文档在Inspector面板里找到导入设置。这里有一个极其关键的选项Texture Type必须设为Normal Map否则Unity把它当普通颜色贴图处理法线数据会在导入时被重新压缩通道被强行改写光照计算全部出错。设置成Normal Map后Unity会自动解包法线数据把颜色空间重新映射回法线向量空间同时会多出一个Create from Grayscale的按钮这个功能相当于内置了高度图转法线图的算法以后你直接给灰度图它也能自动生成法线。4.2 Sprite的材质怎么配我踩过的坑是默认的Sprite材质是不受光的。哪怕你贴了法线贴图如果没有切换材质它照样是纸片一张。正确操作是把默认的Sprite-Lit-Default材质分配给角色的Sprite Renderer组件。这个内置材质支持法线贴图采样配合场景里的2D光源就能产生明暗变化。在URP管线下面更简单只需要给Sprite Renderer的Material属性赋一个Lit材质在Lit材质的Normal Map槽位里拖入你生成的法线贴图确保场景里有2D Point Light或者2D Spot Light这就万事俱备了光是这一步做完你的纸片人在光源旁边晃动时脸上的光影会跟随光源方向游走像真的在深度空间里一样。4.3 Normal Map的压缩陷阱法线贴图在Unity里默认会被压缩成DXT5格式正常3D场景没问题但2D精灵有一个特殊癖好它经常会被打包进图集Sprite Atlas而图集空间是有限的压缩在这个环节里经常出乱子。具体表现为角色身上出现一道一道的色带条纹特别是脸部、手臂这些光滑渐变的区域最明显。这是因为DXT5压缩对法线数据是有损的且损失的主要是高频率细节。解决办法有三个方向在Texture Import Settings里把Format调成RGBA32或者RGBA16绕开DXT5压缩。缺点是内存占用变大2D项目通常还能接受。把角色切图独立出来不放进图集单独引用原始纹理避免图集的全局压缩策略影响单张素材。生成法线贴图时刻意降低细节强度让梯度变化更平缓减少高频信息这样即使被压缩也不至于出现明显条纹。我个人最推荐的是第二种方案。毕竟一套角色素材不过几十张图各自独立引用多占不了多少内存效果却是干干净净的。5. 实战中一定要避开的几个大坑5.1 透明边缘的白色光晕问题这是2D法线贴图最容易翻车的地方十个项目八个栽在这。原因是生成法线贴图的时候角色边缘的透明像素被当成了高度为0的洼地而未透明的内部像素高度是正常值边缘产生了一个大型坡度。在光照下这个坡度形成了一个围绕角色的白色高光圈就像角色被描边了一样。避坑方法生成法线贴图前先把原图透明边缘的1-2像素裁剪掉或者用颜色填充工具扩边让高度变化集中到不透明区域内部。如果你用Laigter它自带的Auto Mask功能也能缓解这个问题。5.2 法线贴图的Y轴方向翻转这属于新手教科书级别的大坑。不同的工具和引擎对法线贴图坐标系的约定不一样。图像处理的Y轴朝下而GPU纹理坐标的Y轴朝上一旦搞反光照效果就会整体颠倒——原本凸起的地方变成凹陷角色脸上会出现诡异的光斑。如果你的角色在光照下看起来像月球表面的陨石坑先别急着怀疑生成算法检查一下贴图导入设置里有没有勾选Flip Green Channel。Unity里这个开关在Normal Map导入设置的最下面。在这里勾选或者把法线贴图在图像处理软件里对调G通道并反转问题就解决了。5.3 光照太强导致角色“油腻”2D角色是平面素材场景光照强度对它的影响和3D模型完全不同。一束强度为2的平行光打在3D模型上看起来是正常的阳光但打在Sprite上明暗对比会被法线贴图放大得十分夸张整个角色像刷了层清漆。避坑方法给角色单独分配一个2D Light组件把光照强度降到场景光的一半以下或者在材质球上叠加一个Fade透明度。也可以在法线贴图生成时压低Strength系数让法线的整体偏移更保守一些光照反应更温和。5.4 序列帧角色动画导致的光照跳变用序列帧动画做角色时通常会遇到一个闹心的问题角色在原地左右转向或者做攻击动画时明明是一张平稳的图光照下却出现忽明忽暗的现象。这是因为不同帧的精灵图在生成法线贴图时产生的整体法线方向分布不一致。有些帧的头部稍微偏左感知到的光照就偏右一些几帧连续播放时明暗变化显得不稳定。避坑方法生成法线贴图时对所有帧使用完全相同的参数设置Laigter支持批量处理整个文件夹这能保证帧间一致性有最低下限。但更可靠的办法是在生成完成后对每一帧的法线贴图做主方向校正把整个贴图的法线均值强制对齐到固定值上这需要用上文代码的力量扩展一个校准函数。具体做法是统计整张法线贴图所有像素的X分量平均值如果整体偏红就在像素级把所有颜色的红色通道减去这个偏移量重新归一化就能保证不同帧之间的光照倾向统一。6. 我个人的一些实操心得6.1 不要在法线贴图上花太大精力法线贴图的本质是为光照系统提供基础数据它不应该比原画更精细AI生成算法也好、手绘也好够用就好。我曾经为了追求极致细节花了一整天手工调整一张角色的法线贴图把每一缕头发都画上了精确的法线方向结果放进游戏里光照一打因为场景光源不够丰富这些细节根本看不出来。白白浪费了时间。反而那些参数简单、整体统一性的法线贴图在真实游戏环境里的表现更稳定也更耐看。6.2 用颜色分区偷懒画师朋友可能听我说过手工画法线贴图其实没有想象中那么难关键是记住一个技巧先定角色各部位的高度关系再按高度关系用灰度图把整个角色画出来最后转法线。这是一种降维打击的思维。实际上如果你时间有限连灰度图都不想画还有一招终极偷懒方案在角色原画上直接按区域填充纯色皮肤区域填中灰色衣服区域填深灰色金属饰品区域填白色头发区域填浅灰色。不需要任何渐变只要色块然后丢给NormalMap滤镜自动转。出来的效果虽然不如精细手绘的细节丰富但整体体积感已经完全出来了并且因为色块足够整齐光照方向切换时也不会出现奇怪的噪点。这在概念验证阶段特别好用先跑通光照流程确认整体方向感没问题再把时间和精力花在精细打磨上。6.3 法线贴图的“极限”是风格化很多人在接触这套技术后都会陷入一个误区法线贴图越接近现实越好越细节越牛。但2D游戏的魅力恰恰在于它天然的平面感和风格化表达。法线贴图在这里的角色不是让2D变成3D而是让2D多一点点纵深错觉该保持的平面感一定要保持。一个很典型的设计选择日式赛璐璐风格的角色脸部通常不需要太强烈的体积感保持大面积平涂色反而是视觉特征之一。如果你硬要把脸画满法线细节光照下会像一只装了水的气球还有鱼眼镜头般的肿胀感。正确的做法是不给脸部生成法线贴图保持原画的绝对平坦只让头发和衣服有轻微的体积感这种平面和立体结合的错位感往往是高级感的来源。我在某个项目里实验过两个同样画风的角色一个全图法线一个只有衣褶和头发有法线后者在画面质感和风格识别度上高出一个档次而前者的全图凹凸反而看起来廉价。所以激活法线贴图之余也给自己留一点“不做”的空间知道什么时候该住手效果往往比一味地堆细节更上乘。