开发Unity项目时,天天和美术资源打交道,免不了碰上“这一批图片都要处理一下”的需求。尤其是像素级的修改:UI图标统一加一圈描边、角色立绘的白边要批量抠掉、凑齐一整套序列帧图片做像素化风格化。一张一张交给Photoshop用动作批处理倒是也行,但图一多、格式一杂,半路就断,而且处理完之后还得手动调整导入参数,流程极其磨人。我这几年的经验是,与其来回点鼠标,不如直接在Unity里写一套批量处理图片像素的工具,把滤镜逻辑、导入设置、格式转换一次性解决。这套思路在多个模拟项目里验证过,能处理几十张到上千张图片,这篇就把完整方案拆开讲清楚。1. 工具立项:什么情况需要自己写一套图片像素批处理1.1 从三个真实需求说起先说几个我在实际开发中反复踩过、最后催生出这套工具的场景。第一个是批量去白边。项目里有一批素材是从设计稿直接导出的角色立绘,每张图的角色边缘都带着一圈半透明的白色光晕,放到深色背景上非常明显。用手工一张张处理,先不说要数几十张图,光是“边缘羽化到什么程度”这个标准就很难统一。用脚本处理就很简单:遍历像素,把满足“高亮度+高不透明明度”条件的像素转成完全透明,再对边缘做一层柔化,效果统一且可重复。第二个是批量像素化。玩法侧需要一个“复古像素”版本的角色贴图,原图全是高清立绘,美术没有时间重绘。这时候脚本派上用场:按固定块大小做降采样,取每个块的平均色回填,把整张图变成马赛克风格。换个参数就能控制像素颗粒大小,从8x8到32x32各出一版,美术看了也能快速定方向。第三个是透明通道修复。从某些渠道拿到的图集,透明边缘经常带一圈“黑边”,原因是原图的RGB通道在透明区域里是黑色,半透明边缘处RGB和Alpha混合后产生了暗边。修复思路是把半透明像素的RGB往白色方向拉伸,或者直接把RGB改为不依赖边缘的预乘结果。这种批量修复靠肉眼在Photoshop里找出每一处,基本不现实。1.2 为什么不用Photoshop批处理而要在Unity里写很多人第一反应是:图片处理不应该用Photoshop的Action功能吗?确实,简单需求用PS动作没问题,但有几个瓶颈在项目里很致命。一是动作脚本对“透明通道”的处理笨重。PS的动作录制做不到“遍历每个像素并判断其颜色再决定要不要改”,它更多是录制菜单操作。透明边缘的白边、黑边修复,本质是像素级逻辑判断,能录成动作的概率很低。二是导入参数脱节。PS处理完图片后,还得回到Unity里重新设置纹理格式、是否可读写、压缩类型。如果处理完的图片有透明通道变化,旧的导入设置还可能导致显示效果不对。在Unity脚本里处理,可以在保存图片的同时顺手改掉导入器配置,整个流程闭环。三是批量规模和数据管理。PS的批处理对上千张图的目录结构管理并不顺手,脚步一多还容易漏。用Unity编辑器扩展写一个独立窗口,把文件夹拖进去,自动列出图片、执行算法、输出统计日志,整个过程可控可撤销可重跑。1.3 这套工具的设计目标基于上面这些需求,我给工具的定位是“一个可扩展的像素处理工作台”。核心设计目标有四条:批量加载:支持选中文件夹或者拖入若干图片,自动列出所有纹理资源。统一滤镜:把去边、像素化、调色这类算法封装成独立模块,互不干扰。安全保存:处理前自动备份,处理中记录失败项,处理后给完整统计。管线衔接:处理完直接调整导入设置,还原到可用状态,不用再手动碰导入面板。2. 原理与前置条件:读懂Unity的纹理数据流2.1 Texture2D与像素数组的关系Unity里的Texture2D本质是一块显存/内存中的图像数据,它不会直接把每个像素暴露给开发者操作。要读取像素,必须调用GetPixels或者GetPixels32这两类接口。GetPixels返回Color[],每个Color分量是float,好处是精度高,坏处是体积大、转换慢。一张1024x1024的图,Color[]要占16MB左右的内存,遍历起来还有float到byte的转换开销。GetPixels32返回Color32[],每个分量是byte,四个分量正好对应RGBA,内存占用只有Color[]的四分之一,速度也快得多。做像素批处理,我基本只推荐GetPixels32。理解了读取,修改就顺理成章。拿到数组后,按索引算像素坐标:第y行第x列的像素下标是y*width+x。因为Unity纹理的坐标原点在左下角,而文件流(PNG/JPEG)的坐标原点在左上角,如果没有特殊需求,单纯做整图滤镜不需要关心翻转问题,但如果你要对某个具体坐标做局部操作,这个差异就会冒出来。2.2 纹理压缩格式与可读写开关是最大的坑Unity对纹理有完整的导入管线,默认情况下很多图片导入后被压缩成ASTC、ETC、PVRTC这类GPU压缩格式。直接对压缩纹理调用GetPixels32会直接报错,错误信息一般是“Texture 'xxx' is not readable”。原因有两个层面:压缩格式本身不支持随机读取像素,同时导入设置里的Read/Write选项默认是关闭的。解决方案有三种:在导入设置里勾选Read/Write Enabled,并把格式改成RGBA32。优点是省事,缺点是所有纹理都会变大一倍,运行时内存也会涨。使用Texture2D.LoadImage从原始文件字节流重新创建纹理。这个接口会忽略原有压缩格式,内部强制转成RGBA32,且创建出的纹理默认可读。缺点是只支持PNG和JPEG源文件。在代码里修改TextureImporter配置,SaveAndReimport后再加载。适合希望处理完之后还保持原有管线设置的场景。我比较推荐的组合是:创建新Texture2D,通过File.ReadAllBytes配合LoadImage加载原文件做像素操作,最后用EncodeToPNG写回。这样不会污染原有导入管线,内存占用也可控。2.3 编辑器脚本的框架搭建这套工具以编辑器扩展的形式存在,不参与运行时逻辑。核心是三个部分:菜单入口、独立窗口、算法模块。菜单入口用MenuItem特性挂在主菜单栏,比如“Tools/Pixel Batch Processor”。独立窗口继承EditorWindow,在里面放路径选择、参数配置、执行按钮。算法模块是最需要花心思的部分:因为图片数量大、参数可调,我会把“遍历像素”和“修改像素”分离开,做成类似滤镜管线的方式。public interface ITextureFilter { Color32[] Process(Color32[] pixels, int width, int height, FilterParams param); }每个滤镜实现这个接口,BatchProcessor只负责按顺序调用。这样后续加新风味滤镜、新算法模块,完全不用动批量框架本身。3. 核心代码:批量读取与像素遍历的实现3.1 批量加载文件夹内所有纹理批量处理的第一步是把“哪几张图要处理”确定下来。我采用AssetDatabase.FindAssets搭配“t:Texture2D”类型过滤,传入目标文件夹路径,拿到所有纹理的GUID,再转成资产路径并加载。这样能保证拿到的是工程内真实存在、已经过导入管线的资源。string[] guids = AssetDatabase.FindAssets("t:Texture2D", new string[] { folderPath }); foreach (string guid in guids) { string assetPath = AssetDatabase.GUIDToAssetPath(guid); Texture2D tex = AssetDatabase.LoadAssetAtPath<Texture2D>(assetPath); // 这里做类型、读权限、格式的检查 }这里有个很容易忽略的点:FindAssets结果里会包含Sprite、SpriteAtlas等继承自Texture2D的资产,还有可能把图集里的子图也算进来。进队列之前,最好判断一下资产路径的后缀名是否为.png、.jpg或.jpeg,否则会出现处理到一半发现“这个文件不是直接图片”的尴尬情况。3.2 读取像素与快速遍历的写法拿到Texture2D引用后,用GetPixels32取像素数组。但要注意,如果原纹理的压缩格式不是RGBA32,直接取会报错。稳妥做法是先把原图字节读出来,通过LoadImage重新解析成可读纹理,再取像素。byte[] rawBytes = File.ReadAllBytes(assetPath); Texture2D tempTex = new Texture2D(2, 2, TextureFormat.RGBA32, false); if (!tempTex.LoadImage(rawBytes)) { Debug.LogError("读取失败: " + assetPath); continue; } Color32[] pixels = tempTex.GetPixels32(0); int width = tempTex.width; int height = tempTex.height;随后遍历像素。这里推荐用一维数组从头到尾扫,不要用双重循环。原因是索引计算更简单,缓存命中率也更高。双重循环里每行都要重新算y*width,在超大图上会平添不必要的整数乘法开销。for (int index = 0; index < pixels.Length; index++) { Color32 c = pixels[index]; // 在这里无差别遍历所有像素,再按滤镜需求修改 pixels[index] = c; }如果你的滤镜逻辑需要知道当前像素的坐标,用先算好y再算x的方式:for (int y = 0; y < height; y++) { int rowOffset = y * width; for (int x = 0; x < width; x++) { int index = rowOffset + x; // 此时 x = index % width, y = index / width } }3.3 把处理结果写回纹理并保存像素数组处理完之后,要分两条路保存。如果只是想在编辑器里预览效果,用SetPixels32配合Apply即可;如果要落地成文件,就得EncodeToPNG转字节再写盘。tempTex.SetPixels32(0, pixels); byte[] outPng = tempTex.EncodeToPNG(); string savePath = targetFolder + "/" + fileName + "_processed.png"; File.WriteAllBytes(savePath, outPng);EncodeToPNG只能输出PNG格式,好处是无损且支持透明通道。JPEG格式不支持透明通道,像素批处理场景下基本不考虑。写完后必须调AssetDatabase.ImportAsset刷新资源数据库。这个步骤经常被忽略,导致保存的PNG不会立刻出现在工程里,需要手动刷新窗口。AssetDatabase.ImportAsset(savePath);如果要追求流程闭环,在ImportAsset之前顺便改掉导入参数:TextureImporter importer = (TextureImporter)TextureImporter.GetAtPath(savePath); importer.textureType = TextureImporterType.Sprite; importer.textureCompression = TextureImporterCompression.Uncompressed; importer.SaveAndReimport();4. 三个实际能直接用的滤镜功能模块4.1 批量去除白边与透明化处理去除白边是处理立绘和UI图标时最高频的需求。原理很简单:检测像素RGB是否都接近白,同时Alpha足够高,判定为“残留白边”,把这部分像素的Alpha改成0。但直接一刀切会让边缘出现锯齿,所以算法要分两步。第一步是全图扫描,找出“需要完全变透明”的像素。第二步是羽化:对透明区域的边缘做一次膨胀处理,把边缘像素的Alpha按距离衰减。private Color32[] RemoveWhiteEdge(Color32[] src, int w, int h, float threshold) { Color32[] result = new Color32[src.Length]; for (int i = 0; i < src.Length; i++) { Color32 c = src[i]; bool isWhite = c.r > threshold * 255 && c.g > threshold * 255 && c.b > threshold * 255 && c.a > 128; if (isWhite) { c.a = 0; } result[i] = c; } return result; }实际项目里,阈值参数一般给0.85到0.95之间。阈值太大会漏掉淡白色边缘,太小会把皮肤的高光区域也删掉。处理完透明化之后,建议再做一次简单的边缘检测:如果一个像素是透明、相邻像素是不透明,就把这个像素做50%的Alpha。这样整个边缘看起来是渐变消失的,不会突然断掉。4.2 批量像素化风格化处理像素化这个效果的核心是“均匀分块+块内降采样”。把原图分成若干个方块,每个方块内所有像素的颜色取平均,然后用这个平均色填充整个方块。块大小决定了像素颗粒的粗细,8代表8x8像素一块,图面小一点的话16甚至32都能做出明显的复古感。private void ApplyPixelize(Color32[] src, int w, int h, int blockSize, Color32[] dst) { for (int blockY = 0; blockY < h; blockY += blockSize) { for (int blockX = 0; blockX < w; blockX += blockSize) { Color32 avg = AverageBlock(src, w, h, blockX, blockY, blockSize); FillBlock(dst, w, h, blockX, blockY, blockSize, avg); } } }取平均色时要注意Alpha通道的处理。如果Alpha不一致,直接平均会导致半透明区域混入奇怪的RGB值。我的做法是只对Alpha大于0的像素做RGB平均,并把该块Alpha设为255;完全透明的块直接设成全透明黑色。这样输出的像素风图在叠加到背景上时不会出现脏色块。4.3 批量颜色替换与色调调整颜色替换不一定是像素级匹配,更实用的是基于色调范围做替换。先把RGB转成HSV,判断色相是否落在目标区间内,再替换成新颜色的同时保留原来的饱和度和明度。这样处理人物衣服换色、图标配色切换特别方便。private Color32 ReplaceHue(Color32 src, float targetHue, float hueRange, Color32 newColor) { // 把src转成HSV,取H分量 // 如果H在 targetHue ± hueRange 内,则用newColor替换 // 保留原明度V,必要时调整饱和度S }色调调整则更简单:每个像素的RGB按比例缩放。想偏暖就R乘1.1、B乘0.9;想提亮就整体加一个固定值。因为Color32是byte结构,运算时先转int,做完后Clamp到0-255再回写,防止溢出。5. 导入设置、导出命名与资源管线衔接5.1 处理前后自动调整导入参数处理完图片后,如果新输出的图片仍然沿用老的导入配置,很可能看不到预期效果。例如原图是压缩格式,新图改成RGBA32后体积变大,压缩参数不变,Unity会在导入时再次压缩,导致透明边缘被压出色斑。我的做法是在保存后直接改TextureImporter:TextureImporter importer = (TextureImporter)TextureImporter.GetAtPath(savePath); importer.isReadable = true; importer.textureCompression = TextureImporterCompression.Uncompressed; importer.filterMode = FilterMode.Bilinear; importer.SaveAndReimport();如果这张图是准备交给美术继续手绘的底图,建议把压缩类型设为Uncompressed;如果最终会进游戏包体,等应用侧确认效果后再手动调回合适的压缩格式即可。5.2 输出目录与命名策略批处理最怕处理完不知道哪些是原图、哪些是结果。我建议默认输出到目标文件夹下的“_processed”子目录,文件名保持原名字后缀加上处理标记。比如原图叫icon_attack.png,输出叫icon_attack_px.png或者icon_attack_clean.png。这样做还有一个好处:重复执行滤镜不会把处理结果再处理一遍。如果你把输出和原图放同一个目录,第二次跑批处理时,FindAssets会把上一次的输出也抓进来,导致效果叠加。加子目录之后,在过滤逻辑里排除带有_processed路径的结果即可。if (assetPath.Contains("_processed")) continue;5.3 与构建流程的衔接批处理工具最适合放在资源入库阶段使用。美术资源合入工程之前先跑一遍统一处理,确认效果后再提交到版本库。这样构建流程里不会出现“开发时看到一套图、打包出来另一套图”的问题。如果在管线里接自动化,可以把工具暴露成静态方法,用命令行参数调用。Unity的编辑器模式支持-executeMethod执行指定静态方法,批处理逻辑只要能脱离编辑器窗口独立运行,就能接入持续集成流程。我在模拟项目X里就这么做过:每晚对全部新增图片自动执行透明边缘清理,第二天美术上班只需要抽查结果。6. 实战踩坑清单与性能优化6.1 导致批处理中断的常见报错报错信息出现原因解决办法Texture not readable纹理导入设置未开启Read/Write用LoadImage读取原文件字节,或修改导入器配置LoadImage failed for path源文件不是PNG/JPEG格式跳过该文件,或在加载前先检查扩展名Cannot access a destroyed objectTexture2D被在遍历中被销毁批量处理时复用Texture2D实例,不要每张图都new再销毁Out of memory一次性加载过多大图分批处理,每批处理完调用Resources.UnloadUnusedAssetsApply() was not called修改了像素数组但没有执行ApplySetPixels32之后务必调用Apply比较隐蔽的是“Cannot access a destroyed object”这个报错。我在早期版本里对每张图片都新建Texture2D,用完立即Destroy,结果批处理跑到一半,前一张图的引用还没释放,后一张图突然访问失败。后来改成固定复用两个Texture2D实例:一个用于LoadImage读入,一个用于SetPixels输出,全程不销毁,批量结束再统一释放,问题就消失了。6.2 让1024平方的遍历速度翻倍像素遍历本身是纯CPU计算,慢的原因通常出在内存分配和颜色转换上。三个优化手段实测效果明显。第一,用GetPixels32而不是GetPixels。这个前面说过,16MB变4MB,分配速度能差三倍以上。第二,避免在循环体里创建临时对象。比如不要每遍历一个像素都new一个Color32,而是用局部变量暂存,最后只往数组里写一次。C#的GC在小循环里不敏感,但上百万像素时,频繁分配会触发多次垃圾回收,一次回收卡几十毫秒,累计起来非常可观。第三,如果对速度要求极高,可以启用unsafe代码块,直接用指针遍历Color32数组。这种写法能绕过数组边界检查,实测能再快20%到30%。代价是需要在Player Settings里开启Allow unsafe Code,而且代码可读性差一截。我一般只在有极端性能需求或大图批量处理时才用,日常滤镜用普通for循环完全足够。6.3 千张图实测结果与分批建议在一台普通配置的开发机上,用上面这套方案批量处理1024x1024的PNG图片,单张图片的完整链路大约是:LoadImage约20到35毫秒,GetPixels32约5到10毫秒,像素遍历约10到30毫秒(取决于滤镜复杂度),SetPixels32加EncodeToPNG约40到70毫秒,写入文件另算。一张图加总起来平均在100到150毫秒之间,一千张图大概两分钟出头。两分钟看起来不长,但等待期间Unity主线程被阻塞,没法做别的事情。而且如果滤镜误判导致输出全黑,跑完两分钟才发现,返工成本偏高。所以我的建议是分三步走:先选3到5张有代表性的图片试跑,确认输出无误;再按文件夹批量处理;最后抽查10%的结果。批量处理的时候可以用EditorApplication.update结合进度条做异步调度,每处理完一张就刷新一次界面,这样至少能知道卡在哪。懒人方案是直接用EditorUtility.DisplayProgressBar做假的进度轮询,代码量少,但Unity反馈窗口会卡住,只适合几十张图的小批量。如果处理上千张图,我建议拆成几批,每批200到300张,处理完一批释放资源,然后继续下一批。这样可以规避单次内存峰值过高导致的崩溃。也可以用FixedUpdate循环来做排队,而不是一口气把所有图读进内存再处理。这一点在低配机器上尤其重要。写到最后再说两句我在实际开发里把这套工具前前后后改了三个版本。第一版很粗糙,功能写死,只解决当时那一个去白边的问题;第二版加了滤镜接口和进度条,能应对几个重复出现的需求;第三版才把导入设置、备份机制和批量统计补齐,真正做到“丢一个文件夹进去,出来全部处理完”。这套东西最有价值的不是某个滤镜算法,而是“让Unity自己处理自己的资源”的思路。图片进入Unity之后,它就不再是静态的素材文件,而是可以被代码检查和修改的资产,这个思路能延伸出很多省力的工具。每次接到新的图片处理需求,先想想能不能用脚本批量做,哪怕只省下半小时,累积起来也相当可观。
开发Unity项目时,天天和美术资源打交道,免不了碰上“这一批图片都要处理一下”的需求。尤其是像素级的修改:UI图标统一加一圈描边、角色立绘的白边要批量抠掉、凑齐一整套序列帧图片做像素化风格化。一张一张交给Photoshop用动作批处理倒是也行,但图一多、格式一杂,半路就断,而且处理完之后还得手动调整导入参数,流程极其磨人。我这几年的经验是,与其来回点鼠标,不如直接在Unity里写一套批量处理图片像素的工具,把滤镜逻辑、导入设置、格式转换一次性解决。这套思路在多个模拟项目里验证过,能处理几十张到上千张图片,这篇就把完整方案拆开讲清楚。1. 工具立项:什么情况需要自己写一套图片像素批处理1.1 从三个真实需求说起先说几个我在实际开发中反复踩过、最后催生出这套工具的场景。第一个是批量去白边。项目里有一批素材是从设计稿直接导出的角色立绘,每张图的角色边缘都带着一圈半透明的白色光晕,放到深色背景上非常明显。用手工一张张处理,先不说要数几十张图,光是“边缘羽化到什么程度”这个标准就很难统一。用脚本处理就很简单:遍历像素,把满足“高亮度+高不透明明度”条件的像素转成完全透明,再对边缘做一层柔化,效果统一且可重复。第二个是批量像素化。玩法侧需要一个“复古像素”版本的角色贴图,原图全是高清立绘,美术没有时间重绘。这时候脚本派上用场:按固定块大小做降采样,取每个块的平均色回填,把整张图变成马赛克风格。换个参数就能控制像素颗粒大小,从8x8到32x32各出一版,美术看了也能快速定方向。第三个是透明通道修复。从某些渠道拿到的图集,透明边缘经常带一圈“黑边”,原因是原图的RGB通道在透明区域里是黑色,半透明边缘处RGB和Alpha混合后产生了暗边。修复思路是把半透明像素的RGB往白色方向拉伸,或者直接把RGB改为不依赖边缘的预乘结果。这种批量修复靠肉眼在Photoshop里找出每一处,基本不现实。1.2 为什么不用Photoshop批处理而要在Unity里写很多人第一反应是:图片处理不应该用Photoshop的Action功能吗?确实,简单需求用PS动作没问题,但有几个瓶颈在项目里很致命。一是动作脚本对“透明通道”的处理笨重。PS的动作录制做不到“遍历每个像素并判断其颜色再决定要不要改”,它更多是录制菜单操作。透明边缘的白边、黑边修复,本质是像素级逻辑判断,能录成动作的概率很低。二是导入参数脱节。PS处理完图片后,还得回到Unity里重新设置纹理格式、是否可读写、压缩类型。如果处理完的图片有透明通道变化,旧的导入设置还可能导致显示效果不对。在Unity脚本里处理,可以在保存图片的同时顺手改掉导入器配置,整个流程闭环。三是批量规模和数据管理。PS的批处理对上千张图的目录结构管理并不顺手,脚步一多还容易漏。用Unity编辑器扩展写一个独立窗口,把文件夹拖进去,自动列出图片、执行算法、输出统计日志,整个过程可控可撤销可重跑。1.3 这套工具的设计目标基于上面这些需求,我给工具的定位是“一个可扩展的像素处理工作台”。核心设计目标有四条:批量加载:支持选中文件夹或者拖入若干图片,自动列出所有纹理资源。统一滤镜:把去边、像素化、调色这类算法封装成独立模块,互不干扰。安全保存:处理前自动备份,处理中记录失败项,处理后给完整统计。管线衔接:处理完直接调整导入设置,还原到可用状态,不用再手动碰导入面板。2. 原理与前置条件:读懂Unity的纹理数据流2.1 Texture2D与像素数组的关系Unity里的Texture2D本质是一块显存/内存中的图像数据,它不会直接把每个像素暴露给开发者操作。要读取像素,必须调用GetPixels或者GetPixels32这两类接口。GetPixels返回Color[],每个Color分量是float,好处是精度高,坏处是体积大、转换慢。一张1024x1024的图,Color[]要占16MB左右的内存,遍历起来还有float到byte的转换开销。GetPixels32返回Color32[],每个分量是byte,四个分量正好对应RGBA,内存占用只有Color[]的四分之一,速度也快得多。做像素批处理,我基本只推荐GetPixels32。理解了读取,修改就顺理成章。拿到数组后,按索引算像素坐标:第y行第x列的像素下标是y*width+x。因为Unity纹理的坐标原点在左下角,而文件流(PNG/JPEG)的坐标原点在左上角,如果没有特殊需求,单纯做整图滤镜不需要关心翻转问题,但如果你要对某个具体坐标做局部操作,这个差异就会冒出来。2.2 纹理压缩格式与可读写开关是最大的坑Unity对纹理有完整的导入管线,默认情况下很多图片导入后被压缩成ASTC、ETC、PVRTC这类GPU压缩格式。直接对压缩纹理调用GetPixels32会直接报错,错误信息一般是“Texture 'xxx' is not readable”。原因有两个层面:压缩格式本身不支持随机读取像素,同时导入设置里的Read/Write选项默认是关闭的。解决方案有三种:在导入设置里勾选Read/Write Enabled,并把格式改成RGBA32。优点是省事,缺点是所有纹理都会变大一倍,运行时内存也会涨。使用Texture2D.LoadImage从原始文件字节流重新创建纹理。这个接口会忽略原有压缩格式,内部强制转成RGBA32,且创建出的纹理默认可读。缺点是只支持PNG和JPEG源文件。在代码里修改TextureImporter配置,SaveAndReimport后再加载。适合希望处理完之后还保持原有管线设置的场景。我比较推荐的组合是:创建新Texture2D,通过File.ReadAllBytes配合LoadImage加载原文件做像素操作,最后用EncodeToPNG写回。这样不会污染原有导入管线,内存占用也可控。2.3 编辑器脚本的框架搭建这套工具以编辑器扩展的形式存在,不参与运行时逻辑。核心是三个部分:菜单入口、独立窗口、算法模块。菜单入口用MenuItem特性挂在主菜单栏,比如“Tools/Pixel Batch Processor”。独立窗口继承EditorWindow,在里面放路径选择、参数配置、执行按钮。算法模块是最需要花心思的部分:因为图片数量大、参数可调,我会把“遍历像素”和“修改像素”分离开,做成类似滤镜管线的方式。public interface ITextureFilter { Color32[] Process(Color32[] pixels, int width, int height, FilterParams param); }每个滤镜实现这个接口,BatchProcessor只负责按顺序调用。这样后续加新风味滤镜、新算法模块,完全不用动批量框架本身。3. 核心代码:批量读取与像素遍历的实现3.1 批量加载文件夹内所有纹理批量处理的第一步是把“哪几张图要处理”确定下来。我采用AssetDatabase.FindAssets搭配“t:Texture2D”类型过滤,传入目标文件夹路径,拿到所有纹理的GUID,再转成资产路径并加载。这样能保证拿到的是工程内真实存在、已经过导入管线的资源。string[] guids = AssetDatabase.FindAssets("t:Texture2D", new string[] { folderPath }); foreach (string guid in guids) { string assetPath = AssetDatabase.GUIDToAssetPath(guid); Texture2D tex = AssetDatabase.LoadAssetAtPath<Texture2D>(assetPath); // 这里做类型、读权限、格式的检查 }这里有个很容易忽略的点:FindAssets结果里会包含Sprite、SpriteAtlas等继承自Texture2D的资产,还有可能把图集里的子图也算进来。进队列之前,最好判断一下资产路径的后缀名是否为.png、.jpg或.jpeg,否则会出现处理到一半发现“这个文件不是直接图片”的尴尬情况。3.2 读取像素与快速遍历的写法拿到Texture2D引用后,用GetPixels32取像素数组。但要注意,如果原纹理的压缩格式不是RGBA32,直接取会报错。稳妥做法是先把原图字节读出来,通过LoadImage重新解析成可读纹理,再取像素。byte[] rawBytes = File.ReadAllBytes(assetPath); Texture2D tempTex = new Texture2D(2, 2, TextureFormat.RGBA32, false); if (!tempTex.LoadImage(rawBytes)) { Debug.LogError("读取失败: " + assetPath); continue; } Color32[] pixels = tempTex.GetPixels32(0); int width = tempTex.width; int height = tempTex.height;随后遍历像素。这里推荐用一维数组从头到尾扫,不要用双重循环。原因是索引计算更简单,缓存命中率也更高。双重循环里每行都要重新算y*width,在超大图上会平添不必要的整数乘法开销。for (int index = 0; index < pixels.Length; index++) { Color32 c = pixels[index]; // 在这里无差别遍历所有像素,再按滤镜需求修改 pixels[index] = c; }如果你的滤镜逻辑需要知道当前像素的坐标,用先算好y再算x的方式:for (int y = 0; y < height; y++) { int rowOffset = y * width; for (int x = 0; x < width; x++) { int index = rowOffset + x; // 此时 x = index % width, y = index / width } }3.3 把处理结果写回纹理并保存像素数组处理完之后,要分两条路保存。如果只是想在编辑器里预览效果,用SetPixels32配合Apply即可;如果要落地成文件,就得EncodeToPNG转字节再写盘。tempTex.SetPixels32(0, pixels); byte[] outPng = tempTex.EncodeToPNG(); string savePath = targetFolder + "/" + fileName + "_processed.png"; File.WriteAllBytes(savePath, outPng);EncodeToPNG只能输出PNG格式,好处是无损且支持透明通道。JPEG格式不支持透明通道,像素批处理场景下基本不考虑。写完后必须调AssetDatabase.ImportAsset刷新资源数据库。这个步骤经常被忽略,导致保存的PNG不会立刻出现在工程里,需要手动刷新窗口。AssetDatabase.ImportAsset(savePath);如果要追求流程闭环,在ImportAsset之前顺便改掉导入参数:TextureImporter importer = (TextureImporter)TextureImporter.GetAtPath(savePath); importer.textureType = TextureImporterType.Sprite; importer.textureCompression = TextureImporterCompression.Uncompressed; importer.SaveAndReimport();4. 三个实际能直接用的滤镜功能模块4.1 批量去除白边与透明化处理去除白边是处理立绘和UI图标时最高频的需求。原理很简单:检测像素RGB是否都接近白,同时Alpha足够高,判定为“残留白边”,把这部分像素的Alpha改成0。但直接一刀切会让边缘出现锯齿,所以算法要分两步。第一步是全图扫描,找出“需要完全变透明”的像素。第二步是羽化:对透明区域的边缘做一次膨胀处理,把边缘像素的Alpha按距离衰减。private Color32[] RemoveWhiteEdge(Color32[] src, int w, int h, float threshold) { Color32[] result = new Color32[src.Length]; for (int i = 0; i < src.Length; i++) { Color32 c = src[i]; bool isWhite = c.r > threshold * 255 && c.g > threshold * 255 && c.b > threshold * 255 && c.a > 128; if (isWhite) { c.a = 0; } result[i] = c; } return result; }实际项目里,阈值参数一般给0.85到0.95之间。阈值太大会漏掉淡白色边缘,太小会把皮肤的高光区域也删掉。处理完透明化之后,建议再做一次简单的边缘检测:如果一个像素是透明、相邻像素是不透明,就把这个像素做50%的Alpha。这样整个边缘看起来是渐变消失的,不会突然断掉。4.2 批量像素化风格化处理像素化这个效果的核心是“均匀分块+块内降采样”。把原图分成若干个方块,每个方块内所有像素的颜色取平均,然后用这个平均色填充整个方块。块大小决定了像素颗粒的粗细,8代表8x8像素一块,图面小一点的话16甚至32都能做出明显的复古感。private void ApplyPixelize(Color32[] src, int w, int h, int blockSize, Color32[] dst) { for (int blockY = 0; blockY < h; blockY += blockSize) { for (int blockX = 0; blockX < w; blockX += blockSize) { Color32 avg = AverageBlock(src, w, h, blockX, blockY, blockSize); FillBlock(dst, w, h, blockX, blockY, blockSize, avg); } } }取平均色时要注意Alpha通道的处理。如果Alpha不一致,直接平均会导致半透明区域混入奇怪的RGB值。我的做法是只对Alpha大于0的像素做RGB平均,并把该块Alpha设为255;完全透明的块直接设成全透明黑色。这样输出的像素风图在叠加到背景上时不会出现脏色块。4.3 批量颜色替换与色调调整颜色替换不一定是像素级匹配,更实用的是基于色调范围做替换。先把RGB转成HSV,判断色相是否落在目标区间内,再替换成新颜色的同时保留原来的饱和度和明度。这样处理人物衣服换色、图标配色切换特别方便。private Color32 ReplaceHue(Color32 src, float targetHue, float hueRange, Color32 newColor) { // 把src转成HSV,取H分量 // 如果H在 targetHue ± hueRange 内,则用newColor替换 // 保留原明度V,必要时调整饱和度S }色调调整则更简单:每个像素的RGB按比例缩放。想偏暖就R乘1.1、B乘0.9;想提亮就整体加一个固定值。因为Color32是byte结构,运算时先转int,做完后Clamp到0-255再回写,防止溢出。5. 导入设置、导出命名与资源管线衔接5.1 处理前后自动调整导入参数处理完图片后,如果新输出的图片仍然沿用老的导入配置,很可能看不到预期效果。例如原图是压缩格式,新图改成RGBA32后体积变大,压缩参数不变,Unity会在导入时再次压缩,导致透明边缘被压出色斑。我的做法是在保存后直接改TextureImporter:TextureImporter importer = (TextureImporter)TextureImporter.GetAtPath(savePath); importer.isReadable = true; importer.textureCompression = TextureImporterCompression.Uncompressed; importer.filterMode = FilterMode.Bilinear; importer.SaveAndReimport();如果这张图是准备交给美术继续手绘的底图,建议把压缩类型设为Uncompressed;如果最终会进游戏包体,等应用侧确认效果后再手动调回合适的压缩格式即可。5.2 输出目录与命名策略批处理最怕处理完不知道哪些是原图、哪些是结果。我建议默认输出到目标文件夹下的“_processed”子目录,文件名保持原名字后缀加上处理标记。比如原图叫icon_attack.png,输出叫icon_attack_px.png或者icon_attack_clean.png。这样做还有一个好处:重复执行滤镜不会把处理结果再处理一遍。如果你把输出和原图放同一个目录,第二次跑批处理时,FindAssets会把上一次的输出也抓进来,导致效果叠加。加子目录之后,在过滤逻辑里排除带有_processed路径的结果即可。if (assetPath.Contains("_processed")) continue;5.3 与构建流程的衔接批处理工具最适合放在资源入库阶段使用。美术资源合入工程之前先跑一遍统一处理,确认效果后再提交到版本库。这样构建流程里不会出现“开发时看到一套图、打包出来另一套图”的问题。如果在管线里接自动化,可以把工具暴露成静态方法,用命令行参数调用。Unity的编辑器模式支持-executeMethod执行指定静态方法,批处理逻辑只要能脱离编辑器窗口独立运行,就能接入持续集成流程。我在模拟项目X里就这么做过:每晚对全部新增图片自动执行透明边缘清理,第二天美术上班只需要抽查结果。6. 实战踩坑清单与性能优化6.1 导致批处理中断的常见报错报错信息出现原因解决办法Texture not readable纹理导入设置未开启Read/Write用LoadImage读取原文件字节,或修改导入器配置LoadImage failed for path源文件不是PNG/JPEG格式跳过该文件,或在加载前先检查扩展名Cannot access a destroyed objectTexture2D被在遍历中被销毁批量处理时复用Texture2D实例,不要每张图都new再销毁Out of memory一次性加载过多大图分批处理,每批处理完调用Resources.UnloadUnusedAssetsApply() was not called修改了像素数组但没有执行ApplySetPixels32之后务必调用Apply比较隐蔽的是“Cannot access a destroyed object”这个报错。我在早期版本里对每张图片都新建Texture2D,用完立即Destroy,结果批处理跑到一半,前一张图的引用还没释放,后一张图突然访问失败。后来改成固定复用两个Texture2D实例:一个用于LoadImage读入,一个用于SetPixels输出,全程不销毁,批量结束再统一释放,问题就消失了。6.2 让1024平方的遍历速度翻倍像素遍历本身是纯CPU计算,慢的原因通常出在内存分配和颜色转换上。三个优化手段实测效果明显。第一,用GetPixels32而不是GetPixels。这个前面说过,16MB变4MB,分配速度能差三倍以上。第二,避免在循环体里创建临时对象。比如不要每遍历一个像素都new一个Color32,而是用局部变量暂存,最后只往数组里写一次。C#的GC在小循环里不敏感,但上百万像素时,频繁分配会触发多次垃圾回收,一次回收卡几十毫秒,累计起来非常可观。第三,如果对速度要求极高,可以启用unsafe代码块,直接用指针遍历Color32数组。这种写法能绕过数组边界检查,实测能再快20%到30%。代价是需要在Player Settings里开启Allow unsafe Code,而且代码可读性差一截。我一般只在有极端性能需求或大图批量处理时才用,日常滤镜用普通for循环完全足够。6.3 千张图实测结果与分批建议在一台普通配置的开发机上,用上面这套方案批量处理1024x1024的PNG图片,单张图片的完整链路大约是:LoadImage约20到35毫秒,GetPixels32约5到10毫秒,像素遍历约10到30毫秒(取决于滤镜复杂度),SetPixels32加EncodeToPNG约40到70毫秒,写入文件另算。一张图加总起来平均在100到150毫秒之间,一千张图大概两分钟出头。两分钟看起来不长,但等待期间Unity主线程被阻塞,没法做别的事情。而且如果滤镜误判导致输出全黑,跑完两分钟才发现,返工成本偏高。所以我的建议是分三步走:先选3到5张有代表性的图片试跑,确认输出无误;再按文件夹批量处理;最后抽查10%的结果。批量处理的时候可以用EditorApplication.update结合进度条做异步调度,每处理完一张就刷新一次界面,这样至少能知道卡在哪。懒人方案是直接用EditorUtility.DisplayProgressBar做假的进度轮询,代码量少,但Unity反馈窗口会卡住,只适合几十张图的小批量。如果处理上千张图,我建议拆成几批,每批200到300张,处理完一批释放资源,然后继续下一批。这样可以规避单次内存峰值过高导致的崩溃。也可以用FixedUpdate循环来做排队,而不是一口气把所有图读进内存再处理。这一点在低配机器上尤其重要。写到最后再说两句我在实际开发里把这套工具前前后后改了三个版本。第一版很粗糙,功能写死,只解决当时那一个去白边的问题;第二版加了滤镜接口和进度条,能应对几个重复出现的需求;第三版才把导入设置、备份机制和批量统计补齐,真正做到“丢一个文件夹进去,出来全部处理完”。这套东西最有价值的不是某个滤镜算法,而是“让Unity自己处理自己的资源”的思路。图片进入Unity之后,它就不再是静态的素材文件,而是可以被代码检查和修改的资产,这个思路能延伸出很多省力的工具。每次接到新的图片处理需求,先想想能不能用脚本批量做,哪怕只省下半小时,累积起来也相当可观。