目前微信小游戏平台已经能够支持多线程和SIMD,但受限于CPU性能(只有原生App的三分之一),以及额外的内存占用,使得CPU Skinning仍然可能存在较大开销。 因此GPU Skinning仍然是一个重要选项。 Unity当前的GPU Skinning都是基于Compute Shader实现的,但WebGL 1 和 2 都不支持Compute Shader。 用户为了使用GPU Skinning,需要自行修改Vertex Shader或者使用一些第三方插件,增大了适配平台的成本。 针对于此我们在团结微信小游戏平台实现了基于Transform Feedback 和 Vertex Shader 的GPU Skinning方案
设置选项位于ProjectSettings->Player->Other Settings,下拉Mesh Skinning选择即可,默认使用CPU Skinning。
顶点蒙皮运算在CPU上执行。每帧动画开始时,先按FK/IK计算出所有骨骼的平移和旋转变换矩阵。 然后读取模型在内存中的顶点数据,依据每个顶点的位置和绑定的骨骼权重计算出变换后的顶点位置。 变换后的顶点数据需要每帧重新上传到GPU,同时供Shader的多个pass(阴影,光照)使用。 CPU Skinning是WebGL平台默认使用的动画方案。
顶点蒙皮运算在GPU上的计算着色器执行。原始顶点数据、骨骼权重等通过rawbuffer或者structurebuffer的方式上传到GPU, 不需要每帧重复上传。骨骼变换矩阵计算在CPU上计算时需要每帧更新到GPU。计算着色器完成顶点蒙皮运算后,将变换后的顶点数据写入到GPU buffer中。 该buffer作为渲染阶段的顶点输入,可以同时给shadow/light等多个renderpass使用。WebGL 1 和 2 都不支持Compute Shader, 因此Compute Shader Skinning无法在WebGL和微信小游戏平台使用。
新增一个TF Pass,在vertex shader中根据骨骼变换矩阵计算新的顶点位置(local space),并把经过vertex shader处理后的位置输出到绑定GL_TRANSFORM_FEEDBACK_BUFFER的缓冲, 作为后续绘制的输入数据。整个流程和compute shader skinning类似,只是用vertex shader代替compute shader计算顶点位置。
使用Transform Feedback功能需要打开WebGL 2.0。 目前Transform Feedback最多支持单个动画64根骨骼,超出数量后则默认切换为CPU Skinning。 需要注意,在某些场景下(角色数量多,每个角色顶点数少),Transform Feedback可能出现负优化的情况。
相比于Transform Feedback,该方案不需要增加新的TF pass。 计算顶点位置的代码(同transform feedback skinning)位于正常绘制的vertex shader中,处理后的数据直接用于后续MVP坐标转换。 但对于多Pass的渲染,比如ShadowCaster + Normal Pass, 每个Pass都需要重新计算顶点位置。 部分引擎内置的shader已支持Vertex Shader Skinning(详见下文)。
在Player Settings中勾选“GPU - Vertex Shader”开启此功能,并在Shader中添加keyword ENABLE_VS_SKINNING
,例如
CGPROGRAM
...
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
#pragma multi_compile _ ENABLE_VS_SKINNING
...
struct appdata_t {
float4 vertex : POSITION;
float2 texcoord : TEXCOORD0;
...
};
struct v2f {
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
...
};
v2f vert (appdata_t v)
{
v2f o;
...
o.vertex = UnityObjectToClipPos(v.vertex);
...
}
...
ENDCG
通过multi_compile添加keyword时,SkinnedMeshRenderer会根据Player Settings自动启用keyword。如果确定该shader只会使用VS Skinning,且不希望生成禁用此keyword的变体,可以通过shader_feature添加keyword。但此时需要添加Material Inspector,并确保keyword处于启用状态,详情可参见MaterialPropertyDrawer。示例:
Shader "Custom/MyShader"
{
Properties
{
// Display a toggle in the Material's Inspector window
[Toggle(ENABLE_VS_SKINNING)] EnableVSSkinning("Enable VS Skinnnig", Float) = 1.0
}
SubShader
{
Pass
{
Tags { "LightMode"="ForwardBase" }
CGPROGRAM
...
#pragma shader_feature ENABLE_VS_SKINNING
....
ENDCG
}
...
}
}
对于启用VS Skinning的SkinnedMeshRenderer,如果其mesh数据没有在其他地方需要访问,可以禁用Read/Write,并勾选释放mesh数据选项(默认值是已勾选),以减少运行时内存占用。同一个Mesh在不同renderer中分别以VS和CPU Skinning方式被使用时,mesh数据不会被释放,类似的,在PlayerSetting中切换skinning方式时,无需再改动Read/Write或释放mesh数据的选项。
ShaderCompiler识别到keyword后,会自动在vertex shader的函数入口注入skinning计算代码,新的顶点位置仍然在Object Space,保存在输入的POSITION通道,例如v.vertex,因此无需额外修改shader函数。
自动注入后的代码示例:
static const int max_bone_count = 64;
uniform int BonesPerVertex;
uniform float4 Bones [max_bone_count * 3];
bool GetLocalToWorldMatrix (in float4 boneWeights_in, in uint4 boneIndices_in, out float4x4 localToWorldMatrix)
{
if (BonesPerVertex == 1)
{
localToWorldMatrix = float4x4 (Bones [int (boneIndices_in . x) * 3 + 0], Bones [int (boneIndices_in . x) * 3 + 1], Bones [int (boneIndices_in . x) * 3 + 2], float4 (0.0, 0.0, 0.0, 1.0));
}
else if (BonesPerVertex == 2)
{
localToWorldMatrix = float4x4 (Bones [int (boneIndices_in . x) * 3 + 0], Bones [int (boneIndices_in . x) * 3 + 1], Bones [int (boneIndices_in . x) * 3 + 2], float4 (0.0, 0.0, 0.0, 1.0)) * boneWeights_in . x;
localToWorldMatrix += float4x4 (Bones [int (boneIndices_in . y) * 3 + 0], Bones [int (boneIndices_in . y) * 3 + 1], Bones [int (boneIndices_in . y) * 3 + 2], float4 (0.0, 0.0, 0.0, 1.0)) * boneWeights_in . y;
}
else if (BonesPerVertex == 4)
{
localToWorldMatrix = float4x4 (Bones [int (boneIndices_in . x) * 3 + 0], Bones [int (boneIndices_in . x) * 3 + 1], Bones [int (boneIndices_in . x) * 3 + 2], float4 (0.0, 0.0, 0.0, 1.0)) * boneWeights_in . x;
localToWorldMatrix += float4x4 (Bones [int (boneIndices_in . y) * 3 + 0], Bones [int (boneIndices_in . y) * 3 + 1], Bones [int (boneIndices_in . y) * 3 + 2], float4 (0.0, 0.0, 0.0, 1.0)) * boneWeights_in . y;
localToWorldMatrix += float4x4 (Bones [int (boneIndices_in . z) * 3 + 0], Bones [int (boneIndices_in . z) * 3 + 1], Bones [int (boneIndices_in . z) * 3 + 2], float4 (0.0, 0.0, 0.0, 1.0)) * boneWeights_in . z;
localToWorldMatrix += float4x4 (Bones [int (boneIndices_in . w) * 3 + 0], Bones [int (boneIndices_in . w) * 3 + 1], Bones [int (boneIndices_in . w) * 3 + 2], float4 (0.0, 0.0, 0.0, 1.0)) * boneWeights_in . w;
}
else
{
localToWorldMatrix = float4x4 (float4 (1.0, 0.0, 0.0, 0.0),
float4 (0.0, 1.0, 0.0, 0.0),
float4 (0.0, 0.0, 1.0, 0.0),
float4 (0.0, 0.0, 0.0, 1.0));
return false;
}
return true;
}
void VertexShaderSkinning_float (in float3 vertex_in, in float4 boneWeights_in, in uint4 boneIndices_in, out float3 vertex_out)
{
float4 inPos = float4 (vertex_in, 1.0);
float4x4 localToWorldMatrix;
if (GetLocalToWorldMatrix (boneWeights_in, boneIndices_in, localToWorldMatrix))
{
vertex_out = mul (localToWorldMatrix, inPos) . xyz;
}
}
void vs_skinning (inout float3 vertex, in float4 boneWeights, in float4 boneIndices)
{
VertexShaderSkinning_float (vertex, boneWeights, uint4 ((uint) boneIndices . x, (uint) boneIndices . y, (uint) boneIndices . z, (uint) boneIndices . w), vertex);
}
v2f vert (appdata_t v, float4 boneIndices : BLENDINDICES, float4 boneWeights : BLENDWEIGHTS)
{
vs_skinning(v.vertex, boneWeights, boneIndices);
v2f o;
o = (v2f) 0;
o.vertex = UnityObjectToClipPos (v.vertex);
...
}
Built-in RP:
URP:
场景1:21,488个面,15,172个顶点,212个骨骼,(接近一般MMO副本需求)
场景2:257,856个面,182,064个顶点,2544 个骨骼,(接近多同屏人数需求)
其他设置: Shader: URP Simple Lit, SRP batcher: on