Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor Spine Runtime for Code and Performance Optimization #105

Open
johanzhu opened this issue Aug 20, 2024 · 6 comments
Open

Refactor Spine Runtime for Code and Performance Optimization #105

johanzhu opened this issue Aug 20, 2024 · 6 comments

Comments

@johanzhu
Copy link
Collaborator

johanzhu commented Aug 20, 2024

背景

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

  • 重构现有的 Spine 运行时代码,使其更加模块化和易于维护
  • 减少不必要的计算和内存开销使用来优化运行时性能
  • 确保重构后的代码与当前版本保持功能一致性,且不会引入新的错误

本 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),故先不做该优化。

条件优化

  • 单 SubMesh 条件优化

简述:大多数的 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 discard

buffer 的 setData 修改为 Discard 模式

  • 合批

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

@singlecoder
Copy link
Member

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

@singlecoder
Copy link
Member

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

@singlecoder
Copy link
Member

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

@johanzhu
Copy link
Collaborator Author

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

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

@johanzhu
Copy link
Collaborator Author

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

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

@johanzhu
Copy link
Collaborator Author

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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants