2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Unity】シェーダーの頂点アニメーションで機構物を動かす

Posted at

これを作ります.

Strandbeest-Final.gif

概要

機構物を動かすときの汎用的な方法は,ボーンを入れて動かす,あるいは,パーツを分けてアニメーションクリップやスクリプトで動かす,だと思います.しかし,オブジェクトを1000個描画するような場合,この方法だと非常に高負荷になります.
機構の動きをシェーダーのアニメーションで代替することにより,負荷を大幅に軽減することが可能です.(CPU → GPU に転嫁するので「軽減」が正しいか怪しいですが,とにかく軽くなります.)
この記事ではその手順の一例について詳細に説明します.

※ 登場するコードがいちいち長いので,デフォルトではすべて折りたたまれた状態で掲載しています.

準備

アニメーションで動かす対象となる機構物の動きを計算で求められるようにしておきます.
この記事ではテオ・ヤンセンのストランドビーストを題材にします.

なお,後述しますが,実際には,動きが周期的であり,かつ,任意の瞬間における節点の座標が特定できるのであれば,計算で求められなくても問題ありません.

ストランドビーストについて

これが歩行タイプのストランドビーストの一つです.美しいですね.

ストランドビーストの脚部はリンク機構の集合で,単一の回転運動から歩行に適した直線的な運動を生み出します.各リンク長の比は「ホーリー・ナンバー」と呼ばれ,この機構の核となります.

image.png

節点の座標の求め方

単純に根元から順に座標を求めていきます.三角形を構成する3点のうち既知の座標とリンク長から未知の座標を求めます.

下図のように節点の番号を決め,次の順で求めることにします.

  • 駆動部の回転角を決め"1"の座標を求める
  • "1"と"2"の座標から"3"と"4"の座標を求める
  • "2"と"3"の座標から"5"の座標を求める
  • "4"と"5"の座標から"6"の座標を求める
  • "4"と"6"の座標から"7"の座標を求める

image.png

実際に節点の座標を求める

HLSLで記述します.

Strandbeest-LinkSolver.cginc
Strandbeest-LinkSolver.cginc
#ifndef STRANDBEEST_LINKSOLVER_INCLUDED
#define STRANDBEEST_LINKSOLVER_INCLUDED

float2 SolveTriangle(in float2 a, in float2 b, in float l0, in float l1, in float which)
{
    float2 ab = b - a;
    float l3 = length(ab);
    float x = (l0 * l0 - l1 * l1 + l3 * l3) / (2 * l3);
    float y = sqrt(l0 * l0 - x * x);
    float2 t = ab / l3;
    float2 n = float2(-t.y, t.x);
    return a + x * t + which * y * n;
}

void SolveStrandbeest(in float angle, out float2 pos[8])
{
    const float a = 0.380;
    const float b = 0.415;
    const float c = 0.393;
    const float d = 0.401;
    const float e = 0.558;
    const float f = 0.394;
    const float g = 0.367;
    const float h = 0.657;
    const float i = 0.490;
    const float j = 0.500;
    const float k = 0.619;
    const float l = 0.078;
    const float m = 0.150;
    const float height = 0.918;

    pos[0] = float2(0, height);
    pos[1] = pos[0] + m * float2(cos(angle), sin(angle));
    pos[2] = pos[0] + float2(a, -l);
    pos[3] = SolveTriangle(pos[1], pos[2], j, b,  1);
    pos[4] = SolveTriangle(pos[1], pos[2], k, c, -1);
    pos[5] = SolveTriangle(pos[3], pos[2], e, d,  1);
    pos[6] = SolveTriangle(pos[4], pos[5], g, f, -1);
    pos[7] = SolveTriangle(pos[4], pos[6], i, h, -1);
}

#endif

ちなみに height は値がわからなかったので目算で適当な値を入れています.シミュレーションではないので,見た目が問題なければ大丈夫です.

計算が正しいか確認しておきます.

Strandbeest-Line.shader
Strandbeest-Line.shader
Shader "Test/Strandbeest/Strandbeest-Line"
{
    Properties
    {
        _Scale ("Scale", Float) = 1
        _Speed ("Speed", Float) = 1
        _Color ("Color", Color) = (1,1,1,1)
    }

    SubShader
    {
        Tags { "RenderType"="Opaque" }

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma geometry geom
            #pragma fragment frag
            #pragma target 4.0

            #include "UnityCG.cginc"
            #include "Strandbeest-LinkSolver.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
            };

            struct v2g
            {
                float4 vertex : SV_POSITION;
            };

            struct g2f
            {
                float4 pos : SV_POSITION;
            };

            float _Scale;
            float _Speed;
            float4 _Color;

            v2g vert(appdata i)
            {
                v2g o;
                o.vertex = i.vertex;
                return o;
            }

            [maxvertexcount(15)]
            void geom(triangle v2g IN[3], uint primitive_id : SV_PrimitiveID, inout LineStream<g2f> stream)
            {
                if (primitive_id != 0)
                {
                    return;
                }

                float angle = - _Speed * _Time.y;
                
                float2 pos[8];
                SolveStrandbeest(angle, pos);

                g2f o[8];
                for (uint i = 0; i < 8; i++)
                {
                    o[i].pos = UnityObjectToClipPos(float4(_Scale * pos[i], 0, 1));
                }

                stream.Append(o[0]);
                stream.Append(o[1]);
                stream.Append(o[3]);
                stream.Append(o[2]);
                stream.Append(o[4]);
                stream.Append(o[1]);
                stream.RestartStrip();
                stream.Append(o[3]);
                stream.Append(o[5]);
                stream.Append(o[2]);
                stream.RestartStrip();
                stream.Append(o[5]);
                stream.Append(o[6]);
                stream.Append(o[4]);
                stream.RestartStrip();
                stream.Append(o[6]);
                stream.Append(o[7]);
                stream.Append(o[4]);
                stream.RestartStrip();
            }

            float4 frag(g2f i) : SV_Target
            {
                return _Color;
            }

            ENDCG
        }
    }
}

angle がマイナスなのは単に向きを合わせたかっただけで,あまり意味はありません.

適当なメッシュに適用すると下のように描画されます.正しそうですね.

Strandbeest-Line.gif

計算結果をテクスチャに保存する

「計算結果をテクスチャに保存」するというのは,つまり,例えば機構運動の1周期が3秒だったとして,0.1秒刻みで動かしたときの各時刻での座標をすべて計算しておき,それら30個の座標データを float4 型のデータとしてテクスチャに保存する,ということです.

image.png

これは必須の工程ではありませんが,今回のように周期的な運動の場合は,毎フレーム計算するよりも,事前に計算結果をテクスチャに保存しておいて描画時に読み出す方が効率的です.また,この形式であれば,節点の座標を数値的に求める場合にも負荷を気にせず描画できます.

さて,実際にテクスチャに保存するデータの内容についてですが,今回は「必要な節点の座標と回転」を使うことにします.
メッシュの座標を操作するのが目的なので,各リンクの端点の座標をすべて保存しておくより,片方の端点の座標と回転を保存しておく方が,描画時の計算が減ります.
また,(2-3-5)と(4-6-7)の三角形はメッシュとして先に形を作っておけばいいので,1辺の情報だけあれば十分です.
ということで,下図の [1]~[7] の座標と回転をテクスチャに保存します.ちなみに,[0] はフレーム部分(動かない部分)です.不要なデータですが,あとで場合分けをしなくて済むようにテクスチャに含めておきます.

image.png

機構運動の1フレーム分のデータの構成は,

float4 data[8]
    x: 節点の x 座標
    y: 節点の y 座標
    z: 節点の z 座標
    w: 節点の回転

みたいな感じにします.2次元運動なので座標は3次元もいらないのですが,今回はそこまで切り詰める必要がないので,これで進めます.

テクスチャには,フレームごとに8個の座標データを1列に並べ,左下から順に敷き詰めていきます.128x128のテクスチャに保存する場合,全体で2048フレーム分のデータが保存されます.(過剰ですね.)

image.png

今回はシェーダーを用いてテクスチャにデータを保存します.

Strandbeest-LinkAnimationTexture.shader
Strandbeest-LinkAnimationTexture.shader
Shader "Test/Strandbeest/Strandbeest-LinkAnimationTexture"
{
    Properties
    {
        _TextureSize ("Texture Size", Int) = 128
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }

        Pass
        {
            CGPROGRAM
            #pragma vertex vert_img
            #pragma fragment frag
            #pragma target 2.0

            #include "UnityCG.cginc"
            #include "Strandbeest-LinkSolver.cginc"

            uint _TextureSize;

            float4 frag(v2f_img i) : SV_Target
            {
                uint2 index2dRaw = i.uv * _TextureSize;
                uint index = index2dRaw.x + (index2dRaw.y / 8) * _TextureSize;
                uint subIndex = index2dRaw.y % 8;
                uint totalIndex = (_TextureSize / 8) * _TextureSize;

                float angle = UNITY_TWO_PI * (float(index) / float(totalIndex));

                float2 pos[8];
                SolveStrandbeest(angle, pos);

                float2 ps[8][2] = {
                    { pos[0], pos[0] + float2(1,0) },
                    { pos[0], pos[1] },
                    { pos[1], pos[3] },
                    { pos[1], pos[4] },
                    { pos[2], pos[5] },
                    { pos[2], pos[4] },
                    { pos[5], pos[6] },
                    { pos[4], pos[7] },
                };

                float2 p0 = ps[subIndex][0];
                float2 p1 = ps[subIndex][1];
                float2 dir = p1 - p0;
                float rot = -atan2(dir.y, dir.x);

                float3 p = float3(0, p0.y, p0.x);

                return float4(p, rot);
            }

            ENDCG
        }
    }
}

テクスチャへの焼き込みはスクリプトを使います.今回はエディタ上で焼いてアセット化しますが,Custom Render Texture で実行時に生成するという手もあります.

CreateStrandbeestLinkAnimationTexture.cs
CreateStrandbeestLinkAnimationTexture.cs
#if UNITY_EDITOR

using UnityEngine;
using UnityEditor;
using UnityEngine.Rendering;

namespace Test
{
    public class CreateStrandbeestLinkAnimationTexture : ScriptableWizard
    {
        const string k_shaderName = "Test/Strandbeest/Strandbeest-LinkAnimationTexture";
        const string k_saveFolder = "Assets/Temp";
        public int textureSize = 128;

        [MenuItem("Test/Create Strandbeest Link Animation Texture")]
        static void Open()
        {
            DisplayWizard<CreateStrandbeestLinkAnimationTexture>("Create Strandbeest Link Animation Texture");
        }

        void OnWizardCreate()
        {
            Shader shader = Shader.Find(k_shaderName);
            if (shader == null)
            {
                return;
            }

            RenderTexture rt = new RenderTexture(textureSize, textureSize, 0, RenderTextureFormat.ARGBFloat);
            rt.Create();
            Material material = new Material(shader);
            material.SetInt("_TextureSize", textureSize);
            Graphics.Blit(null, rt, material);

            Texture2D tex2d = new Texture2D(textureSize, textureSize,
                UnityEngine.Experimental.Rendering.GraphicsFormat.R32G32B32A32_SFloat,
                UnityEngine.Experimental.Rendering.TextureCreationFlags.None
            );

            AsyncGPUReadbackRequest request = AsyncGPUReadback.Request(rt);
            request.WaitForCompletion();
            if (request.hasError)
            {
                Debug.LogError("AsyncGPUReadback failed to read texture data.");
                return;
            }
            tex2d.SetPixelData(request.GetData<Color>(), 0);
            tex2d.Apply(false, false);
            tex2d.wrapMode = TextureWrapMode.Clamp;
            tex2d.filterMode = FilterMode.Point;

            if (!AssetDatabase.IsValidFolder(k_saveFolder))
            {
                System.IO.Directory.CreateDirectory(Application.dataPath + k_saveFolder.Substring(6));
            }
            string savePath = k_saveFolder + "/Strandbeest-LinkAnimationTexture.asset";
            savePath = AssetDatabase.GenerateUniqueAssetPath(savePath);
            AssetDatabase.CreateAsset(tex2d, savePath);
            AssetDatabase.SaveAssets();
            EditorGUIUtility.PingObject(tex2d);
        }
    }
}

#endif

ScriptableWizard なので,Editor か EditorOnly のフォルダに保存してください.保存したらメニューバーの「Test」からウィンドウを開いてテクスチャを作成します.

座標データをテクスチャから読み出す

テクスチャを作成したら,読み出して確認してみます.実際の使い方を想定して,節点の座標と回転を線で描画します.

Strandbeest-Line2.shader
Strandbeest-Line2.shader
Shader "Test/Strandbeest/Strandbeest-Line2"
{
    Properties
    {
        [NoScaleOffset] _LinkAnimationTexture ("Link Animation Texture", 2D) = "white" {}
        _Scale ("Scale", Float) = 1
        _Speed ("Speed", Float) = 1
        _Color ("Color", Color) = (1,1,1,1)
    }

    CGINCLUDE
        #include "UnityCG.cginc"

        sampler2D _LinkAnimationTexture;
        float4 _LinkAnimationTexture_TexelSize;
        
        void GetNode(in float angle, in uint subIndex, in float direction, out float3 pos, out float3x3 rot)
        {
            float t = frac(angle / UNITY_TWO_PI);
            uint2 texelSize = _LinkAnimationTexture_TexelSize.zw;
            uint totalIndex = texelSize.x * (texelSize.y / 8);
            float indexRaw = float(totalIndex) * t;
            float ratio = frac(indexRaw);
            uint index0 = uint(indexRaw);
            uint2 index2d0 = uint2(index0 % texelSize.x, (index0 / texelSize.x) * 8 + subIndex);
            uint index1 = (index0 + 1) % totalIndex;
            uint2 index2d1 = uint2(index1 % texelSize.x, (index1 / texelSize.x) * 8 + subIndex);
            float4 data = lerp(tex2Dlod(_LinkAnimationTexture, float4((index2d0 + 0.5) * _LinkAnimationTexture_TexelSize.xy, 0, 0)),
                               tex2Dlod(_LinkAnimationTexture, float4((index2d1 + 0.5) * _LinkAnimationTexture_TexelSize.xy, 0, 0)),
                               ratio);
            pos = data.xyz;
            pos.z *= direction;
            data.w *= direction;
            rot = float3x3(
                1, 0, 0,
                0, cos(data.w), -sin(data.w),
                0, sin(data.w), cos(data.w)
            );
        }
    ENDCG

    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma geometry geom
            #pragma fragment frag
            #pragma target 4.0

            struct appdata
            {
                float4 vertex : POSITION;
            };

            struct v2g
            {
                float4 vertex : SV_POSITION;
            };

            struct g2f
            {
                float4 position : SV_POSITION;
            };

            float _Scale;
            float _Speed;
            float4 _Color;

            v2g vert(appdata i)
            {
                v2g o;
                o.vertex = i.vertex;
                return o;
            }

            [maxvertexcount(16)]
            void geom(triangle v2g IN[3], uint primitive_id : SV_PrimitiveID, inout LineStream<g2f> stream)
            {
                if (primitive_id != 0)
                {
                    return;
                }

                float angle = _Speed * _Time.w;

                g2f o;
                for (uint i = 0; i < 8; i++)
                {
                    float3 pos;
                    float3x3 rot;
                    GetNode(angle, i, 1, pos, rot);
                    pos *= _Scale;
                    o.position = UnityObjectToClipPos(float4(pos, 1));
                    stream.Append(o);
                    o.position = UnityObjectToClipPos(float4(pos + mul(rot, float3(0, 0, 0.1 * _Scale)), 1));
                    stream.Append(o);
                    stream.RestartStrip();
                }
            }

            float4 frag(g2f i) : SV_Target
            {
                return _Color;
            }
            ENDCG
        }
    }
}

適当なメッシュに適用すると,下のように描画されます.わかりにくいですが,足の動きに沿って位置と方向が動いているのを確認できます.

レコーディング 2025-09-21 233521.gif

ちなみに,さっきと回転が逆になっていますが,あまり気にしないでください.
そういうお年頃なんです.

実装

冒頭のストランドビースト群を作成していきます.

  • 専用のメッシュを作成
  • 頂点アニメーション用のサーフェスシェーダーを作成
  • 作成したサーフェスシェーダーをパーティクル用に改造

の流れで作成します.

頂点の識別について

ここで,地味に重要な点ですが,最終的にパーティクル化することを目指しているので,作成するストランドビーストは,1体が単一のメッシュで構成され,かつマテリアルも1種類である必要があります.
したがって,単一のメッシュであっても,どの頂点がどのリンクの所属であるかを,シェーダー側で識別できるようにしておく必要があります.

識別に必要な情報は,

  • リンクの番号(先ほどテクスチャを作成したときにつけた番号です)
  • 回転の向き (前足と後ろ足で回転の向きが逆になります)
  • 位相のずれ (隣り合う脚同士で位相を120度ずらします)

の3つです.

レコーディング 2025-09-24 210536.gif

今回は,これらの識別情報を頂点カラーとしてメッシュに保存します.
頂点カラーは fixed4 なので,0 ~ 255 までの整数を4つ格納できます.

識別情報の取りうる値を整理しておくと,

  • リンクの番号:0,1,2,3,4,5,6,7(8種類)
  • 回転の向き :0,1(正か逆かの2種類)
  • 位相のずれ :0,1,2(0度,120度,240度の3種類)

なので,fixed4rgb にそれぞれ格納するだけですが,都合により圧縮して格納します.

fixed4 c;
c.r = ((link_index << 3) | (rot_dir << 2) | phase) / 255.0;
c.g = 0;
c.b = 0;

識別データはすべて c.r に格納します.下位 2bit が位相の情報,次の 1bit が回転方向の情報,上位 5bit がリンク番号です.
"都合" というのは,c.gb にほかのデータを入れたかっただけですが,この記事では扱いません.こういう方法もあるよ,という紹介なだけです.
c.a は 1.0 固定です.Blender から fbx をエクスポートする時に 1.0 に固定されます.アドオンを使うと変更できるそうですが,筆者は使ったことがありません.

頂点アニメーション用のメッシュを作成

メッシュは Blender で作成します.Unity 内でスクリプトを作ってモデリングすることもできますが(おまけ),Unity はモデリングツールではないですし,Blender で作るのが汎用的でしょう.

先ほどテクスチャに座標を保存した [1] ~ [7] のリンクをそれぞれ独立のメッシュとしてモデリングします.

  • メッシュの原点を緑の矢印の根元に一致させます
  • 矢印が Blender の -Y 軸を向くようにします
  • メッシュの名前は「link."リンク番号"."回転方向"."位相"」とします
    • 図の場合は,正回転,位相0度,の脚になります

image.png

link.2 ~ link.7 を複製し,Z軸に180度回転します.回転方向は "逆回転" です.

image.png

全体を複製して,「位相120度」と「位相240度」を作ります.自動で採番されるはずです.

image.png

スクリプトを使って頂点カラーを設定します.筆者は Blender の python がわからないので ChatGPT に作ってもらったものを修正して使っています.

頂点カラーを設定するスクリプト
import bpy

# 頂点カラーの編集はOBJECTモードで行う必要がある.
currentMode = bpy.context.object.mode
bpy.ops.object.mode_set(mode="OBJECT")

# 選択されているメッシュの頂点カラーを指定の値にする.
for obj in bpy.context.selected_objects:
    mesh = obj.data
    name = obj.name

    # オブジェクト名から識別情報を取得し,頂点カラーを計算
    link_index, rot_dir, phase = map(int, name.split(".")[1:4])
    c = (((link_index << 3) | (rot_dir << 2) | phase) / 255.0, 0, 0, 1)
    
    # 頂点カラー属性を取得または作成
    color_attr = mesh.color_attributes.get("Col")
    if color_attr is None:
        color_attr = mesh.color_attributes.new("Col", "BYTE_COLOR", "POINT")

    # 頂点カラーを設定
    vertex_color = color_attr.data
    for col in vertex_color:
        col.color = c

    # ビューポート更新
    mesh.update()

# 元のモードに戻す
bpy.ops.object.mode_set(mode=currentMode)

「スクリプト作成」のワークスペースで上記のスクリプトを作成した後,メッシュをすべて選択して,「▷」ボタンで実行します.「スクリプト作成」が見当たらない場合は「+」ボタンで追加してください.

image.png

スクリプトを実行すると,メッシュのカラー属性に "Col" が追加されます.

image.png

頂点カラーの設定が終わったら,メッシュは統合してしまいましょう.ただし,うまくいかなかったときのためにバックアップは取っておきましょう.

image.png

ビューポートで頂点カラーを確認する場合は,メッシュの頂点カラーを選択した状態で,オブジェクトカラーに「属性」を選択します.今回はメッシュが赤黒く表示されていれば正解です.

image.png

統合した脚のメッシュを複製して,フレームも作成しましょう.

image.png

単純に作成すると,フレームは灰色に表示されると思いますが,これは頂点カラーが存在しないためです.このままメッシュを統合すると,脚の頂点カラーがリセットされてしまうので,先にフレームの頂点カラーを作成します.

image.png

「名前」「ドメイン」「データタイプ」は脚に合わせます.カラーに関しては,

  • リンク番号:0
  • 回転方向:0(何でもいい)
  • 位相:0(何でもいい)

なので,黒のままで OK です.

フレームが黒くなったら全体を統合して一つのメッシュにします.メッシュの原点はシーンの原点に合わせておきます.

最後に,Unity と軸を合わせます.Unity での描画の際,メッシュのローカル座標系で頂点を動かすことになるので,軸を合わせることが必須です.

メッシュを X 軸に -90度 回転させて,Ctrl+A から回転を適用します.

image.png

その後,+90度 回転させます.

image.png

メッシュを fbx でエクスポートします.設定は Unity 向けにエクスポートするときの一般的な設定と基本的には変わりませんが,「頂点カラー」は「リニア」に設定する必要があります.「sRGB」になっているとガンマ補正がかかって正しい識別データを読めない可能性があります.

image.png

メッシュの作成はこれで完了です.fbx を Unity にドラッグアンドドロップでインポートしてください.

BoundingBox の修正

作成した fbx を Unity にインポートしたら,BoundingBox の調整をします.
メッシュの BoundingBox は自動的に計算されるので,インポートの時点では,モデリングした通りの,脚がたたまれた状態での BoundingBox が設定されています.立ち上がった状態に合わせて BoundingBox を設定しないと適切に描画されません.

image.png

BoundingBox はエディタ上で編集できないので,スクリプトを使って編集します.

ConfigureMeshBounds.cs
ConfigureMeshBounds.cs
#if UNITY_EDITOR

using UnityEngine;
using UnityEditor;

namespace TsukimiWS.Test
{
    public class ConfigureMeshBounds : ScriptableWizard
    {
        const string k_saveFolder = "Assets/Temp";
        public Mesh mesh;
        public Vector3 center = Vector3.zero;
        public Vector3 size = Vector3.one;

        [MenuItem("Test/Configure Mesh Bounds")]
        static void Open()
        {
            DisplayWizard<ConfigureMeshBounds>("Configure Mesh Bounds");
        }

        void OnWizardCreate()
        {
            if (mesh == null)
            {
                return;
            }
            Mesh newMesh = Instantiate(mesh);
            newMesh.bounds = new Bounds(center, size);
            
            if (!AssetDatabase.IsValidFolder(k_saveFolder))
            {
                System.IO.Directory.CreateDirectory(Application.dataPath + k_saveFolder.Substring(6));
            }
            string savePath = k_saveFolder + $"/{mesh.name}.asset";
            savePath = AssetDatabase.GenerateUniqueAssetPath(savePath);
            AssetDatabase.CreateAsset(newMesh, savePath);
            AssetDatabase.SaveAssets();
            EditorGUIUtility.PingObject(newMesh);
        }
    }
}
#endif

頂点アニメーション用のサーフェスシェーダーを作成

本題のシェーダーを作っていきます.
といっても,必要な要素は説明済みなので,あとは組み合わせるだけです.

処理の流れとしては,

  • 頂点カラーを取得して識別情報を取り出す
  • 現在時刻と位相ずれから,アニメーションの時間を計算
  • テクスチャからリンクの位置と角度を取得
  • 頂点を移動

といった感じです.すべて頂点シェーダーで実施します.

注意としては,頂点を動かすので,ShadowCaster や SceneSelection のパスも明示的に記述しないと正しく描画されません.コードは長くなりますが,内容はどのパスもほとんど同じです.

Strandbeest-Surface.shader
Strandbeest-Surface.shader
Shader "Test/Strandbeest/Strandbeest-Surface"
{
    Properties
    {
        [NoScaleOffset] _LinkAnimationTexture ("Link Animation Texture", 2D) = "white" {}
        _Scale ("Scale", Float) = 1
        _Speed ("Speed", Float) = 1
        _Color ("Color", Color) = (1,1,1,1)
        _MainTex ("Albedo (RGB)", 2D) = "white" {}
        _Glossiness ("Smoothness", Range(0,1)) = 0.5
        _Metallic ("Metallic", Range(0,1)) = 0.0
    }
    
    CGINCLUDE
        #include "UnityCG.cginc"

        sampler2D _LinkAnimationTexture;
        float4 _LinkAnimationTexture_TexelSize;
        float _Scale;
        float _Speed;
        
        void GetNode(in float angle, in uint subIndex, in float direction, out float3 pos, out float3x3 rot)
        {
            float t = frac(angle / UNITY_TWO_PI);
            uint2 texelSize = _LinkAnimationTexture_TexelSize.zw;
            uint totalIndex = texelSize.x * (texelSize.y / 8);
            float indexRaw = float(totalIndex) * t;
            float ratio = frac(indexRaw);
            uint index0 = uint(indexRaw);
            uint2 index2d0 = uint2(index0 % texelSize.x, (index0 / texelSize.x) * 8 + subIndex);
            uint index1 = (index0 + 1) % totalIndex;
            uint2 index2d1 = uint2(index1 % texelSize.x, (index1 / texelSize.x) * 8 + subIndex);
            float4 data = lerp(tex2Dlod(_LinkAnimationTexture, float4((index2d0 + 0.5) * _LinkAnimationTexture_TexelSize.xy, 0, 0)),
                               tex2Dlod(_LinkAnimationTexture, float4((index2d1 + 0.5) * _LinkAnimationTexture_TexelSize.xy, 0, 0)),
                               ratio);
            pos = data.xyz;
            pos.z *= direction;
            data.w *= direction;
            rot = float3x3(
                1, 0, 0,
                0, cos(data.w), -sin(data.w),
                0, sin(data.w), cos(data.w)
            );
        }

        void VertControl(inout float3 vertex, out float3x3 rot, in float4 color)
        {
            float time = _Time.y;
            float speed = _Speed;
            float size = _Scale;

            uint4 colorData = color * 255.0;
            uint partIndex = (colorData.x >> 3) & 0x1F;
            float direction = ((colorData.x >> 2) & 0x1) == 0 ? 1 : -1;
            float angleOffset = float(colorData.x & 0x3) * UNITY_TWO_PI / 3.0;

            float angle = direction * (speed * time + angleOffset);
            if (direction < 0)
            {
                angle += UNITY_PI;
            }
            float3 pos;
            GetNode(angle, partIndex, direction, pos, rot);
            vertex = (mul(rot, vertex) + pos) * size;
        }
    ENDCG

    SubShader
    {
        Tags { "RenderType"="Opaque" }

        CGPROGRAM
        #pragma surface surf Standard vertex:vert fullforwardshadows nolightmap nodynlightmap nolppv
        #pragma target 3.5

        half _Glossiness;
        half _Metallic;
        fixed4 _Color;

        struct appdata
        {
            float4 vertex   : POSITION;
            float3 normal   : NORMAL;
            fixed4 color    : COLOR;
            UNITY_VERTEX_INPUT_INSTANCE_ID
        };

        struct Input
        {
            UNITY_POSITION(pos);
            UNITY_VERTEX_INPUT_INSTANCE_ID
            UNITY_VERTEX_OUTPUT_STEREO
        };
        
        void vert(inout appdata v, out Input o)
        {
            UNITY_SETUP_INSTANCE_ID(v);
            UNITY_INITIALIZE_OUTPUT(Input,o);
            UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
            UNITY_TRANSFER_INSTANCE_ID(v, o);

            float3x3 M_vert_rot;
            VertControl(v.vertex.xyz, M_vert_rot, v.color);
            o.pos = UnityObjectToClipPos(v.vertex);
            v.normal = mul(M_vert_rot, v.normal);
        }

        void surf (Input IN, inout SurfaceOutputStandard o)
        {
            o.Albedo = _Color;
            o.Metallic = _Metallic;
            o.Smoothness = _Glossiness;
            o.Alpha = 1.0;
        }
        ENDCG
        
		Pass
		{
			Name "ShadowCaster"
			Tags{ "LightMode"="ShadowCaster" }
			ZWrite On
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
            #pragma target 3.5
            #pragma multi_compile_instancing
			#pragma multi_compile_shadowcaster
			#pragma multi_compile UNITY_PASS_SHADOWCASTER

			#include "Lighting.cginc"
			#include "UnityPBSLighting.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
                fixed4 color  : COLOR;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

			struct v2f
			{
				V2F_SHADOW_CASTER_NOPOS
                UNITY_POSITION(pos);
				UNITY_VERTEX_INPUT_INSTANCE_ID
				UNITY_VERTEX_OUTPUT_STEREO
			};

			v2f vert( appdata v )
			{
				v2f o;
				UNITY_SETUP_INSTANCE_ID(v);
				UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
				UNITY_TRANSFER_INSTANCE_ID(v, o);

                float3x3 M_vert_rot;
                VertControl(v.vertex.xyz, M_vert_rot, v.color);
                o.pos = UnityObjectToClipPos(v.vertex);
                v.normal = mul(M_vert_rot, v.normal);

				TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)
				return o;
			}

			half4 frag(v2f i) : SV_Target
			{
				UNITY_SETUP_INSTANCE_ID(i);
				SHADOW_CASTER_FRAGMENT(i)
			}

			ENDCG
		}
        
        Pass
        {
            Name "SceneSelection"
            Tags { "LightMode"="SceneSelectionPass" }

            BlendOp Add
            Blend One Zero
            ZWrite On
            Cull Off

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma target 3.5
            #pragma multi_compile_instancing
            
            struct appdata
            {
                float4 vertex : POSITION;
                fixed4 color  : COLOR;
				UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            struct v2f
            {
                UNITY_POSITION(pos);
            };
            
            float _ObjectId;
            float _PassValue;

            v2f vert(appdata v)
            {
                v2f o;
				UNITY_SETUP_INSTANCE_ID(v);

                float3x3 M_vert_rot;
                VertControl(v.vertex.xyz, M_vert_rot, v.color);
                o.pos = UnityObjectToClipPos(v.vertex);
                return o;
            }

            half4 frag (v2f i) : SV_Target
            {
                return half4(_ObjectId, _PassValue, 1, 1);
            }

            ENDCG
        }

        Pass
        {
            Name "Picking"
            Tags { "LightMode"="Picking" }

            BlendOp Add
            Blend One Zero
            ZWrite On
            Cull Off

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma target 3.5
            #pragma multi_compile_instancing
                
            struct appdata
            {
                float4 vertex : POSITION;
                fixed4 color  : COLOR;
				UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            struct v2f
            {
                UNITY_POSITION(pos);
            };
            
            float4 _SelectionID;

            v2f vert(appdata v)
            {
                v2f o;
				UNITY_SETUP_INSTANCE_ID(v);

                float3x3 M_vert_rot;
                VertControl(v.vertex.xyz, M_vert_rot, v.color);
                o.pos = UnityObjectToClipPos(v.vertex);
                return o;
            }

            half4 frag (v2f i) : SV_Target
            {
                return _SelectionID;
            }

            ENDCG
        }
    }
    FallBack "Diffuse"
}

作成したメッシュに適用しましょう.Link Animation Texture には「準備」で作成したテクスチャをセットします.
Scene ビューの右上にあるこのボタンが Enabled になっていることを確認し,"Always Refresh" にチェックを入れます.

image.png

ようやく動き出します.

レコーディング 2025-09-24 230938.gif

パーティクル対応

作成したシェーダーをパーティクルに対応させます.

まず必要なのは,パーティクル用の Instancing の設定です.

#pragma exclude_renderers gles
#pragma instancing_options procedural:vertInstancingSetup

#define UNITY_PARTICLE_INSTANCE_DATA MyParticleInstanceData
#define UNITY_PARTICLE_INSTANCE_DATA_NO_ANIM_FRAME
struct MyParticleInstanceData
{
    float3x4 transform;
    uint color;
    float speed;
    float size;
    float agePercent;
};

#include "UnityCG.cginc"
#include "UnityStandardParticleInstancing.cginc"

exclude_renderers gles というのは,よくわかりませんが,正方行列じゃない行列を使う場合に必要だそうです.

instancing_options procedural:vertInstancingSetup は,インスタンスごとに unity_ObjectToWorldunity_WorldToObject をセットアップするための設定です. "UnityStandardParticleInstancing.cginc" に vertInstancingSetup という関数が定義されています.なお,この記述がないと,"UnityStandardParticleInstancing.cginc" の内容がほとんどスキップされてしまい,Instancing されたデータを受け取れなくなります.

そのあとが,パーティクルシステムから受け取るデータの構造体の設定です.構造体の標準の要素に transform color animFrame があり,coloranimFrame が不要の場合は UNITY_PARTICLE_INSTANCE_DATA_NO_COLOR などを定義します.transform は,基本的には必須の要素です.独自の構造体を定義しなかった場合は,DefaultParticleInstanceData が使われます.

struct DefaultParticleInstanceData
{
    float3x4 transform;
    uint color;
    float animFrame;
};

最後に依存ファイルをインクルードします."UnityStandardParticleInstancing.cginc" は使いたい構造体の定義のあとでインクルードする必要があります.また,"UnityCG.cginc" を先にインクルードしないと "UnityStandardParticleInstancing.cginc" の内容がほとんどスキップされてしまいます.("UnityCG.cginc" 内でインクルードする "UnityInstancing.cginc" で UNITY_PROCEDURAL_INSTANCING_ENABLED が定義されており,これがないと "UnityStandardParticleInstancing.cginc" はほぼスキップされます.)
ちなみに,Surface シェーダーの場合は問答無用で "UnityCG.cginc" が真っ先に読み込まれるらしく,順番はあまり関係ありません.

パーティクルの Instancing の準備はこれで完了です.あとは使いたいときにバッファから読み出すだけです.

#ifdef UNITY_PARTICLE_INSTANCING_ENABLED
    UNITY_PARTICLE_INSTANCE_DATA data = unity_ParticleInstanceData[unity_InstanceID];
    speed = data.speed;
    size = data.size;
    agePercent = data.agePercent;
#endif

Instancing しないパスでエラーが出ないように,ifdef で囲っていますが,実際に使う上ではあまり関係ありません.

color に関しては専用の関数が用意されています.

vertInstancingColor(c);

触れていませんでしたが,coloruint で提供されるので,fixed4 に分解してから使います.自分で分解してもいいですが,定義済みの関数を使う方が安全です.

対応すべき内容は以上です.先ほどのサーフェスシェーダーを改造してみます.

Strandbeest-Particle.shader
Strandbeest-Particle.shader
Shader "Test/Strandbeest/Strandbeest-Particle"
{
    Properties
    {
        [NoScaleOffset] _LinkAnimationTexture ("Link Animation Texture", 2D) = "white" {}
        _Glossiness ("Smoothness", Range(0,1)) = 0.5
        _Metallic ("Metallic", Range(0,1)) = 0.0
    }
    
    CGINCLUDE
        #pragma exclude_renderers gles
        #pragma instancing_options procedural:vertInstancingSetup

        #define UNITY_PARTICLE_INSTANCE_DATA MyParticleInstanceData
        #define UNITY_PARTICLE_INSTANCE_DATA_NO_ANIM_FRAME
        struct MyParticleInstanceData
        {
            float3x4 transform;
            uint color;
            float speed;
            float size;
            float agePercent;
        };

        #include "UnityCG.cginc"
        #include "UnityStandardParticleInstancing.cginc"

        sampler2D _LinkAnimationTexture;
        float4 _LinkAnimationTexture_TexelSize;

        void GetNode(in float angle, in uint subIndex, in float direction, out float3 pos, out float3x3 rot)
        {
            float t = frac(angle / UNITY_TWO_PI);
            uint2 texelSize = _LinkAnimationTexture_TexelSize.zw;
            uint totalIndex = texelSize.x * (texelSize.y / 8);
            float indexRaw = float(totalIndex) * t;
            float ratio = frac(indexRaw);
            uint index0 = uint(indexRaw);
            uint2 index2d0 = uint2(index0 % texelSize.x, (index0 / texelSize.x) * 8 + subIndex);
            uint index1 = (index0 + 1) % totalIndex;
            uint2 index2d1 = uint2(index1 % texelSize.x, (index1 / texelSize.x) * 8 + subIndex);
            float4 data = lerp(tex2Dlod(_LinkAnimationTexture, float4((index2d0 + 0.5) * _LinkAnimationTexture_TexelSize.xy, 0, 0)),
                               tex2Dlod(_LinkAnimationTexture, float4((index2d1 + 0.5) * _LinkAnimationTexture_TexelSize.xy, 0, 0)),
                               ratio);
            pos = data.xyz;
            pos.z *= direction;
            data.w *= direction;
            rot = float3x3(
                1, 0, 0,
                0, cos(data.w), -sin(data.w),
                0, sin(data.w), cos(data.w)
            );
        }

        void VertControl(inout float3 vertex, out float3x3 rot, in float4 color)
        {
            float time = _Time.y;
            float walkSpeed = 1;
            float size = 1;
            float agePercent = 0.5;

            #ifdef UNITY_PARTICLE_INSTANCING_ENABLED
                UNITY_PARTICLE_INSTANCE_DATA data = unity_ParticleInstanceData[unity_InstanceID];
                walkSpeed = data.speed;
                size = data.size;
                agePercent = data.agePercent;
            #endif

            float speed = walkSpeed / size * 4.4;
            size *= smoothstep(0.5, 0.4, abs(agePercent - 0.5));

            uint4 colorData = color * 255.0;
            uint partIndex = (colorData.x >> 3) & 0x1F;
            float direction = ((colorData.x >> 2) & 0x1) == 0 ? 1 : -1;
            float angleOffset = float(colorData.x & 0x3) * UNITY_TWO_PI / 3.0;

            float angle = direction * (speed * time + angleOffset);
            if (direction < 0)
            {
                angle += UNITY_PI;
            }
            float3 pos;
            GetNode(angle, partIndex, direction, pos, rot);
            vertex = (mul(rot, vertex) + pos) * size;
        }
    ENDCG

    SubShader
    {
        Tags { "RenderType"="Opaque" }

        CGPROGRAM
        #pragma surface surf Standard vertex:vert fullforwardshadows nolightmap nodynlightmap nolppv
        #pragma target 3.5

        half _Glossiness;
        half _Metallic;

        struct appdata
        {
            float4 vertex : POSITION;
            float3 normal : NORMAL;
            fixed4 color  : COLOR;
            UNITY_VERTEX_INPUT_INSTANCE_ID
        };

        struct Input
        {
            UNITY_POSITION(pos);
            UNITY_VERTEX_INPUT_INSTANCE_ID
            UNITY_VERTEX_OUTPUT_STEREO
        };
        
        void vert(inout appdata v, out Input o)
        {
            UNITY_SETUP_INSTANCE_ID(v);
            UNITY_INITIALIZE_OUTPUT(Input,o);
            UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
            UNITY_TRANSFER_INSTANCE_ID(v, o);

            float3x3 M_vert_rot;
            VertControl(v.vertex.xyz, M_vert_rot, v.color);
            o.pos = UnityObjectToClipPos(v.vertex);
            v.normal = mul(M_vert_rot, v.normal);
        }

        void surf (in Input IN, inout SurfaceOutputStandard o)
        {
            fixed4 c = fixed4(1,1,1,1);
            vertInstancingColor(c);
            o.Albedo = c;
            o.Metallic = _Metallic;
            o.Smoothness = _Glossiness;
            o.Alpha = 1.0;
        }
        ENDCG
        
		Pass
		{
			Name "ShadowCaster"
			Tags{ "LightMode"="ShadowCaster" }
			ZWrite On
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
            #pragma target 3.5
            #pragma multi_compile_instancing
			#pragma multi_compile_shadowcaster
			#pragma multi_compile UNITY_PASS_SHADOWCASTER

			#include "HLSLSupport.cginc"
			#include "Lighting.cginc"
			#include "UnityPBSLighting.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
                fixed4 color  : COLOR;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

			struct v2f
			{
				V2F_SHADOW_CASTER_NOPOS
                UNITY_POSITION(pos);
				UNITY_VERTEX_INPUT_INSTANCE_ID
				UNITY_VERTEX_OUTPUT_STEREO
			};

			v2f vert(appdata v)
			{
				v2f o;
				UNITY_SETUP_INSTANCE_ID(v);
				UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
				UNITY_TRANSFER_INSTANCE_ID(v, o);

                float3x3 M_vert_rot;
                VertControl(v.vertex.xyz, M_vert_rot, v.color);
                o.pos = UnityObjectToClipPos(v.vertex);
                v.normal = mul(M_vert_rot, v.normal);

				TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)
				return o;
			}

			half4 frag(v2f i) : SV_Target
			{
				UNITY_SETUP_INSTANCE_ID(i);
				SHADOW_CASTER_FRAGMENT(i)
			}

			ENDCG
		}
        
        Pass
        {
            Name "SceneSelection"
            Tags { "LightMode"="SceneSelectionPass" }

            BlendOp Add
            Blend One Zero
            ZWrite On
            Cull Off

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma target 3.5
            #pragma multi_compile_instancing

            struct appdata
            {
                float4 vertex : POSITION;
                fixed4 color  : COLOR;
				UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            struct v2f
            {
                UNITY_POSITION(pos);
            };
            
            float _ObjectId;
            float _PassValue;

            v2f vert(appdata v)
            {
                v2f o;
				UNITY_SETUP_INSTANCE_ID(v);

                float3x3 M_vert_rot;
                VertControl(v.vertex.xyz, M_vert_rot, v.color);
                o.pos = UnityObjectToClipPos(v.vertex);
                return o;
            }

            half4 frag (v2f i) : SV_Target
            {
                return half4(_ObjectId, _PassValue, 1, 1);
            }

            ENDCG
        }

        Pass
        {
            Name "Picking"
            Tags { "LightMode"="Picking" }

            BlendOp Add
            Blend One Zero
            ZWrite On
            Cull Off

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma target 3.5
            #pragma multi_compile_instancing

            struct appdata
            {
                float4 vertex : POSITION;
                fixed4 color  : COLOR;
				UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            struct v2f
            {
                UNITY_POSITION(pos);
            };
            
            float4 _SelectionID;

            v2f vert(appdata v)
            {
                v2f o;
				UNITY_SETUP_INSTANCE_ID(v);

                float3x3 M_vert_rot;
                VertControl(v.vertex.xyz, M_vert_rot, v.color);
                o.pos = UnityObjectToClipPos(v.vertex);
                return o;
            }

            half4 frag (v2f i) : SV_Target
            {
                return _SelectionID;
            }

            ENDCG
        }
    }
    FallBack "Diffuse"
}

都合により,speedsize を調整しています.
脚の回転速度は,移動速度に比例し,サイズに反比例します.筆者が作成したメッシュの場合,それの 4.4 倍がちょうどよかったです.適宜調整してください.
サイズに関してですが,speedsize に依存しているため,パーティクルの機能を使ってサイズをいじると足の動きがおかしくなる,という事情から,ライフタイムを見てシェーダー側でサイズを変更することにしています.

パーティクルの設定

メッシュとシェーダーができたので,パーティクルシステムでセットアップしていきます.

Main モジュールと Emission モジュールに関しては,特筆することはあまりないです.
image.png

Shape は何でもいいですが,"Align To Direction" にチェックを入れます.
image.png

脚の回転速度をサイズとスピードで決めている都合上,"Velocity" や "Size" が変化すると描画が崩れてしまうので,これらに関するモジュールは無効にしておきます.
"Velocity" と "Size" の比が一定であればよいので,"Size by Speed" はうまく調節すれば描画が崩れないはずです.
"Collision" については,衝突直後に "Lifetime" をゼロにする使い方であれば問題ないです.
image.png

"Renderer" モジュールは設定する項目がたくさんあります.
特に,"Custom Vertex Streams" はシェーダー側の構造体と合わせる必要があるので,この要素をこの順番で設定しないと正しく描画されません.
また,こうした「同じメッシュをたくさん描画する」場合の頂点アニメーションでは GPU Instancing が重要な役割を果たします."Enable Mesh GPU Instancing" を切ると大変なことになるのでぜひ試してみてください.
image.png

完成

完成です.お疲れさまでした.

(再掲)
Strandbeest-Final.gif

雑記

パーティクルシステム向けのシェーダーをちゃんと書いたのは今回が初めてでした.GPU パーティクルなら全部オレオレで書けばいいのですが,パーティクルシステムはいろいろお作法があって難しいですね.

題材のストランドビーストは,もともと VRChat 向けに作っていて,PC-VR や Quest では正常に動作しましたが,Android 環境ではメモリ不足によりインスタンス化が失敗して描画が崩壊してしまいました.そのため,ワールド用にはパーティクルシステムを使わず GPU パーティクルとして作り直すことにしました.GPU パーティクルにしてもメモリ不足は発生しますが,そもそもインスタンス化をしていないので描画が崩れることはありません.ただ,調整はしにくくなるので,できればパーティクルシステムで何とかしたいものです.

ストランドビーストのメッシュを生成するスクリプト

これはおまけですが,この記事を書くにあたって,「再現性は100%であるべき」と思って,ストランドビーストのメッシュを生成するスクリプトを作成していました.

CreateStrandbeestMesh.cs
CreateStrandbeestMesh.cs
#if UNITY_EDITOR

using UnityEngine;
using UnityEditor;
using System.Collections.Generic;
using System.Linq;

namespace Test
{
    public static class StrandbeestLinks
    {
        public const float a = 0.380f;
        public const float b = 0.415f;
        public const float c = 0.393f;
        public const float d = 0.401f;
        public const float e = 0.558f;
        public const float f = 0.394f;
        public const float g = 0.367f;
        public const float h = 0.657f;
        public const float i = 0.490f;
        public const float j = 0.500f;
        public const float k = 0.619f;
        public const float l = 0.078f;
        public const float m = 0.150f;
    }
    
    public class CreateStrandbeestMesh : ScriptableWizard
    {
        const string k_saveFolder = "Assets/Temp";
        public float linkThickness = 0.03f;
        public float linkWidth = 0.2f;
        public float legsMargin = 0.02f;
        public float frameRadius = 0.03f;
        public float centerWidth = 0.1f;

        [MenuItem("Test/Create Strandbeest Mesh")]
        static void Open()
        {
            DisplayWizard<CreateStrandbeestMesh>("Create Strandbeest Mesh");
        }

        void OnWizardCreate()
        {
            List<Mesh> meshes = new List<Mesh>();
            float positionOffset = 0.5f * linkWidth + 2f * legsMargin + 5f * frameRadius + 0.5f * centerWidth;
            for (int i = 0; i < 3; i++)
            {
                meshes.Add(BuildLegMesh(positionOffset, 1, i));
                meshes.Add(BuildLegMesh(positionOffset, -1, i));
                meshes.Add(BuildLegMesh(-positionOffset, 1, 2 - i));
                meshes.Add(BuildLegMesh(-positionOffset, -1, 2 - i));
                positionOffset += linkWidth + 2f * legsMargin + 2f * frameRadius;
            }
            meshes.Add(BuildBodyMesh());

            Mesh mesh = new Mesh();
            mesh.CombineMeshes(meshes.Select(m => new CombineInstance { mesh = m }).ToArray(), true, false);
            mesh.Optimize();
            mesh.bounds = new Bounds(
                new Vector3(0, 0.75f, 0),
                new Vector3(centerWidth + 28f * frameRadius + 16 * legsMargin + 6 * linkWidth, 1.5f, 2.2f)
            );
            
            if (!AssetDatabase.IsValidFolder(k_saveFolder))
            {
                System.IO.Directory.CreateDirectory(Application.dataPath + k_saveFolder.Substring(6));
            }
            string savePath = k_saveFolder + "/Strandbeest-Mesh.asset";
            savePath = AssetDatabase.GenerateUniqueAssetPath(savePath);
            AssetDatabase.CreateAsset(mesh, savePath);
            AssetDatabase.SaveAssets();
            EditorGUIUtility.PingObject(mesh);
        }

        Mesh BuildLegMesh(float positionOffset, int direction, int phase)
        {
            List<CombineInstance> meshes = new List<CombineInstance>();

            var links = new (Vector3 start, Vector3 end, int index)[10];
            {
                float b = StrandbeestLinks.b;
                float c = StrandbeestLinks.c;
                float d = StrandbeestLinks.d;
                float e = StrandbeestLinks.e;
                float f = StrandbeestLinks.f;
                float g = StrandbeestLinks.g;
                float h = StrandbeestLinks.h;
                float i = StrandbeestLinks.i;
                float j = StrandbeestLinks.j;
                float k = StrandbeestLinks.k;

                Vector3 p3 = SolveTriangle(e, b, d);
                Vector3 p6 = SolveTriangle(h, g, i);

                links[0] = (Vector3.zero, Vector3.forward * j, 2); // j
                links[1] = (Vector3.zero, Vector3.forward * k, 3); // k
                links[2] = (Vector3.zero, Vector3.forward * d, 4); // d
                links[3] = (Vector3.zero, p3, 4); // b
                links[4] = (Vector3.forward * d, p3, 4); // e
                links[5] = (Vector3.zero, Vector3.forward * c, 5); // c
                links[6] = (Vector3.zero, Vector3.forward * f, 6); // f
                links[7] = (Vector3.zero, Vector3.forward * i, 7); // i
                links[8] = (Vector3.zero, p6, 7); // g
                links[9] = (Vector3.forward * i, p6, 7); // h
            }

            for (int i = 0; i < links.Length; i++)
            {
                links[i].start.z *= direction;
                links[i].end.z *= direction;
            }

            foreach (var link in links)
            {
                var mesh = LinkMesh(link.start, link.end, linkThickness, linkWidth);
                Color32 color = GetColor(link.index, direction, phase);
                mesh.SetColors(Enumerable.Repeat(color, mesh.vertexCount).ToList());
                meshes.Add(new CombineInstance() { mesh = mesh });
            }

            Mesh legMesh = new Mesh();
            legMesh.CombineMeshes(meshes.ToArray(), true, false);

            Vector3[] vertices = legMesh.vertices;
            for (int i = 0; i < vertices.Length; i++)
            {
                vertices[i] += new Vector3(positionOffset, 0, 0);
            }
            legMesh.vertices = vertices;
            legMesh.RecalculateNormals();
            return legMesh;
        }

        Mesh BuildBodyMesh()
        {
            List<Mesh> frameMeshes = new List<Mesh>();
            List<Mesh> driveMeshes = new List<Mesh>();
            float spineHeight = 0.3f;

            Mesh TriFrameMesh(float positionOffset)
            {
                float a = StrandbeestLinks.a;
                float l = StrandbeestLinks.l;
                float diameter = 2f * frameRadius;

                List<Mesh> meshes = new List<Mesh>();
                meshes.Add(RegHexMesh(new Vector3(positionOffset - 1.5f * frameRadius, 0, 0), new Vector3(positionOffset + 1.5f * frameRadius, 0, 0), 3f * frameRadius));
                meshes.Add(RegHexMesh(new Vector3(positionOffset - 1.5f * frameRadius, -l, a), new Vector3(positionOffset + 1.5f * frameRadius, -l, a), 2f * frameRadius));
                meshes.Add(RegHexMesh(new Vector3(positionOffset - 1.5f * frameRadius, -l, -a), new Vector3(positionOffset + 1.5f * frameRadius, -l, -a), 2f * frameRadius));
                meshes.Add(RegHexMesh(new Vector3(positionOffset - 1.5f * frameRadius, spineHeight, 0), new Vector3(positionOffset + 1.5f * frameRadius, spineHeight, 0), 2f * frameRadius));
                meshes.Add(RegHexMesh(new Vector3(positionOffset, 0, 0), new Vector3(positionOffset, spineHeight, 0), frameRadius));
                meshes.Add(RegHexMesh(new Vector3(positionOffset, -l, a), new Vector3(positionOffset, 0, 0), frameRadius));
                meshes.Add(RegHexMesh(new Vector3(positionOffset, -l, -a), new Vector3(positionOffset, 0, 0), frameRadius));
                meshes.Add(RegHexMesh(new Vector3(positionOffset, -l, a), new Vector3(positionOffset, spineHeight, 0), frameRadius));
                meshes.Add(RegHexMesh(new Vector3(positionOffset, -l, -a), new Vector3(positionOffset, spineHeight, 0), frameRadius));

                Mesh mesh = new Mesh();
                mesh.CombineMeshes(meshes.Select(m => new CombineInstance { mesh = m }).ToArray(), true, false);
                return mesh;
            }

            {
                float a = StrandbeestLinks.a;
                float l = StrandbeestLinks.l;
                float m = StrandbeestLinks.m;

                float driverRadius = m + 2f * frameRadius;
                float positionOffset = 0.5f * centerWidth + 1.5f * frameRadius;
                frameMeshes.Add(TriFrameMesh(positionOffset));
                frameMeshes.Add(TriFrameMesh(-positionOffset));
                positionOffset += 2.5f * frameRadius + legsMargin;
                frameMeshes.Add(RegHexMesh(new Vector3(-positionOffset, 0, 0), new Vector3(positionOffset, 0, 0), frameRadius));
                for (int i = 0; i < 4; i++)
                {
                    driveMeshes.Add(RegHexMesh(new Vector3(positionOffset - frameRadius, 0, 0), new Vector3(positionOffset + frameRadius, 0, 0), driverRadius));
                    driveMeshes.Add(RegHexMesh(new Vector3(-positionOffset - frameRadius, 0, 0), new Vector3(-positionOffset + frameRadius, 0, 0), driverRadius));
                    if (i == 3) break;
                    float angle = Mathf.PI / 1.5f * i;
                    Vector3 start = new Vector3(positionOffset, Mathf.Sin(angle) * m, -Mathf.Cos(angle) * m);
                    positionOffset += linkWidth + 2f * (legsMargin + frameRadius);
                    Vector3 end = new Vector3(positionOffset, start.y, start.z);
                    driveMeshes.Add(RegHexMesh(start, end, frameRadius));
                    angle = Mathf.PI / 1.5f * (2 - i);
                    start = new Vector3(-start.x, Mathf.Sin(angle) * m, -Mathf.Cos(angle) * m);
                    end = new Vector3(-end.x, start.y, start.z);
                    driveMeshes.Add(RegHexMesh(start, end, frameRadius));
                }
                {
                    float start = positionOffset;
                    positionOffset += 2.5f * frameRadius + legsMargin;
                    float end = positionOffset;
                    frameMeshes.Add(RegHexMesh(new Vector3(start, 0, 0), new Vector3(end, 0, 0), frameRadius));
                    frameMeshes.Add(RegHexMesh(new Vector3(-start, 0, 0), new Vector3(-end, 0, 0), frameRadius));
                }
                frameMeshes.Add(TriFrameMesh(positionOffset));
                frameMeshes.Add(TriFrameMesh(-positionOffset));
                frameMeshes.Add(RegHexMesh(new Vector3(-positionOffset, -l, a), new Vector3(positionOffset, -l, a), frameRadius));
                frameMeshes.Add(RegHexMesh(new Vector3(-positionOffset, -l, -a), new Vector3(positionOffset, -l, -a), frameRadius));
                frameMeshes.Add(RegHexMesh(new Vector3(-positionOffset, spineHeight, 0), new Vector3(positionOffset, spineHeight, 0), frameRadius));
            }

            Mesh frameMesh = new Mesh();
            frameMesh.CombineMeshes(frameMeshes.Select(m => new CombineInstance { mesh = m }).ToArray(), true, false);
            Color32 frameColor = GetColor(0, 0, 0);
            frameMesh.SetColors(Enumerable.Repeat(frameColor, frameMesh.vertexCount).ToList());

            Mesh driveMesh = new Mesh();
            driveMesh.CombineMeshes(driveMeshes.Select(m => new CombineInstance { mesh = m }).ToArray(), true, false);
            Color32 driveColor = GetColor(1, 0, 0);
            driveMesh.SetColors(Enumerable.Repeat(driveColor, driveMesh.vertexCount).ToList());

            Mesh bodyMesh = new Mesh();
            bodyMesh.CombineMeshes(new CombineInstance[]{
                new CombineInstance(){ mesh = frameMesh },
                new CombineInstance(){ mesh = driveMesh },
            }, true, false);
            bodyMesh.RecalculateNormals();
            return bodyMesh;
        }

        Mesh LinkMesh(Vector3 start, Vector3 end, float thickness, float width)
        {
            float length = (end - start).magnitude;
            Quaternion rot = Quaternion.LookRotation((end - start).normalized);
            float halfThickness = thickness * 0.5f;
            Vector3[] hexPos = {
                start + rot * new Vector3(0, -halfThickness, halfThickness),
                start + rot * new Vector3(0, 0, 0),
                start + rot * new Vector3(0, halfThickness, halfThickness),
                start + rot * new Vector3(0, halfThickness, length - halfThickness),
                start + rot * new Vector3(0, 0, length),
                start + rot * new Vector3(0, -halfThickness, length - halfThickness),
            };
            Mesh mesh = HexMesh(hexPos, rot * Vector3.right, width);

            return mesh;
        }

        private static Mesh RegHexMesh(Vector3 start, Vector3 end, float radius)
        {
            float length = (end - start).magnitude;
            Vector3 axis = (end - start).normalized;
            Vector3 center = (start + end) * 0.5f;
            Quaternion rot = Quaternion.LookRotation(axis);
            Vector3[] hexPos = new Vector3[6];
            for (int i = 0; i < 6; i++)
            {
                float angle = Mathf.PI / 3f * i;
                hexPos[i] = center + rot * (radius * new Vector3(Mathf.Cos(angle), Mathf.Sin(angle), 0));
            }
            return HexMesh(hexPos, axis, length);
        }

        private static Mesh HexMesh(Vector3[] hexPos, Vector3 axis, float height)
        {
            List<Vector3> vertices = new List<Vector3>();
            List<int> triangles = new List<int>();

            int[] hexIndex = { 0, 1, 2, 0, 2, 3, 0, 3, 5, 3, 4, 5 };
            int[] quadIndex = { 0, 1, 2, 3, 2, 1 };

            int vertexCount = 0;
            foreach (var p in hexPos) { vertices.Add(p + axis * height * 0.5f); }
            foreach (var i in hexIndex) { triangles.Add(vertexCount + i); }
            vertexCount = vertices.Count;
            foreach (var p in hexPos) { vertices.Add(p - axis * height * 0.5f); }
            for (int i = hexIndex.Length - 1; i >= 0; i--) { triangles.Add(vertexCount + hexIndex[i]); }
            vertexCount = vertices.Count;
            foreach (var p in hexPos)
            {
                vertices.Add(p + axis * height * 0.5f);
                vertices.Add(p - axis * height * 0.5f);
            }
            for (int i = 0; i < 6; i++)
            {
                foreach (var j in quadIndex) { triangles.Add(vertexCount + (i * 2 + j) % 12); }
            }

            Mesh mesh = new Mesh();
            mesh.SetVertices(vertices);
            mesh.SetTriangles(triangles, 0);
            return mesh;
        }

        private static Color32 GetColor(int partIndex, int direction, int phase)
        {
            Color32 color = new Color32(0, 0, 0, 0);
            color.r = (byte)(((partIndex & 0xF) << 3) | ((direction > 0 ? 0 : 1) << 2) | (phase & 0x3));
            return color;
        }

        private static Vector3 SolveTriangle(float a, float b, float c, bool flipY = false)
        {
            float cosA = (b * b + c * c - a * a) / (2 * b * c);
            float flip = flipY ? -1 : 1;
            return new Vector3(0, b * Mathf.Sin(Mathf.Acos(cosA)) * flip, b * cosA);
        }
    }
}

#endif

ただ,実際の制作においてもこういうスクリプトを作っているかというと,そんなわけはないので,本編のほうは Blender での手順を書くことにしました.参考までに.

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?