ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Axmol RHI升级:GPU Compute如何重塑2D引擎粒子与渲染管线

Axmol RHI升级:GPU Compute如何重塑2D引擎粒子与渲染管线 1. 这次升级到底解决什么问题先看清 RHI 的能力边界1.1 老 RHI 只管画不管算三个月前我在一个用 Axmol 引擎做的 2D 项目里碰了一鼻子灰粒子数量上到八千之后帧时间平白多出三四毫秒而这三四毫秒几乎全烧在 CPU 侧的位置更新循环里。换机型、改数据结构、上多线程都压不动——那条for (int i 0; i n; i)跑在 CPU 上本身就是物理上限。当时唯一看得见的路是把粒子模拟搬到 GPU 上但 Axmol 的 RHI 层是从图形渲染抽象起步的CommandBuffer里只有 draw、copy、blit 这类命令没有 dispatch没有可读写缓冲区的概念更没有跨阶段同步的机制。换句话说你想让 GPU 算完一笔数据再画出来中间算这条管道是断的。RHIRendering Hardware Interface说白了就是把 Metal、Vulkan、D3D、OpenGL 这些图形 API 包成一套统一接口上层写一遍渲染逻辑底层切后端。Axmol 之前的 RHI 设计得很典型Device负责创建资源CommandBuffer负责记录命令RenderPipeline描述固定功能状态和着色器组合draw call 绑上顶点缓冲、索引缓冲、纹理然后交给 GPU。这套抽象覆盖了画三角形的全部路径但它有个隐含假设GPU 干的事必须从绘制命令开始以绘制命令结束。这句话的信息量在于管线是单向的。顶点着色器拿到的顶点数据必须在 CPU 侧准备好片元着色器输出的颜色必须送进渲染目标。如果你想让 GPU 自己维护一份跨帧更新的数据比如粒子位置、布料顶点、风场采样点老 RHI 没有一条干净的通道让你写。你当然可以把数据伪装成纹理在片元着色器里读出来再计算但不是所有算法都适合这么拧着来而且同一张纹理在这帧被别人读、下帧被 compute 写的同步语义在多个后端上处理起来非常痛苦。所以这次升级的核心不是简单加一个 dispatch 命令而是把 RHI 从只管画扩展成既能算又能画。1.2 2D 引擎里哪些算值得搬上 GPU先说结论不是所有计算都值得搬。搬运有成本包括数据上传下载带宽、同步点、调试难度这些后面会具体讲。但在 2D 游戏里至少这几类负载是我实际遇到过的、适合 GPU Compute 的典型场景。粒子系统几千到几万个粒子每帧只是速度积分 位置更新 生命值递减天然并行一个线程处理一个粒子连通信都不需要。骨骼动画蒙皮Spine、DragonBones 的网格变形顶点数一多CPU 侧一帧能吃掉 1 到 2 毫秒而且每个顶点的变换逻辑完全相同。后处理效果Bloom、高斯模糊、辉光、马赛克过渡本来就是像素级并行用 compute 先算中间缓冲比在片元着色器里反复采样更直观。程序化纹理噪声、Voronoi 图、流体场可以在初始化阶段用一次性 compute 生成省掉 CPU 加载时间。关键点是2D 引擎的 CPU 预算非常紧张。和 3D 引擎不同2D 的绘制提交本身相对廉价玩家交互逻辑、游戏脚本、UI 布局才是大头一个 Lua 层的粒子循环就能把主线程打满。把纯数据的并行计算挪到 GPU让 CPU 专注逻辑这笔账在移动端尤其好算——移动 GPU 的浮点能力比 CPU 通常高一个数量级而且做并行计算时整体功耗往往更低。当然物理引擎不要盲目往 GPU 搬。刚体碰撞、关节约束这类算法强依赖顺序执行和随机访问GPU 上写起来复杂调试也困难收益不稳定。我做这次升级时定了一条取舍线能用一个 pass 算完、且每个线程只读写独立数据的问题优先考虑 GPU Compute需要跨线程反复交换中间结果的问题除非算法本身并行友好否则别碰。1.3 为什么必须做进 RHI而不是在引擎外自己调 API这是我被问得最多的一个问题既然现在 Metal 和 Vulkan 都能 dispatch compute我直接在引擎外写一个 manager绕开 RHI 不行吗不行而且不是风格问题。RHI 是资源所有权的归属点。Axmol 的纹理、缓冲区从创建到销毁都由 RHI 管理GPU 资源的状态——比如当前能不能被顶点着色器读当前能不能被 compute 写——也理应在 RHI 里统一跟踪。如果 compute 路径单独写在引擎外就会出现两套资源生命周期、两套同步状态跨模块传递一个 buffer 时要么重复记账要么漏掉 barrier然后就是那种窗口期随机闪退、真机上必现的经典 bug。另一个理由是跨后端一致性。四个后端对 compute 的暴露方式差异极大Metal 的 threadgroup、Vulkan 的 descriptor set、D3D12 的 root signature、GLES 的 SSBO各有各的脾气。如果每个后端单独写一套 compute 封装等于把同样的问题做四遍测试量翻四倍修一个同步 bug 得在四个地方同步修。放进 RHI 之后上层渲染器拿到的是同一个资源对象、同一条命令流不需要知道底下跑的是 Metal 还是 Vulkan。2. 四种后端同步加料的取舍Compute 接口是这样抽象出来的2.1 四种 API 的 compute 语义差在哪先摆一张对照表这是我们在设计前把四个后端摸了一遍之后整理的底账后端计算单元概念调度命令可写资源同步手段Metalcompute kernel / threadgroupdispatchThreadgroups:threadsPerThreadgroupMTLBuffer、readWrite 纹理MTLBarrierScopememory buffer textureVulkanworkgroupvkCmdDispatchSSBO、storage imagevkCmdPipelineBarrier memory barriersD3D12线程组Dispatch(x, y, z)UAV buffer / textureUAV barrier resource transitionGLES 3.1workgroupglDispatchComputeSSBO、image load/storeglMemoryBarrier光看这张表就知道抽象层真正难处理的是同步手段那一列。调度和可写资源的概念其实大同小异都是告诉 GPU 起多少个线程组、每个组里多少线程、然后去读写哪块内存。但compute 写完的数据怎么让后面的 draw 看到四个后端给了四个答案没有一个能直接映射到另一个。Metal 的 barrier 是按域来声明的你可以只 barrier 某一个 buffer也可以全 barrierVulkan 要你把源阶段、目标阶段、资源、还有内存访问类型全部写清楚写错一个 mask 就等着 flashing 或者崩D3D12 把资源状态转换transition和 UAV 同步拆成两个概念GLES 最省事一个glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT)下去驱动自己看着办但也因此最不透明很多老移动驱动会直接选择把整个管线停顿一下来保证正确性性能损失肉眼可见。2.2 求同存异我们在最低公分母上做的接口设计抽象层的第一原则是不要试图暴露每个后端的全部能力而是找到四个后端都支持、能满足绝大多数业务场景的子集。我们第一版只定了四件事计算管线状态对象一个只有 compute shader、没有混合深度光栅化这些固定功能状态的轻量对象。三种绑定类型只读 uniform、可读写存储缓冲、可读写纹理。dispatch 命令传入 x、y、z 三组线程数后端各自换算成自己的线程组概念。一个显式的 barrier 命令参数是源阶段、目标阶段、受影响资源列表。这里有一个典型取舍Metal 有很实用的 threadgroupMemory可以在线程组内共享一块快速内存而 GLES 和 Vulkan 虽然也有shared内存和 workgroup memory 的概念但语法细节差异很大。我们第一版没有把这层透传出去只暴露了一个每个线程组内共享内存大小的声明后端各自翻译先保证能跑通后续有高频需求再考虑统一语法。很多新加的抽象都该这么做别一开始就想做成完美抽象先让 90% 的场景能跑剩下 10% 特殊需求留给后续迭代。另一个心得是命令流的设计。compute 和 render 如果混在同一个CommandBuffer里就会产生阶段依赖所以我们在命令流里用submitBarrier显式切开阶段而不是像 D3D11 那样每次绑定资源都隐式做状态推断。显式虽然在写代码时烦一点但调试时能看到清晰的阶段边界出问题也容易排查。2.3 barrier 抽象compute 和 render 之间那条看不见的线barrier 是我花时间最长的地方也是这次升级里最容易被低估的部分。很多人第一次写 compute 的直觉是我 dispatch 完下一帧 draw 不就行了结果在桌面 GPU 上确实能行——因为桌面驱动的默认调度顺序刚好和提交顺序一致但换到移动端驱动为了省电会重排命令compute 还没写完draw 已经开始读旧数据画面就花了。我们最后定的是这样一层简化接口rhi::BarrierInfo barrier; barrier.srcStage rhi::Stage::Compute; // 数据从计算阶段出来 barrier.dstStage rhi::Stage::Vertex; // 要被顶点阶段消费 barrier.resources { posBuffer, velBuffer }; cmd-submitBarrier(barrier);后端映射关系是Vulkan 把它转成VK_ACCESS_SHADER_WRITE_BIT - VK_ACCESS_SHADER_READ_BIT的 memory barrierMetal 根据资源列表生成MTLBarrierScopeD3D12 同时补一个 UAV barrier 和 transitionGLES 则聚合成一次glMemoryBarrier把需要的 bit 按资源类型枚举出来。这里想提醒的是barrier 不是越少越好也不是越多越好。少了会出数据竞争多了会把 GPU 流水线打断性能断崖式下跌。我们实测过在一个 10 万粒子的场景里每帧多插三个不必要的全资源 barrier帧时间能多出 1 毫秒以上。所以后面我们加了优化barrier 命令的资源列表支持空数组空数组时后端可以做最细粒度的阶段同步而不是全屏障。3. 新增计算管线的完整链路从创建、绑定到调度3.1 计算管线状态对象比图形 PSO 简单一半图形 PSOPipeline State Object要描述的东西很多顶点输入布局、混合模式、深度模板状态、光栅化状态、着色器组合一个 PSO 就是一台绘制机器的完整配置。compute pipeline 没有这些它只需要两样东西一个 compute shader以及声明线程组大小的编译参数。线程组大小一般写在 shader 里GLSL 的layout(local_size_x 64) in;、HLSL 的[numthreads(64,1,1)]、Metal 在 dispatch 时传入所以创建设计时甚至不需要额外传参。在 Axmol RHI 里创建流程变得非常短auto* cs rhi::Shader::createFromSource(rhi::ShaderStage::Compute, computeSource); rhi::ComputePipelineDesc desc; desc.shader cs; auto* pipeline device-createComputePipeline(desc);相比图形 PSO这里少了所有 render target 格式描述和固定功能状态创建成本也低很多。正因为对象轻我们建议业务层可以按shader 粒度缓存 compute pipeline不要每帧创建销毁省掉不必要的驱动编译开销。3.2 存储缓冲区和可读写纹理的绑定方式算完的数据总得有个地方放放的地方就是我们新加的存储缓冲区概念。它和普通顶点缓冲区最大的区别是可以被 shader 随机读写。之前的缓冲区创建时只要告诉 RHI 用途是 vertex 还是 uniform现在多了一个StorageReadWrite用法标志。比较微妙的是同一块缓冲区可以在不同阶段扮演不同角色compute 阶段它是读写存储区draw 阶段它又被当成顶点实例数据读。所以 RHI 对缓冲区的管理从创建时定死类型变成了运行时跟踪当前状态barrier 命令里除了阶段信息还要带上这个状态转换。绑定逻辑和图形管线类似按 binding point 把 buffer 绑到 compute pipeline 上。我们设计了和图形阶段几乎一致的绑定接口目的是降低上手成本cmd-bindComputePipeline(particlePipeline); cmd-bindBuffer(0, posBuffer, rhi::BufferBindPoint::StorageReadWrite); cmd-bindBuffer(1, velBuffer, rhi::BufferBindPoint::StorageReadWrite); cmd-bindUniformBuffer(2, uniformBuffer); // dt、重力、指针坐标 cmd-dispatch(groupX, 1, 1);可读写纹理主要给后处理用。这里要说明一下各后端对可读写纹理的格式支持并不一致RGBA8、RGBA16F、R32F 这类是共识但个别老移动驱动对imageLoadStore的 R16F 支持有坑。我们第一版只承诺了这三种格式其他格式留给后端填充。任何抽象层都要有能力声明的概念RHI 加了一个queryComputeCapabilities()接口跑在真机上先探一下格式支持不支持的直接走 CPU 回退别硬来。3.3 dispatch 参数换算线程数、线程组和边界处理调度计算时最容易搞混的是线程数和线程组数这两层概念。以 GLSL 为例layout(local_size_x 64)声明的是每个线程组里有 64 个线程你 dispatch 的时候传的是起多少个线程组。假设有 10000 个粒子一个组 64 个线程那组数就是ceil(10000 / 64) 157最后一组只有 16 个线程干活其余空闲。所以 shader 里几乎每一段 compute 代码第一行都要做边界检查uint id gl_GlobalInvocationID.x; if (id count) return;线程组大小怎么选这是我们实测了几轮之后的一个参考区间移动端 64 或 128 表现都不错Apple GPU 的 threadgroup 上限通常是 512 或 1024分设备桌面 Vulkan 宽松一些。选 64 的好处是尾部浪费小选 256 的好处是调度开销低如果每帧都要 dispatch 且粒子数波动不大建议选 64 或 128 这种中间值再针对目标机型微调。这一步没有理论最优只能拿着真实场景去跑看GPU Frame Capture里的 dispatcher 时间。还有一点容易被忽略dispatch 的 x/y/z 三个维度都有上限Vulkan 的maxComputeWorkGroupCount在移动端某些驱动上只有 65535。如果你的数据量需要超过这个组数比如做一张 4096×4096 的全屏通用计算必须拆成多次 dispatch 或者在 shader 里自己分块。我建议 RHI 的 dispatch 参数校验层就直接检查这个上限超了就报错而不是让驱动跑飞。4. 实战把粒子系统改成 GPU Compute 后发生了什么4.1 CPU 侧准备两个存储缓冲区加一个计算管线以我们迁移的最典型的粒子效果为例粒子在屏幕上受重力下落碰到手指位置会被弹开生命周期结束就在原点重生。这是所有粒子 demo 的 hello world但用来验证 RHI 的新接口足够了。CPU 侧初始化做了三件事。第一件创建位置缓冲区和速度缓冲区都带StorageReadWrite用法标志大小按粒子上限分配初始化数据可以在 CPU 侧写好然后通过一次 upload 命令拷进去。第二件创建计算管线和 uniform bufferuniform 里每帧更新deltaTime、gravity、pointerX/Y。第三件创建实例化绘制用的顶点缓冲——注意这里我用的是同一块位置缓冲区它在 compute 阶段是存储缓冲在 draw 阶段直接作为每实例的 position 数据被顶点着色器读取。这样就免掉了算完再拷给顶点缓冲的拷贝是最能体现 RHI 统一资源管理的做法。每帧的命令序列非常直白更新 uniform bufferCPU 写16 字节对齐的数据块。cmd-beginComputePass()绑定管线、绑定三块缓冲、dispatch。cmd-submitBarrier()把位置和速度缓冲从 Compute 阶段切到 Vertex 阶段。cmd-beginRenderPass()绑定实例化 quad 顶点缓冲和位置缓冲draw instanced。结束 render pass提交命令流。完整跑下来整个 compute 和 draw 之间没有任何 CPU 和 GPU 之间的数据回读粒子数据从出生到消亡全待在 GPU 侧。4.2 计算着色器的核心逻辑长什么样下面这段是 GLSL ES 3.1 风格的伪代码去掉了引擎封装只保留最核心的更新逻辑#version 310 es layout(local_size_x 64) in; layout(std430, binding 0) buffer PositionBuffer { vec4 posAndLife[]; // xy 位置, z 生命, w 未用 }; layout(std430, binding 1) buffer VelocityBuffer { vec4 velAndSeed[]; // xy 速度, z 未用, w 随机种子 }; uniform float uDeltaTime; uniform float uGravity; uniform vec2 uPointer; void main() { uint i gl_GlobalInvocationID.x; if (i posAndLife.length()) return; vec2 pos posAndLife[i].xy; vec2 vel velAndSeed[i].xy; float life posAndLife[i].z; float seed velAndSeed[i].w; vel.y - uGravity * uDeltaTime; vec2 delta pos - uPointer; float dist length(delta); if (dist 0.001 dist 2.0) { vel (delta / dist) * (10.0 / dist) * uDeltaTime; } pos vel * uDeltaTime; life - uDeltaTime; if (life 0.0) { pos vec2(0.0); float angle seed * 6.28318; vel vec2(sin(angle), cos(angle)) * 8.0; life 2.0 seed; } posAndLife[i] vec4(pos, life, 0.0); velAndSeed[i] vec4(vel, 0.0, seed); }这个 shader 没有用到任何线程间通信每个线程只读写自己的i索引这是 GPU Compute 最省心也最高效的模式。注意std430布局vec4数组每个元素严格占 16 字节和后端的 buffer 对齐要求天然吻合不会出现排列错位。4.3 从 Dispatch 到 Draw 的衔接和实测数据衔接的重点还是 barrier。以这个例子来说compute 写完位置缓冲后如果立刻让顶点着色器读同一块缓冲必须有 barrierrhi::BarrierInfo barrier; barrier.srcStage rhi::Stage::Compute; barrier.dstStage rhi::Stage::Vertex; barrier.resources { posBuffer }; cmd-submitBarrier(barrier);这里只 barrier 位置缓冲就够了速度缓冲在 draw 阶段不被读不需要参与。一开始我图省事把所有缓冲都塞进 resources性能立刻掉了一截后来才养成只 barrier 必要资源的习惯。下面是我们在同一台设备上、同一套渲染路径下CPU 更新和 GPU Compute 更新的耗时对比工程机为某骁龙 8 Gen 2 Android 设备数值仅供参考粒子数CPU 更新耗时GPU Compute 耗时说明10000.08 ms0.05 ms差距不明显调度开销占大头50000.42 ms0.08 msGPU 开始有明显优势100000.91 ms0.12 ms手游典型负载区间200001.87 ms0.21 ms带宽压力开始显现500004.60 ms0.55 ms吞吐量接近带宽上限在 10000 粒子这个典型负载下GPU 方案快了将近 7 倍而且 CPU 的那 0.9 毫秒是纯主线程占用省下来正好能给游戏逻辑腾预算。到了 50000 粒子的极端场景GPU 耗时开始明显爬升瓶颈变成了内存带宽而不是计算能力——每帧读写位置速度合共 6.4 MB带宽不足时加再多的 ALU 也没用。所以不要盲目堆粒子数2D 游戏里 1 到 2 万粒子已经是一个视觉上非常饱和的数量。5. 三个月踩坑实录对齐、驱动限界和生命周期5.1 Metal 上 buffer 大小必须对齐到 16 字节第一个坑出在 Apple 平台。compute shader 访问的 buffer长度和 offset 都要求是 16 字节的倍数否则编译链接阶段就报错或者运行时不报错但数据读取完全是乱的。我们当时有个结构体刚好是float x, y, z, life一共 16 字节没什么问题但另一个存放粒子尺寸旋转角的缓冲用了float size; float rotation;一个元素 8 字节总长度不是 16 的倍数Metal 后端直接给了个 link error信息还特别隐晦。解决办法是统一约定所有会被 compute 读写的 buffer创建时就把大小向上对齐到 16 字节元素级别的数据结构也尽量设计成 16 字节的整数倍。RHI 的 buffer 创建接口内部做了这个兜底上层不会感知但这个心智一定要有——换后端调试时数据莫名其妙错位的第一怀疑对象就是布局对齐。5.2 移动端驱动对存储缓冲和本地内存的限界第二个坑在移动驱动。GLES 3.1 的glDispatchCompute在 Mali 和 Adreno 上的实现差异很大。Mali 的驱动对 shader 里shared本地内存比较敏感如果你在 compute shader 里声明一个大数组shared float tile[4096];某些驱动会直接编译不过因为它把每个线程组的本地内存上限压得很低Adreno 的老驱动则对非向量化的随机访问表现很差一个线程读自己索引之外的 vec4 分量性能能掉两三倍。我们后来归纳的规避清单有三条第一本地内存数组尽量控制在 1KB 以内大的中间数据拆成多次 dispatch第二访问存储缓冲时尽量整块读写vec4避免按标量跳来跳去第三在真机上用glGetIntegeri_v或 Vulkan 的 properties 把每线程组的最大存储大小查出来运行时做能力门禁支撑不住的设备走 CPU 回退路径。这套代码同样适用于 D3D12 和 Metal它们只是相对宽松不代表没有上限。5.3 生命周期管理GPU 还在读CPU 就想写这是我差点放弃的一个坑。初版实现跑起来之后桌面端一切正常换到 iPad 上运行几分钟就会随机闪退没有任何 log。后来用 Metal 的 GPU Frame Capture 一看典型的 CPU 和 GPU 竞争同一帧里CPU 已经把下一帧的 uniform 数据写进了 buffer而 GPU 还在读取上一帧的位置缓冲去绘制。解决方案是 RHI 层做了一整套 in-flight 管理命令流提交时带上帧序号缓冲区分成 2 到 3 份轮转CPU 只能写入当前帧对应的那份等 GPU 的 fence 回来再把这些 buffer 放回可用池。这套机制在图形管线里通常只需要管顶点缓冲但 compute 把CPU 写、GPU 读、下一帧 GPU 再写的节奏变得更快生命周期出问题的概率高得多。如果你也在自家引擎里加 compute我建议在第一天就把 in-flight 机制设计进去而不是等出现随机闪退再补。5.4 排查compute 写了但 draw 没看到的完整思路最后分享一个典型 bug 的排查链路这件事非常能说明 compute 调试和普通渲染调试的差异。现象是粒子完全不动画面上的粒子一直停在初始位置。我当时的第一反应是 shader 写错了但换了最简单的赋值 shader 也一样。于是按下面顺序排查检查 buffer 的创建标志发现位置缓冲创建时只带了VertexBuffer用法忘了加StorageReadWrite。在 Vulkan 和 Metal 上这会直接导致绑定失败但在 GLES 部分驱动上不会报错只是默默忽略写入这是最隐蔽的一种错误。检查 dispatch 的组数。粒子数 10000、线程组 64应该传 157。当时传成了 156最后一个线程组内的粒子永远不会更新但因为大部分粒子在动这个 bug 从视觉上几乎发现不了。检查 barrier。确认在 compute 和 draw 之间插了 barrier但发现只 barrier 了位置缓冲速度缓冲在下一帧 compute 里还要被读这里漏了一个阶段间的依赖好在移动端驱动通常会兜住桌面端 GPU 才会现形。最终锁定在第一步加上StorageReadWrite后问题解决。这个排查花了整整两天。事后复盘最省时间的做法其实是先在桌面端开 Vulkan validation layer 或者 D3D12 debug layer 去查资源状态这些工具会直接告诉你buffer 在创建时没有声明可写访问而不是靠肉眼盯画面。移动端渲染了一半的 bug拿到桌面端验证层上多半一秒现原形。6. 升级之后这套 RHI 还能长出哪些能力compute 管线落地之后最直接的受益人其实是后处理和程序化内容粒子只是个开场白。以 Axmol 这个体量的 2D 引擎来说我看到的下一步有三个明确方向。第一个是完整后处理链。以前做 Bloom 要在片元着色器里反复采样、用多个 render pass 模拟降采样代码绕来绕去。现在可以用 compute 一次性把亮度提取、高斯模糊的水平和垂直 pass 都实现中间结果直接放在可读写纹理里不打断渲染管线。特别是移动端这种写法还能让驱动更准确地做算子融合功耗比同等的片元方案低。第二个是骨骼动画和网格变形。2D 骨骼动画的顶点数虽然不多但 Spine 和 DragonBones 重度使用自由变形CPU 蒙皮在低端机上仍是明显热点。把蒙皮矩阵计算搬到 compute 里一次 dispatch 处理一批顶点CPU 只负责提交骨骼矩阵可以在不改变上层接口的前提下把性能拉满。第三个是程序化资产和全局效果。比如动态水波、流动的熔岩地面、屏幕空间噪点都可以用 compute 实时生成或更新一张噪声纹理再由现有 shader 采样。优势在于生成逻辑集中在 CPU 侧的几百行代码里不用跑图工具预烘焙迭代美术效果也快很多。我个人接下去的计划是先用这套接口把后处理链完整跑通把各后端最容易出问题的 barrier 参数集中到一个文件里管理。如果你也在给自家引擎做类似的升级我最后想给的建议是第一版接口尽量少暴露后端特性宁可少提供能力也要保证行为一致资源生命周期设计要前置别等随机 bug 出现才补调试工具链第一时间接上Vulkan validation 和 Metal Frame Capture 不是可选项是刚需。这套路走下来compute 带来的不只是帧时间上的提升更是整个引擎在仿真和视觉结合这件事上多出来的自由度。
返回列表