
碰到 Unity 里要把Texture2D保存成 PNG、JPG 的需求基本都是在做游戏截图、关卡编辑器导资源、生成分享卡片或者排查纹理显示问题的时候。我自己最早写这类功能是在做编辑器工具时美术希望把场景里的物件贴图一键导出来看效果后来做数字孪生项目又遇到需要定时把三维视图截图存档报给甲方。这个需求表面看只是两行 API 的事实际用起来牵扯到纹理可读性、压缩格式、坐标翻转、存储路径、权限等一堆问题踩坑踩多了才慢慢把整套流程理顺。今天就把这套保存功能从原理到实现完整梳理一遍包括EncodeToPNG/EncodeToJPG的用法、从屏幕和RenderTexture抓图的完整链路、黑图绿图和压缩纹理报错的排查方法适合刚接触 Unity 的开发者也适合写工具脚本时想直接抄作业的老手。1. 保存文件前先想清楚PNG 和 JPG 的选型逻辑很多朋友一上来就写EncodeToPNG()写完了才发现在已经透明背景的角色图变成黑底或者白底了。这不是编码写错了而是格式选错了。动手保存之前先搞清楚两种格式的差异比多写一个工具函数更重要。1.1 两者的编码区别到底影响什么PNG 用的是无损压缩内部通过类似 Deflate 的方式压缩像素数据压缩过程中不会丢弃原始颜色信息。最重要的是PNG 支持 Alpha 通道透明区域能够被完整保留下来。所以 UI 图标、角色立绘、带透明通道的序列帧、需要二次编辑的原始素材都该用 PNG。JPG 则是有损压缩核心是离散余弦变换DCT它会把图像分成 8x8 的块再通过丢掉高频细节来压缩体积。JPG 不支持 Alpha 通道透明信息在编码前就已经被丢掉了所以当你把一个带透明通道的Texture2D存成 JPG 时Unity 会把透明区域当成黑色或白色来处理具体结果取决于你纹理里 RGB 通道存的是什么。体积差别也很直观。一张 1024x1024 的 RGBA32 纹理原始数据约 4MB保存成 PNG 后可能在 1~2MB保存成 JPG质量 85一般只有 200~500KB。如果做的是网络传输或分享图JPG 的体积优势非常明显但不能牺牲透明通道。1.2 根据使用场景做选择我现在的习惯是先列一个简单判断表再决定用哪种格式场景推荐格式原因存档 / 编辑器资源导出PNG无损、保留透明通道方便二次编辑分享图 / 缩略图 / 日志截图JPG体积小不要求透明加载快UI 图标、序列帧、特效贴图PNG有透明通道需求HDR 光照信息 / 高精度法线EXR / TGA支持更高位深PNG 只支持 8bit/16bit 有限情况微信小游戏本地保存PNG转 base64 或通过平台接口平台文件系统受限需要额外处理另外还要考虑色彩空间问题。如果项目在 Linear 色彩空间下运行直接对Texture2D保存 PNG颜色可能和屏幕上看到的偏亮或偏暗。因为 GPU 采样时会对纹理做 sRGB 解码而EncodeToPNG保存的是原始像素值。遇到这种情况保存前最好把纹理转换到 Gamma 空间或者先确认你的纹理是否已经正确标记了 sRGB。2. Unity 原生 API从 Texture2D 到图片文件的完整链路Unity 官方提供了两组编码方法Texture2D.EncodeToPNG()、Texture2D.EncodeToJPG()以及后来统一入口的ImageConversion.EncodeToPNG()/ImageConversion.EncodeToJPG()。底层逻辑一样都是把Texture2D的像素数据编码成对应格式的字节数组拿到byte[]后再用 C# 的文件接口写盘。2.1 最简实现EncodeToPNG 和 EncodeToJPG先看最基础的一段代码把一个运行时创建的Texture2D保存成 PNGTexture2D tex new Texture2D(256, 256, TextureFormat.RGBA32, false); // 填充像素比如纯红色 Color[] pixels new Color[256 * 256]; for (int i 0; i pixels.Length; i) { pixels[i] Color.red; } tex.SetPixels(pixels); tex.Apply(); byte[] pngBytes tex.EncodeToPNG(); string path Path.Combine(Application.persistentDataPath, test.png); File.WriteAllBytes(path, pngBytes); Debug.Log(保存完成: path);保存 JPG 也类似区别在于可以传一个质量参数范围是 1 到 100数值越大画质越高、体积越大byte[] jpgBytes tex.EncodeToJPG(85); string pathJpg Path.Combine(Application.persistentDataPath, test.jpg); File.WriteAllBytes(pathJpg, jpgBytes);这里有几个细节容易忽略。第一tex.Apply()不一定必须调用但如果你在SetPixels之后马上EncodeToPNG没有Apply()的话像素数据可能没有被真正上传到 GPU导致编码出的图片是全黑的。第二编码得到的byte[]是一次性分配的大数组尤其在 2048x2048 这种尺寸下PNG 编码过程和数组本身都会占不少内存。用完尽早释放或者直接写到文件后不再持有引用。第三路径尽量不要拼到Application.dataPath下编辑器里无所谓但移动端dataPath通常在只读的安装包目录里写入会失败。2.2 拿到 Texture2D 之前的隐性条件isReadable 和纹理格式EncodeToPNG不是随便拿来一个Texture2D就能用的它要求纹理是可读的。Unity 里导入的纹理默认Read/Write Enabled是关闭的也就是说isReadable为 false此时调用EncodeToPNG会直接抛异常。检查方式很简单if (!texture.isReadable) { Debug.LogError(纹理不可读请勾选 Read/Write Enabled 或运行时转换); return; }运行时通过new Texture2D(...)创建的纹理默认可读但从 AssetBundle 加载的、从图集SpriteAtlas里取的、或者被 GPU 压缩过的纹理往往不可读或格式特殊。比如 ASTC、ETC、DXT 这类压缩格式EncodeToPNG可能不支持甚至直接报错。解决办法是把压缩纹理转换到RGBA32。常见的做法是利用RenderTexture中转public static Texture2D ConvertToRGBA32(Texture2D source) { RenderTexture rt RenderTexture.GetTemporary( source.width, source.height, 0, RenderTextureFormat.ARGB32); rt.name TempRT_ source.name; Graphics.Blit(source, rt); Texture2D result new Texture2D(source.width, source.height, TextureFormat.RGBA32, false); RenderTexture.active rt; result.ReadPixels(new Rect(0, 0, source.width, source.height), 0, 0); result.Apply(); RenderTexture.active null; RenderTexture.ReleaseTemporary(rt); return result; }这一步相当于把 GPU 上的纹理重新采样到 CPU 可读的内存纹理里转换后isReadable为 trueformat也变成RGBA32再保存就稳了。2.3 从 Sprite、RenderTexture、屏幕里“抓”出可保存的 Texture2D实际开发中我们要保存的对象往往不是现成的Texture2D而是经过渲染、裁剪、合成后的结果。最常见的是把相机画面截成一张图。从Sprite取纹理要小心sprite.texture返回的是整张图集而不是这个Sprite的被裁切区域。如果只想要局部需要根据sprite.rect去像素数组里切片Texture2D temp new Texture2D((int)sprite.rect.width, (int)sprite.rect.height, TextureFormat.RGBA32, false); Color[] pixels sprite.texture.GetPixels( (int)sprite.rect.x, (int)sprite.rect.y, (int)sprite.rect.width, (int)sprite.rect.height); temp.SetPixels(pixels); temp.Apply();从屏幕或者RenderTexture抓图核心流程是先渲染到一张RenderTexture再ReadPixels读取public static Texture2D CaptureCamera(Camera cam, int width, int height) { RenderTexture rt RenderTexture.GetTemporary(width, height, 24); RenderTexture oldRT cam.targetTexture; cam.targetTexture rt; cam.Render(); Texture2D result new Texture2D(width, height, TextureFormat.RGBA32, false); RenderTexture.active rt; result.ReadPixels(new Rect(0, 0, width, height), 0, 0); result.Apply(); cam.targetTexture oldRT; RenderTexture.active null; RenderTexture.ReleaseTemporary(rt); return result; }抓完再调上一节的EncodeToPNG就能落盘。如果你用ScreenCapture.CaptureScreenshot它会自己处理这些流程但无法指定保存格式细节而且只能截全屏不能截某个相机。3. 直接抄作业一套完整的保存工具类与截图功能只讲 API 不讲工程落地没什么意义。我直接把现在项目里在用的工具类简化后放上来包含同步保存、截图、异步写盘的完整实现你复制到项目里改个命名空间就能用。3.1 整合一个可复用的 SaveTextureHelper这个工具类的核心是把“编码 写文件”封装成统一入口支持 PNG 和 JPG自动创建目录并返回是否成功using System; using System.IO; using UnityEngine; public static class SaveTextureHelper { public enum SaveFormat { Png, Jpg } public static bool Save(Texture2D texture, string savePath, SaveFormat format, int jpgQuality 85) { if (texture null) { Debug.LogError(Save failed: texture is null.); return false; } if (!texture.isReadable) { Debug.LogError(Save failed: texture is not readable.); return false; } try { string directory Path.GetDirectoryName(savePath); if (!string.IsNullOrEmpty(directory) !Directory.Exists(directory)) { Directory.CreateDirectory(directory); } byte[] bytes; switch (format) { case SaveFormat.Png: bytes texture.EncodeToPNG(); break; case SaveFormat.Jpg: bytes texture.EncodeToJPG(jpgQuality); break; default: bytes texture.EncodeToPNG(); break; } File.WriteAllBytes(savePath, bytes); return true; } catch (Exception e) { Debug.LogError(Save failed: e.Message \n e.StackTrace); return false; } } }注意我做了两个前置检查texture null和isReadable false。很多莫名其妙的黑图、报错都是这两条触发的提前暴露问题比让调用方猜原因好得多。另外Path.GetDirectoryName对以“/”结尾的路径会返回空值调用时最好统一规范路径分隔符比如Path.Combine来拼路径。3.2 截图到文件CaptureScreenToFile结合前面的CaptureCamera我们直接做一个“截某个相机并保存成图片”的完整方法public static bool CaptureCameraToFile(Camera cam, int width, int height, string savePath, SaveFormat format) { RenderTexture rt RenderTexture.GetTemporary(width, height, 24); RenderTexture oldRT cam.targetTexture; cam.targetTexture rt; cam.Render(); Texture2D result new Texture2D(width, height, TextureFormat.RGBA32, false); RenderTexture.active rt; result.ReadPixels(new Rect(0, 0, width, height), 0, 0); result.Apply(); cam.targetTexture oldRT; RenderTexture.active null; RenderTexture.ReleaseTemporary(rt); bool ok Save(result, savePath, format); // 如果是编辑器工具可以用 DestroyImmediate运行时用 Destroy UnityEngine.Object.Destroy(result); return ok; }使用的时候最容易被忽略的是相机背景色。如果相机clearFlags是SolidColor背景色被设置成黑色且 alpha 为 0最终 PNG 的背景是透明黑在图片查看器里看着像黑底。如果你的目的是“白底分享图”保存前先把背景色设成白色再渲染或者后续对像素做填充处理。3.3 异步保存不卡主线程运行时的截图经常是 2048x2048 甚至更高分辨率EncodeToPNG在低端手机上可能要几十到上百毫秒。如果直接在 UI 按钮回调里同步调用用户会明显感到卡顿。而且文件写盘也是个 IO 操作同样会阻塞主线程。建议做法是主线程里完成EncodeToPNG拿到byte[]后扔到后台线程写文件。这里有个关键点——EncodeToPNG是 Unity 引擎 API写代码时尽量不要在子线程里调用。虽然某些版本偶尔能跑但官方不保证线程安全崩溃概率很高。最稳妥的方案是只把File.WriteAllBytes放到异步线程public static async System.Threading.Tasks.Taskbool SaveAsync( Texture2D texture, string savePath, SaveFormat format, int jpgQuality 85) { if (!texture.isReadable) { Debug.LogError(SaveAsync failed: texture is not readable.); return false; } byte[] bytes format SaveFormat.Png ? texture.EncodeToPNG() : texture.EncodeToJPG(jpgQuality); string directory Path.GetDirectoryName(savePath); if (!string.IsNullOrEmpty(directory) !Directory.Exists(directory)) { Directory.CreateDirectory(directory); } return await System.Threading.Tasks.Task.Run(() { try { File.WriteAllBytes(savePath, bytes); return true; } catch (Exception e) { Debug.LogError(Write file failed: e.Message); return false; } }); }这样主线程只承担编码部分编码完成后文件写入不会阻塞 UI。如果你连编码都不想卡主线程可以把一个大图拆成多个小块分别异步编码但一般用不到EncodeToPNG的耗时和纹理尺寸强相关低于 1024x1024 的截图基本无所谓。4. 实战踩坑记录黑图、绿图、上下颠倒与压缩纹理这部分是重点因为网上类似问题的讨论非常多但很多答案只给“你这样试试”的解决步骤不讲底层原因。我把这几年遇到的高频问题整理成排查表每个问题都附带原因分析和解决方案。4.1 保存出来全黑全黑大概是最常见的问题原因有几种第一isReadable为 false。纹理来自 AssetBundle 或图集时EncodeToPNG直接抛异常或编码出来全黑。这个好排查打印texture.isReadable就知道。第二ReadPixels之前没有设置RenderTexture.active。如果从 RenderTexture 读取像素时忘了RenderTexture.active rt读取到的数据可能是空的结果就是一张黑图。第三SetPixels之后没有Apply()。很多新手以为SetPixels已经把数据写进纹理了但实际它只是把 CPU 侧的像素数组改掉了没有上传到 GPU。编码的时候如果读取的是 GPU 资源没Apply()就会读到全黑或旧数据。所以我的建议是任何SetPixels之后只要不是马上又SetPixels都老老实实调一次Apply()。第四时机问题。如果是在OnPostRender之外立刻截某个相机画面可能还没渲染完读出来的就是黑屏。要在相机渲染完成后再读或者通过协程在WaitForEndOfFrame之后执行。4.2 图片上下颠倒ReadPixels的坐标体系坑过不少开发者。Unity 的Texture2D坐标原点在左下角0,0而ReadPixels的Rect也是从 RenderTexture 的左下角开始读取的。大多数情况下你直接new Rect(0, 0, width, height)读出来方向是正常的。但如果你用Screen.width相关坐标去拼Rect或者从 UI 摄像机RenderTexture里读取某些后处理结果就可能出现上下颠倒。解决方法是读取后检查一遍像素方向必要时做翻转。翻转代码public static Texture2D FlipTextureVertically(Texture2D source) { Texture2D flipped new Texture2D(source.width, source.height, source.format, false); Color[] pixels source.GetPixels(); Color[] newPixels new Color[pixels.Length]; int w source.width; int h source.height; for (int y 0; y h; y) { for (int x 0; x w; x) { newPixels[y * w x] pixels[(h - 1 - y) * w x]; } } flipped.SetPixels(newPixels); flipped.Apply(); return flipped; }这个函数性能一般但排查阶段够用。如果是在编辑器工具里批量处理建议直接写循环在原始数组上做翻转减少一次纹理拷贝。4.3 JPG 保存透明区域变成黑块或白块很多 UI 贴图是透明背景比如按钮、角色立绘。如果你直接对这些贴图做 JPG 编码透明区域的 RGB 值如果原来存的是Color(0, 0, 0, 0)保存 JPG 后就会变成黑色块如果原来底色是白色又变成白块。JPG 本身不支持 Alpha所以这一步只能做“合成背景”。常见做法是把透明像素填充到指定背景色上public static Texture2D FlattenBackground(Texture2D source, Color backgroundColor) { Texture2D result new Texture2D(source.width, source.height, TextureFormat.RGBA32, false); Color[] srcPixels source.GetPixels(); Color[] dstPixels new Color[srcPixels.Length]; for (int i 0; i srcPixels.Length; i) { Color c srcPixels[i]; float a c.a; dstPixels[i] new Color( c.r * a backgroundColor.r * (1f - a), c.g * a backgroundColor.g * (1f - a), c.b * a backgroundColor.b * (1f - a), 1f ); } result.SetPixels(dstPixels); result.Apply(); return result; }如果你的目标是微信小游戏或网页端分享图一般都要这种白底或自定义底色的 JPG提前做合成比用户传上来再出错要省事很多。4.4 压缩纹理无法直接编码Unity 移动端常用 ASTC、ETC 压缩格式。这类纹理在显存里已经不是原始的 RGBA 像素EncodeToPNG或者会报错或者输出一片噪点。原因就是编码器不知道如何把 GPU 压缩块还原成普通像素。遇到这种情况先看texture.formatDebug.Log(texture.format);如果是TextureFormat.ASTC_4x4、ETC2_RGBA8、DXT1等压缩格式先按 2.2 节的方式转成RGBA32。需要注意运行时用RenderTexture.GetTemporary转换时如果源纹理尺寸不是偶数可能遇到平台兼容性问题。一般建议转换前打印一次尺寸小尺寸奇数纹理要特别小心。4.5 路径与权限问题编辑器下保存到任意路径都行但移动端限制很多。Android 上直接把文件写到Application.dataPath会失败因为安装包目录是只读的写到根目录/sdcard/又需要存储权限。合规做法是先写到Application.persistentDataPath这个目录不需要额外权限只是卸载应用时会跟着清除。iOS 上保存到persistentDataPath没问题但保存到系统相册需要申请相册权限同时要绕过 Unity 的文件接口自己写原生插件或者用第三方插件。项目里如果只是“导出文件给用户自己用”用persistentDataPath就够了如果要“保存到系统相册”建议直接评估平台原生方案或成熟插件而不是在 C# 层硬憋。微信小游戏环境更特殊没有完整的System.IO文件系统File.WriteAllBytes可能失效。通常得用平台提供的wx.saveImageToPhotosAlbum或先把图片转成 base64 再通过 JS 桥传给小程序端。做 Unity 微信小游戏导出时建议把保存图片的逻辑单独抽象出一层方便不同平台替换实现。4.6 内存暴涨大尺寸Texture2D保存 PNG 时内存占用是纹理本身 PNG 编码临时缓冲区 结果byte[]三份叠加。一张 4096x4096 的 RGBA32 纹理原始数据 64MB编码过程可能再翻一倍。手机上遇到 OOM 崩溃一点也不奇怪。优化手段有几种。一是保存前先降低分辨率只保存缩略图二是用System.Buffers.ArrayPoolbyte复用字节数组三是保存完立即释放临时Texture2D别让它被 GC 拖到不确定时机四是在编辑器工具里用DestroyImmediate释放临时对象运行时用Destroy。如果同时要保存几十张图建议加一个简单的队列每帧只处理一张避免瞬间压爆内存。5. 批量处理与进阶扩展如果你只是保存单张图前面的内容已经够用。但很多工具型项目会要求批量导出资源、生成缩略图或者保存 HDR 信息这些都需要再做一些扩展。5.1 一键导出项目中所有贴图编辑器扩展里经常要遍历指定目录下的所有贴图把它们导出成 PNG。用AssetDatabase加载判断类型后保存#if UNITY_EDITOR using UnityEditor; using UnityEngine; public static class TextureBatchExporter { [MenuItem(Tools/Export All Textures To PNG)] public static void ExportAllTextures() { string[] guids AssetDatabase.FindAssets(t:Texture2D, new[] { Assets/Art }); foreach (string guid in guids) { string assetPath AssetDatabase.GUIDToAssetPath(guid); Texture2D tex AssetDatabase.LoadAssetAtPathTexture2D(assetPath); if (tex null || !tex.isReadable) { Debug.LogWarning(跳过不可读纹理: assetPath); continue; } byte[] bytes tex.EncodeToPNG(); string outPath Export/ tex.name .png; System.IO.Directory.CreateDirectory(Export); System.IO.File.WriteAllBytes(outPath, bytes); } AssetDatabase.Refresh(); } } #endif这里有个经验导出前先把目标贴图的Read/Write Enabled打开或者在代码里用TextureImporter临时设置并Reimport。批量工具最怕遇到不可读纹理然后中断所以我把跳过逻辑写得很明确。5.2 生成缩略图再保存如果只需要小图别直接对大图GetPixels后再逐像素缩采样性能差且代码啰嗦。更高效的做法是先用Graphics.Blit把大图缩到 RenderTexture再读取小尺寸纹理public static Texture2D ResizeTexture(Texture2D source, int targetWidth, int targetHeight) { RenderTexture rt RenderTexture.GetTemporary(targetWidth, targetHeight, 0); RenderTexture.active rt; Graphics.Blit(source, rt); Texture2D result new Texture2D(targetWidth, targetHeight, TextureFormat.RGBA32, false); result.ReadPixels(new Rect(0, 0, targetWidth, targetHeight), 0, 0); result.Apply(); RenderTexture.active null; RenderTexture.ReleaseTemporary(rt); return result; }这样做的好处是缩放过程在 GPU 上完成比 CPU 循环快很多而且内存占用也更小。需要注意Graphics.Blit出来的是经过采样过滤的图默认是双线性过滤如果你需要像素风精确缩放反而应该走 CPU 最近邻采样。5.3 保存 EXR / TGA 作为扩展除了 PNG 和 JPGUnity 还支持Texture2D.EncodeToEXR()和Texture2D.EncodeToTGA()。EXR 用于保存 HDR 数据比如光照贴图、高动态范围效果。调用方式类似byte[] exrBytes texture.EncodeToEXR(Texture2D.EXRFlags.OutputAsFloat); File.WriteAllBytes(lightmap.exr, exrBytes);需要确认目标平台和 Unity 版本是否支持有些老版本不支持EncodeToEXR而且 EXR 文件体积非常大。TGA 则通常用于编辑器导出带透明通道且需要老软件兼容的场景EncodeToTGA在较新版本 Unity 里已经提供但质量参数不适用于 TGA。我也建议把保存扩展名和格式绑定起来比如.png对应EncodeToPNG.jpg或.jpeg对应EncodeToJPG.exr对应EncodeToEXR避免调用方传错扩展名导致解码器不识别。5.4 批量保存时的协程调度如果一次要保存很多张图比如从图集里拆出几十个 Sprite 分别导出 PNG用同步循环会卡编辑器或卡游戏。建议用协程做分帧处理每帧只编码一两张public static IEnumerator ExportSpritesToPNG(Sprite[] sprites, string outputFolder, int perFrameCount 1) { for (int i 0; i sprites.Length; i) { Sprite s sprites[i]; Texture2D tex new Texture2D((int)s.rect.width, (int)s.rect.height, TextureFormat.RGBA32, false); tex.SetPixels(s.texture.GetPixels( (int)s.rect.x, (int)s.rect.y, (int)s.rect.width, (int)s.rect.height )); tex.Apply(); byte[] bytes tex.EncodeToPNG(); File.WriteAllBytes(Path.Combine(outputFolder, s.name .png), bytes); Object.Destroy(tex); if ((i 1) % perFrameCount 0) { yield return null; } } }这个套路在编辑器工具里不是必须的但如果你做的是运行时“资源打包导出”比如玩家自建关卡导出分享就必须考虑帧率影响不能一口气遍历几十张图。我个人在实际操作中的体会是Texture2D保存图片这个功能代码量不多但绝大多数坑都出在纹理本身的“可读性”和“格式”两个前置条件上。写工具类的时候把这两个条件在入口处一次性检查掉后面能省很多排查时间。最后再分享一个小技巧所有保存函数最好统一返回成功或失败的错误码而不是只打日志这样接入自动化打包、CI 出图流程时可以靠返回值快速定位是哪一张图、哪一个环节出了问题。