はじめに
キャラクターの肌や布、フィルムなど変形が発生する物体において、物体表面のしわをUnity上でリアルタイムにシミュレートする表現の実現に取り組みましたので共有します。しわの動的な表現は、ゲームのリアリズム、没入性を向上させる表現となりえます。本記事では、そのような表現の基本的な仕組みから実装方法までを順を追って説明します。
方法の紹介
布のしわ表現自体はクロスシミュレーションの一環として広くみられます。またリアルタイムを対象とした場合においては、クロスシミュレーション単体では解像度が荒いため、しわ表現が困難ですが、簡易的な変形勾配の計算や法線の変形を行うことで視覚的なしわ表現が十分に可能となります。実際にSakura Rabbit氏の動画ではUnity上での衣服のしわ表現が確認でき、より高品質な表現、体験をゲームに与えることを可能としています。

Sakura Rabbit 氏による Unity 上でのしわ表現
本記事では、動画のような表現を簡単にUnity上で実現する方法について、基本的な仕組みから実装までを説明し、さらなる応用表現のための貢献となればと考えています。また本記事で紹介する方法について、独自の調査に基づいて行ったため、他の方法と異なる場合がある点にご注意ください。
基本的な仕組み
リアルタイムで実現するため、(リアルタイムでない場合と比較して)少ない頂点数のジオメトリに焦点を当てます。少ない頂点数を持つジオメトリでは、しわ表現のために必要な解像度が不足しています。そのため、法線の変形によって視覚的なしわ表現を実現します。また法線の変形について、本記事では詳細な模様にはSine波を用い、しわの向きと強さをジオメトリ変形から分析します。
しわの向きは、ジオメトリの接線空間上で考えると解釈が容易になり、接線方向と従法線方向の2軸で表現されます。よって接線空間上においてジオメトリの変形を分析し、しわの向きと大きさを計算します。ジオメトリの変形は、変形前の頂点の位置と変形後の頂点の位置から考えられます。ある頂点に焦点を当てれば、頂点ごとに局所的な変形勾配が計算でき、頂点ごとに伸縮の強さと向きを得られます。これをしわの向きと強さに対応させることでしわ表現を実現します。
変形勾配
本記事では、連続体力学でみられる変形勾配の概念を、頂点や三角形、接線空間によって単純化したうえで扱います。ある頂点とその周辺の三角形に焦点を当てれば、頂点の変形勾配は接線空間上における周辺の三角形を線形変換する行列として表現されます。しかし実際には、三角形ごとにわずかに変形は異なるため、周辺の三角形の変形を重み付き平均して頂点の変形勾配を計算します。
頂点$ V_1, V_2, V_3 $からなる三角形の頂点$ V_1 $の変形勾配は、変形前の頂点位置$ \boldsymbol{X}_1, \boldsymbol{X}_2, \boldsymbol{X}_3 $と変形後の頂点位置$ \boldsymbol{x}_1, \boldsymbol{x}_2, \boldsymbol{x}_3 $を用いて、以下のように考えられます。
\boldsymbol{e}_{ij} = \boldsymbol{x}_j - \boldsymbol{x}_i, \quad \boldsymbol{E}_{ij} = \boldsymbol{X}_j - \boldsymbol{X}_i \\
F =
[ \boldsymbol{e}_{12} \quad \boldsymbol{e}_{13} \quad \boldsymbol{e}_{12} \times \boldsymbol{e}_{13} ]
[ \boldsymbol{E}_{12} \quad \boldsymbol{E}_{13} \quad \boldsymbol{E}_{12} \times \boldsymbol{E}_{13} ]^{-1}
また頂点$ V_1 $が属する全ての三角形について、面積を重みとして平均することで頂点$ V_1 $の変形勾配$ \tilde{F} $を得られます。
伸縮の強さと向き
変形勾配は伸縮のみならず回転も変形として含むため、しわ表現に必要な伸縮の成分を抽出します。本記事では、グリーンのひずみテンソルの考えを用いて、変形勾配$F$から伸縮成分$E$を次のように計算します。
E = \frac{1}{2} ( \tilde{F}^T \tilde{F} - I )
ここで$ E $は行列であるため、固有値分解を行うことで直行する3方向の伸縮の強さを得られます。うち接線空間上の法線方向を除く2方向の伸縮成分をしわの向きと強さに対応させます。
実装方法
上記の方法をUnity 6.0上で実装する方法について説明します。
本記事では、汎用性の観点から、Skinned Mesh Rendererを持つオブジェクトに対してスクリプトをアタッチすることで、しわ表現を可能にする設計を行います。Skinned Mesh Rendererはアニメーション等によって頂点位置を変化させるコンポーネントです。このコンポーネントによって引き起こされた変形の前後の頂点位置はGPUメモリ上(Graphics Buffer)ならば容易に取得が可能です。上記の方法は頂点ごとに並列計算が可能なため、計算についてもGPU上(Compute Shader)で行います。以上の処理をUnity 6.0のURP上で組み込むため、Render Graphを利用します。最後に、計算されたしわの向きと強さはMaterial Property Blockに設定し、そのMaterialのShader Graph内で法線マップを生成します。
Render Graphとデータ転送
Compute Shaderでの計算とそれに必要なデータについて、Render Graphの枠組みを使用して実装します。下記のSkinnedMeshFlowクラスでは、RenderPipelineManager.beginCameraRenderingを利用して、毎フレームRender Graphの処理を行います。これにより、レンダリングパイプラインの中にCompute Shaderのパス(SkinnedMeshFlowPass)を組み込みます。
public class SkinnedMeshFlow : MonoBehaviour
{
public ComputeShader shader;
public SkinnedMeshRenderer skinnedMeshRenderer;
private RenderGraph _renderGraph;
private SkinnedMeshFlowPass _pass;
void OnEnable()
{
_renderGraph = new RenderGraph("Skinned Mesh Flow Graph");
_pass = new SkinnedMeshFlowPass(shader, skinnedMeshRenderer);
RenderPipelineManager.beginCameraRendering += OnBeginCamera;
}
void OnDisable()
{
RenderPipelineManager.beginCameraRendering -= OnBeginCamera;
_pass.Dispose();
}
private void OnBeginCamera(ScriptableRenderContext ctx, Camera cam)
{
var cmd = CommandBufferPool.Get("Skinned Mesh Flow Command");
var param = new RenderGraphParameters
{
scriptableRenderContext = ctx,
commandBuffer = cmd,
currentFrameIndex = Time.frameCount,
};
try
{
_renderGraph.BeginRecording(param);
_pass.RecordRenderGraph(_renderGraph);
_renderGraph.EndRecordingAndExecute();
}
catch (System.Exception e)
{
_renderGraph.Cleanup();
throw e;
}
ctx.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
ctx.Submit();
}
}
class PassData
{
public ComputeShader shader;
public int vertexCount;
public BufferHandle srcVertexBuffer;
public BufferHandle dstVertexBuffer;
public BufferHandle adjacentBuffer;
public BufferHandle addressBuffer;
public BufferHandle strainBuffer;
}
class SkinnedMeshFlowPass : System.IDisposable
{
private ComputeShader _shader;
private SkinnedMeshRenderer _skinnedMeshRenderer;
private int _vertexCount;
private GraphicsBuffer _adjacentBuffer;
private GraphicsBuffer _addressBuffer;
private GraphicsBuffer _strainBuffer;
public SkinnedMeshFlowPass(ComputeShader shader, SkinnedMeshRenderer skinnedMeshRenderer)
{
if (shader == null) throw new System.ArgumentNullException(nameof(shader));
if (skinnedMeshRenderer == null) throw new System.ArgumentNullException(nameof(skinnedMeshRenderer));
_shader = shader;
_skinnedMeshRenderer = skinnedMeshRenderer;
_skinnedMeshRenderer.sharedMesh.vertexBufferTarget |= GraphicsBuffer.Target.Structured;
_skinnedMeshRenderer.vertexBufferTarget |= GraphicsBuffer.Target.Structured;
_vertexCount = _skinnedMeshRenderer.sharedMesh.vertexCount;
// ここに事前のデータ転送処理
_strainBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured, _vertexCount, 4 * 3);
}
public void RecordRenderGraph(RenderGraph renderGraph)
{
var srcVertexBuffer = _skinnedMeshRenderer.sharedMesh.GetVertexBuffer(0);
var dstVertexBuffer = _skinnedMeshRenderer.GetVertexBuffer();
if (dstVertexBuffer == null) return;
var adjustMatrix = _skinnedMeshRenderer.worldToLocalMatrix * _skinnedMeshRenderer.rootBone.localToWorldMatrix;
using (var builder = renderGraph.AddComputePass<PassData>("Skinned Mesh Flow Pass", out var passData))
{
passData.shader = _shader;
passData.vertexCount = _vertexCount;
passData.srcVertexBuffer = renderGraph.ImportBuffer(srcVertexBuffer);
passData.dstVertexBuffer = renderGraph.ImportBuffer(dstVertexBuffer);
passData.adjacentBuffer = renderGraph.ImportBuffer(_adjacentBuffer);
passData.addressBuffer = renderGraph.ImportBuffer(_addressBuffer);
passData.strainBuffer = renderGraph.ImportBuffer(_strainBuffer);
builder.SetRenderFunc(static (PassData passData, ComputeGraphContext ctx) =>
{
// compute flow
var kernelId = passData.shader.FindKernel("CSMain");
var groupX = Mathf.CeilToInt(passData.vertexCount / 256.0f);
ctx.cmd.SetComputeIntParam(passData.shader, "VertexCount", passData.vertexCount);
ctx.cmd.SetComputeBufferParam(passData.shader, kernelId, "SrcVertexBuffer", passData.srcVertexBuffer);
ctx.cmd.SetComputeBufferParam(passData.shader, kernelId, "DstVertexBuffer", passData.dstVertexBuffer);
ctx.cmd.SetComputeBufferParam(passData.shader, kernelId, "AdjacentBuffer", passData.adjacentBuffer);
ctx.cmd.SetComputeBufferParam(passData.shader, kernelId, "AddressBuffer", passData.addressBuffer);
ctx.cmd.SetComputeBufferParam(passData.shader, kernelId, "StrainBuffer", passData.strainBuffer);
ctx.cmd.DispatchCompute(passData.shader, kernelId, groupX, 1, 1);
});
}
// ここにStrainBufferのMaterial Property Block登録処理
}
public void Dispose()
{
_adjacentBuffer.Dispose();
_addressBuffer.Dispose();
_strainBuffer.Dispose();
}
}
次に、CPU側でメッシュの隣接三角形について事前計算し、前もってGPUへデータを転送します。SkinnedMeshFlowPassのコンストラクタ内において、全三角形を探索し、各頂点ごとに三角形を構成する他の2頂点のデータを作成します。この情報は_adjacentBufferおよび_addressBufferの2つのGraphics Bufferに格納され、Compute Shaderによって周囲の三角形を把握できるようにします。_adjucentBufferには頂点ごとに、自頂点が属する三角形について、自頂点を除く2頂点のインデックスが複数個並んで格納されます。_addressBufferには各頂点に対応する_adjacentBuffer内の開始位置と終了位置が格納されます。
// build ajdacent list
var adjacentLists = new List<(int, int)>[_vertexCount];
for (int i = 0; i < _vertexCount; i++) adjacentLists[i] = new List<(int, int)>();
var indices = new List<int>();
for (int i = 0; i < _skinnedMeshRenderer.sharedMesh.subMeshCount; i++)
{
_skinnedMeshRenderer.sharedMesh.GetTriangles(indices, i);
for (int j = 0; j < indices.Count; j += 3)
{
var idx0 = indices[j + 0];
var idx1 = indices[j + 1];
var idx2 = indices[j + 2];
adjacentLists[idx0].Add((idx1, idx2));
adjacentLists[idx1].Add((idx2, idx0));
adjacentLists[idx2].Add((idx0, idx1));
}
indices.Clear();
}
// build buffer data
var adjacents = new List<int>();
var neighbors = new List<int>();
for (int i = 0; i < _vertexCount; i++)
{
var startIdx = adjacents.Count;
foreach (var (idx0, idx1) in adjacentLists[i])
{
adjacents.Add(idx0);
adjacents.Add(idx1);
}
var endIdx = adjacents.Count;
neighbors.Add(startIdx / 2);
neighbors.Add(endIdx / 2);
}
var adjacentCount = adjacents.Count;
_adjacentBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured, adjacentCount, 4 * 2);
_adjacentBuffer.SetData(adjacents);
_addressBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured, _vertexCount, 4 * 2);
_addressBuffer.SetData(neighbors);
Compute Shaderによるジオメトリ変形の分析
次に、Compute Shaderで変形勾配の算出と固有値分解を行います。Compute ShaderのCSMainカーネルにおいて、変形前の頂点データSrcVertexBufferと、変形後の頂点データDstVertexBufferの両方にアクセスします。頂点ごとに、接線、従法線、法線からなるTBN行列を構築します。
#pragma kernel CSMain
struct StandardVertex
{
float3 position;
float3 normal;
float4 tangent;
};
[numthreads(256, 1, 1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
int idx0 = id.x;
if (idx0 >= VertexCount) return;
float3 srcPosition = SrcVertexBuffer[idx0].position;
float3 dstPosition = DstVertexBuffer[idx0].position;
// Create World to TBN matrix on source mesh
StandardVertex srcVertex = SrcVertexBuffer[idx0];
float3x3 srcTBN = 0;
srcTBN[0] = srcVertex.tangent.xyz;
srcTBN[1] = cross(srcVertex.normal, srcVertex.tangent.xyz) * srcVertex.tangent.w;
srcTBN[2] = srcVertex.normal;
srcTBN = transpose(srcTBN);
srcTBN = inverse(srcTBN);
// Create World to TBN matrix on destination mesh
StandardVertex dstVertex = DstVertexBuffer[idx0];
float3x3 dstTBN = 0;
dstTBN[2] = dstVertex.normal;
dstTBN[0] = dstVertex.tangent.xyz;
dstTBN[1] = cross(dstVertex.normal, dstVertex.tangent.xyz) * dstVertex.tangent.w;
dstTBN = transpose(dstTBN);
dstTBN = inverse(dstTBN);
各頂点において、事前計算した隣接三角形のリストを基に、TBN空間上での変形勾配$ F $を計算し、面積重み付き平均$ \tilde{F} $を求めます。
float3x3 srcA = 0;
float3x3 dstA = 0;
float3x3 F = 0;
float F_area = 0;
int2 address = AddressBuffer[idx0];
[loop]
for (int i = address.x; i < address.y; i++)
{
int idx1 = AdjacentBuffer[i].x;
int idx2 = AdjacentBuffer[i].y;
float3 srcPosition1 = SrcVertexBuffer[idx1].position;
float3 srcPosition2 = SrcVertexBuffer[idx2].position;
srcA[0] = srcPosition1 - srcPosition;
srcA[1] = srcPosition2 - srcPosition;
srcA[2] = normalize(cross(srcA[0], srcA[1]));
srcA = transpose(srcA);
srcA = mul(srcTBN, srcA);
float3 dstPosition1 = DstVertexBuffer[idx1].position;
float3 dstPosition2 = DstVertexBuffer[idx2].position;
dstA[0] = dstPosition1 - dstPosition;
dstA[1] = dstPosition2 - dstPosition;
dstA[2] = normalize(cross(dstA[0], dstA[1]));
dstA = transpose(dstA);
dstA = mul(dstTBN, dstA);
float area = length(cross(srcPosition1 - srcPosition, srcPosition2 - srcPosition)) * 0.5;
F += area * mul(dstA, inverse(srcA));
F_area += area;
}
F = F / F_area;
次に、グリーンのひずみテンソル$ E $を算出します。算出したひずみ行列$ E $に対して、ヤコビ法(jacobiRotate)を用いて固有値分解を行い、主ひずみの大きさとその方向ベクトルを抽出します。
得られた最大ひずみ方向の接線空間上の成分と、ひずみの大きさをStrainBufferに書き出します。
float3x3 C = mul(transpose(F), F);
float3x3 E = 0.5 * (C - float3x3(1, 0, 0, 0, 1, 0, 0, 0, 1));
float3 eigenValues;
float3x3 eigenVectors;
eigenDecomp(E, eigenValues, eigenVectors);
float3 l = abs(eigenValues);
if (l.x >= l.y && l.x >= l.z) StrainBuffer[idx0] = float3(eigenVectors[0].xy, eigenValues.x);
if (l.y >= l.z && l.y >= l.x) StrainBuffer[idx0] = float3(eigenVectors[1].xy, eigenValues.y);
if (l.z >= l.x && l.z >= l.y) StrainBuffer[idx0] = float3(eigenVectors[2].xy, eigenValues.z);
}
また、Compute Shader内で使用した逆行列とヤコビ法の計算には以下の関数を用いています。
float3x3 inverse(float3x3 m)
{
float3x3 n;
n._11 = m._22 * m._33 - m._23 * m._32;
n._12 = m._13 * m._32 - m._12 * m._33;
n._13 = m._12 * m._23 - m._13 * m._22;
n._21 = m._23 * m._31 - m._21 * m._33;
n._22 = m._11 * m._33 - m._13 * m._31;
n._23 = m._13 * m._21 - m._11 * m._23;
n._31 = m._21 * m._32 - m._22 * m._31;
n._32 = m._12 * m._31 - m._11 * m._32;
n._33 = m._11 * m._22 - m._12 * m._21;
return rcp(determinant(m)) * n;
}
void eigenDecomp(in float3x3 M, out float3 eigenValues, out float3x3 eigenVectors)
{
const int MAX_ITERATIONS = 32;
eigenVectors = float3x3(1, 0, 0, 0, 1, 0, 0, 0, 1);
float3x3 A = M;
[loop]
for (int n = 0; n < MAX_ITERATIONS; n++)
{
jacobiRotate(A, eigenVectors, 0, 1);
jacobiRotate(A, eigenVectors, 1, 2);
jacobiRotate(A, eigenVectors, 2, 0);
}
eigenVectors = transpose(eigenVectors);
eigenValues = float3(A._11, A._22, A._33);
}
しわの描画
計算されたひずみ情報StrainBufferは、Material Property Blockを介してMaterialに渡します。
var block = new MaterialPropertyBlock();
block.SetBuffer("StrainBuffer", _strainBuffer);
_skinnedMeshRenderer.SetPropertyBlock(block);
ここで、MaterialにはShader Graphを使用し、法線マップの変形を行います。Shader Graph内において、カスタム関数(SampleStrainBuffer.hlsl)を作成し、頂点IDに基づいてStrainBufferからひずみデータを取得します。取得したひずみの方向とSine波を組み合わせます。ひずみの大きさが一定値を超えた場合に、その方向に沿って法線マップを回転させることで、しわを描写します。
#ifndef SAMPLE_STRAIN_BUFFER_HLSL
#define SAMPLE_STRAIN_BUFFER_HLSL
StructuredBuffer<float3> StrainBuffer;
void SampleStrainBuffer_float(float id, out float3 strain)
{
strain = StrainBuffer[id];
}
#endif // SAMPLE_STRAIN_BUFFER_HLSL
動作確認
動作の様子は下記の動画にて確認できます。

動作の様子(https://github.com/user-attachments/assets/3cf4d4ca-bcb7-4017-90ca-efd053da6463)
動画内から、圧縮が発生する箇所におい横しわが描写されていることが確認できます。本記事で作成したShader Graphは圧縮のみに焦点を当てたため、伸張が発生する箇所に縦しわの描写は行っていません。しかし、伸張に対しても同様の方法でしわ表現を行うことが可能と考えています。以上から、ジオメトリの変形をリアルタイムに分析し、それに応じた視覚的なしわ表現をUnity上で実現しました。
また、作成したUnityプロジェクトは下記のGitHubリポジトリで公開しました。
おわりに
本記事では、Unity上でしわを動的にシミュレーションする方法について説明しました。本方法の限界として、しわの描写に関しては単純なSine波を用いているため、物理現象に基づいたしわの微細な模様の形成は考慮していません。また、頂点数が非常に少ない場合や極端な変形が発生する場合には、しわ表現が不十分になる可能性があります。そのため、本方法はさらなる応用の余地があると考えています。しわの模様に対してより複雑な形成プロセスを取ることや、物理シミュレーションと組み合わせることで、よりリアルな表現が可能となります。
最後に、今回の試みではリアルタイムでのしわ表現を実装し、ゲームのリアリズムと没入性を向上させる方法を確認しました。今後、本記事がゲームにおけるしわ等の表現の実装に貢献できることを期待しています。

