好像又快一年没写博客啦 🙁

前一段时时间在做角色表情相关的方案,由于项目中的骨骼计算相关方案都是自己实现的,没有使用Unity SkinnedMeshRenderer, 而且Unity的骨骼方案性能超级烂和各种黑盒代码不能信任,所以表情方案也需要自己来做了。

插个题外话,Unity的骨骼方案到底哪里烂
1.SkinnedMeshRenderer在每帧计算骨骼数据的时候是否有做预计算优化?在抛开动作融合的需求上,实际上所有动画的骨骼计算结果都是可以离线计算的好的,运行时不需要再次从关节骨骼空间变换到模型空间,直接计算好模型空间的数据运行时输出即可。

2.为什么PlayerSetting 里的GPU Skinning选项永远都是*号? 在Opengles3.0以及以下Es2.0 其寄存器数量不会超过256,大部分设备是224个顶点寄存器,假设我在这些设备上上传 1000个骨骼上去,Unity会成功进行GPU Skinning 还是fallback 到Cpu Skinning上?

3.使用Animator制作的大型状态机,如果带有大量动画,那么在加载的时候会加载整个状态机中包含的所有动画,此时加载性能会瞬间达到瓶颈,因此一般MMO项目都会自己来写额外分拆加载逻辑,而且Animator中的State融合时间又很难交给策划灵活配置,最后是很难做网络游戏的表现层和逻辑层分离,似乎是天生对单机游戏亲和力更好。

4.对Instancing的内置没有支持方案,以及没有做JobSystem的整合等等

5.存储空间和计算量大,由于Unity的骨骼使用Matrix4x4 矩阵,实际上骨骼计算使用3×4矩阵就可以满足, 不带缩放的骨骼甚至可以使用 8个float的双四元数就能解决,无论是在叉乘计算还是空间存储极限情况下性能会差距1倍左右

题外话完毕~进入正题啦

业界中制作表情动画的方案大致分2种, 骨骼 或者 BlendShape
什么时候用骨骼? 什么时候用BlendShape?
从要变现的结果上来看,2者都可以实现,但是从制作流程上2者差距很大

骨骼方案
优点:运行时计算量小,离线存储的数据量少
缺点:表情做到一半发现表现不够到位,需要临时增加骨骼,于是又要回炉到3dmax.
针对原画设计的表情,用骨骼细调参数的过程实现需要大量的时间

运行时预设的表情需要策划配置大量不好阅读的参数

BlendShape方案
优点:针对原画设计的表情,美术直接按表情建模即可,效率非常高,对于要做跨人种,高矮胖瘦的体型类变化后的表情支持,可以不受骨骼权重影响,总结就是表现一步到位
缺点:对于三角面较多的模型,做BlendShape的数据量等同于表情数量*模型数量,后期程序可以抽出只发生变化的顶点来存储,但是数据量还是较骨骼来说会多出很多

总结来说,如果不要做挤眉弄眼,只是简单表情的话,直接BlendShape.
如果要做兽人,萝莉,狼人等跨人种 以及变身的话,直接BlendShape.
其他的要做细的表情,可以混合骨骼方案实现

以下是项目中用到的表情资源 BlendShape
1.张嘴

2.闭嘴

3.闭眼

整合后的效果

基于骨骼表情动画资源的话面部会包含较多的骨骼

操作骨骼变化表情的效果主要是通过缩放和旋转骨骼来实现

核心代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
 void Start()
    {
        Mf = this.GetComponent<MeshFilter>();
        Mr = this.GetComponent<MeshRenderer>();

        _baseVertices = BaseMesh.vertices;
        _baseNormal = BaseMesh.normals;
        _tmpVertices = new Vector3[_baseVertices.Length];
        _tmpNormal = new Vector3[_baseNormal.Length];

        _tmpMesh = new Mesh();
        _tmpMesh.vertices = _baseVertices;
        _tmpMesh.normals = _baseNormal;
        _tmpMesh.triangles = BaseMesh.triangles;
        _tmpMesh.uv = BaseMesh.uv;
        _tmpMesh.MarkDynamic();

        Mf.sharedMesh = _tmpMesh;

        _vertexCount = BaseMesh.vertices.Length;
        BlendShapeWeight = new float[BlendShapeMesh.Length];
        _blendShapes = new BlendShape[BlendShapeMesh.Length];

        for (int i = 0; i < BlendShapeMesh.Length; i++)
        {
            _blendShapes[i] = new BlendShape();

            //差异顶点数量
            List<VertexDeltaData> tmpBSVertexList = new List<VertexDeltaData>();
            for(int j = 0; j < _vertexCount; j++)
            {
                //变形器与基础顶点的差异
                if(_baseVertices[j] != BlendShapeMesh[i].vertices[j])
                {
                    VertexDeltaData blendShapeVertex = new VertexDeltaData();
                    blendShapeVertex.vertexIdx = j;
                    blendShapeVertex.deltaVertex = BlendShapeMesh[i].vertices[j] - _baseVertices[j]; //delta position
                    blendShapeVertex.deltaNormal = BlendShapeMesh[i].normals[j] - _baseNormal[j]; //delta normal

                    tmpBSVertexList.Add(blendShapeVertex);
                }
            }

            _blendShapes[i].vertices = tmpBSVertexList.ToArray();
        }

        ModifyWeight();
        _hasInited = true;
    }

    void ModifyWeight()
    {
        System.Array.Copy(_baseVertices, _tmpVertices, _baseVertices.Length);
        System.Array.Copy(_baseNormal, _tmpNormal, _baseNormal.Length);

        for(int i = 0; i < BlendShapeMesh.Length; i++)
        {
            BlendShape curBs = _blendShapes[i];
            int deltaDataCount = curBs.vertices.Length;
            for(int j = 0; j < deltaDataCount; j++)
            {
                VertexDeltaData deltaData = curBs.vertices[j];
                int idx = deltaData.vertexIdx;

                _tmpVertices[idx] += deltaData.deltaVertex * BlendShapeWeight[i];
                _tmpNormal[idx] += deltaData.deltaNormal * BlendShapeWeight[i];
            }
        }

        _tmpMesh.vertices = _tmpVertices;
        _tmpMesh.normals = _tmpNormal;
    }

btw:骨骼动画集大成的学习资料还是要靠隔壁国家的小黄油,推荐下最新的[AI少女]

很久很久以前,做过一个离线Mesh切割方式的Decay效果Unity3D中的贴花效果 适合场景景观布置,批次合并等,但运行时性能较差,这次我们来玩玩运行时投影器。


先上成平图


测试效果图, 图中的裤子上投影了一个眼睛
那么投影的原理是什么呢。。。 那么请看下面这张

这张图左下角就是投影器看到的景象,投影贴图“眼睛” 充满了整个投影器的视野,那么原理就呼之而出了。
在正常渲染裤子的顶点时,顺便变换到投影器的屏幕空间,然后再渲染裤子的片段处理函数中将位于投影器屏幕空间的像素都换成眼睛即可。

渲染裤子的Shader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
Shader "Unlit/ProjectorShader"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
           
            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 projectorUV : TEXCOORD1;
                float4 vertex : SV_POSITION;
            };

            float4x4 _ProjectorP;
            float4x4 _ProjectorV;
            float4x4 _ProjectorVP;
            sampler2D _ProjectorTex;
            sampler2D _ProjectorFallOut;
            sampler2D _MainTex;
            float4 _MainTex_ST;
           
            v2f vert (appdata v)
            {
                v2f o;

                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);

                float4x4 propMVP = mul(_ProjectorVP, unity_ObjectToWorld);
                float4 projProjPos = mul(propMVP, v.vertex);

                projProjPos = ComputeScreenPos(projProjPos);
                o.projectorUV = projProjPos;

                return o;
            }
           
            fixed4 frag (v2f i) : SV_Target
            {
                // sample the texture
                fixed4 col = tex2D(_MainTex, i.uv);
               
                fixed4 projectorCol = tex2Dproj(_ProjectorTex, i.projectorUV);

                // tex2Dproj = xyz/ w
                fixed4 projectorFallOutCol = tex2Dproj(_ProjectorFallOut, i.projectorUV);

                projectorCol *= projectorFallOutCol;

                col.rgb += projectorCol.rgb;


                return col;
            }
            ENDCG
        }
    }
}

自定义投影器的CS脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[ExecuteInEditMode]
public class CustomProjector : MonoBehaviour {
    public Camera ProjectorCam;
    public Texture2D Tex;
    public Texture2D FallOut;
    public Material Mat;

    private void Start()
    {
       
    }

    private void Update()
    {
        Matrix4x4 P = GL.GetGPUProjectionMatrix(ProjectorCam.projectionMatrix, false);
        Matrix4x4 V = ProjectorCam.worldToCameraMatrix;
        Mat.SetMatrix("_ProjectorV", V);
        Mat.SetMatrix("_ProjectorP", P);
        Mat.SetMatrix("_ProjectorVP", P * V);
        Mat.SetTexture("_ProjectorTex", Tex);
        Mat.SetTexture("_ProjectorFallOut", FallOut);
    }
}

范例中的代码借用了Unity的相机,实际上并不需要相机,仅仅是借用了相机的投影矩阵和世界空间矩阵而已。

很多项目组在制作移动端游戏时,都使用Projector来制作主角的投影,虽然比起ShadowMap是优化了许多,但是实际上只要和Projector碰撞到物件其DC 都会翻倍, 对于我来说,这还是不可接受的。
而使用上面范例的代码,可以让DC不翻倍,但是并不通用, 因为受Projector影响的物体都需要定制Shader.

Unity自带Projector会翻倍的原因主要也是通用性,跨平台,使用方便, 因此它的原理是
1.找到所有和Projector有碰撞的MeshRenderer
2.使用Projector的材质球,将MeshRenderer的顶点再渲染一遍,并贴图
也因此被投影的物体无法触发动态合批

那么问题来,有没有一种方案,既可以保证通用性,不需要定制被投影目标的Shader,又可以使DC不翻倍呢? 答案是:有的, 但是有代价
代价1:需要使用深度图
代价2:DC不会翻倍,但是总共的DC为,被投影物体数量 + 1. 既物体自带的DC + 1 * (Projector数量) 其实代价2根本不算个事

说搞就搞
1.开启相机的深度渲染
Camera.main.depthTextureMode |= DepthTextureMode.Depth;
2.创建一个表示投影器范围的网格,我搞了个Cube Mesh
3.创建Cube Mesh对应的相关矩阵,因为是Cube 因此创建的投影为正交投影, 当然,如果也可以使用透视投影。

1
2
3
4
5
6
7
8
9
10
11
12
13
BoxCollider collider = this.GetComponent<BoxCollider>();
        this.m_size = collider.size.x / 2;
        this.m_nearClip = -collider.size.x / 2;
        this.m_farClip = collider.size.x / 2;
        this.m_aspect = 1;

        Matrix4x4 projector = default(Matrix4x4);
        projector = Matrix4x4.Ortho(-m_aspect * m_size, m_aspect * m_size, -m_size, m_size, m_nearClip, m_farClip);

        m_worldToProjector = projector * this.transform.worldToLocalMatrix;

        MeshRenderer mr = this.GetComponent<MeshRenderer>();
        mr.sharedMaterial.SetMatrix("_WorldToProjector", m_worldToProjector);

好了,准备工作做完了。开始渲染吧。
首先是将投影器覆盖的区域,采样出当前屏幕空间的深度,类似这样的效果

要实现这样的效果,就是讲顶点变换到投影平面,并将坐标变换到UV值域下
大概这样

1
2
3
4
5
6
7
vert part
o.screenPos = ComputeScreenPos(o.vertex);

fragment part
fixed4 screenPos = i.screenPos;
screenPos.xy = screenPos.xy / screenPos.w;
float depth = tex2D(_CameraDepthTexture, screenPos).r;

好了,现在我们有了深度,下一部就是讲当前像素的深度还原回该深度对应的世界坐标了
只需要两部矩阵变换
1.从屏幕空间变换到相机空间 unity_CameraInvProjection
2.从相机空间变换到世界空间 unity_MatrixInvV

有了世界坐标后,就可以将该坐标变换到Projector的控件,就是准备工作中的 _WorldToProjector
变换到Projector空间后,还记得范例上的投影器的全部视野就是需要投射的贴图范围吗?因此这里要做UV值域的变换

1
2
3
4
5
6
7
8
//变换到自定义投影器投影空间
fixed4 projectorPos = mul(_WorldToProjector, worldSpacePos);
projectorPos /= projectorPos.w;

fixed2 projUV = projectorPos.xy * 0.5 + 0.5;  //变换到uv坐标系
fixed4 col = tex2D(_ProjectorTex, projUV);  //采样投影贴图
fixed4 mask = tex2D(_ProjectorTexMask, projUV); //采样遮罩贴图
col.rgb =  lerp(fixed3(1, 1, 1), col.rgb, (1 - mask.r)); //融合

大功告成! 你可能会好奇,为什么多了一个遮罩贴图? 虽然你讲投影器视野内的像素部分都贴了投影贴图,但是是野外的像素怎么办?这个时候就需要遮罩图抹掉,因此遮罩图的纹理设置要设置为Clamp,保证边缘像素为拉伸且外侧的Alpha为0

项目完整源码:
https://github.com/dreamfairy/Unity-CubeProjector