したかみ ぶろぐ

Unity成分多め

人型モデルの周りにベクトル場を生成する

f:id:vxd-naoshi-19961205-maro:20201102190840p:plain

始めに

Boidsアルゴリズムで衝突回避の実装について考えた結果、周りにベクトル場を生成すればいいのではと思いました。

そこで今回はSkinnedMeshRendererでアニメーションをするオブジェクトの周りにベクトル場を生成してみようと思います。


今回のサンプルリポジトリです。モデルデータやアニメーションは入っていないので、記事の最後に貼ってあるアセットなどを入れて試してみてください。

github.com



処理の流れ

モデルの法線を取得する

SkinnedMeshRenderer.BakeMeshを使用することでアニメーションしているモデルのメッシュ情報を取得できます。その情報をもとに法線方向を取得します。

f:id:vxd-naoshi-19961205-maro:20201102191513p:plain



空間を分割する

3次元空間をマス目に区切ります。

f:id:vxd-naoshi-19961205-maro:20201102220150p:plain



マス目に法線情報を書き込む

マス目に法線情報を書き込みます。書き込む際は加算して、使用する際はベクトルを正規化するようにします。

f:id:vxd-naoshi-19961205-maro:20201102220643p:plain


法線ベクトルを色として出力した結果が次の画像です。 法線が設定されていないマス目は描画していません。

f:id:vxd-naoshi-19961205-maro:20201102220758p:plain



実装

C#プログラム

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Runtime.InteropServices;
using UnityEngine.Rendering;
public class VectorFieldCalculator : MonoBehaviour
{

    [SerializeField]
    private ComputeShader m_vectorGridCalculator;

    private Vector3Int m_calcVectorGridGroupSize;
    private Vector3Int m_initializeGridGroupSize;
    private int m_calcVectorGridKernel;
    private int m_initializeGridKernel;

    [SerializeField]
    private SkinnedMeshRenderer m_skinnedMeshRenderer;

    [Space(20)]
    [SerializeField]
    private float m_boxScale;

    [SerializeField]
    private int m_boxLength;

    [SerializeField]
    private Vector3 m_boxCenter;

    [Header("DrawMeshInstancedInDirectの項目")]
    [Space(20)]
    [SerializeField]
    private Mesh m_mesh;

    [SerializeField]
    private Material m_material;

    [SerializeField]
    private Bounds m_bounds;

    private ComputeBuffer m_argsBuffer;
    private ComputeBuffer m_boxDataBuffer;
    private ComputeBuffer m_normalsBuffer;
    private ComputeBuffer m_positionsBuffer;
    private List<Vector3> m_vertices = new List<Vector3>(); // GC.Collectを防ぐため
    private List<Vector3> m_normals = new List<Vector3>();  // GC.Collectを防ぐため
    private Mesh m_bakeMesh;
    private Transform m_modelTransform;
    private readonly int m_normalsPropID = Shader.PropertyToID("normals");
    private readonly int m_positionsPropID = Shader.PropertyToID("positions");
    private readonly int m_modelRotationPropID = Shader.PropertyToID("modelRotation");
    private readonly int m_modelEulerAnglePropID = Shader.PropertyToID("modelEulerAngle");
    private readonly int m_modelLocalScale = Shader.PropertyToID("modelLocalScale");
    private readonly int m_modelPositionPropID = Shader.PropertyToID("modelPosition");

    
    struct BoxData {
        public Vector3 position;
        public Vector3 direction;
    }


    // Start is called before the first frame update
    void Start()
    {
        InitializeArgsBuffer();
        InitializeVectorGridCalculator();
        
        m_material.SetFloat("_Scale", m_boxScale);

        m_modelTransform = m_skinnedMeshRenderer.transform;

        // GC.Allocを防ぐため
        m_bakeMesh = new Mesh();
    }

    // Update is called once per frame
    void Update()
    {

        UpdateVectorField();

    }

    void LateUpdate() {

        Graphics.DrawMeshInstancedIndirect(
            m_mesh,
            0,
            m_material,
            m_bounds,
            m_argsBuffer,
            0,
            null,
            ShadowCastingMode.Off,
            false
        );

    }

    public void UpdateVectorField() {

        // ベクトル場の初期化
        m_vectorGridCalculator.Dispatch(m_initializeGridKernel, 
                                       m_initializeGridGroupSize.x, 
                                       m_initializeGridGroupSize.y, 
                                       m_initializeGridGroupSize.z);
        
        m_skinnedMeshRenderer.BakeMesh(m_bakeMesh);
        m_bakeMesh.GetVertices(m_vertices);
        m_bakeMesh.GetNormals(m_normals);

        m_normalsBuffer.SetData(m_normals);
        m_positionsBuffer.SetData(m_vertices);

        m_vectorGridCalculator.SetVector(m_modelLocalScale, m_modelTransform.localScale);
        m_vectorGridCalculator.SetVector(m_modelEulerAnglePropID, m_modelTransform.eulerAngles);
        m_vectorGridCalculator.SetVector(m_modelPositionPropID, m_modelTransform.position);
        m_vectorGridCalculator.Dispatch(m_calcVectorGridKernel,
                                       m_calcVectorGridGroupSize.x,
                                       m_calcVectorGridGroupSize.y,
                                       m_calcVectorGridGroupSize.z);

    }

    private void InitializeArgsBuffer() {

        uint[] args = new uint[5] {0, 0, 0, 0, 0};

        args[0] = m_mesh.GetIndexCount(0);
        args[1] = (uint)(m_boxLength * m_boxLength * m_boxLength);

        m_argsBuffer = new ComputeBuffer(1, args.Length * sizeof(uint), ComputeBufferType.IndirectArguments);
        m_argsBuffer.SetData(args);
    }

    private void InitializeBoxDataBuffer() {

        int boxesCount = m_boxLength * m_boxLength * m_boxLength;
        BoxData[] boxDatas = new BoxData[boxesCount];
        float startPosX = m_boxCenter.x - m_boxLength * m_boxScale / 2.0f + m_boxScale/2.0f;
        float startPosY = m_boxCenter.y - m_boxLength * m_boxScale / 2.0f + m_boxScale/2.0f;
        float startPosZ = m_boxCenter.z - m_boxLength * m_boxScale / 2.0f + m_boxScale/2.0f;

        for(int i = 0; i < m_boxLength; ++i) {
            for(int j = 0; j < m_boxLength; ++j) {
                for(int k = 0; k < m_boxLength; ++k) {
                    int index = i * m_boxLength * m_boxLength + j * m_boxLength + k;
                    boxDatas[index].position = new Vector3(startPosX + i * m_boxScale, startPosY + j * m_boxScale, startPosZ + k * m_boxScale);
                    boxDatas[index].direction = Vector3.zero;
                }
            }
        }

        m_boxDataBuffer = new ComputeBuffer(boxesCount, Marshal.SizeOf(typeof(BoxData)));
        m_boxDataBuffer.SetData(boxDatas);
        m_material.SetBuffer("_BoxDataBuffer", m_boxDataBuffer);
    }

    private void InitializeVectorGridCalculator() {

        InitializeBoxDataBuffer();

        uint x, y, z;

        m_initializeGridKernel = m_vectorGridCalculator.FindKernel("InitializeGrid");
        m_vectorGridCalculator.GetKernelThreadGroupSizes(m_initializeGridKernel, out x, out y, out z);        
        m_initializeGridGroupSize = new Vector3Int(m_boxLength*m_boxLength*m_boxLength/(int)x, (int)y, (int)z);
        m_vectorGridCalculator.SetBuffer(m_initializeGridKernel, "boxData", m_boxDataBuffer);

        m_calcVectorGridKernel = m_vectorGridCalculator.FindKernel("CalcVectorGrid");

        m_vectorGridCalculator.GetKernelThreadGroupSizes(m_calcVectorGridKernel, out x, out y, out z);
        m_calcVectorGridGroupSize = new Vector3Int(m_skinnedMeshRenderer.sharedMesh.vertexCount / (int)x, (int)y, (int)z);

        float halfLength = m_boxScale * m_boxLength/2.0f;

        Vector3 minField = m_boxCenter - new Vector3(halfLength, halfLength, halfLength);
        Vector3 maxField = m_boxCenter + new Vector3(halfLength, halfLength, halfLength);

        m_vectorGridCalculator.SetVector("minField", minField);
        m_vectorGridCalculator.SetVector("maxField", maxField);

        m_vectorGridCalculator.SetFloat("boxScale", m_boxScale);
        m_vectorGridCalculator.SetInt("boxLength", m_boxLength); 

        m_vectorGridCalculator.SetBuffer(m_calcVectorGridKernel,
                                        "boxData",
                                        m_boxDataBuffer);

        m_normalsBuffer = new ComputeBuffer(m_skinnedMeshRenderer.sharedMesh.vertexCount, Marshal.SizeOf(typeof(Vector3)));
        m_positionsBuffer = new ComputeBuffer(m_skinnedMeshRenderer.sharedMesh.vertexCount, Marshal.SizeOf(typeof(Vector3)));
        m_vectorGridCalculator.SetBuffer(m_calcVectorGridKernel, m_normalsPropID, m_normalsBuffer);
        m_vectorGridCalculator.SetBuffer(m_calcVectorGridKernel, m_positionsPropID, m_positionsBuffer);
    }

    void OnDestroy() {
        m_argsBuffer?.Release();
        m_boxDataBuffer?.Release();
        m_normalsBuffer?.Release();
        m_positionsBuffer?.Release();
    }

}


ComputeShader

#pragma kernel CalcVectorGrid
#pragma kernel InitializeGrid

#include "Assets/cgincFiles/rotation.cginc"

struct BoxData {
    float3 position;
    float3 direction;
};

RWStructuredBuffer<BoxData> boxData;
StructuredBuffer<float3> normals;
StructuredBuffer<float3> positions;

float3 minField;
float3 maxField;

float boxScale;
int boxLength;

float4 modelRotation;
float3 modelPosition;
float3 modelLocalScale;
float3 modelEulerAngle;

[numthreads(8,1,1)]
void CalcVectorGrid (uint id : SV_DispatchThreadID)
{

    float3 pos = positions[id];
    float3 normal = normals[id];
    float4x4 rotMat = EulerAnglesToMatrix(modelEulerAngle); // 回転行列を求める

    pos = mul(rotMat, pos);
   
    pos.x *= modelLocalScale.x;
    pos.y *= modelLocalScale.y;
    pos.z *= modelLocalScale.z;

    pos += modelPosition;

    // 範囲外は計算しない
    if(pos.x < minField.x || maxField.x <= pos.x ||
       pos.y < minField.y || maxField.y <= pos.y ||
       pos.z < minField.z || maxField.z <= pos.z)
        return;

    int3 index = (pos - minField) / boxScale;

    normal = mul(rotMat, normal);

    boxData[(index.x * boxLength * boxLength + index.y * boxLength + index.z)].direction += normal;

}

[numthreads(8, 1, 1)]
void InitializeGrid(uint id : SV_DISPATCHTHREADID) {

    boxData[id].direction = float3(0, 0, 0);

}


Shader

Shader "Hidden/BoxShader"
{
    Properties
    {
        _Color("Color", Color) = (1, 1, 1, 1)
        _MainTex("Texture", 2D) = "white"
    }
    SubShader
    {
        Tags { "RenderType"="Transparent" "Queue" = "Transparent" }
        LOD 100
    //    Blend SrcAlpha OneMinusSrcAlpha
        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            // make fog work
            #pragma multi_compile_fog

            #include "UnityCG.cginc"

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

            struct v2f
            {
                float2 uv : TEXCOORD;
                UNITY_FOG_COORDS(1)
                float4 vertex : SV_POSITION;
                float3 normal : TEXCOORD1;
            };

            struct BoxData {
                float3 position;
                float3 normal;
            };

            StructuredBuffer<BoxData> _BoxDataBuffer;
            float4 _Color;
            sampler2D _MainTex;
            half _Scale;

            v2f vert (appdata v, uint instancedId : SV_INSTANCEID)
            {
                v2f o;

               float3 worldPos = _BoxDataBuffer[instancedId].position;
                
                float4x4 scale = float4x4(
                    float4(_Scale, 0, 0, 0),
                    float4(0, _Scale, 0, 0),
                    float4(0, 0, _Scale, 0),
                    float4(0, 0, 0, 1)
                );

                v.vertex = mul(v.vertex, scale);
                o.vertex = UnityObjectToClipPos(v.vertex + worldPos);
                o.uv = v.uv;
                o.normal = _BoxDataBuffer[instancedId].normal;
                UNITY_TRANSFER_FOG(o,o.vertex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                float4 col = tex2D(_MainTex, i.uv) * float4(i.normal, 1);
                if(length(i.normal))
                    col.a = 1;
                else {
                    discard;
                    // 全Cubeを出力する場合は以下の処理をする
                    // col = tex2D(_MainTex, i.uv) * float4(1, 1, 1, 1); 
                }
                return col;
            }
            ENDCG
        }
    }
}



解説

3次元配列を1次元配列にする

マス目自体は3次元ですが、1次元配列でデータを管理します。

一片のマス目の個数を Length とすると1次元配列の長さはLength3になります。

また、マス目の座標を(x, y, z)とするとこの3つの変数を使って1次元配列のインデックスは x * Length2 + y * Length + z と書き換えることが出来ます。 (n進数的な考え方がわかりやすいかもしれません)

参考 : 【JavaScript】二次元配列を一次元配列にする、とはどういうこと?要するにこういうこと。 - オドフラン ~いつもどこかに「なるほど」を~


追記:解説記事を書きました。 shitakami.hatenablog.com

基本的にはこの計算方法でマス目を求めていきます。



ベクトル場の更新

ベクトル場を更新する際は始めにベクトル場の初期化を行います。

初期化処理は単純にすべてのマス目に(0, 0, 0)を代入するだけです。ただ、量が多いのでComputeShaderで実行します。

        // ベクトル場の初期化
        m_vectorGridCalculator.Dispatch(m_initializeGridKernel, 
                                       m_initializeGridGroupSize.x, 
                                       m_initializeGridGroupSize.y, 
                                       m_initializeGridGroupSize.z);
[numthreads(8, 1, 1)]
void InitializeGrid(uint id : SV_DISPATCHTHREADID) {

    boxData[id].direction = float3(0, 0, 0);

}



次に、現在のモデルのMeshを取得します。SkinnedMeshRenderer.BakeMeshを使用することで、現在のMeshを取得できます。 (BakeMeshについては公式を参照してください: SkinnedMeshRenderer-BakeMesh - Unity スクリプトリファレンス

取得したMeshから頂点座標と法線ベクトルを取得して、ComputeShaderに設定します。

        m_skinnedMeshRenderer.BakeMesh(m_bakeMesh);
        m_bakeMesh.GetVertices(m_vertices);
        m_bakeMesh.GetNormals(m_normals);

        m_normalsBuffer.SetData(m_normals);
        m_positionsBuffer.SetData(m_vertices);



最後にSkinnedMeshRendererのTransform情報もComputeShaderに設定して、ComputeShaderを実行します。

 m_vectorGridCalculator.SetVector(m_modelLocalScale, m_modelTransform.localScale);
        m_vectorGridCalculator.SetVector(m_modelEulerAnglePropID, m_modelTransform.eulerAngles);
        m_vectorGridCalculator.SetVector(m_modelPositionPropID, m_modelTransform.position);
        m_vectorGridCalculator.Dispatch(m_calcVectorGridKernel,
                                       m_calcVectorGridGroupSize.x,
                                       m_calcVectorGridGroupSize.y,
                                       m_calcVectorGridGroupSize.z);



ComputeShader内での法線更新処理についても解説します。

BakeMeshで受け取った頂点座標はローカル座標なので、モデルの座標、回転、スケールを使ってワールド座標に変換します。

    float3 pos = positions[id];
    float3 normal = normals[id];
    float4x4 rotMat = EulerAnglesToMatrix(modelEulerAngle); // 回転行列を求める

    pos = mul(rotMat, pos);
   
    pos.x *= modelLocalScale.x;
    pos.y *= modelLocalScale.y;
    pos.z *= modelLocalScale.z;

    pos += modelPosition;



次に頂点座標がベクトル場内にあるかを調べます。

ベクトル場の範囲は ベクトル場の中心座標 ± ベクトル場の一片の長さ / 2 になります。C#プログラムでは次のように計算しています。

        float halfLength = m_boxScale * m_boxLength/2.0f;

        Vector3 minField = m_boxCenter - new Vector3(halfLength, halfLength, halfLength);
        Vector3 maxField = m_boxCenter + new Vector3(halfLength, halfLength, halfLength);



この範囲を使用して先ほどの頂点座標が範囲内にあるか確かめて、範囲内であれば法線ベクトルもワールド空間のベクトルに直してベクトル場に加算しています。

また、頂点座標からインデックスを取得する際は (頂点座標 - 境界値) / マスの大きさ で求まります。

 // 範囲外は計算しない
    if(pos.x < minField.x || maxField.x <= pos.x ||
       pos.y < minField.y || maxField.y <= pos.y ||
       pos.z < minField.z || maxField.z <= pos.z)
        return;

    int3 index = (pos - minField) / boxScale;

    normal = mul(rotMat, normal);

    boxData[(index.x * boxLength * boxLength + index.y * boxLength + index.z)].direction += normal;



GC.Colloectを防ぐ

ベクトル場を更新する際にMeshクラスとListクラスを使用するので、毎回newするのではなく変数として保持して使いまわすことでGC.Collectを防ぎます。

また、Mesh.verticesを使用するとその都度Vector3[]がnewされるみたいなのでMesh.GetVerticesを使って頂点座標を取得しています。

    private List<Vector3> m_vertices = new List<Vector3>(); // GC.Collectを防ぐため
    private List<Vector3> m_normals = new List<Vector3>();  // GC.Collectを防ぐため
. . . . . . . . . . 

    // Start is called before the first frame update
    void Start()
    {
        . . . . . . . . . .

        // GC.Allocを防ぐため
        m_bakeMesh = new Mesh();
    }
. . . . . . . . . . . .
. . . . . . . . . . . .

        m_skinnedMeshRenderer.BakeMesh(m_bakeMesh);
        m_bakeMesh.GetVertices(m_vertices);
        m_bakeMesh.GetNormals(m_normals);



ベクトル場の情報を色で出力する

vert関数で法線ベクトルをStructuredBufferから取り出してfrag関数で法線ベクトルをそのまま色で出力しています。もし、ベクトルの大きさが 0 である場合は出力しません。

fixed4 frag (v2f i) : SV_Target
            {
                float4 col = tex2D(_MainTex, i.uv) * float4(i.normal, 1);
                if(length(i.normal))
                    col.a = 1;
                else {
                    discard;
                    // 全Cubeを出力する場合は以下の処理をする
                    // col = tex2D(_MainTex, i.uv) * float4(1, 1, 1, 1); 
                }
                return col;
            }



追記

ベクトル場の拡大

ベクトル場の範囲が少し狭いと感じたのでベクトル場を広げようと思います。

ベクトル場の拡大は次のような計算を行いました。

f:id:vxd-naoshi-19961205-maro:20201109223736p:plain



プログラムではComputeShaderで次の計算を加えました。

. . . . . .
    boxData[(index.x * boxLength * boxLength + index.y * boxLength + index.z)].direction += normal;

    
    // ベクトルの要素の符号を取得、Epsilonは0除算回避
    int xd = normal.x / abs(normal.x + _Epsilon);
    int yd = normal.y / abs(normal.y + _Epsilon);
    int zd = normal.z / abs(normal.z + _Epsilon);

    if(0 <= index.x + xd && index.x + xd < boxLength)
        boxData[(index.x + xd) * boxLength * boxLength +
                           index.y * boxLength +
                           index.z].direction += normal * abs(normal.x);
    
    if(0 <= index.y + yd && index.y + yd < boxLength)
        boxData[index.x * boxLength * boxLength +
                           (index.y + yd) * boxLength +
                           index.z].direction += normal * abs(normal.y);
    
    if(0 <= index.z + zd && index.z + zd < boxLength)
        boxData[index.x * boxLength * boxLength +
                           index.y * boxLength +
                           index.z + zd].direction += normal * abs(normal.z);



結果は次のようにベクトル場の範囲が大きくなりました。

f:id:vxd-naoshi-19961205-maro:20201109230126p:plain

結果

以下のモデルのアニメーションからベクトル場を生成しました。

f:id:vxd-naoshi-19961205-maro:20201104113215g:plain

f:id:vxd-naoshi-19961205-maro:20201104112333g:plain

参考

法線ベクトルを出力する際にお借りしました。

qiita.com



使用したAsset

assetstore.unity.com

assetstore.unity.com