galacean / engine-spine

MIT License
9 stars 8 forks source link

Refactor Spine Runtime for Code and Performance Optimization #105

Open johanzhu opened 2 months ago

johanzhu commented 2 months ago

背景

当前的 Spine 运行时在代码结构和性能方面仍有改进空间,需要进一步优化。
在 1.3 里程碑中,我们集中优化了一波内存占用;在本次里程碑中,会针对运行时性能进一步进行的优化。
目标:

本 RFC 包含对于代码结构和代码细节的一些调整,以优化运行时的性能。这些优化点来自于对竞品的调研,包括 spine 官方的: unity 运行时(主要), ue 运行时,threejs 运行时,webgl 运行时以及 pixi 运行时。

index 更新优化

简述:Spine 动画在播放时,顶点的序号一般不会发生修改,更新的是顶点的位置。优化 index 更新机制能够减少相关计算,并减少和 GPU 的交互从而提升性能。

方案:为了优化 index 的更新,增加一个对象存储渲染的基本信息(比如顶点的总数,子网格顶点数,待渲染的附件等)。每帧对比这些信息,满足条件的情况下跳过 index 的更新以优化性能。

优劣:该方案虽然优化了 index 更新但是增加了内存,以及部分 CPU 开销。

代码方案:

index 更新优化涉及到修改核心的 buildPrimitive 方法的结构。下面方案的图示参考:

绿色是逻辑有更新的部分,核心的改动只有两条:

  1. 通过引入渲染指令,把 material ,submesh 的创建过程提升到了创建渲染指令阶段
  2. 更新 buffer 前,会根据渲染指令的对比结果,来选择是否跳过 index 的更新

双缓冲优化

简述:对于 Spine 这种需要高频更新 buffer 并渲染的场景,双缓冲能有效提高帧率的稳定性,减少GPU 的空闲等待时间,提升了硬件资源的利用率。

方案:在渲染器内维护两个 vertexBuffer 和 indexBuffer,用于实现双缓冲。每一帧,都会绑定下一个 buffer 用于渲染,当前 buffer 则用于计算得到当前帧的数据。

优劣:优势上面已经提到了,双缓冲的劣势也是显而易见的:增加输入延迟,占用更多内存是否能够带来切实提升需要在端上进行测试。

代码方案:

参考 Unity 的实现,Unity 内部维护了一个 MeshRenderBuffer 对象,并在内部管理双缓冲对象,通过一个DoubleBuffered 类以及两个 unity 的 Mesh 来实现缓冲的 swap。
https://github.com/EsotericSoftware/spine-runtimes/blob/4.2/spine-unity/Assets/Spine/Runtime/spine-unity/Mesh%20Generation/MeshRendererBuffers.cs

https://github.com/EsotericSoftware/spine-runtimes/blob/4.2/spine-unity/Assets/Spine/Runtime/spine-unity/Mesh%20Generation/DoubleBuffered.cs

新增一个类(暂定 RenderBuffer)来管理 Primitive 和 Buffer。Buffer 的修改以及交换,都交给 RenderBuffer 类处理。

裁减优化

  1. 简述:目前 update 方法在裁减后依旧会调用,会浪费性能。

方案:裁减后,暂停动画的更新

代码方案:isCulled 如果为 true,停止动画的更新

  1. 简述: Spine 的裁减需要大量的 CPU 计算,可以替换成 Mask 的实现方式以优化性能

方案:根据 Clip 附件的形状绘制一个 Mask 添加到场景中用于遮罩 spine 动画。

优劣:CPU 裁减 在大面积裁减,尤其是裁减了复杂 Mesh 附件的情况下,不但会增加 CPU 计算开销,而且会增加非常多顶点数量。使用遮罩能够有效减少这部分开销。但是,使用遮罩会打断合批,在 Spine 动画数量非常多的情况下,会增加大量 drawcall。

⚠️ 目前 2d 还不支持 Graphic,除非自己定制一个 spine 专用的遮罩类(非常 hack),故先不做该优化。

条件优化

简述:大多数的 Spine 动画只有一个 submesh。在这一前提下,Spine 动画在渲染循环中,可以跳过对于 Submesh 分割的逻辑判断(对比 Texture, BlendMode ),节省性能。

方案:增加 SingleSubMesh 开关,开关开启后,每帧跳过 subMesh 判断逻辑,以优化性能。

简述:大多数的 Spine 动画并不会上裁减。可以针对这种情况,可以优化每帧顶点的赋值计算,优化计算性能。

方案:遍历当前状态下的 skeleton 对象,若不存在裁减附件即无裁减时,跳过裁减判断,同时优化 buffer 的赋值计算逻辑。

代码方案:

  1. 顶点数据赋值优化。当无裁减时,每个附件的顶点数据都是确定的可以在 computeWorldVertices 后按照顶点的顺序直接赋值;无需增加中间变量,二次遍历赋值。

存在裁减时:需要添加一个中间变量记录顶点数据,然后二次遍历进行赋值

优化方案:得到顶点数据后,直接按照顺序赋值至顶点数据中

MeshAttachment 同理

  1. 索引赋值优化。

当存在裁减时,由于裁剪后的多边形和三角形可能是不规则的,需要根据裁剪后的形状重新生成顶点和三角形索引。因此,索引的生成同样需要一个中间变量,并二次遍历赋值。

当无裁减时,索引可以直接通过固定的规则生成(region 每 4 个顶点生成 2 个三角形, mesh 附件直接用 attachmentTriangles),无需引入新的中间变量,可根据按照顺序依次赋值。

Region 按照顶点顺序赋值:

tris[triangleIndex] = attachmentFirstVertex;
tris[triangleIndex + 1] = attachmentFirstVertex + 2;
tris[triangleIndex + 2] = attachmentFirstVertex + 1;
tris[triangleIndex + 3] = attachmentFirstVertex + 2;
tris[triangleIndex + 4] = attachmentFirstVertex + 3;
tris[triangleIndex + 5] = attachmentFirstVertex + 1;

Mesh 需要遍历其索引数据赋值:

const attachmentTriangles = meshAttachment.triangles;
for (let i = 0, n = attachmentTriangles.length; i < n; i++, triangleIndex++) {
  tris[triangleIndex] = attachmentFirstVertex + attachmentTriangles[i];
  attachmentFirstVertex += meshAttachment.worldVerticesLength >> 1; // length/2;
}
  1. 不存在裁减时,跳过全部的 clip 判断
    • index 更新优化 一般来说,如果不存在附件的显示与隐藏,spine的 index 无需更新。所以,可以通过增加开关,来实现条件优化。 方案: 增加一个 immutableTriangles 开关,开启时,直接跳过index更新阶段,同时渲染过程中,不存储上一帧的渲染指令缓存,也不进行指令对比。

其他优化

把 Math.max 和 Math.min 替换成 > < 比较

性能参考:https://stackoverflow.com/questions/1232345/speed-and-style-of-math-max-vs-ternary-operator-in-javascript

PS: 目前的包围盒的计算会在以下时机更新

- 创建 spine 动画时刻
- 动画播放过程中
- spine entity 的 Transform 发生改变时

buffer 的 setData 修改为 Discard 模式

由引擎合批管线负责处理,runtime 层不进行额外处理

singlecoder commented 2 weeks ago

Spine 的裁减需要大量的 CPU 计算 这块主要计算在哪里,包围盒么?Mask 的话,性能不一定会有帮助

singlecoder commented 2 weeks ago

包围盒是否可以估算,而不是精确计算,可以把大部分在屏幕外面的裁剪掉就行

singlecoder commented 2 weeks ago

我记得 Spine 里面的更新 Update 函数是无论是否被裁剪都会被执行?是放在 onUpdate 里的

johanzhu commented 2 weeks ago

Spine 的裁减需要大量的 CPU 计算 这块主要计算在哪里,包围盒么?Mask 的话,性能不一定会有帮助

这里指的是 Spine 自身的遮罩裁减哈~

johanzhu commented 2 weeks ago

我记得 Spine 里面的更新 Update 函数是无论是否被裁剪都会被执行?是放在 onUpdate 里的

是的。这里需要做处理,RFC里给出了方案,有个参数判断一下就好啦

johanzhu commented 2 weeks ago

包围盒是否可以估算,而不是精确计算,可以把大部分在屏幕外面的裁剪掉就行

有道理,我思考一下能否估算。因为计算包围盒会影响每帧性能。