ComputeShaderによるアニメーション中の位置計算とDrawMeshInstancedIndirectによるメッシュの描画
リポジトリ https://github.com/SakuragiYoshimasa/ShaderLab
以前ブログに書いたDrawMeshInstancedIndirectのサンプルの拡張.
ComputeShaderを書く練習をしたかったので,
任意のアニメーション中のSkinnedMeshに対してComputeShaderを使って
毎フレームメッシュの描画位置を再計算するのを実装してみた.
前バージョン.固定位置でしかない.
準備
最初に描画のためのメッシュの構築と,
アニメーション中のMeshの頂点位置のベイクを行う機構を作ります.
描画するメッシュの構築
今回用いたのはHairと名付けたメッシュ.
描画するときに円錐形を作りたいのでここではメッシュの各頂点の座標の代わりに
(x,y,z) = (位相, 0, 高さ) とした.
public class Hair : ScriptableObject { [SerializeField] public int divisions = 4; [SerializeField] public int segments = 32; [SerializeField] public Mesh mesh; public void RebuildMesh(){ List<Vector3> vertices = new List<Vector3>(); for(int i = 0; i < segments + 1; i++){ for(int j = 0; j < divisions + 1; j++){ float phai = Mathf.PI * 2.0f * (float)j / (float)divisions; vertices.Add(new Vector3(phai, 0, i)); } } List<int> indices = new List<int>(); int refi = 0; //インデックスの設定 for (var i = 0; i < segments; i++){ for (var j = 0; j < divisions; j++){ indices.Add(refi); indices.Add(refi + 1); indices.Add(refi + 1 + divisions); indices.Add(refi + 1); indices.Add(refi + 2 + divisions); indices.Add(refi + 1 + divisions); refi++; } refi++; } mesh.SetVertices(vertices); mesh.SetIndices(indices.ToArray(), MeshTopology.Triangles, 0); mesh.UploadMeshData(true); } void OnEnable(){ if (mesh == null){ mesh = new Mesh(); mesh.name = "Hair"; } } }
これをアセットとして保存
public class HairEditor : Editor { [MenuItem("Assets/Create/Hair")] static void CreteHair(){ var path = AssetDatabase.GetAssetPath(Selection.activeObject); if (string.IsNullOrEmpty(path)) path = "Assets"; else if (Path.GetExtension(path) != "") path = path.Replace(Path.GetFileName(path), ""); var assetPathName = AssetDatabase.GenerateUniqueAssetPath(path + "/Hair.asset"); var asset = ScriptableObject.CreateInstance<Hair>(); AssetDatabase.CreateAsset(asset, assetPathName); AssetDatabase.AddObjectToAsset(asset.mesh, asset); asset.RebuildMesh(); AssetDatabase.SaveAssets(); EditorUtility.FocusProjectWindow(); Selection.activeObject = asset; } }
アニメーション中のSkinnedMeshのTextureBaking
アニメーション中のSkinnedMeshRendererから直接頂点位置を引っ張る方法がわからなかったので
keijiroさんのSkinner(https://github.com/keijiro/Skinner )を参考に
RenderTexture currPositionBuffer; RenderTexture currNormalBuffer;
この2つのバッファにベイクします.
実装としてはカメラにFragmentShaderの出力として頂点の位置と法線を出力するShaderを
ReplacementShaderとして設定し,対象のMeshのみから情報を抽出するようにします.
その前準備として対象のSkinnedMeshのuv座標をそのindexを元に書き換えます.
[SerializeField] public SkinnedMeshRenderer targetSMR; void RecreateMesh(){ Mesh mesh = new Mesh(); Mesh origMesh = targetSMR.sharedMesh; var vertices = new List<Vector3>(origMesh.vertices); var normals = new List<Vector3>(origMesh.normals); var tangents = new List<Vector4>(origMesh.tangents); var boneWeights = new List<BoneWeight>(origMesh.boneWeights); int[] indices = new int[origMesh.vertexCount]; List<Vector2> uv = new List<Vector2>(); for(int i = 0; i < origMesh.vertexCount; i++){ uv.Add(new Vector2(((float)i+0.5f) / (float) origMesh.vertexCount, 0)); indices[i] = i; } mesh.subMeshCount = 1; mesh.SetVertices(vertices); mesh.SetNormals(normals); mesh.SetTangents(tangents); mesh.SetIndices(indices, MeshTopology.Points, 0); mesh.SetUVs(0, uv); mesh.bindposes = origMesh.bindposes; mesh.boneWeights = boneWeights.ToArray(); mesh.UploadMeshData(true); targetSMR.sharedMesh = mesh; }
これをReplacementShaderでBufferの位置として利用して位置と法線をベイクします.
struct v2f { float4 position : SV_POSITION; float3 texcoord: TEXCOORD0; float3 normal: NORMAL; float psize : PSIZE; }; struct fragout { float4 position: SV_TARGET0; float4 normal: SV_TARGET1; }; v2f vert (appdata v) { v2f o; o.position = float4(v.texcoord.x * 2 - 1, 0, 0, 1); o.normal = UnityObjectToWorldNormal(v.normal); o.texcoord = mul(unity_ObjectToWorld, v.vertex).xyz; o.psize = 1; return o; } fragout frag (v2f i) : SV_TARGET0 { fragout o; o.position = float4(i.texcoord, 1.0); o.normal = float4(i.normal, 1.0); return o; }
詳しくはSkinnerもしくは上記のリポジトリを参考にしてください.
Skinnerから多少改変してあります.
メッシュの描画位置の計算と描画
CSスクリプト
全体のコントローラとしてSkinnedMeshHairRenderer.csを記述する.
まずはShaderに流すパラメータやバッファ等の定義.
//描画用のマテリアルとメッシュ [SerializeField] Hair _hair; [SerializeField] Material _material; //Shaderに流すパラメータ [SerializeField] int _instanceCount; [SerializeField] float _scale = 1.0f; [SerializeField] float _zScale = 0.07f; [SerializeField] float _noisePower = 0.1f; [SerializeField] float _frequency = 1.0f; [SerializeField] float _radiusAmp = 1.0f; [SerializeField] Vector3 _gravity = new Vector3(0f, -8.0f, 4.0f); [SerializeField] Color _color = new Vector4(0,0,0,0); [SerializeField] Color _gradcolor = new Vector4(0,0,0,0); //バッファ uint[] _drawArgs = new uint[5]{0, 0, 0, 0, 0}; ComputeBuffer _drawArgsBuffer; ComputeBuffer positionBuffer; ComputeBuffer rotationBuffer; ComputeBuffer indicesBuffer; ComputeBuffer weightsBuffer; ComputeBuffer prevPositionBuffer; //ベイク用のコンポーネントとその他参照 AnimatedSkinnedMesh asm; SkinnedMeshRenderer _skinnedMeshR; Mesh _targetmesh; Bounds _bounds = new Bounds(Vector3.zero, Vector3.one * 4 * 32); MaterialPropertyBlock _props; //計算用のComputeShader [SerializeField] ComputeShader _recalcGlownPositionsShader;
初期化.
InitBuffers()でバッファの初期化を行います.
SkinnedMeshの各トライアングルに対して面積が小さいものは除外して
描画するメッシュがなるべく重複しないように三角形を選んでバッファに格納していきます.
ついでにメッシュの半径と長さとランダムに選択して位置と回転のwに格納しています.
void Start(){ //まずComputeShaderのDispatchに使う引数をセット _drawArgsBuffer = new ComputeBuffer(1, 5 * sizeof(uint), ComputeBufferType.IndirectArguments); _drawArgs[0] = (uint)_hair.mesh.GetIndexCount(0); _drawArgs[1] = (uint)_instanceCount; _drawArgsBuffer.SetData(_drawArgs); //マテリアルに基本パラメータを流す _props = new MaterialPropertyBlock(); _props.SetFloat("_UniqueID", Random.value); _material.SetInt("_ArraySize", _hair.segments + 1); _material.SetInt("_InstanceCount", _instanceCount); _material.SetInt("_SegmentCount", _hair.segments); //ベイク用のコンポーネントから参照を引っ張る asm = GetComponent<AnimatedSkinnedMesh>(); _skinnedMeshR = asm.targetSMR; _targetmesh = new Mesh(); InitBuffers(); } void InitBuffers(){ if(positionBuffer != null) positionBuffer.Release(); if(rotationBuffer != null) rotationBuffer.Release(); if(indicesBuffer != null) indicesBuffer.Release(); if(weightsBuffer != null) weightsBuffer.Release(); if(prevPositionBuffer != null) prevPositionBuffer.Release(); positionBuffer = new ComputeBuffer(_instanceCount, sizeof(float) * 4); prevPositionBuffer = new ComputeBuffer(_instanceCount, sizeof(float) * 4); rotationBuffer = new ComputeBuffer(_instanceCount, sizeof(float) * 4); indicesBuffer = new ComputeBuffer(_instanceCount * 3, sizeof(int)); weightsBuffer = new ComputeBuffer(_instanceCount, sizeof(float) * 3); Vector4[] positions = new Vector4[_instanceCount]; Vector4[] rotations = new Vector4[_instanceCount]; Vector3[] weights = new Vector3[_instanceCount]; int[] indices = new int[_instanceCount * 3]; _skinnedMeshR.BakeMesh(_targetmesh); int targetTriangleCount = _targetmesh.triangles.Length / 3; HashSet<int> nouse = new HashSet<int>(); int index = (int)Random.Range(0f, targetTriangleCount + 0.5f); float threshold = 0.04f; for (int i=0; i < _instanceCount; i++) { index = (index + (int)Random.Range(0f, targetTriangleCount + 0.5f)) % targetTriangleCount; while(nouse.Contains(index)){ index = (index + (int)Random.Range(0f, targetTriangleCount + 0.5f)) % targetTriangleCount; } Vector3 v1 = _targetmesh.vertices[_targetmesh.triangles[index * 3]]; Vector3 v2 = _targetmesh.vertices[_targetmesh.triangles[index * 3 + 1]]; Vector3 v3 = _targetmesh.vertices[_targetmesh.triangles[index * 3 + 2]]; Vector3 n1 = _targetmesh.normals[_targetmesh.triangles[index * 3]]; Vector3 n2 = _targetmesh.normals[_targetmesh.triangles[index * 3 + 1]]; Vector3 n3 = _targetmesh.normals[_targetmesh.triangles[index * 3 + 2]]; float mag = ((v1 - v2).magnitude + (v2 - v3).magnitude + (v1 - v3).magnitude)/3.0f; if(mag < threshold) { nouse.Add(index); i--; continue; } float p1 = Random.Range(0, 1.0f); float p2 = Random.Range(0, 1.0f - p1); float p3 = 1.0f - p1 - p2; Vector3 p = v1 * p1 + v2 * p2 + v3 * p3; Vector3 n = n1 * p1 + n2 * p2 + n3 * p3 + new Vector3(0, 1.0f,0); float radius = Random.Range(0.015f, 0.05f); positions[i] = new Vector4(p.x, p.y, p.z, radius); Vector3 rotation = Quaternion.LookRotation(n, Vector3.up).eulerAngles; rotations[i] = new Vector4(rotation.x / 180.0f * Mathf.PI, rotation.y / 180.0f * Mathf.PI, rotation.z / 180.0f * Mathf.PI, mag); indices[i * 3] = _targetmesh.triangles[index * 3]; indices[i * 3 + 1] = _targetmesh.triangles[index * 3 + 1]; indices[i * 3 + 2] = _targetmesh.triangles[index * 3 + 2]; weights[i] = new Vector3(p1, p2, p3); } positionBuffer.SetData(positions); rotationBuffer.SetData(rotations); indicesBuffer.SetData(indices); weightsBuffer.SetData(weights); _material.SetBuffer("_PositionBuffer", positionBuffer); _material.SetBuffer("_RotationBuffer", rotationBuffer); }
ComputeShaderのDispatchとDrawMeshInstancedIndirectによる描画
ComputeShaderと描画用のマテリアルにバッファを渡してDispatch, DrawMeshInstancedIndirectを呼ぶだけ.
多分こんなに毎回バッファ送らなくていい.
void Update(){ int kernel =_recalcGlownPositionsShader.FindKernel("RecalcPositions"); prevPositionBuffer = positionBuffer; var data = new Vector4[_instanceCount]; positionBuffer.GetData(data); prevPositionBuffer.SetData(data); _recalcGlownPositionsShader.SetBuffer(kernel, "PositionBuffer", positionBuffer); _recalcGlownPositionsShader.SetBuffer(kernel, "RotationBuffer", rotationBuffer); _recalcGlownPositionsShader.SetBuffer(kernel, "IndicesBuffer", indicesBuffer); _recalcGlownPositionsShader.SetBuffer(kernel, "WeightsBuffer", weightsBuffer); _recalcGlownPositionsShader.SetTexture(kernel, "CurrPositionBuffer", asm.CurrPositionBuffer); _recalcGlownPositionsShader.SetTexture(kernel, "CurrNormalBuffer", asm.CurrNormalBuffer); _recalcGlownPositionsShader.Dispatch(kernel, 64, 1, 1); } void LateUpdate(){ _material.SetFloat("_Scale",_scale); _material.SetFloat("_ZScale",_zScale); _material.SetFloat("_NoisePower",_noisePower); _material.SetVector("_Gravity", _gravity); _material.SetFloat("_Scale",_scale); _material.SetFloat("_Frequency", _frequency); _material.SetFloat("_RadiusAmp", _radiusAmp); _material.SetColor("_MainColor", _color); _material.SetColor("_GradColor", _gradcolor); _material.SetBuffer("_PositionBuffer", positionBuffer); _material.SetBuffer("_RotationBuffer", rotationBuffer); _material.SetBuffer("_PrevPositionBuffer", prevPositionBuffer); Graphics.DrawMeshInstancedIndirect(_hair.mesh, 0, _material, _bounds, _drawArgsBuffer, 0, _props); } }
メッシュの描画位置の計算
RecalcGlownPositionsShader.computeに実装を書きます.
結構単純.
kernelとバッファの宣言.
ローポリでも位置が被らないように
メッシュを描画する位置としてベイクされた頂点を直接には使わない.
#pragma kernel RecalcPositions //ベイクされた座標と法線 RWTexture2D<float4> CurrPositionBuffer; RWTexture2D<float4> CurrNormalBuffer; //メッシュを描画する位置の計算のためのインデックスと重み係数,初期化以降変更なし StructuredBuffer<int> IndicesBuffer; StructuredBuffer<float3> WeightsBuffer; //メッシュを実際に描画する位置と回転情報 RWStructuredBuffer<float4> PositionBuffer; RWStructuredBuffer<float4> RotationBuffer;
実際の関数.
位置と法線は三点から重みを元に3頂点の位置の線形和をとる.
さらにこの辺を参考に,法線からオイラー回転量を計算してる.
けどそのままだと微妙だったので2倍して散らしてる(かなり雑).
http://answers.unity3d.com/questions/467614/what-is-the-source-code-of-quaternionlookrotation.html
https://en.wikipedia.org/wiki/Conversion_between_quaternions_and_Euler_angles
[numthreads(64,1,1)] void RecalcPositions (uint id : SV_DispatchThreadID) { int id1 = IndicesBuffer[id * 3]; int id2 = IndicesBuffer[id * 3 + 1]; int id3 = IndicesBuffer[id * 3 + 2]; float3 weights = WeightsBuffer[id].xyz; float3 p1 = CurrPositionBuffer[int2(id1, 0)].xyz; float3 p2 = CurrPositionBuffer[int2(id2, 0)].xyz; float3 p3 = CurrPositionBuffer[int2(id3, 0)].xyz; PositionBuffer[id].xyz = weights.x * p1 + weights.y * p2 + weights.z * p3; float3 n1 = CurrNormalBuffer[int2(id1, 0)].xyz; float3 n2 = CurrNormalBuffer[int2(id2, 0)].xyz; float3 n3 = CurrNormalBuffer[int2(id3, 0)].xyz; float3 forward = (weights.x * n1.xyz + weights.y * n2.xyz + weights.z * n3.xyz).xyz; float3 up = float3(0, 1.0, 0); float3 v1 = normalize(forward); float3 v2 = normalize(cross(up, v1)); float3 v3 = cross(v1, v2); float m00 = v2.x; float m01 = v2.y; float m02 = v2.z; float m10 = v3.x; float m11 = v3.y; float m12 = v3.z; float m20 = v1.x; float m21 = v1.y; float m22 = v1.z; float num8 = (m00 + m11) + m22; float4 quaternion = float4(0,0,0,0); if (num8 > 0){ float num = sqrt(num8 + 1.0); quaternion.w = num * 0.5; num = 0.5 / num; quaternion.x = (m12 - m21) * num; quaternion.y = (m20 - m02) * num; quaternion.z = (m01 - m10) * num; }else if ((m00 >= m11) && (m00 >= m22)){ float num7 = sqrt(1.0 + m00 - m11 - m22); float num4 = 0.5 / num7; quaternion.x = 0.5 * num7; quaternion.y = (m01 + m10) * num4; quaternion.z = (m02 + m20) * num4; quaternion.w = (m12 - m21) * num4; }else if (m11 > m22){ float num6 = sqrt(1.0 + m11 - m00 - m22); float num3 = 0.5 / num6; quaternion.x = (m10+ m01) * num3; quaternion.y = 0.5 * num6; quaternion.z = (m21 + m12) * num3; quaternion.w = (m20 - m02) * num3; }else{ float num5 = sqrt(1.0 + m22 - m00 - m11); float num2 = 0.5 / num5; quaternion.x = (m20 + m02) * num2; quaternion.y = (m21 + m12) * num2; quaternion.z = 0.5 * num5; quaternion.w = (m01 - m10) * num2; } float PI = 3.14159265; float4 q = quaternion; q = q / length(q); float rotz = atan2(2.0 * (q.x * q.y + q.z * q.w), 1.0 - 2.0 * (q.y * q.y + q.z * q.z)); float roty = asin(2 * (q.x * q.z - q.w * q.y)); float rotx = atan2(2.0 * (q.x * q.w + q.y * q.z), 1.0 - 2.0 * (q.z * q.z + q.w * q.w)); float3 rot = float3(rotx * 2.0, roty * 2.0, rotz * 2.0); RotationBuffer[id].xyz = rot; }
これを毎回dispatchして位置と回転の更新を行う.
メッシュの描画用のShader
最後に大事な描画.
基本構成はこれ.
vertの中身は後述.
Shader "Instanced/SkinnedInstancedHairShader" { Properties { _MainTex ("Albedo (RGB)", 2D) = "white" {} _MainColor ("Color", Color) = (1.0, 1.0, 1.0, 1.0) _GradColor ("Color", Color) = (1.0, 1.0, 1.0, 1.0) _Glossiness ("Smoothness", Range(0,1)) = 0.5 _Metallic ("Metallic", Range(0,1)) = 0.0 _Scale("Scale", Range(1.0, 10.0)) = 1.0 } SubShader { Tags { "RenderType"="Opaque" } CGPROGRAM #pragma surface surf Standard vertex:vert #pragma instancing_options procedural:setup #include "SimplexNoise3D.cginc" sampler2D _MainTex; struct Input { float2 uv_MainTex; int seg; }; #ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED StructuredBuffer<float4> _PositionBuffer; StructuredBuffer<float4> _RotationBuffer; StructuredBuffer<float4> _PrevPositionBuffer; uint _ArraySize; uint _InstanceCount; uint _SegmentCount; float3 _Gravity; float _Scale; float _ZScale; float _NoisePower; float _Frequency; float _RadiusAmp; #endif void vert(inout appdata_full v, out Input data){ UNITY_INITIALIZE_OUTPUT(Input, data); } void setup(){ #ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED #endif } half _Glossiness; half _Metallic; fixed4 _MainColor; fixed4 _GradColor; void surf (Input IN, inout SurfaceOutputStandard o) { #ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED fixed4 c = _MainColor * (float)IN.seg / _SegmentCount + (1.0 - (float)IN.seg / _SegmentCount) * _GradColor; o.Albedo = c.rgb; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = c.a; #endif } ENDCG } FallBack "Diffuse" }
vertの記述
ComputeShaderで計算したデータを元にメッシュの描画を行います.
まずは最初に定義したように各頂点の高さと位相を抽出し,
ComputeShaderの計算結果をInstanceIDを用いて取得します.
float phi = v.vertex.x; int seg = (int)v.vertex.z; data.seg = seg; float id = (float)unity_InstanceID; float4 base = _PositionBuffer[unity_InstanceID]; float4 rotation = _RotationBuffer[unity_InstanceID]; //ついでに格納してあった半径と長さの取得 float radius = base.w; float length = rotation.w;
次に抽出した位相等を元にメッシュの形状を作ります.
float2 xy = float2(cos(phi), sin(phi)) * radius * _RadiusAmp * ((float)(_SegmentCount - seg) / (float)_SegmentCount); float4 offset = float4(xy, v.vertex.z * length * _ZScale, 1.0) float3 n_normal = float3(cos(phi), sin(phi), 0);
ここまでの結果を見るために
v.vertex.xyz = offset.xyz; v.normal.xyz = n_normal;
と一時的にかいて見ると
これが設定したインスタンスの数だけ一箇所に描画されます.
次はComputeShaderの解散結果を用いてアニメーション中のメッシュの位置に配置していきます.
v.vertex.xyz = (base.xyz + offset.xyz) * _Scale; v.normal.xyz = n_normal;
こうなる .
base.xyzがComputeShaderでの位置計算の結果で,
offset.xyzはそれぞれの頂点のメッシュ内での相対位置に値します.
次に計算されたオイラー回転量を元に回転行列を生成しoffset.xyzとの乗算をとりposとします.
float a = rotation.x; float b = rotation.y; float c = rotation.z; float4 low1 = float4(cos(a) * cos(b) * cos(c) - sin(a) * sin(c), -cos(a) * cos(b) * sin(c) - sin(a) * cos(c), cos(a) * sin(b), 0); float4 low2 = float4(sin(a) * cos(b) * cos(c) + cos(a) * sin(c), -sin(a) * cos(b) * sin(c) + cos(a) * cos(c), sin(a) * sin(b), 0); float4 low3 = float4(-sin(b) * cos(c), sin(b) * sin(c), cos(b), 0); float4 low4 = float4(0, 0, 0, 1); float4x4 rotateMat; rotateMat._11_12_13_14 = low1; rotateMat._21_22_23_24 = low2; rotateMat._31_32_33_34 = low3; rotateMat._41_42_43_44 = low4; float3 pos = mul(rotateMat, offset).xyz;
この段階で最終的な出力とすると,
先ほどのメッシュが回転できたことがわかります.
v.vertex.xyz = (base.xyz + pos.xyz) * _Scale; v.normal.xyz = n_normal;
最後に回転前にノイズと重力による摂動を加えて終わりです.
if(seg != 0){ offset.x += snoise(float3( (float)seg * 0.02 + sin((_Time.x + id * 0.1) * _Frequency), radius + sin(_Frequency *(_Time.x +(float)seg * 0.03)) * cos(_Frequency * (_Time.y - id * 0.1)), length + cos(_Frequency * (_Time.y)))) * _NoisePower; offset.y += snoise(float3( (float)seg * 0.02 + cos(_Time.y * _Frequency), radius - sin(_Time.y * _Frequency) * cos(_Frequency * (_Time.x +(float)seg * 0.03 + id * 0.1)), length + cos(_Frequency * (_Time.x +(float)seg * 0.03 + id *0.2)))) * _NoisePower; } ///////////// //ここで回転 . . . float3 pos = mul(rotateMat, offset).xyz; ///////////// if(seg!=0) pos += _Gravity * (abs(pos.x) + abs(pos.y - 1.5)) * 0.03; v.vertex.xyz = (base.xyz + pos.xyz) * _Scale; v.normal.xyz = mul(rotateMat, n_normal);
以上.
結果
結果はこんな感じ.
終わりに
回転の部分をかなり雑にやったので時間があったら直します.
4000個とかメッシュの描画しても耐えるからすごい.
2017.06.25
追記 2017. 09 LT会での資料を追加しました.
www.slideshare.net