Notifications
Article
用Unity实现离屏渲染
Updated 8 days ago
7
2
用Unity实现离屏渲染
在我的上一个项目中产品经理曾经提出过一个需求:他希望将一段长度为40秒的3D人物骨骼动画(一段跳舞动画)压制成视频,以达到让用户分享的目的。
对于这个需求我的思路是:我们可以利用Unity相机上的OnRenderImage方法,在Unity的Render Loop中首先等待场景中的其他物体绘制完成,再逐帧将动画变换后的Mesh数据以离屏渲染的方式绘制进缓冲区,再从缓冲区中逐帧提取color buffer并保存在磁盘上以压制视频。
要实现这一思路,首先我们要有一个MonoBehaviour并实现OnRenderImage方法,以使我们可以对当前帧进行处理。
using UnityEngine; [RequireComponent(typeof(Camera))] public class OffscreenRendering : MonoBehaviour { public void OnRenderImage(RenderTexture source, RenderTexture destination) { Graphics.Blit(source, destination); } }
OnRenderImage方法传入的第一个参数source便是Camera绘制完的当前帧。而我们希望做的则是将人物的跳舞动画绘制在这一帧上。为此我们创建一个方法叫Render,并且在这个例子里我用一个bool值来控制是否要调用它(仅供参考)。
public bool render; private void Render(RenderTexture source) { } public void OnRenderImage(RenderTexture source, RenderTexture destination) { if (render) { Render(source); render = false; } Graphics.Blit(source, destination); }
接下来我们要开始在Render方法中实现绘制部分。首先要考虑的是,如果我们直接对source进行绘制,就无法对前一次绘制进行撤销,而每下一次绘制都会保留上一次的绘制内容。因此我们要创建一个新的RenderTexture用以绘制,以保证source不被修改。
private void Render(RenderTexture source) { RenderTexture rt = new RenderTexture(source); rt.format = RenderTextureFormat.ARGB32; rt.Create(); Graphics.SetRenderTarget(rt); }
这里要注意的是我们把RenderTexture的格式设置成了ARGB32,是为了在保存图像时也使用ARGB32格式。因为假如你的相机开启了HDR,你的source就可能会是其他的(ARGBFloat,ARGBHalf,RGB111110Float,等)格式,这在后期保存数据时就会很麻烦。
我们还需要创建一个Mesh用来保存每一帧的网格数据。
Mesh mesh = new Mesh();
除此之外我们还得能跳过Render Loop实现手动刷新蒙皮网格顶点变化。在Animator类中通过调用Update(deltaTime);可以实现这一点。但在继续之前我们先看一下当前的对象设置。
为了可以实现手动调用Animator的Update,我把Component禁掉了,不然Update就会自动执行。为了计算正确的deltaTime,我们需要知道动画帧速率和时间长度以计算出时间变化量。
AnimatorClipInfo animatorClipInfo = animator.GetCurrentAnimatorClipInfo(0)[0]; float totalFrames = Mathf.RoundToInt(animatorClipInfo.clip.length * animatorClipInfo.clip.frameRate); float deltaTime = 1.0f / animatorClipInfo.clip.frameRate;
在这之后我们就可以通过调用Animator的Update逐帧计算出新的Mesh并绘制到缓冲区里去。
for (int i = 0; i < totalFrames; i++) { GL.Clear(true, false, Color.black); Graphics.Blit(source, rt); animator.Update(deltaTime); material.SetPass(0); for (int j = 0; j < skinnedMeshRenderers.Length; j++) { skinnedMeshRenderers[j].BakeMesh(mesh); Graphics.DrawMeshNow(mesh, Vector3.zero, Quaternion.identity); } }
由于Graphics.Blit是不会复制depth buffer的,因此在每一次绘制前我们要做两件事:通过调用GL.Clear清除掉前一次的depth bufer,以及调用Graphics.Blit将source的color buffer拷贝到当前Render Target。
之后我们调用material.SetPass(0)通知GPU我们要使用material上的0号Pass来渲染。我用的Mixamo的这个Model有两份Skinned Mesh,因此我需要遍历一下所有的Skinned Mesh Renderer,并调用BakeMesh方法将蒙皮网格的顶点变化写到之前创建的Mesh对象里,之后再调用Graphics.DrawMeshNow方法绘制最终的Mesh。
在实现了绘制后,我还要把每一帧的color buffer拷贝出来并写到磁盘上。为了不阻碍主线程我们可以使用新加入的AsyncGPUReadback类以实现这一目的。
AsyncGPUReadback.Request(rt, 0, rt.graphicsFormat, (obj) => { NativeArray<Color32> buffer = obj.GetData<Color32>(); // 至此可以使用线程池或单线程通过调用ImageConversion.EncodeArrayToPNG方法将buffer编码并写入磁盘, // 这里就不详细解释了。 });
至此我们就实现了在Unity中的离屏渲染。在Unity的Bulti-in管线上利用现有API实现离屏渲染还是很方便的,效率也还可以。在笔者老掉牙的2013 MacMini + egpu组合下80帧的动画只渲了172ms,也就是每帧渲染2.15ms,而在保存时通过利用新的线程安全接口ImageConversion.EncodeArrayToPNG也可以实现多线程磁盘写入。但是这种实现方式也有其局限性。比如在这个例子中渲出来的人物身上没有影子,因为调用Graphics.DrawMeshNow并不会更新Shadow Map,而当前Bulti-in管线也没有C#层面的接口可以直接操作Shadow Map。如果对效果要求很高,恐怕就需要去通过Unity的Internal-ScreenSpaceShadows.shader里面的各种函数实现阴影绘制,或者是自己实现一些低成本的阴影绘制方案。目前笔者正在学习SRP,希望可以在不远的将来找到更好的解决方案。
效果
Zhang Jw
Unity Certified Expert Gameplay Programmer - Programmer
1
Comments
Zhang Jw
7 days ago
Unity Certified Expert Gameplay Programmer
xunshu@Zhang Jw 大佬,unity中国社区文章发布入口是:unity.cn/articles - 写文章,unity.cn文章和unity connect app首页、unity hub社区上的文章同步。这里是国际站,比较适合用来发英文博客。
是的我发现了...
0
xunshu
Staff
8 days ago
@Zhang Jw 大佬,unity中国社区文章发布入口是:unity.cn/articles - 写文章,unity.cn文章和unity connect app首页、unity hub社区上的文章同步。这里是国际站,比较适合用来发英文博客。
0