浅尝ComputeShader-魔法图书馆

  1. 1. 前言
  2. 2. 实现思路
    1. 2.1. 0.场景准备
    2. 2.2. 1.从生成一排/一面书本物体开始,初始化位置和旋转
    3. 2.3. 2.加入随机数、三角函数使物体凌乱并做漂浮运动
    4. 2.4. 3.增加书本物体的位置、旋转对玩家靠近时的响应
    5. 2.5. 4.将CPU端的旋转计算转到GPU端进行
    6. 2.6. 5.解决书本不随父物体位移旋转的问题
    7. 2.7. 6.更好的效果控制
  3. 3. 完整效果、参数、代码展示

 

前言

  • 其他:
    • 初学ComputeShader,只是想到了以前用HDRP中的VFX粒子复刻过的效果,可以作为练习
    • 效果中的旋转并非原效果中的旋转规则,可自定义
    • 性能上还有许多优化空间还望读者见谅
    • 本文仅作为个人学习记录和思路分享

实现思路

0.场景准备

  • 准备一个空场景
  • 准备好用于触发效果的玩家、一个书本预制体、一个C#脚本、一个ComputeShader。

1.从生成一排/一面书本物体开始,初始化位置和旋转

  • 利用ComputeShader将每个书本物体的位置设置为其SV_DispatchThreadID号
  • 线程组设为(8, 8, 1),为了性能考虑,线程大小设为(4, 4, 1),后续可自行调整
  • 那么一个线程的SV_DispatchThreadID如图所示:

  • 整个线程组的排列如图所示:

  • 那么,数组大小为(4×8)×(4×8)=32×32,每个单位在数组中的索引可以用id.y * 4 * 8 + id.x来表示,即第几行的第几列,如下图黄色数字所示:

  • 而这每个单位都指代一本书所处的位置,为Vector3或float3类型,因此需要一个Vector3[]容器装这些数据,并使用Buffer与GPU进行数据传输
展开代码
  • ComputeShader:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    #pragma kernel InitPosition

    RWStructuredBuffer<float3> InitPositions;

    [numthreads(4,4,1)]
    void InitPosition (uint3 id : SV_DispatchThreadID)
    {
    InitPositions[id.y * 32 + id.x] = float3(id.x, id.y, 0);
    }
  • C#:
    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
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;

    public class MagicLibrary : MonoBehaviour
    {
    // 1.
    [SerializeField] private ComputeShader shader;
    private int size;
    private ComputeBuffer resultInitPosBuffer;
    private int kernelInitPosHandle;

    private Vector3[] initPositions;

    [SerializeField] private GameObject prefab;
    private List<GameObject> books;

    private void Start()
    {
    size = 32 * 32;

    InitShader();
    InitBookAndPosition();
    }

    private void InitShader()
    {
    // Get KernelID and Init Buffer
    kernelInitPosHandle = shader.FindKernel("InitPosition");
    resultInitPosBuffer = new ComputeBuffer(size, sizeof(float) * 3);
    shader.SetBuffer(kernelInitPosHandle, "InitPositions", resultInitPosBuffer);

    // Dispatch initPosition
    shader.Dispatch(kernelInitPosHandle, 8, 8, 1);
    }

    private void InitBookAndPosition()
    {
    books = new List<GameObject>(size);
    // Get Buffer Data and Apply to GameObject
    initPositions = new Vector3[size];
    resultInitPosBuffer.GetData(initPositions);
    for (int i = 0; i < initPositions.Length; i++)
    {
    GameObject book = Instantiate(prefab, initPositions[i], Quaternion.identity, transform);
    books.Add(book);
    }
    }

    private void OnDestroy()
    {
    resultInitPosBuffer.Release();
    }
    }
  • 第一步效果图:

2.加入随机数、三角函数使物体凌乱并做漂浮运动

  • 说到运动,自然是要增加一个核函数,并与CPU端Update联动
  • 物体的随机旋转值使用C#里的Quaternion.EulerRotation()实现,参数为物体自身位置。读者也可以使用其他方法自行实现。
  • 书本从书架(一个平面)飘出,实际上就是Z轴的偏移,因此只需要计算Z轴的偏移量,而不用把整个三维坐标加入计算,节约性能
展开代码
  • ComputeShader:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    #pragma kernel UpdatePosition

    // 2
    RWStructuredBuffer<float3> UpdatePositions;
    float Time;
    float ZTimeScale;
    float ZStrength;

    float Random(float2 p)
    {
    return frac(sin(p.x * 23.23123 + p.y * 12.335) * 2343523.2345234);
    }

    [numthreads(4,4,1)]
    void UpdatePosition (uint3 id : SV_DispatchThreadID)
    {
    float z_offset = Random(id.xy);
    float flowZ = z_offset * ZStrength + cos(Time * ZTimeScale * z_offset);
    UpdatePositions[id.y * 32 + id.x] = float3(id.x, id.y, flowZ);
    }
  • C#:
    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
    // 2.
    private int kernelUpdateHandle;
    private ComputeBuffer resultUpdatePosBuffer;
    private Vector3[] nowPositions;
    [SerializeField] private float ZStrength; // Z轴离原平面的距离强度
    [SerializeField] private float ZTimeScale; // 漂浮速度

    private void InitShader()
    {
    kernelUpdateHandle = shader.FindKernel("UpdatePosition");
    resultUpdatePosBuffer = new ComputeBuffer(size, sizeof(float) * 3);
    }

    private void InitBookAndPosition()
    {
    nowPositions = new Vector3[size];
    }


    private void Update()
    {
    UpdateBookPosition();
    }

    private void UpdateBookPosition()
    {
    // Set Shader param
    shader.SetFloat("ZStrength", ZStrength);
    shader.SetFloat("ZTimeScale", ZTimeScale);
    shader.SetFloat("Time", Time.time);

    shader.SetBuffer(kernelUpdateHandle, "UpdatePositions", resultUpdatePosBuffer);
    shader.Dispatch(kernelUpdateHandle, 8, 8, 1);
    // Get Buffer and Apply to GameObject
    resultUpdatePosBuffer.GetData(nowPositions);
    for (int i = 0; i < nowPositions.Length; i++)
    {
    books[i].transform.position = nowPositions[i];
    books[i].transform.rotation = Quaternion.EulerRotation(nowPositions[i]);
    }
    }

    private void OnDestroy()
    {
    // ...
    resultUpdatePosBuffer.Release();
    }
  • 第二步效果图:

3.增加书本物体的位置、旋转对玩家靠近时的响应

  • 通过传入玩家的世界坐标,和一些函数运算,来对每个书本单位进行位置和旋转的平滑复位
  • 此时会发现一些问题,后续会解决:
    • 旋转是在CPU端计算的,性能不好
    • CPU端的SmoothStep函数与GPU端效果不同,需要自己实现
    • 书本位置、旋转固定,不会跟随其父物体位移和旋转
展开代码
  • ComputeShader:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // 3
    float3 PlayerWorldPos;
    float PlayerResponseStartDistance;
    float PlayerResponseRange;

    [numthreads(4,4,1)]
    void UpdatePosition (uint3 id : SV_DispatchThreadID)
    {
    float3 p = float3(id.x, id.y, 0);
    float z_offset = Random(id.xy);
    // Reply PlayerPos
    float dis = distance(p, PlayerWorldPos);

    float flowZ = z_offset * ZStrength + cos(Time * ZTimeScale * z_offset);
    float newZ = flowZ * (1 - smoothstep(PlayerResponseStartDistance + PlayerResponseRange, PlayerResponseStartDistance, dis));

    UpdatePositions[id.y * 32 + id.x] = float3(id.x, id.y, newZ);
    }
  • C#:
    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
    // 3.
    [SerializeField] private float PlayerResponseStartDistance; // 玩家距离能够造成影响的起始距离
    [SerializeField] private float PlayerResponseRange; // 玩家距离能够造成影响的范围
    [SerializeField] private GameObject player;

    private void UpdateBookPosition()
    {
    var playerPos = player.transform.position;
    shader.SetVector("PlayerWorldPos", playerPos);
    shader.SetFloat("PlayerResponseStartDistance", PlayerResponseStartDistance);
    shader.SetFloat("PlayerResponseRange", PlayerResponseRange);

    // ...
    books[i].transform.rotation = Quaternion.EulerRotation(nowPositions[i] * SmoothStep(PlayerResponseStartDistance, PlayerResponseStartDistance + PlayerResponseRange, Vector3.Distance(playerPos, nowPositions[i])));
    }

    private float SmoothStep(float a, float b, float t)
    {
    if (t <= a)
    {
    return 0;
    }
    else if (t >= b)
    {
    return 1;
    }
    else
    {
    return (t - a) / (b - a);
    }
    }
  • 第三步效果图:

4.将CPU端的旋转计算转到GPU端进行

  • CPU端的旋转是通过Quaternion.EulerRotation()函数中传入位置得到的
  • 因此我的思路是再开一个线程组,记录对应物体的旋转,直接在GPU中计算旋转后,在CPU端使用
  • 将原先的shader.Dispatch(kernelUpdateHandle, 8, 8, 1)改为(8, 8, 2)
  • 相当于增加一张表,表中的索引从32*32-1开始,到32*32*2-1结束,原先记录Vector3信息的下标加上一半的表容量即可得到其对应的旋转值
展开代码
  • ComputeShader:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    [numthreads(4,4,1)]
    void UpdatePosition (uint3 id : SV_DispatchThreadID)
    {
    float3 p = float3(id.x, id.y, 0);
    float z_offset = Random(id.xy);
    // Reply PlayerPos
    float dis = distance(p, PlayerWorldPos);

    float flowZ = z_offset * ZStrength + cos(Time * ZTimeScale * z_offset);
    float newZ = flowZ * (1 - smoothstep(PlayerResponseStartDistance + PlayerResponseRange, PlayerResponseStartDistance, dis));
    p.z += newZ;

    UpdatePositions[32*32*id.z + id.y * 32 + id.x] = p * (id.z == 0 ? 1 : smoothstep(PlayerResponseStartDistance, PlayerResponseStartDistance + PlayerResponseRange, dis));
    }
  • C#:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    private void InitShader()
    {
    // ...
    resultUpdatePosBuffer = new ComputeBuffer(size * 2, sizeof(float) * 3);
    // ...
    }

    private void InitBookAndPosition()
    {
    // ...
    nowPositions = new Vector3[size * 2];
    }

    private void UpdateBookPosition()
    {
    // ...
    shader.Dispatch(kernelUpdateHandle, 8, 8, 2);
    // ...
    for (int i = 0; i < nowPositions.Length / 2; i++)
    {
    books[i].transform.position = nowPositions[i];
    books[i].transform.rotation = Quaternion.EulerRotation(nowPositions[i + nowPositions.Length / 2]);
    }
    }

5.解决书本不随父物体位移旋转的问题

  • 原因是坐标系不同,ComputeShader中计算得出的位移和旋转值,在CPU中赋值给了books[i].transform.position和rotation
  • 而不论是改为localPosition还是在Instantiate时不以一个物体为父物体,都存在不好控制、旋转后响应效果错误的问题
  • 最终的解决方案是,以一个空物体作为父物体,控制所有的子物体书本进行位移和旋转,并统一为世界坐标系
  • CPU端直接将父物体的坐标和旋转传入GPU,使GPU端计算得到的值能够加入父物体的影响,成为世界坐标系
  • 子物体的世界坐标=父物体transform的right、up、forward三个分量分别乘上子物体x、y、z轴向上的分量
  • 其中又可以通过两个轴的叉乘得到第三个轴的朝向向量
  • 此时书本飘出的方向就不是Z轴了,而是父物体的transform.forawrd
  • 需要重构部分ComputeShader代码
展开代码
  • ComputeShader:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    // 5
    float3 ParentRight;
    float3 ParentForward;
    float3 ParentPos;

    [numthreads(4,4,1)]
    void UpdatePosition (uint3 id : SV_DispatchThreadID)
    {
    float3 p = float3(id.x, id.y, 0);
    p = p.x * ParentRight + p.y * cross(ParentForward, ParentRight) + p.z * ParentForward;
    p += ParentPos;

    float z_offset = Random(id.xy);

    // Reply PlayerPos
    float dis = distance(p, PlayerWorldPos);
    // Rdm flow Z
    float flowZ = z_offset * ZStrength + cos(Time * ZTimeScale * z_offset);
    float newZ = flowZ * (1 - smoothstep(PlayerResponseStartDistance + PlayerResponseRange, PlayerResponseStartDistance, dis));

    p += newZ * ParentForward;
    UpdatePositions[32*32*id.z + id.y * 32 + id.x] = p * (id.z == 0 ? 1 : smoothstep(PlayerResponseStartDistance, PlayerResponseStartDistance + PlayerResponseRange, dis));
    }
  • C#:
    1
    2
    3
    4
    5
    6
    7
    8
    private void UpdateBookPosition()
    {
    // ...
    shader.SetVector("ParentRight", transform.right);
    shader.SetVector("ParentForward", transform.forward);
    shader.SetVector("ParentPos", transform.position);
    // ...
    }
  • 第五步效果图:

6.更好的效果控制

  • 此时基本的功能都实现了,而在具体使用场景中,还需要控制物体间隔、物体的初始旋转值等参数
  • 在这就演示这两项参数的增加
  • 还可以加入玩家在书架背面时,不响应效果的设置,通过向量叉乘结果的正负来判断
展开代码
  • ComputeShader:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 6
    float2 BooksScale;

    [numthreads(4,4,1)]
    void UpdatePosition (uint3 id : SV_DispatchThreadID)
    {
    float3 p = float3(id.x * BooksScale.x, id.y * BooksScale.y, 0);
    // ...
    }
  • C#:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // 6.
    [SerializeField] private Vector3 InitRotationEular = new Vector3(0, 0, 0);
    [SerializeField] private Vector2 InitScale = new Vector2(1, 1);

    private void UpdateBookPosition()
    {
    // ...
    shader.SetVector("BooksScale", InitScale);
    // ...
    books[i].transform.rotation = Quaternion.EulerRotation(nowPositions[i + nowPositions.Length / 2]) * Quaternion.Euler(InitRotationEular);
    // ...
    }

完整效果、参数、代码展示

展开代码
  • ComputeShader:
    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
    #pragma kernel InitPosition
    #pragma kernel UpdatePosition

    // 1
    RWStructuredBuffer<float3> InitPositions;
    // 2
    RWStructuredBuffer<float3> UpdatePositions;
    float Time;
    float ZTimeScale;
    float ZStrength;
    // 3
    float3 PlayerWorldPos;
    float PlayerResponseStartDistance;
    float PlayerResponseRange;
    // 5
    float3 ParentRight;
    float3 ParentForward;
    float3 ParentPos;
    // 6
    float2 BooksScale;

    float Random(float2 p)
    {
    return frac(sin(p.x * 23.23123 + p.y * 12.335) * 2343523.2345234);
    }

    [numthreads(4,4,1)]
    void InitPosition (uint3 id : SV_DispatchThreadID)
    {
    InitPositions[id.y * 32 + id.x] = float3(id.x, id.y, 0);
    }

    [numthreads(4,4,1)]
    void UpdatePosition (uint3 id : SV_DispatchThreadID)
    {
    float3 p = float3(id.x * BooksScale.x, id.y * BooksScale.y, 0);
    p = p.x * ParentRight + p.y * cross(ParentForward, ParentRight) + p.z * ParentForward;
    p += ParentPos;

    float z_offset = Random(id.xy);

    // Reply PlayerPos
    float dis = distance(p, PlayerWorldPos);
    // Rdm flow Z
    float flowZ = z_offset * ZStrength + cos(Time * ZTimeScale * z_offset);
    float newZ = flowZ * (1 - smoothstep(PlayerResponseStartDistance + PlayerResponseRange, PlayerResponseStartDistance, dis));

    p += newZ * ParentForward;
    UpdatePositions[32*32*id.z + id.y * 32 + id.x] = p * (id.z == 0 ? 1 : smoothstep(PlayerResponseStartDistance, PlayerResponseStartDistance + PlayerResponseRange, dis));
    }
  • C#:
    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
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;

    public class MagicLibrary : MonoBehaviour
    {
    // 1.
    [SerializeField] private ComputeShader shader;
    private int size;
    private ComputeBuffer resultInitPosBuffer;
    private int kernelInitPosHandle;

    private Vector3[] initPositions;

    [SerializeField] private GameObject prefab;
    private List<GameObject> books;

    // 2.
    private int kernelUpdateHandle;
    private ComputeBuffer resultUpdatePosBuffer;
    private Vector3[] nowPositions;
    [SerializeField] private float ZStrength; // Z轴离原平面的距离强度
    [SerializeField] private float ZTimeScale; // 漂浮速度

    // 3.
    [SerializeField] private float PlayerResponseStartDistance; // 玩家距离能够造成影响的起始距离
    [SerializeField] private float PlayerResponseRange; // 玩家距离能够造成影响的范围
    [SerializeField] private GameObject player;

    // 6.
    [SerializeField] private Vector3 InitRotationEular = new Vector3(0, 0, 0);
    [SerializeField] private Vector2 InitScale = new Vector2(1, 1);


    private void Start()
    {
    size = 32 * 32;

    InitShader();
    InitBookAndPosition();
    }

    private void Update()
    {
    UpdateBookPosition();
    }

    private void InitShader()
    {
    // Get KernelID and Init Buffer
    kernelInitPosHandle = shader.FindKernel("InitPosition");
    resultInitPosBuffer = new ComputeBuffer(size, sizeof(float) * 3);
    shader.SetBuffer(kernelInitPosHandle, "InitPositions", resultInitPosBuffer);

    // Dispatch initPosition
    shader.Dispatch(kernelInitPosHandle, 8, 8, 1);

    kernelUpdateHandle = shader.FindKernel("UpdatePosition");
    resultUpdatePosBuffer = new ComputeBuffer(size * 2, sizeof(float) * 3);
    }

    private void InitBookAndPosition()
    {
    books = new List<GameObject>(size);
    // Get Buffer Data and Apply to GameObject
    initPositions = new Vector3[size];
    resultInitPosBuffer.GetData(initPositions);
    for (int i = 0; i < initPositions.Length; i++)
    {
    GameObject book = Instantiate(prefab, initPositions[i], Quaternion.identity, transform);
    books.Add(book);
    }
    nowPositions = new Vector3[size * 2];
    }

    private void UpdateBookPosition()
    {
    // Set Shader param
    shader.SetFloat("ZStrength", ZStrength);
    shader.SetFloat("ZTimeScale", ZTimeScale);
    shader.SetFloat("Time", Time.time);

    shader.SetVector("ParentRight", transform.right);
    shader.SetVector("ParentForward", transform.forward);
    shader.SetVector("ParentPos", transform.position);

    shader.SetVector("BooksScale", InitScale);

    var playerPos = player.transform.position;
    shader.SetVector("PlayerWorldPos", playerPos);
    shader.SetFloat("PlayerResponseStartDistance", PlayerResponseStartDistance);
    shader.SetFloat("PlayerResponseRange", PlayerResponseRange);

    shader.SetBuffer(kernelUpdateHandle, "UpdatePositions", resultUpdatePosBuffer);
    shader.Dispatch(kernelUpdateHandle, 8, 8, 2);
    // Get Buffer and Apply to GameObject
    resultUpdatePosBuffer.GetData(nowPositions);
    for (int i = 0; i < nowPositions.Length / 2; i++)
    {
    books[i].transform.position = nowPositions[i];
    books[i].transform.rotation = Quaternion.EulerRotation(nowPositions[i + nowPositions.Length / 2]) * Quaternion.Euler(InitRotationEular);
    }
    }

    private void OnDestroy()
    {
    resultInitPosBuffer.Release();
    resultUpdatePosBuffer.Release();
    }
    }