はじめに
BlenderのGeometryNodesで作成した頂点アニメーションをVRChatに持ち込みたかったのでAlembicからVATを生成するエディタ拡張を書いてみました。
VRCで動作することを確認した pic.twitter.com/gZDJKA110A
— metaaa (@meta556) October 23, 2022
VATとは
VAT(Vertex Animation Texture)とはそれぞれのピクセルに頂点の位置、回転などの情報を特定のフレーム分含めたデータの事です。
詳しくは以下のリンクを参照してください。
Alembicとは
Alembicとは、3Dモデルのファイル形式の一つです。ボーンを用いたアニメーションでは表現が困難な頂点アニメーションを利用できる点が特徴です。
VATとAlembicの比較
VATはアニメーションの再生をshaderで実行するためVRChatに持ち込むことができます。また、Alembicに比べると処理負荷が軽いようです(未検証)
Alembicはshaderにほとんど制限がありませんが、VATは再生機能が頂点シェーダーに依存しているので、適宜VATに対応したシェーダーを作成する必要があります。
実装
環境
Unity2019.4.31f1
Alembic 2.3.0 (UPMのデフォルトは1.0.7ですが、AlembicのScriptを参照できないので2.3.0を使用)
コード全文
使用するAlembicは下記のTutorialを参考に作成したものと下記のリポジトリに置いてあったものを利用させていただきました。
処理の流れ
- alembicからトポロジーの種類を推測
- メッシュの生成
- VATの作成
- アセットの保存
// initialize
_startTime = alembic.StartTime + adjugstTime;
_meshFilters = alembic.gameObject.GetComponentsInChildren<MeshFilter>();
// check VAT Type
_topologyType = GetTopologyType();
// bake mesh
var mesh = BakeMesh();
// bake texture
var texTuple = BakeTextures(mesh);
// create assets
SaveAssets(texTuple.posTex, texTuple.normTex, mesh);
alembicからトポロジーの種類を推測する
HoudiniのVATは4種類あります。
- Soft Body
- Rigid Body
- Fluid
- Sprite
今回はトポロジーが変化するものとしないもので分けることにしました。
メッシュのトポロジーが変化しないSoft Body と Rigid Body を Soft、トポロジーの変化する Fluid を Liquid としました。
以下の処理ではVATに記録する範囲のAlembicを再生し、各フレーム時点でのポリゴン数を比較することでトポロジーの種類を推測しています。
public enum TopologyType
{
Soft,
Liquid,
}
private TopologyType _topologyType;
public AlembicStreamPlayer alembic;
private TopologyType GetTopologyType()
{
_maxTriangleCount = 0;
_minTriangleCount = Int32.MaxValue;
int frames = ((int)(alembic.Duration * samplingRate));
var dt = alembic.Duration / frames;
for (var frame = 0; frame < frames; frame++)
{
alembic.UpdateImmediately(_startTime + dt * frame);
int triangleCount = 0;
foreach (var meshFilter in _meshFilters)
{
triangleCount += meshFilter.sharedMesh.triangles.Length / 3;
}
if (triangleCount > _maxTriangleCount)
_maxTriangleCount = triangleCount;
if (triangleCount < _minTriangleCount)
_minTriangleCount = triangleCount;
}
var type = _maxTriangleCount == _minTriangleCount ? TopologyType.Soft : TopologyType.Liquid;
// reset
alembic.UpdateImmediately(_startTime);
return type;
}
メッシュの生成
表示用のメッシュを生成します。トポロジーの種類により生成手法が違います。
トポロジーが変化しないものの場合は、Alembicの最初のFrameのメッシュを利用します。メッシュが複数ある場合は1つのメッシュに纏めます。
トポロジーが変化するものの場合は、最大ポリゴン数分のポリゴンが含まれているだけのメッシュを生成します。
どこにも繋がっていない(1つの頂点が1つのポリゴンだけに属する) 面積0の三角形が大量にあるイメージです。
private Mesh BakeMesh()
{
var bakedMesh = new Mesh();
bakedMesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
var verticesCount = 0;
var trianglesIndexCount = 0;
bool hasNormal = false;
bool hasUVs = false;
bool hasColors = false;
if (_topologyType == TopologyType.Liquid)
{
hasNormal = true;
verticesCount = _maxTriangleCount * 3;
trianglesIndexCount = _maxTriangleCount * 3;
}
else
{
foreach (var meshFilter in _meshFilters)
{
var sharedMesh = meshFilter.sharedMesh;
verticesCount += sharedMesh.vertices.Length;
trianglesIndexCount += sharedMesh.triangles.Length;
hasNormal |= (sharedMesh.normals.Length > 0);
hasColors |= (sharedMesh.colors.Length > 0);
hasUVs |= (sharedMesh.uv.Length > 0);
}
}
var vertices = new Vector3[verticesCount];
var uv = new Vector2[verticesCount];
var normals = new Vector3[verticesCount];
var colors = new Color[verticesCount];
var triangles = new int[trianglesIndexCount];
if (_topologyType == TopologyType.Liquid)
{
for (int i = 0; i < verticesCount; i++) // everything is initialized to 0
{
triangles[i] = i;
vertices[i] = Vector3.zero;
normals[i] = Vector3.up;
}
}
else
{
int currentTrianglesIndex = 0;
int verticesOffset = 0;
foreach (var meshFilter in _meshFilters)
{
float random = UnityEngine.Random.value;
var sharedMesh = meshFilter.sharedMesh;
var vertCount = sharedMesh.vertices.Length;
for (int j = 0; j < vertCount; j++)
{
if (hasUVs)
uv[j + verticesOffset] = sharedMesh.uv[j];
if (hasColors)
colors[j + verticesOffset] = sharedMesh.colors[j];
vertices[j + verticesOffset] = sharedMesh.vertices[j];
}
var sharedTriangles = sharedMesh.triangles;
for (int j = 0; j < sharedTriangles.Length; j++)
{
triangles[currentTrianglesIndex++] = sharedTriangles[j] + verticesOffset;
}
verticesOffset += vertCount;
}
}
bakedMesh.vertices = vertices;
if (hasUVs)
bakedMesh.uv = uv;
if (hasNormal)
bakedMesh.normals = normals;
if (hasColors)
bakedMesh.colors = colors;
bakedMesh.triangles = triangles;
bakedMesh.RecalculateBounds();
return bakedMesh;
}
VATの作成
Alembicを再生し、頂点位置と法線を取得してTextureに焼きます。今回はComputeShaderを用いてVATを作成します。
private int GetMaxVertexCount()
{
int maxVertCount = 0;
if (_topologyType == TopologyType.Liquid)
{
maxVertCount = _maxTriangleCount * 3;
}
else
{
maxVertCount = _meshFilters.Select(x => x.sharedMesh.vertexCount).Sum();
}
return maxVertCount;
}
private Vector2Int GetTextureSize()
{
var size = new Vector2Int();
int maxVertCount = GetMaxVertexCount();
int maxTextureWitdh = (int)this.maxTextureWitdh;
var x = Mathf.NextPowerOfTwo(maxVertCount);
x = x > maxTextureWitdh ? maxTextureWitdh : x;
var y = ((int)(alembic.Duration * samplingRate) * ((int)((maxVertCount - 1) / maxTextureWitdh) + 1));
size.x = x;
size.y = y;
if (y > maxTextureWitdh)
{
Debug.LogError("data size over");
}
return size;
}
private List<VertInfo> GetVertInfos(int maxVertCount)
{
var infoList = new List<VertInfo>();
var meshes = _meshFilters.Select(meshFilter => meshFilter.sharedMesh);
var vertices = new List<Vector3>();
var normals = new List<Vector3>();
if(_topologyType == TopologyType.Soft)
{
foreach (var mesh in meshes)
{
vertices.AddRange(mesh.vertices);
normals.AddRange(mesh.normals);
}
infoList.AddRange(Enumerable.Range(0, maxVertCount)
.Select(idx =>
{
var pos = idx < vertices.Count ? vertices[idx] : Vector3.zero;
var norm = idx < normals.Count ? normals[idx] : Vector3.zero;
return new VertInfo()
{
position = pos,
normal = norm
};
})
);
}
else if(_topologyType == TopologyType.Liquid)
{
var mesh = meshes.First();
var tris = mesh.GetTriangles(0);
var verts = mesh.vertices;
var norms = mesh.normals;
foreach (var tri in tris)
{
vertices.Add(verts[tri]);
normals.Add(norms[tri]);
}
infoList.AddRange(Enumerable.Range(0, maxVertCount)
.Select(idx =>
{
var pos = idx < vertices.Count ? vertices[idx] : Vector3.zero;
var norm = idx < normals.Count ? normals[idx] : Vector3.zero;
return new VertInfo()
{
position = pos,
normal = norm
};
})
);
}
return infoList;
}
private (Texture2D posTex, Texture2D normTex) BakeTextures(Mesh mesh)
{
var maxVertCount = GetMaxVertexCount();
var frames = ((int)(alembic.Duration * samplingRate));
var texSize = GetTextureSize();
var dt = alembic.Duration / frames;
var pRt = new RenderTexture(texSize.x, texSize.y, 0, RenderTextureFormat.ARGBHalf);
pRt.name = string.Format("{0}.posTex", alembic.gameObject.name);
var nRt = new RenderTexture(texSize.x, texSize.y, 0, RenderTextureFormat.ARGBHalf);
nRt.name = string.Format("{0}.normTex", alembic.gameObject.name);
foreach (var rt in new[] { pRt, nRt })
{
rt.enableRandomWrite = true;
rt.Create();
RenderTexture.active = rt;
GL.Clear(true, true, Color.clear);
}
var infoList = new List<VertInfo>(texSize.y * maxVertCount);
float progress = 0f;
for (var frame = 0; frame < frames; frame++)
{
progress = (float)frame / (float)frames;
string progressText = ((frame % 2) == 0) ? "processing ₍₍(ง˘ω˘)ว⁾⁾" : "processing ₍₍(ว˘ω˘)ง⁾⁾";
bool isCancel = EditorUtility.DisplayCancelableProgressBar("AlembicToVAT", progressText, progress);
alembic.UpdateImmediately(_startTime + dt * frame);
infoList.AddRange(GetVertInfos(maxVertCount));
if (isCancel)
{
EditorUtility.ClearProgressBar();
Debug.Log("Canceled");
return (null, null);
}
}
var maxBounds = Vector3.zero;
var minBounds = Vector3.zero;
foreach (var info in infoList)
{
minBounds = Vector3.Min(minBounds, info.position);
maxBounds = Vector3.Max(maxBounds, info.position);
}
if(minBounds.magnitude < maxBounds.magnitude)
{
minBounds = maxBounds * -1;
}
else
{
maxBounds = minBounds * -1;
}
mesh.bounds = new Bounds(){max = maxBounds, min = minBounds};
var buffer = new ComputeBuffer(infoList.Count, System.Runtime.InteropServices.Marshal.SizeOf(typeof(VertInfo)));
buffer.SetData(infoList.ToArray());
int rows = (int)((float)maxVertCount / (float)texSize.x - 0.00001f) + 1;
var kernel = infoTexGen.FindKernel("CSMain");
uint x, y, z;
infoTexGen.GetKernelThreadGroupSizes(kernel, out x, out y, out z);
infoTexGen.SetInt("MaxVertexCount", maxVertCount);
infoTexGen.SetInt("TextureWidth", texSize.x);
infoTexGen.SetBuffer(kernel, "Info", buffer);
infoTexGen.SetTexture(kernel, "OutPosition", pRt);
infoTexGen.SetTexture(kernel, "OutNormal", nRt);
infoTexGen.Dispatch(kernel, Mathf.Clamp(maxVertCount / (int)x + 1 , 1, texSize.x / (int)x + 1) , (frames / (int)y) * rows + 1, 1);
buffer.Release();
var posTex = RenderTextureToTexture2D(pRt);
var normTex = RenderTextureToTexture2D(nRt);
Graphics.CopyTexture(pRt, posTex);
Graphics.CopyTexture(nRt, normTex);
posTex.filterMode = FilterMode.Point;
normTex.filterMode = FilterMode.Point;
posTex.wrapMode = TextureWrapMode.Repeat;
normTex.wrapMode = TextureWrapMode.Repeat;
EditorUtility.ClearProgressBar();
return (posTex, normTex);
}
ComputeShaderでC#から受け取った頂点情報をTextureに焼きます。
VATの仕様は以下のようにしました。
- x軸が頂点ID(頂点数がTexture幅より大きい場合は改行)、y軸がFrame、 左下(0, 0)がスタート
- sRGBHalfで頂点位置そのものを記録 (元の頂点位置の差分ではない)
- filterModeはPoint(改行する場合があるため) 、wrapModeはRepeat
- TextureWidthはPOT, TextureHeightはNPOT
#pragma kernel CSMain
struct MeshInfo
{
float3 position;
float3 normal;
};
RWTexture2D<float4> OutPosition;
RWTexture2D<float4> OutNormal;
StructuredBuffer<MeshInfo> Info;
uint MaxVertexCount;
uint TextureWidth;
[numthreads(8,1,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
uint rows = (uint)(MaxVertexCount /TextureWidth) + 1;
int frame = (int)(id.y / rows);
int rowNum = id.y % rows;
int index = frame * MaxVertexCount + rowNum * TextureWidth + id.x;
MeshInfo info = Info[index];
OutPosition[id.xy] = float4(info.position, 1.0);
OutNormal[id.xy] = float4(info.normal, 1.0);
}
VAT再生用Shader
VertexShaderでVATを読み込み頂点位置と法線を設定することでVATを再生しています。
FragmentShaderは適当なので適宜書く必要があります。
Shader "AlembicToVAT/TextureAnimPlayer"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_PosTex("position texture", 2D) = "black"{}
_NmlTex("normal texture", 2D) = "white"{}
_DT ("delta time", float) = 0
_Length ("animation length", Float) = 1
_VertCount ("VertCount", Int) = 1
[Toggle(ANIM_LOOP)] _Loop("loop", Float) = 1
[Toggle(IS_FLUID)] _IsFluid("IsFluid", Float) = 0
[Enum(UnityEngine.Rendering.CullMode)]
_Cull("Cull", Float) = 2
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100
Cull [_Cull]
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile ___ ANIM_LOOP
#pragma multi_compile_instancing
#pragma shader_feature _ IS_FLUID
#include "UnityCG.cginc"
#define ts _PosTex_TexelSize
struct appdata
{
float2 uv : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct v2f
{
float2 uv : TEXCOORD0;
float3 normal : TEXCOORD1;
float4 vertex : SV_POSITION;
UNITY_VERTEX_OUTPUT_STEREO
};
sampler2D _MainTex, _PosTex, _NmlTex;
float4 _PosTex_TexelSize;
float _Length, _DT;
int _VertCount;
v2f vert (appdata v, uint vid : SV_VertexID)
{
uint texWidth = ts.z;
uint rowNum = _VertCount * ts.x + 1;
float t = 0;
#if ANIM_LOOP
t = frac(_Time.y / _Length);
#else
t = frac( _DT / _Length);
#endif
float x = (vid % texWidth) * ts.x;
float tsy = 1.0 / (ts.w -0.1);
float blockHeihgt = tsy * rowNum;
float baseY = floor(t / blockHeihgt) * blockHeihgt;
float rowDiff = floor(vid * ts.x) * tsy;
float y = baseY + rowDiff;
float4 pos = tex2Dlod(_PosTex, float4(x, y, 0, 0));
float3 normal = tex2Dlod(_NmlTex, float4(x, y, 0, 0));
#ifdef IS_FLUID
#else
float nextY = (y - rowDiff + blockHeihgt >= 1.0) ? y : y + blockHeihgt;
float4 pos2 = tex2Dlod(_PosTex, float4(x, nextY, 0, 0));
float3 normal2 = tex2Dlod(_NmlTex, float4(x, nextY, 0, 0));
float p = fmod(t, blockHeihgt) / blockHeihgt;
pos = lerp(pos, pos2, p);
normal = lerp(normal, normal2, p);
#endif
v2f o;
UNITY_INITIALIZE_OUTPUT(v2f, o);
UNITY_SETUP_INSTANCE_ID(v);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
o.vertex = UnityObjectToClipPos(pos);
o.normal = UnityObjectToWorldNormal(normal);
o.uv = v.uv;
return o;
}
half4 frag (v2f i) : SV_Target
{
half diff = dot(i.normal, float3(0,1,0))*0.5 + 0.5;
half4 col = tex2D(_MainTex, i.uv);
return diff * col;
}
ENDCG
}
}
}
フレームの補間
以下の部分で現在のFrameと次のFrameの頂点情報を線形補間しています。
トポロジーが変化しないもの場合は線形補間を用いることでサンプリング数が少ない場合でも滑らかなアニメーションをレンダリングすることができます。
float4 pos = tex2Dlod(_PosTex, float4(x, y, 0, 0));
float3 normal = tex2Dlod(_NmlTex, float4(x, y, 0, 0));
#ifdef IS_FLUID
#else
float nextY = (y - rowDiff + blockHeihgt >= 1.0) ? y : y + blockHeihgt;
float4 pos2 = tex2Dlod(_PosTex, float4(x, nextY, 0, 0));
float3 normal2 = tex2Dlod(_NmlTex, float4(x, nextY, 0, 0));
float p = fmod(t, blockHeihgt) / blockHeihgt;
pos = lerp(pos, pos2, p);
normal = lerp(normal, normal2, p);
#endif
参考