【VFX工具链源码拆解 07】不进 Play 模式也能看特效!手写 3D 离屏预览与封面自动烘焙引擎

(本文为《Unity VFX 提效工具源码拆解》系列第 7 篇,承接第 6 篇的拖拽与引用隔离。本篇将已入库的 Prefab 转成可交互预览,再把观察到的构图保存为封面。)

一、资源库缺少的不是另一张静态缩略图

特效的主要信息存在于时间过程中

一张缩略图难以说明粒子何时爆发、刀光如何移动、循环是否连贯。若每次浏览都进入 Play 模式或临时搭场景,美术需要在检索与验证之间反复切换,新人还要记住灯光、相机和重播方式。预览工具应当接受一个明确的 Prefab,在资源浏览上下文中提供可重复的观察环境。

这里的输入是GameObject prefab与预览矩形Rect;输出有两种:每次重绘显示的离屏图像,以及用户烘焙时返回的Texture2D封面。VFXCategoryOverview负责传入资源和接收封面,VFXStudioUtils.VFXPreviewEngine负责预览对象、时间与渲染。

离屏渲染把观察环境与工作层级分开

Unity 原生PreviewRenderUtility提供用于编辑器预览的场景、相机和灯光。工具把待预览 Prefab 实例化到该环境,渲染后把纹理绘制到面板矩形。这样可以减少对当前工作场景层级的干扰,并集中管理相机与临时实例。

这并不等于完整复现游戏运行环境。依赖业务脚本生命周期、场景灯光、全局管理器、物理状态或特定渲染管线的效果,仍需进入实际运行环境验证。

在特效资产库中选中 Prefab 后,右侧交互预览显示临时实例的动态效果;拖动视图调整观察角度与平移,滚轮改变视野。资源选择、时间推进和面板显示在同一窗口完成,减少了为每次浏览建立测试场景的步骤。

二、预览实例如何建立并复用

相机和灯光在首次使用时创建

RenderVFXPreview被资源概览反复调用,因此不能每次调用都新建完整环境。下面节选采用延迟初始化:_utility为空时才创建工具,并配置相机裁剪面与基础灯光。

// PreviewRenderUtility 延迟初始化:以下保留实际处理语句,外围上下文见正文。if(_utility==null){_utility=newPreviewRenderUtility();_utility.camera.fieldOfView=30f;_utility.camera.nearClipPlane=0.01f;_utility.camera.farClipPlane=1000f;_utility.ambientColor=newColor(0.2f,0.2f,0.2f);_utility.lights[0].intensity=1.2f;_utility.lights[0].transform.rotation=Quaternion.Euler(50,-30,0);}

fieldOfView、nearClipPlane、farClipPlane属于相机参数,决定能看见的空间;ambientColor与预览灯光提供默认照明。它们是便于浏览的起始条件,不是对所有特效正确曝光和管线兼容的保证。

切换 Prefab 时更新实例和组件缓存

若仍预览同一资源,工具复用现有实例;资源改变时销毁旧实例,再收集需要驱动的组件。

// 预览实例切换与组件缓存:以下保留实际处理语句,外围上下文见正文。if(_currentPrefab!=prefab){if(_instance!=null)Object.DestroyImmediate(_instance);_currentPrefab=prefab;if(prefab!=null){_instance=_utility.InstantiatePrefabInScene(prefab);_cachedRenderers=_instance.GetComponentsInChildren<Renderer>(true);_cachedParticleSystems=_instance.GetComponentsInChildren<ParticleSystem>(true);_cachedAnimators=_instance.GetComponentsInChildren<Animator>(true);_cachedAnimations=_instance.GetComponentsInChildren<Animation>(true);_cachedDirectors=_instance.GetComponentsInChildren<UnityEngine.Playables.PlayableDirector>(true);}}

_currentPrefab是资源身份,_instance是预览场景中的临时对象,不能将二者混为同一份资产。InstantiatePrefabInScene建立预览实例,随后把 Renderer、粒子、Animator、旧版 Animation 和 Director 分别存入数组,避免每轮重绘重新扫描层级。

组件数组包含未激活孩子。这扩大了可观察范围,但也意味着后面的驱动逻辑需要关注父子组件是否被重复推进。

三、编辑模式下,工具需要主动维护时间

用编辑器时钟生成增量与累计时间

没有进入 Play 模式时,不能假定所有组件会按游戏帧自行更新。工具从编辑器启动时钟取差值,再乘以面板速度。

// 编辑器时间与速度:以下保留实际处理语句,外围上下文见正文。floatrawDelta=(float)(EditorApplication.timeSinceStartup-_lastEditorTime);_lastEditorTime=EditorApplication.timeSinceStartup;floatscaledDelta=rawDelta*_timeSpeed;_time+=scaledDelta;

rawDelta是两次执行间隔,scaledDelta是乘过_timeSpeed的增量,_time是累计预览时间。增量适合推进粒子与 Animator,累计时间适合采样动画或设置 Director 时间。

重绘调度受到编辑器负载影响,因此这个时钟不是固定步长。暂停、切换资源或长时间未重绘后,较大增量的处理需要特别测试,不能把面板播放速度当成确定性的离线模拟。

不同组件使用不同的驱动接口

下面原文展示粒子生命检查、Animator 更新、旧动画采样与 Timeline 评估。

// 粒子 Animator Animation Director驱动:以下保留实际处理语句,外围上下文见正文。boolisAlive=false;foreach(varpsin_cachedParticleSystems)if(ps!=null&&ps.IsAlive(true))isAlive=true;if(!isAlive&&_cachedParticleSystems.Length>0){_time=0;foreach(varpsin_cachedParticleSystems)if(ps!=null)ps.Simulate(0,true,true,true);}foreach(varpsin_cachedParticleSystems)if(ps!=null)ps.Simulate(scaledDelta,true,false,false);foreach(varanimin_cachedAnimators)if(anim!=null)anim.Update(scaledDelta);foreach(varanimin_cachedAnimations){if(anim!=null&&anim.clip!=null){floatclipTime=_time%Mathf.Max(0.01f,anim.clip.length);anim.clip.SampleAnimation(anim.gameObject,clipTime);}}foreach(vardirin_cachedDirectors){if(dir!=null){dir.timeUpdateMode=UnityEngine.Playables.DirectorUpdateMode.Manual;dir.time=_time%Mathf.Max(0.01f,(float)dir.duration);dir.Evaluate();}}

粒子全部不存活时,工具把时间归零并重置模拟;随后用Simulate(scaledDelta, true, false, false)增量推进。第二个参数会带上孩子,而缓存中也包含孩子粒子,因此嵌套系统存在被多次模拟的风险;实际工具需要按层级策略验证,不能宣称所有复杂粒子都严格只更新一次。

Animator.Update使用增量;AnimationClip.SampleAnimation以模长后的绝对时间采样;Director 改为Manual后设置time并执行Evaluate。若同一对象同时被这些系统控制,驱动顺序会影响结果,当前实现没有建立完整的冲突调度器。

渲染前临时替换 Shader 时钟,结束后恢复

纹理滚动或溶解常读取 Shader 的时间变量,仅推进粒子组件不足以让材质动画显示。工具保存原值,用预览时间构造替代值,再在finally还原。

// Shader时间替换与finally恢复:以下保留实际处理语句,外围上下文见正文。Vector4originalTime=Shader.GetGlobalVector("_Time");Vector4originalSinTime=Shader.GetGlobalVector("_SinTime");Vector4originalCosTime=Shader.GetGlobalVector("_CosTime");floatoriginalTimeScale=Time.timeScale;Vector4fakeTime=newVector4(_time/20f,_time,_time*2f,_time*3f);Vector4fakeSinTime=newVector4(Mathf.Sin(_time/8f),Mathf.Sin(_time/4f),Mathf.Sin(_time/2f),Mathf.Sin(_time));Vector4fakeCosTime=newVector4(Mathf.Cos(_time/8f),Mathf.Cos(_time/4f),Mathf.Cos(_time/2f),Mathf.Cos(_time));try{Time.timeScale=_timeSpeed;Shader.SetGlobalVector("_Time",fakeTime);Shader.SetGlobalVector("_SinTime",fakeSinTime);Shader.SetGlobalVector("_CosTime",fakeCosTime);_utility.BeginPreview(rect,GUIStyle.none);_utility.camera.Render();Textureresult=_utility.EndPreview();GUI.DrawTexture(rect,result,ScaleMode.ScaleToFit);}finally{Shader.SetGlobalVector("_Time",originalTime);Shader.SetGlobalVector("_SinTime",originalSinTime);Shader.SetGlobalVector("_CosTime",originalCosTime);Time.timeScale=originalTimeScale;}

fakeTime对应_Time的四个分量,另两组向量供正弦和余弦动画使用。BeginPreview、camera.Render、EndPreview完成一轮离屏渲染,GUI.DrawTexture把结果显示到窗口。

保存并恢复全局状态是必要的,因为这些变量不是当前材质私有字段。不同管线还可能在相机渲染时设置自己的常量,这种替换并不覆盖所有 Shader 时间机制。这里的finally恢复时间与缩放,但没有进一步包装所有预览资源和相机状态的异常恢复。

四、让拖尾有运动,让构图有可重复的输入

按命名约定为拖尾实例制造位移

静止对象的拖尾可能只是一点。源码以资源名称是否包含Trails或“拖尾”决定是否为实例增加圆周运动。

// 拖尾预览位移:以下保留实际处理语句,外围上下文见正文。boolisTrailAsset=prefab.name.Contains("Trails")||prefab.name.Contains("拖尾");if(isTrailAsset){floatspeed=4f;floatmoveScale=_radius*0.4f;_instance.transform.position=newVector3(Mathf.Sin(_time*speed),Mathf.Cos(_time*speed),0)*moveScale;_instance.transform.rotation=Quaternion.identity;}else{_instance.transform.position=Vector3.zero;_instance.transform.rotation=Quaternion.identity;}

_radius来自 Renderer 包围盒,影响运动尺度;正弦和余弦让实例在一个平面内移动。它是预览用运动,并不是读取游戏里的轨迹。依赖其他名称、真实速度和碰撞反馈的拖尾不会因此自动获得完整测试条件。

安全框建立窗口构图与输出像素之间的对应

相机围绕包围盒与平移偏移定位,面板还提供预设视角、重播、速度与封面安全框。安全框用于说明即将裁出的区域,不会修改游戏相机或资源自身的尺寸。

选中符合命名约定的拖尾资源后,临时实例沿圆周运动,便于观察轨迹连续性。调整播放倍率、视角和安全框内构图,再触发封面烘焙,可以把同一观察角度用于资源卡片;这些操作改变的是预览状态,不是业务场景的运动逻辑。

五、交互截图如何变成 256 像素封面

先按安全框缩放,再从中间裁切

BakeCurrentInteractiveView不直接读取屏幕截图。它根据窗口大小与安全框比例,计算离屏渲染分辨率,使安全框对应最终256 × 256区域。

// 安全框缩放与ReadPixels:以下保留实际处理语句,外围上下文见正文。intfinalSize=256;floatsafeBoxSize=Mathf.Min(viewWidth,viewHeight)*0.8f;floatscaleFactor=finalSize/safeBoxSize;intrtWidthPixels=Mathf.RoundToInt(viewWidth*scaleFactor);intrtHeightPixels=Mathf.RoundToInt(viewHeight*scaleFactor);rtWidthPixels=Mathf.Max(rtWidthPixels,finalSize);rtHeightPixels=Mathf.Max(rtHeightPixels,finalSize);RenderTexturetempRT=RenderTexture.GetTemporary(rtWidthPixels,rtHeightPixels,24,RenderTextureFormat.ARGB32);RenderTextureoldRT=_utility.camera.targetTexture;_utility.camera.targetTexture=tempRT;_utility.camera.aspect=(float)rtWidthPixels/rtHeightPixels;_utility.camera.Render();RenderTexture.active=tempRT;Texture2Dtex2D=newTexture2D(finalSize,finalSize,TextureFormat.RGBA32,false);intstartX=(rtWidthPixels-finalSize)/2;intstartY=(rtHeightPixels-finalSize)/2;tex2D.ReadPixels(newRect(startX,startY,finalSize,finalSize),0,0);tex2D.Apply();RenderTexture.active=null;_utility.camera.targetTexture=oldRT;RenderTexture.ReleaseTemporary(tempRT);

scaleFactor将当前安全框边长映射到输出边长;rtWidthPixels/rtHeightPixels保持窗口构图比例。渲染到临时RenderTexture后,startX/startY定位居中的正方形区域,ReadPixels将 GPU 图像读回 CPU 纹理。

像素读回可能使 CPU 等待 GPU,适合按钮触发,不适合无条件放入高频重绘。当前交互烘焙分支恢复camera.targetTexture并释放临时纹理,但直接把RenderTexture.active设为null,没有恢复之前的活动目标,也没有用try...finally包裹全部过程;这应作为维护边界说明。

PNG 文件与封面配置有两个保存步骤

方法把纹理编码成 PNG,写入Assets/VFXStudio_Data/Snapshots,导入后返回资产对象。

// PNG写入与TextureImporter:以下保留实际处理语句,外围上下文见正文。stringsavePath=$"{saveDir}/{prefab.name}_snapshot.png";byte[]bytes=tex2D.EncodeToPNG();File.WriteAllBytes(savePath,bytes);Object.DestroyImmediate(tex2D);AssetDatabase.ImportAsset(savePath,ImportAssetOptions.ForceUpdate);TextureImporterimporter=AssetImporter.GetAtPath(savePath)asTextureImporter;if(importer!=null){importer.textureType=TextureImporterType.GUI;importer.alphaIsTransparency=true;importer.SaveAndReimport();}returnAssetDatabase.LoadAssetAtPath<Texture2D>(savePath);

EncodeToPNG与WriteAllBytes写文件,ImportAsset让 Unity 识别变化,TextureImporter调整 GUI 类型与透明度处理。启用alphaIsTransparency只影响导入解释,不能把原本不透明的背景变成透明。

回调收到snapshot后,把它写入VFXAssetItem.Icon。若当前卡片只是临时包装,则先通过GetOrCreateConfig建立持久化配置,再保存引用。这与第 1 篇的配置注册表是同一条数据持久化路径。

六、自动快照与临时对象清理

默认封面使用独立的固定观察策略

另一个入口BakePrefabSnapshot为资源提供默认封面。它新建预览工具与实例,把粒子模拟到0.5f,根据包围盒估计观察距离,再渲染固定尺寸图片。下面节选对应取样和清理阶段。

instance=utility.InstantiatePrefabInScene(prefab);instance.transform.position=Vector3.zero;ParticleSystem[]pss=instance.GetComponentsInChildren<ParticleSystem>(true);foreach(varpsinpss)ps.Simulate(0.5f,true,true,false);Renderer[]renderers=instance.GetComponentsInChildren<Renderer>(true);Boundsbounds=newBounds(Vector3.zero,Vector3.zero);boolhasBounds=false;foreach(varrinrenderers){if(r.bounds.extents.magnitude<50f){if(!hasBounds){bounds=r.bounds;hasBounds=true;}else{bounds.Encapsulate(r.bounds);}}}if(!hasBounds)bounds=newBounds(Vector3.zero,Vector3.one);floatradius=Mathf.Clamp(bounds.extents.magnitude,0.5f,10f);// 此处省略与本段说明无关的代码。utility.BeginPreview(newRect(0,0,256,256),GUIStyle.none);utility.camera.Render();TexturerawTex=utility.EndPreview();RenderTextureprevRT=RenderTexture.active;RenderTexture.active=rawTexasRenderTexture;tex2D=newTexture2D(256,256,TextureFormat.RGBA32,false);tex2D.ReadPixels(newRect(0,0,256,256),0,0);tex2D.Apply();RenderTexture.active=prevRT;File.WriteAllBytes(savePath,tex2D.EncodeToPNG());}finally{if(instance!=null)Object.DestroyImmediate(instance);if(tex2D!=null)Object.DestroyImmediate(tex2D);utility.Cleanup();}

包围盒过大的 Renderer 被过滤,半径会裁剪到预定区间,相机目标也包含经验偏移。因此这是资源库默认视角,不保证每个特效最强表现帧都出现在半秒,也不会自动识别艺术构图。

finally销毁临时实例与 CPU 纹理,调用Cleanup释放预览环境。交互引擎还提供Dispose,窗口销毁时由工作台统一清理。原始资产保留,临时对象则应与窗口或烘焙任务保持同样的生命周期。

同名资源与特殊渲染依赖需要单独验证

两条烘焙路径都以prefab.name命名快照,没有追加 GUID 或使用唯一文件路径。同名 Prefab 因而可能覆盖同一 PNG。依赖 VFX Graph、后处理、场景深度或特定光照的效果,也不能仅凭组件数组推断已被完整支持。

离屏预览的价值在于把动态判断留在资产浏览流程,并让图像结果回到配置资产。维护这条链时,要同时跟踪预览实例、时钟、渲染目标、CPU 纹理和配置引用,避免只看到面板在播放,却忽略其资源与状态成本。


下一篇预告:继续处理 Unity 之外的数据入口,拆解 P4 进程执行、动作备注解析与可选宿主工具反射接入。

【VFX工具链源码拆解 08】打通上下游管线:在 Unity 中集成 P4 异步拉取与 3ds Max 备注智能解析