资讯详情

资讯详情

MaxScript批量翻转法线贴图:完美解决DX/GL通道反向问题

做游戏资源优化时遇到过一个特别头疼的需求手头几百个低模的材质球上全是法线贴图美术在Max里看模型一切正常一进引擎光照方向全反了高光区域像凹陷暗部反而凸出来。最初美术组的方案是拿Photoshop一张张反相绿通道遇到DDS还得先转格式折腾两天才处理了三分之一中间还压坏了好几张贴图。后来我把这套流程全部搬进了MaxScript从读取法线贴图、翻转通道、保存输出到批量跑完整个资源目录一次性解决再也没让美术手动碰过这些贴图。这篇文章就把这套方法完整拆开讲。需要提前说明的是MaxScript修改法线映射不是一个单一操作而是分两条线走一是直接操作法线贴图像素数据二是配合修改模型几何法线。只改贴图、模型法线还是反的导出后照样出问题。下面按我实际项目里排查和处理问题的顺序来写你跟着走一遍就能直接用在项目上。1. 从光照全反了到MaxScript法线映射问题的三类真实场景先说需求来源。法线映射出问题大多数时候不是渲染管线配错了而是贴图数据本身跟目标引擎约定的空间坐标系不一致。1.1 最常见的批量事故不同项目之间资源互导我在公司同时维护过好几个项目每个项目的法线贴图约定都不一样。有的项目用的是Max自带Normal Bump输出有的项目美术喜欢从Substance Painter直接导出还有老项目用的却是某个自制引擎的烘焙工具。这些工具输出的法线贴图在切线空间里对绿色通道的定义完全不同。美术在Max里看所有贴图都是正常的因为3ds Max自己的视图和扫描线渲染器在采样法线贴图时用的是一套跟引擎不同的约定。同一条法线在Max的约定里写成(0.5, 0.5, 1.0)到了引擎的可能就是(0.5, 0.5, 1.0)的反方向渲染出来自然全是反的。遇到这种跨项目资源迁移人工逐张检查不现实最有效率的方式就是用脚本批量判断并统一通道方向。MaxScript在这里的价值是可以在不打开Photoshop的前提下直接从图像文件层处理数据。1.2 烘焙结果在引擎里反向但Max里看起来正常这个场景更隐蔽。美术从Max的Render to Texture烘焙了一张法线贴图在Max里预览一切正确但晚上引擎后模型表面的法线方向全部反了。原因通常不是贴图导出错而是烘焙时选择的切线空间坐标系跟引擎期望的不一致。比如某些烘焙工具会问你是针对DirectX还是OpenGL生成法线贴图选错一个就全反。这种错误出现在单个模型上还好说重新烘焙一张就行但如果是一大批已经制作完毕、贴图也已经画好细节的模型重新烘焙会损失大量手工调整的细节。这时候用脚本直接翻转法线贴图的绿色通道保留颜色细节不动是最稳妥的修复方式。1.3 手头有几百张贴图需要换算成引擎标准游戏项目提交前技术美术经常要对所有角色的法线贴图做一次统一规范化。比方说把角色、武器、场景地编的所有法线贴图从OpenGL风格转换成DirectX风格或者反过来。这种批量操作用PS动作也能做但PS动作在处理大量文件时不够灵活没法根据每张图的位深、通道数量做条件判断也没法把处理好的文件按原目录结构输出去。MaxScript写一个函数就能解决一次跑几百张稳定且可复现。2. 动手前必须搞懂法线贴图各通道的数学含义与DX/GL差异脚本本身不复杂但如果你不理解法线贴图各通道的数学含义很容易写错方向把一个本来只有绿通道反的贴图改成红、蓝通道也乱了。这一节先把概念理清楚后面代码才不会踩坑。2.1 切线空间法线贴图里RGB各代表什么法线贴图存储的并不是世界空间里的法线而是切线空间Tangent Space里的扰动向量。所谓切线空间是建立在模型表面每个点上的一个局部坐标系。它有三个轴T轴Tangent切线方向、B轴Bitangent副切线方向、N轴Normal法线方向。这个局部坐标系的三个轴分别对应贴图存进RGB通道时的三个分量。R通道存的是T分量的偏移量G通道存的是B分量的偏移量B通道存的是N分量的偏移量。每个分量的原始值域是[-1,1]但图像文件的通道只能存[0,1]或8bit下的[0,255]所以存储以前加了一个映射存储值 原始值 * 0.5 0.5。这个映射关系直接决定了一个重要结论反向一个法线分量在像素层面不是简单地看成黑色的反色而是用1 - 原始值或者255 - 原始值。因为原始值从-1到1映射到0到1之后取相反数就变成了1减去原值。这一点在后面G通道翻转的代码里会直接用上。2.2 DirectX与OpenGL的绿色通道差异为什么专提绿色通道因为DX和GL对副切线方向的约定正好相反。DirectX约定法线贴图的G通道副切线方向指向上方。换句话说绿色通道的数值越大法线越偏向贴图坐标的V轴正向。OpenGL约定法线贴图的G通道指向下方与DX正好完全相反。于是一张为DX写的法线贴图丢到GL环境里所有法线在副切线方向上的分量都会反号模型表面看起来就是凹凸完全反转。反过来说也一样。在实际表现上如果只是翻转了副切线的方向材质表面会出现凸变凹、凹变凸的视觉效果尤其是圆滑表面上的细节特别明显。如果连R通道也反了那说明是左右手坐标系转换出现了问题处理思路是翻转红通道而不是绿通道。大多数情况下你只需要记得一条规则从DX切空间转GL切空间翻转G通道即可从GL转DX同样是翻转G通道。这是最常用的轴转换。2.3 为什么法线贴图中心像素大部分是(128,128,255)如果你把一张法线贴图放大看会发现大片区域的像素颜色偏向淡蓝色RGB大致在(128, 128, 255)附近。这不奇怪。平坦表面的法线在切线空间里就是(0,0,1)经过映射后就是(0.5,0.5,1)也就是8bit下的(128,128,255)。如果某张贴图大面积像素不是这个分布比如B通道整体偏低那说明这张图极有可能不是切线空间法线贴图而是世界空间法线贴图或模型空间法线贴图。空间不同修改方式完全不同脚本就不能乱套。这个判断步骤虽然简单但能避免你把一张世界空间法线贴图当切线空间贴图处理结果越修越乱。3. 核心脚本用MaxScript逐像素重写法线贴图环境准备好了概念也清晰了这章直接上代码。下面这套脚本我平时处理资源就在用你可以直接复制到MaxScript Editor里跑。3.1 处理前先做格式与色彩空间准备脚本之前先花两分钟处理两张准备工作。一是用原图副本测试。openBitmap在打开文件时会占用文件句柄脚本跑完后如果文件还被占用save会失败或覆盖不了原文件。我习惯的处理方式是指定输出到新路径不直接覆盖源文件。这样即使脚本写到一半崩了源文件也是完好的。二是色彩空间问题。法线贴图是数据贴图不应该按颜色贴图那样做sRGB伽马校正。在3ds Max的Color Management设置里如果你把文件当作sRGB纹理导入脚本读出来的像素值是已经被伽马处理过的此时做255 - x这类操作得到的结果在数值上是对的但保存回去后如果又被当sRGB读整体会出现偏色或发灰。处理法线贴图之前建议先在偏好设置里把相关文件的解释方式设为raw数据或者Gamma 1.0。如果项目用的是线性工作流这步尤其重要。3.2 单张法线贴图翻转G通道的基础函数下面是基础函数。这个函数打开一张图逐行读取像素翻转G通道再写回并保存为新文件。fn flipNormalMapGChannel inPath outPath ( local bmp openBitmap inPath if bmp undefined do ( print (无法打开文件: inPath) return false ) -- 关键判断8bit整数位图返回0-25516bit/浮点位图返回0.0-1.0 local sample (getPixels bmp [0,0] 1)[1].y local rangeMax if sample 1.5 then 255.0 else 1.0 local w bmp.width local h bmp.height local line for y 0 to h-1 do ( line getPixels bmp [0,y] w for x 1 to line.count do ( line[x].y rangeMax - line[x].y ) setPixels bmp [0,y] line ) -- 保存并释放句柄 save bmp outPath close bmp true ) -- 调用示例 flipNormalMapGChannel \ D:/GameAssets/char_hero_norm.png \ D:/GameAssets/char_hero_norm_GL.png函数里两处细节我需要特别说明。第一getPixels返回的数组下标从1开始这一点很容易写错。你传入的坐标是[0,y]但返回的数组里第一个元素是line[1]不是line[0]。用for x 1 to line.count才能正确遍历。用for x 0 to line.count - 1就会越界或者漏掉一个像素。第二getPixels按位图位深返回不同数据类型。8bit整数位图返回0到255的整数16bit或浮点位图返回0.0到1.0的浮点数。脚本里不能写死255 - x否则处理EXR或HDR格式时会把数据翻成一个奇怪的值。上面代码通过第一个像素的G分量值来判断当前位图是整数还是浮点这是一种实用的容错写法。3.3 顺便处理16bit浮点贴图和多格式支持上面函数用了rangeMax动态判断所以16bit浮点格式一样能跑。需要注意的只是save行为。MaxScript的save会根据输出文件的后缀决定保存格式例如输出outPath以.dds结尾它就会尝试以DDS格式保存如果当前脚本环境没有安装对应编解码器保存就会失败。稳妥的做法是全部输出为.png或.tga后续再交给专门的压缩工具转换DDS。另外如果你处理的贴图带Alpha通道比如引擎里用Alpha存光滑度或金属度那么setPixels只改RGB通道Alpha通道会被保留。实际上getPixels返回的Point3类型里没有Alpha脚本根本碰不到它所以不会污染Alpha数据。如果你的Alpha通道之前就坏了那是另一个问题处理法线通道时不用管。3.4 批量处理整个目录的完整脚本单张处理只是第一步实际项目里很少有人会一张张调用函数。下面这段代码遍历指定目录下的所有PNG和TGA输出到后缀带_GL的新文件。fn batchFlipNormalMaps folder ( local files getFiles (folder *.png) join files (getFiles (folder *.tga)) join files (getFiles (folder *.jpg)) local successCount 0 for f in files do ( local outFile replace f \ (getFilenameFile f) \ ((getFilenameFile f) _GL) if (flipNormalMapGChannel f outFile) then successCount 1 ) format 处理完成% / %\n successCount files.count ) -- 修改成你自己的目录 batchFlipNormalMaps D:/GameAssets/characters/注意这里有一个排序问题getFiles返回的文件顺序在Windows下不一定稳定如果你有几百张图建议先对files数组做一次排序方便对照日志查看是哪些文件处理失败。-- 对文件列表排序便于输出日志时定位 fn sortByFileName a b ( local nameA getFilenameFile a local nameB getFilenameFile b if nameA nameB then -1 else if nameA nameB then 1 else 0 ) qsort files sortByFileName3.5 如果需要处理红蓝通道交换或更大范围转换上面只翻了G通道。某些情况下左右手坐标系转换还会影响R通道甚至B通道。常见需求是把法线贴图从DX风格转成GL风格只翻G通道就够了。但如果目标引擎的切线空间定义更特殊比如B通道也需要反向那就在同一个循环里加上line[x].x rangeMax - line[x].x -- 翻转R通道 line[x].z rangeMax - line[x].z -- 翻转B通道不过绝大多数项目不要动R和B除非你已经确认过目标引擎的切线空间确实是相反定义的。改之前拿一小块测试资源做对照比盲改要稳妥得多。4. 几何层法线只修贴图不修模型照样白搭处理完贴图像素并不代表工作结束。渲染时模型表面的最终法线是几何法线经过插值后再叠加法线贴图采样值的结果。如果几何法线本身就是反的贴图再怎么翻也只是把问题挪到另一个方向。4.1 几何法线方向异常的判断方法怎么判断几何法线有问题在Max里看模型表面如果整体呈暗灰色部分面出现透视的黑色区域多半就是法线方向不对。另一个更明确的判断方式是开启阴影模式阴影面混乱的基本是法线翻转。如果你的模型在Max里看起来就发暗发黑但贴图是正常的那就得先处理几何法线。这种资源常见于从旧引擎导出的模型顶点法线数据在导出时就已经损坏了。4.2 用Normal Modifier批量翻转几何法线MaxScript处理几何法线最简单的方式是给对象加一个Normal Modifier设置flip和unify参数。Normal Modifier能对所有源法线做一个整体翻转不需要进入每个顶点的法线层级去操作对批量处理来说非常高效。fn flipGeometryNormals obj ( local nm NormalModifier() nm.unify true -- 统一法线方向 nm.flip true -- 整体翻转 addModifier obj nm ) -- 对当前选中对象统一处理 for obj in selection do flipGeometryNormals objunify和flip这两个参数要分清unify是让所有法线朝一个方向统一适合面法线混乱的模型flip是把所有法线反向。如果你的模型只是法线方向反了不需要unify光用flip就行。如果模型本身面朝向不一先用unify再决定要不要flip。加完Normal Modifier后如果后续导出流程不识别修改器可以在导出前把模型转成可编辑多边形烘焙掉convertToPoly obj这一步会在对象上保留当前法线结果然后移除修改器。注意convertToPoly会把整个堆栈塌陷如果模型上还有其他重要修改器执行前先存个档。4.3 Edit Normals的高级修正只翻转指定法线如果只翻转部分顶点法线Normal Modifier就做不到了。这时候要考虑Edit Normals修改器。它的脚本接口比较绕需要操作editNormals管道里的法线数据。我平时在批量流程里很少用它因为管道对象访问不稳定也容易拖慢处理速度。只有当个别模型存在局部法线异常时才手动打开Edit Normals修。在MaxScript里给对象加Edit Normals修改器很简单local en Edit_Normals() addModifier obj en但进一步操作特定法线需要进入mod.getNormal这类接口不同Max版本之间属性变化较大写起来费劲这里就不展开了。如果你的业务场景必须做局部法线修正建议先对目标Max版本跑一遍showProperties Edit_Normals()确认可用属性后再动手不要照老版本的文档硬套。4.4 UV镜像对法线映射的隐性问题这里要补一个容易被忽略的坑法线贴图采样效果跟UV的镜像和旋转有直接关系。如果模型某个UV岛是水平镜像的切线空间的T轴方向就会反转法线贴图采样后法线方向同样会受影响。这种问题从贴图上看不出来只能在模型上看出光照不连续。MaxScript可以检查UV岛的手性。方法是遍历每个面的贴图坐标计算纹理坐标系下的有向面积也就是叉积的Z分量。为负数的面就可能是镜像UV。具体实现虽然不复杂但涉及网格面序和拓扑遍历代码较长这里提一下思路。实际项目里遇到光照不连续先检查UV镜像比反复翻转贴图通道省事得多。这块优化可以单独开一篇讲此处先不铺开。5. 实测避坑色彩空间、压缩格式与性能优化的经验最后一章把实际跑批处理踩过的坑集中说一下。这些坑在单张测试时基本遇不到一上几百张的批处理就全部冒出来。5.1 色彩空间问题会让通道翻转结果偏移前面说过法线贴图应该按线性数据Gamma 1.0处理不能按sRGB解释。实际操作过程中很多贴图是从Substance Painter或Photoshop导出的PNG文件头里带的色彩空间标记可能是sRGB。你在Max的Bitmap Preferences里看它可能是sRGB但如果脚本直接按数值翻转得到的结果在数值上没问题放进引擎里却会明显偏色。处理前先在Color Management设置里把该文件类型设为raw或者处理完后用图像软件验证一下关键像素值。验证方法很简单翻转后原本中心区域(128,128,255)的像素应该变为(128,127,255)或(128,128,255)。如果出现(128,64,255)说明处理错了通道或者色彩空间完全不对。5.2 8bit贴图翻转后的精度损失8bit的PNG或JPG只能存256个灰阶。翻转G通道255 - x虽然是无损的数值运算但JPG本身是有损压缩每处理一次再保存为JPG都会再次损失精度。如果资源管线最终要的是DDS我强烈建议工作流里保留一份16bit或32bit的原始无损格式PNG/TIFF脚本处理用无损文件输出也是无损PNG最后一步再压缩成DDS。这样压出来的法线贴图细节最多黑点、条纹最少。如果在DDS上直接翻G通道由于DXT5压缩算法是基于块的有损压缩翻转后压缩块内数据重新编码原本平滑的法线过渡区域可能出现4x4像素块状噪点表面粗糙度高的位置尤其明显。5.3 DDS压缩贴图的隐藏坑openBitmap读出来已经是被解压的数据3ds Max自带的DDS读取支持有限对DXT1、DXT5这类GPU压缩格式的处理方式是读取时解压成RGBA保存时不一定能压缩回去。也就是说你用openBitmap打开一张DXT5的DDS处理完save回去很可能得到一张未压缩或存储格式不同的DDS体积和精度都和原图不一样。这个坑我踩过不止一次。后来定的规矩是所有需要脚本处理的法线贴图在进入脚本管线前先统一转换成PNG/TGA脚本只负责像素逻辑输出后再用专门的压缩工具转回引擎需要的DDS格式。这样脚本和压缩工具各管一段互不干扰。5.4 性能优化从逐像素到逐行再到分块处理如果你直接对每个像素调用getPixels4K贴图跑起来会非常慢每张可能要十几分钟。把循环切成逐行读取后就快多了一次读取整行像素到数组内存里处理完再整行写回。上面给的代码就是逐行处理。更大的贴图比如8K的UDIM贴图整行数组也可能比较大。这时可以进一步分块每次读64行处理完写回再读下一批。类似一个滑动窗口local chunkSize 64 for yStart 0 to h-1 by chunkSize do ( local yEnd (yStart chunkSize - 1) if yEnd h do yEnd h - 1 local readHeight yEnd - yStart 1 for yy 0 to readHeight-1 do ( line getPixels bmp [0, yStart yy] w for x 1 to line.count do ( line[x].y rangeMax - line[x].y ) setPixels bmp [0, yStart yy] line ) )这种分块方式还能避免Max在大图处理时的内存尖峰。如果你跑批处理时发现Max占用了1到2GB内存基本就是单次读取数据量太大分块后内存占用会明显下降。5.5 批处理完的文件名和后缀策略最后一条经验输出文件名不要覆盖源文件。我用固定后缀_GL或_DX来区分通道方向。这样做的好处是同一张贴图在项目里可以同时保留两个版本哪个方向正确就用哪个。如果直接覆盖源文件万一转换方向判断错了重新恢复源文件很麻烦。如果你要处理的是整个项目目录建议输出到单独的转换文件夹例如D:/GameAssets/processed/处理完由技术美术抽查几张确认方向后再统一覆盖进游戏资源目录。这个流程虽然多了一步拷贝但能避免批量操作失误把整批资源搞坏。实际上我后来把这套脚本搭成了一个小工具面板半自动处理选目录、选转换类型GL转DX还是DX转GL、勾选是否同时翻转几何法线点一下按钮就批量跑。工作流固定下来之后新项目的法线贴图方向问题几乎没再需要美术手工返工过。核心逻辑其实就是这篇文章里这几个函数最大的价值就是节省了那些重复劳动的时间。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →