简介:针对Unity粒子光效无法直接导出PNG序列帧的常见需求,这份资源提供了一套基于编辑器扩展的完整实现方案,主要面向游戏特效美术和Unity开发者。资源为一份PDF文档,共1个文件,大小约72KB,篇幅精炼,聚焦核心代码与配置方法。文档从ParticleExporter脚本入手,详细说明了如何在Start方法中设置Time.captureFramerate控制动画速度,如何根据导出帧数、屏幕宽高、摄像机位置与旋转角度等参数批量录制粒子效果,并利用RenderTexture逐帧截取画面生成PNG图片;同时涉及黑、白两种背景RenderTexture的创建方式,可帮助理解不同背景下的序列帧输出处理。这套方案虽然适合在外部工具无法复用Unity特效时的过渡场景,但能让读者深入理解Unity粒子系统与序列帧生成的内在机制,也可以作为后续自建特效导出工作流的参考模板。目前已有3394人学习,适合熟悉Unity基础操作、希望将粒子光效应用到其他平台或项目中的技术读者。
1. 粒子光效导出成png序列帧到底在解决什么问题
实时粒子光效很美,但几万粒子的动态模拟、独立渲染和叠加Bloom,在移动端GPU与CPU上的开销并不低。登录背景、抽卡特效、UI Tip、微信小游戏的过场演出,这些场景真正需要的是“一段长度固定、内容固定的特效播放”,而不是每次运行时重新随机、重新模拟。把 Unity 粒子光效导出成 png 序列帧,本质是把昂贵的实时计算提前录制完,运行时只做纹理采样和播放,适合特效、TA、UI 程序员和技术美术在项目优化和跨端交付时使用。
这个方案初学者会误以为等于“截屏”,真正棘手的是颜色空间、HDR亮度、透明通道和粒子时间步进。例子:粒子光效多数是Additive混合,直接保存PNG后再放进UGUI,会出现灰边、发暗、光晕叠加不正确。本文按照录制、保存、批量导出、回放验证的顺序,给出一套不依赖第三方插件的Unity实现思路,关键代码可以直接复制到编辑器工具或运行时脚本中。
2. 把粒子光效录进RenderTexture:相机、RT参数和Simulate步进
2.1 为什么不用截屏,而是使用专用录制相机
很多需要导出序列帧的人第一反应是用ScreenCapture.CaptureScreenshot。这个接口截取的是GameView最终合成结果,包含场景背景、UI、后处理,而且分辨率受GameView设置限制;更关键的是,粒子光效一旦叠加到非纯黑场景上,保存出来的RGB颜色就已经被污染,后续没有办法再还原成可用的特效序列帧。
常见做法是在临时场景里搭一台“特效录制相机”。相机的CullingMask只保留粒子所在的Layer,例如FX层;targetTexture指向一张RenderTexture;相机的ClearFlags设为Solid Color,背景色用纯黑(0,0,0,0)。这样一来,渲染结果就是一张干净的、只有粒子光效的图片。这种做法的另一个好处是输出分辨率、背景、视角完全受控,可以按项目需要出512、1024或2048尺寸的序列帧,不受屏幕大小影响。
录制粒子光效还需要注意粒子系统的SimulationSpace。如果粒子挂在角色身上,并且粒子的世界坐标会受到角色移动影响,建议把SimulationSpace设为World,录制相机保持固定,这样粒子运动不会因为相机位移产生额外偏移。如果只是为了UI光效,粒子一般挂在空物体下,World与Local差异不大。
2.2 RenderTexture参数与相机背景怎么配
搭建录制环境时,RenderTexture的参数决定了最终PNG的亮度和色彩精度。直接使用默认的ARGB32录制光效,会遇到一个明显问题:粒子光效里超过1.0的高光一旦写入ARGB32,会被截断成白色一片,Bloom或辉光的层次全部丢失。因此录制RT要尽量使用ARGBHalf,也就是半浮点格式,保留HDR亮度。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| RenderTextureFormat | ARGBHalf | 保留HDR光效与高光层次,避免高亮区域死白 |
| DepthBuffer | 24 bit | 粒子之间有深度排序;纯UI特效可考虑0 |
| AntiAliasing | 1x 或 4x | 录制时可不做MSAA,依赖最终纹理尺寸;4x会增加后处理成本 |
| Camera ClearFlags | Solid Color | 背景必须纯黑,且Alpha设为0 |
| Camera Background | (0, 0, 0, 0) | Alpha写0,方便后面生成透明通道 |
| Camera.orthographic | true | 大部分粒子光效建议正交投影,保持UI比例稳定 |
创建录制相机和RT的代码可以这样写:
RenderTexture rt = RenderTexture.GetTemporary( 512, 512, 24, RenderTextureFormat.ARGBHalf, RenderTextureReadWrite.Linear ); rt.name = "FXCaptureRT"; captureCamera.targetTexture = rt; captureCamera.enabled = false; // 不交给Unity自动渲染,手动控制 captureCamera.backgroundColor = new Color(0f, 0f, 0f, 0f); captureCamera.clearFlags = CameraClearFlags.SolidColor;使用GetTemporary而不是new RenderTexture,是为了避免每次导出都产生一块只能靠GC回收的显存对象。这里显式指定RenderTextureReadWrite.Linear,和后续的PNG颜色空间转换有关。captureCamera.enabled = false表示不让相机在每帧自动渲染,而是在每一帧粒子状态设置好后手动调用camera.Render(),这样才能精确定位序列帧的每一帧内容。
2.3 ParticleSystem.Simulate:手动驱动粒子演出
导出序列帧最忌讳直接用协程Play后等待。编辑器或真机上Time.deltaTime不稳定,一帧可能16毫秒,下一帧可能30毫秒,最后导出的60帧并不是均匀的1/60秒。Unity的ParticleSystem.Simulate可以绕开真实时间,直接告诉粒子系统“现在播放到第几秒”,这是最稳定、最可复现的录制方式。
下面的代码展示了如何按固定帧率步进粒子,并手动渲染录制相机:
public void ExportSequence(ParticleSystem ps, Camera captureCamera, RenderTexture rt, string outDir, int fps = 30) { var main = ps.main; main.looping = false; main.playOnAwake = false; ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); ps.useAutoRandomSeed = false; ps.randomSeed = 20240601; // 固定随机种子,保证每次导出结果一致 float duration = main.duration; float step = 1f / fps; int frameCount = Mathf.CeilToInt(duration * fps) + 1; for (int i = 0; i < frameCount; i++) { ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); ps.Simulate(i * step, true, true); captureCamera.Render(); SaveRTToPng(rt, Path.Combine(outDir, string.Format("fx_{0:D4}.png", i))); } }这段代码的关键是ps.Simulate(i * step, true, true)。第一个参数是粒子系统从第0秒开始的绝对时间;第二个参数withChildren = true表示子发射器、Sub Emitter也一起参与模拟;第三个参数restart = true表示每次重新从时间戳开始计算,避免上一次调用遗留的内部状态影响当前帧。由于光效通常在0秒左右还没有粒子产生,第一帧往往是纯黑帧,可以忽略,也可以在保存时从i = 1开始。
如果粒子光效里有C#脚本在Update中修改粒子发射速率或颜色,Simulate不会触发这些脚本,导出的序列帧会缺失动态变化。遇到这种情况,要么把驱动逻辑迁移到Particle System的Curve或Shader,要么改用真实时间播放并按屏幕录制,但后者稳定性会差很多。
提示:粒子Duration长了以后,序列帧数量会很大。30fps导出1秒特效就是31张,2秒就是61张。建议先将Duration控制在1.5秒以内,循环特效可以只导出核心周期,再由播放端循环。
3. 逐帧保存PNG:透明通道和颜色空间最容易被忽视
3.1 从RenderTexture读回PNG的标准写法
前面录制的RT是ARGBHalf,但Texture2D.EncodeToPNG不支持半浮点纹理。因此保存PNG前,需要把HDR RT转换到一张ARGB32的RT,再通过ReadPixels读回CPU内存。为使PNG在显示器上看起来正常,转换目标RT要使用sRGB读写标记:
private void SaveRTToPng(RenderTexture rt, string path, Material encoder) { RenderTexture argb = RenderTexture.GetTemporary( rt.width, rt.height, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB ); Graphics.Blit(rt, argb, encoder); RenderTexture.active = argb; Texture2D tex = new Texture2D(argb.width, argb.height, TextureFormat.RGBA32, false, true); tex.ReadPixels(new Rect(0, 0, argb.width, argb.height), 0, 0); tex.Apply(false); File.WriteAllBytes(path, tex.EncodeToPNG()); RenderTexture.ReleaseTemporary(argb); RenderTexture.active = null; Object.DestroyImmediate(tex); }这段代码里的encoder材质承担了两个任务:把HDR亮度映射到LDR范围,同时生成Alpha通道。RenderTextureReadWrite.sRGB让RT在写像素时完成线性到sRGB的编码,避免PNG保存后整体偏暗。Texture2D tex构造参数中,TextureFormat.RGBA32表示最终像素格式,最后的true表示这是一个线性纹理。ReadPixels的时机必须在Graphics.Blit完成之后,并且恢复RenderTexture.active = null,防止影响后续其他渲染操作。
3.2 用Shader把光效亮度写入Alpha,解决灰边问题
粒子光效最常见渲染方式是Additive混合,例如Blend One One或Blend SrcAlpha One。当背景是黑色时,Additive粒子渲染后RGB等于光源颜色,但Alpha往往被写成了直接量或固定值。如果直接把这个结果作为普通Sprite放进UGUI,光效会呈现为一个不透明色块,或者边缘出现深色“灰边”。
灰边的本质是透明度信息不等于亮度信息。通常做法是增加一个编码Shader,在Blit时将亮度存入Alpha,同时保留RGB作为光效颜色:
fixed4 frag(v2f i) : SV_Target { float3 c = tex2D(_MainTex, i.uv).rgb; float luma = dot(c, float3(0.2126, 0.7152, 0.0722)); return fixed4(c, saturate(luma * _Intensity)); }这样PNG的Alpha表示“这个位置有多少光”。黑色背景处亮度为0,Alpha也为0,播放时透明边缘干净。_Intensity用于控制透明度敏感度:数值越大,只有很亮的区域保持不透明,适合粒子核心;数值越小,光晕整体变得更透明,适合柔和辉光。实际导出时需要根据最终播放背景调整,一般从0.8到1.2之间微调。
要注意,这个方案只适合Additive光效。如果粒子本身是标准Alpha混合,例如烟雾、溶解碎片,那么亮度转Alpha会严重破坏原效果。这种情况下编码Shader应该直接输出原Alpha,或者提供两个切换模式,由导出工具的参数决定。
3.3 颜色空间:线性项目里导出后偏暗的排查方向
很多Unity项目已经切换为Linear色彩空间。粒子材质在Shader中输出的颜色通常是线性光照结果,而PNG文件存储的是Gamma空间sRGB值。保存前如果缺少线性到Gamma的转换,导出的序列帧在非Unity软件里看起来会比GameView暗一个档次。
两个处理位置:一是依赖sRGB RT自动转换,大多数情况下RenderTextureReadWrite.sRGB会触发硬件编码;二是在编码Shader中显式转换:
#if defined(UNITY_COLORSPACE_LINEAR) c.rgb = LinearToGammaSpace(c.rgb); #endif如果你发现导出结果正好在Gamma项目和Linear项目中一个偏亮、一个偏暗,通常就是转换重复或缺失。确认方法很简单:在Photoshop里看光效中心高光区域的RGB值,若白色高光只有200左右而不是255附近,就要考虑是否缺少一次LinearToGammaSpace。
4. 光效序列帧批量自动化:Bloom、命名和平台压缩参数
4.1 封装成Editor工具,批量导出多个粒子特效
手动点击Play再录屏只适合调试。项目里特效师可能要同时导出几十个粒子Prefab,因此常见做法是把导出流程放进Editor脚本,使用MenuItem菜单触发:
[MenuItem("Tools/FX/Export Particle PNG Sequence")] public static void ExportSelected() { Transform selected = Selection.activeTransform; if (selected == null || selected.GetComponentInChildren<ParticleSystem>() == null) { Debug.LogError("请先选中一个包含ParticleSystem的物体"); return; } ParticleSystem ps = selected.GetComponentInChildren<ParticleSystem>(); Camera captureCamera = CreateCaptureCamera(); RenderTexture rt = RenderTexture.GetTemporary(512, 512, 24, RenderTextureFormat.ARGBHalf); ExportSequence(ps, captureCamera, rt, "Assets/FX/Generated"); RenderTexture.ReleaseTemporary(rt); AssetDatabase.Refresh(); }为了防止相机残留到游戏场景,CreateCaptureCamera方法里创建的相机可以设置为hideFlags = HideFlags.HideAndDontSave,或者导出结束后直接DestroyImmediate。输出目录统一放在Assets/FX/Generated下,命名包含粒子Prefab名、帧率、秒数,例如Lightning_30fps_60frames,这样不同版本导出不会互相覆盖。常规命名参数有:
| 参数 | 示例 | 理由 |
|---|---|---|
| 帧率 | 30 | 移动端UI序列帧30fps足够,减少包体 |
| 尺寸 | 512x512 | UGUI光效一般不需要超过屏幕的1/2 |
| 命名 | AbuTest_Fire_30fps_062 | 保留特效名、帧率和日期,方便版本对比 |
| Alpha | luma | Additive光效使用亮度Alpha |
4.2 把Bloom和辉光烘焙进序列帧
如果粒子光效依赖场景中的Bloom后处理,录制相机的RT不会自动包含辉光。实时Bloom是由后处理栈在相机渲染后处理的阶段叠加的,而这里我们直接调用camera.Render(),并不会走完整的后处理管线。要让序列帧有光晕,常见做法是录制时在HDR阶段用几个Blit手动烘焙Bloom。
核心思路是先提取亮部,再模糊亮部,最后把模糊结果叠加回原图。简化步骤是:
// 1. 提取亮度超过阈值的像素 Graphics.Blit(rt, brightRT, brightThresholdMaterial); // 2. 对brightRT做双向高斯模糊 Graphics.Blit(brightRT, blurRT, blurMaterial); Graphics.Blit(blurRT, brightRT, blurMaterial); // 3. 把模糊后的辉光与原始粒子光效叠加,同时生成Alpha combineMaterial.SetTexture("_BloomTex", brightRT); Graphics.Blit(rt, argbRT, combineMaterial);brightThresholdMaterial里的阈值决定了哪些像素会产生辉光,光效高光通常取0.6到0.9。模糊的扩散半径和迭代次数直接决定光晕柔和程度。多次迭代会产生很好的软光斑,但导出时间也成倍增加。注意这些Blit操作都要在HDR RT之间进行,等到转成ARGB32后再做Bloom,分离的光晕会有明显色阶断层。
如果你只在部分平台需要光晕,另一种选择是在序列帧中保留HDR颜色,运行时不烘焙任何后处理,让UGUI的Shader自行实现外发光。但这个方案会把表现压力重新推回运行时,离开“预烘焙优化”的初衷,所以我一般只在采集阶段解决。
4.3 PNG导入Unity后的压缩与内存怎么选
导出的PNG进入Unity后会重新导入为Texture。默认RGBA32最清晰,但序列帧动辄几十张,每张512x512就会占掉几十MB内存。移动端建议在导入设置里手动选择压缩格式,而不是全部保持RGBA32。
| 目标平台 | 导出RT尺寸 | 导入压缩 | 说明 |
|---|---|---|---|
| iOS / Android | 512x512 | ASTC 6x6 | 透明通道质量相对平衡 |
| WebGL | 512x512 | RGBA32或ETC2 | 需要测透明边缘,ETC2对Alpha压缩明显 |
| 微信小游戏 | 256或512 | RGBA32 | 小游戏包体敏感时可再压到ETC2 |
| 视频/广告帧 | 1024x1024 | RGBA32 | 保证非Unity播放器不出现压缩色块 |
微信小游戏打包时,粒子系统本身、C#粒子脚本和Bloom后处理代码都会增加包体和首包下载时间。换成PNG序列帧后,这四个运行时模块都可以被移除或弱化,所以导出时付出一点容量成本,换取打包后的小程序体积更可控,是值得的。如果序列帧是循环特效,也可以导出后合入一张水平或垂直的TextureAtlas,减少外部加载文件数量。
5. 序列帧回放、验证Alpha和最终上线细节
最后一步是播放与验证。在UGUI里播放PNG序列帧,最轻量的方式是使用RawImage切换texture:
using UnityEngine; using UnityEngine.UI; public class PNGSequencePlayer : MonoBehaviour { public RawImage target; public Texture2D[] frames; public float fps = 30f; private float timer; void Update() { timer += Time.deltaTime; int index = Mathf.FloorToInt(timer * fps) % frames.Length; target.texture = frames[index]; } }RawImage.texture直接换纹理,不创建额外的Material实例,适合批量挂在UI层级中。记得把RawImage的raycastTarget设为false,避免特效透明区域拦截鼠标事件。如果以后序列帧数量爆炸,可以考虑把数组换成Texture2DArray或图集,减少Graphics API切换。
验证时不要只在编辑器里看播放效果,建议在Game视图并排放两个面板:左边是实时粒子系统,右边是刚导出的序列帧。检查三个点:一是粒子从出现到消失的总时长是否一致;二是中心高光是否接近白色且边缘有渐变,而不是一整块不透明方块;三是把UI背景颜色从纯黑拖到白色,观察序列帧是否出现明显灰边。灰边明显的,调高编码Shader的_Intensity,让更多半透明区域变成全透明。
对于循环光效,还要把序列帧的最后一帧和第一帧做一次衔接检查。如果粒子系统Duration恰好是某个周期的整数倍,通常可以直接循环;如果不是,要么在播放端使用交叉淡入,要么补录过渡帧。验证完毕后,把序列帧连同播放器组件打进AssetBundle,移动端用Profiler观察播放时每帧的CPU耗时,确认没有因为纹理采样和GC产生新的性能风险。
本文还有配套的精品资源,点击获取