ゼロから始めるゲーム制作8:線とテキストを簡単にウゴウゴ動かす方法【Unity】

ゼロから始めるゲーム制作8:線とテキストを簡単にウゴウゴ動かす方法【Unity】

書いた人 : HAYAO(PG) HAYAO(PG)


 どうもプログラマーのHAYAOです。Twitter

 またまた技術記事を書きました。今回は、Unityを実際に触っている人向けの記事になっています。前回の記事でソースコードサンプルが欲しいという声がありましたので、今回はソースコードの解説となっています。


 いもたぱをプレイした方はご存知のこの画面。


 今回はこの画面でウゴウゴしている線と文字のアニメーションを簡単に実装する方法を紹介します。 

 前回記事では、この演出を着想するまでの小話を書いているので、興味のある方はどうぞ。→


 ウゴウゴしている方が生物感が出て不気味になります。(当社比)

 アクセントにどうぞ。


 それでは、この演出で使っている2つの小さな演出パーツを解説します。


  • UgoUgoDoodle — LineRendererに載せると、線がウゴウゴ揺れるURPシェーダー
  • CreepyTextWiggle — TextMeshPro の文字を1文字ずつ震わせ・傾かせる C# コンポーネント


Part 1. UgoUgoDoodle(LineRenderer用 ウゴウゴ落書きシェーダー)


概要

 処理は大きく 頂点シェーダー(線そのものを揺らす)フラグメントシェーダー(線の太さと縁を汚す) の2段構えです。

 

 ソース(全文)

Shader "HazeDenki/LineRenderer/UgoUgoDoodleVertex"
{
    Properties
    {
        _MainTex ("Stroke Texture", 2D) = "white" {}
        _Color ("Tint", Color) = (0,0,0,1)


        _AlphaCutoff ("Alpha Cutoff", Range(0,1)) = 0.35
        _EdgeSoftness ("Edge Softness", Range(0.001,0.5)) = 0.08


        _VertexXOffset ("Vertex X Offset", Range(0,0.1)) = 0.006
        _VertexYOffset ("Vertex Y Offset", Range(0,0.1)) = 0.02
        _VertexNoiseFreq ("Vertex Noise Freq", Float) = 8.0
        _VertexNoiseSpeed ("Vertex Noise Speed", Float) = 6.0


        _ScrollX ("Scroll X", Float) = 0.6
        _WaveAmp ("Wave Amplitude", Range(0,0.2)) = 0.02
        _WaveFreq ("Wave Frequency", Float) = 10.0
        _WaveSpeed ("Wave Speed", Float) = 6.0


        _JitterAmp ("Jitter Amplitude", Range(0,0.1)) = 0.01
        _JitterSpeed ("Jitter Speed", Float) = 10.0


        _WidthNoiseAmp ("Width Noise Amp", Range(0,0.3)) = 0.05
        _WidthNoiseFreq ("Width Noise Freq", Float) = 8.0
        _WidthNoiseSpeed ("Width Noise Speed", Float) = 5.0
        _BaseHalfWidth ("Base Half Width", Range(0.01,0.5)) = 0.18


        _StepFPS ("Step FPS", Float) = 8.0
    }


    SubShader
    {
        Tags
        {
            "RenderPipeline"="UniversalPipeline"
            "Queue"="Transparent"
            "RenderType"="Transparent"
        }


        Blend SrcAlpha OneMinusSrcAlpha
        ZWrite Off
        Cull Off


        Pass
        {
            Name "ForwardUnlit"


            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag


            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"


            struct Attributes
            {
                float4 positionOS : POSITION;
                float2 uv         : TEXCOORD0;
                float4 color      : COLOR;
            };


            struct Varyings
            {
                float4 positionHCS : SV_POSITION;
                float2 uv          : TEXCOORD0;
                float4 color       : COLOR;
            };


            TEXTURE2D(_MainTex);
            SAMPLER(sampler_MainTex);


            CBUFFER_START(UnityPerMaterial)
                float4 _MainTex_ST;
                float4 _Color;


                float _AlphaCutoff;
                float _EdgeSoftness;


                float _VertexXOffset;
                float _VertexYOffset;
                float _VertexNoiseFreq;
                float _VertexNoiseSpeed;


                float _ScrollX;
                float _WaveAmp;
                float _WaveFreq;
                float _WaveSpeed;


                float _JitterAmp;
                float _JitterSpeed;


                float _WidthNoiseAmp;
                float _WidthNoiseFreq;
                float _WidthNoiseSpeed;
                float _BaseHalfWidth;


                float _StepFPS;
            CBUFFER_END


            float hash21(float2 p)
            {
                p = frac(p * float2(123.34, 456.21));
                p += dot(p, p + 45.32);
                return frac(p.x * p.y);
            }


            float noise21(float2 p)
            {
                float2 i = floor(p);
                float2 f = frac(p);


                float a = hash21(i);
                float b = hash21(i + float2(1.0, 0.0));
                float c = hash21(i + float2(0.0, 1.0));
                float d = hash21(i + float2(1.0, 1.0));


                float2 u = f * f * (3.0 - 2.0 * f);


                return lerp(
                    lerp(a, b, u.x),
                    lerp(c, d, u.x),
                    u.y
                );
            }


            float GetSteppedTime(float rawTime, float fps)
            {
                fps = max(fps, 0.01);
                return floor(rawTime * fps) / fps;
            }


            Varyings vert(Attributes IN)
            {
                Varyings OUT;


                float steppedTime = GetSteppedTime(_Time.y, _StepFPS);


                float2 uv = TRANSFORM_TEX(IN.uv, _MainTex);


                float vertexNoiseA = noise21(float2(uv.x * _VertexNoiseFreq, steppedTime * _VertexNoiseSpeed));
                float vertexNoiseB = noise21(float2(uv.x * (_VertexNoiseFreq * 1.73) + 13.17, steppedTime * (_VertexNoiseSpeed * 0.87)));


                float xOffset = (vertexNoiseA - 0.5) * 2.0 * _VertexXOffset;
                float yOffset = (vertexNoiseB - 0.5) * 2.0 * _VertexYOffset;


                yOffset += sin(uv.x * (_VertexNoiseFreq * 0.8) + steppedTime * (_VertexNoiseSpeed * 1.2)) * (_VertexYOffset * 0.35);


                float3 posOS = IN.positionOS.xyz;
                posOS.x += xOffset;
                posOS.y += yOffset;


                VertexPositionInputs posInputs = GetVertexPositionInputs(posOS);


                OUT.positionHCS = posInputs.positionCS;
                OUT.uv = uv;
                OUT.color = IN.color;


                return OUT;
            }


            half4 frag(Varyings IN) : SV_Target
            {
                float steppedTime = GetSteppedTime(_Time.y, _StepFPS);


                float2 uv = IN.uv;


                uv.x += steppedTime * _ScrollX;
                uv.y += sin(uv.x * _WaveFreq + steppedTime * _WaveSpeed) * _WaveAmp;


                float jitter = (noise21(float2(uv.x * 8.0, steppedTime * _JitterSpeed)) - 0.5) * 2.0;
                uv.y += jitter * _JitterAmp;


                half4 tex = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv);


                float widthNoise = (noise21(float2(uv.x * _WidthNoiseFreq, steppedTime * _WidthNoiseSpeed)) - 0.5) * 2.0;
                float halfWidth = _BaseHalfWidth + widthNoise * _WidthNoiseAmp;


                float centeredY = abs(IN.uv.y - 0.5);
                float widthMask = 1.0 - smoothstep(
                    halfWidth,
                    halfWidth + _EdgeSoftness,
                    centeredY
                );


                float edgeNoise = (noise21(float2(uv.x * 20.0 + 7.3, steppedTime * 4.0 + 1.8)) - 0.5) * 0.15;


                float a = tex.a * widthMask;
                a = smoothstep(
                    _AlphaCutoff - _EdgeSoftness + edgeNoise,
                    _AlphaCutoff + edgeNoise,
                    a
                );


                half4 col = tex * _Color * IN.color;
                col.a *= a;


                return col;
            }
            ENDHLSL
        }
    }
}


 手順1: シェーダーファイルを作る

 Assets/Projects/Shaders/UgoUgoDoodle.shader を作成し、上記を貼り付けます(シェーダーファイルは BOM なしの UTF-8)。


 手順2: ストロークテクスチャを用意する(任意)

 かすれ感を出す場合はテクスチャを用意する必要があります。横に長い「鉛筆の線」のテクスチャを1枚。要件は次の通り。

  • α付き(白い線+透明背景、または黒線+透明)。線の濃淡ムラが α に入っているほど、かすれが良く出る
  • Import 設定で Wrap Mode = Repeat(横スクロールするため必須。Clamp だと端で線が消える)
  • 縦は「線1本分」だけあればよい。V 方向は幅マスクで切るので上下に余白があってもよい。

手順3: マテリアルを作る

 Create > Material → Shader を HazeDenki/LineRenderer/UgoUgoDoodleVertex に変更。


プロパティ値の説明

Color

 白 (1,1,1,1)

 色は LineRenderer の Gradient 側で付ける

AlphaCutoff

 かすれの量。上げるほど欠ける

EdgeSoftness

 縁のぼけ幅

VertexXOffset

 頂点の揺れ幅(ワールド単位)

VertexNoiseFreq

 揺れの細かさ・速さ

ScrollX

 テクスチャの流れる速度

WaveAmp / Freq / Speed

 テクスチャ UV の波打ち幅

JitterAmp / Speed

 細かいガタつき

WidthNoiseAmp / Freq / Speed

 線幅の揺らぎ

StepFPS

 コマ送り FPS。最重要


手順4: LineRenderer をセットアップする

  • Materials にこのマテリアルを設定
  • Texture Mode = Stretch
  • Width は見せたい太さの 1.5〜2倍 を設定。テストしつつ調整。
  • Alignment = View(2D なら TransformZ でも可)
  • Corner Vertices を 2〜4 入れると角で線幅マスクが破綻しにくい。上記サンプルのような鋭い直角を作る場合は0。
  • Use World Space は用途次第。シェーダーの頂点オフセットはオブジェクト空間で足すので、ワールド座標モードでも動作は同じ


手順5: 色を付ける

 色はマテリアルのColorプロパティではなく LineRenderer の Color Gradient(頂点カラー)で付けると、1マテリアルで複数の色の線を運用可能。

 最終色は テクスチャRGB × _Color × 頂点カラー の乗算なので、ストロークテクスチャは白(RGB=1)で描いておくと Gradient の色がそのまま出ます。黒線テクスチャを使うと Gradient の色が乗らず黒くなります。


 後は動かしながら、Shaderのプロパティを弄って理想に近づけてください。


Part 2. CreepyTextWiggle(TextMeshPro 文字ゆらぎ)

概要

 CreepyTextWiggle は毎フレーム(または指定 FPS で)文字ごとに位置オフセットと Z 回転を計算し、4頂点を書き換えて UpdateGeometry でメッシュに反映します。文字の色やアウトラインは触りません。

 


サンプルコード

using UnityEngine;
using TMPro;


[RequireComponent(typeof(TMP_Text))]
public class CreepyTextWiggle : MonoBehaviour
{
    [Header("Motion")]
    [SerializeField] private float positionAmplitude = 1.0f;
    [SerializeField] private float rotationAmplitude = 1.2f;
    [SerializeField] private float speed = 2.0f;


    [Header("Noise")]
    [SerializeField] private float noiseScale = 1.3f;
    [SerializeField] private float perCharacterPhase = 0.37f;


    [Header("Frame Rate")]
    [Tooltip("アニメーションの更新FPS。0以下で毎フレーム更新(制限なし)")]
    [SerializeField] private float targetFps = 0f;
    [Tooltip("テキスト変更直後、このミリ秒間はFPS制限を無効化し滑らか更新(0で常時throttle)")]
    [SerializeField, Min(0f)] private float revealGracePeriodMs = 150f;


    private TMP_Text tmpText;
    private Vector3[][] baseVertices;
    private bool dirty = true;
    private float lastSampledTime = -1f;
    private float lastTextChangeTime = -999f;
    private bool isUGUI;


    private void Awake()
    {
        tmpText = GetComponent<TMP_Text>();
        isUGUI = tmpText is TextMeshProUGUI;
    }


    private void OnEnable()
    {
        TMPro_EventManager.TEXT_CHANGED_EVENT.Add(HandleTextChanged);
        if (isUGUI) Canvas.willRenderCanvases += ApplyWiggle;
        dirty = true;
    }


    private void OnDisable()
    {
        TMPro_EventManager.TEXT_CHANGED_EVENT.Remove(HandleTextChanged);
        if (isUGUI) Canvas.willRenderCanvases -= ApplyWiggle;
    }


    private void HandleTextChanged(Object obj)
    {
        if (obj == tmpText)
        {
            dirty = true;
            lastTextChangeTime = Time.unscaledTime;
        }
    }


    private void CacheBaseVertices()
    {
        var textInfo = tmpText.textInfo;
        int meshCount = textInfo.meshInfo.Length;
        if (baseVertices == null || baseVertices.Length != meshCount)
            baseVertices = new Vector3[meshCount][];


        for (int i = 0; i < meshCount; i++)
        {
            var src = textInfo.meshInfo[i].vertices;
            if (baseVertices[i] == null || baseVertices[i].Length != src.Length)
                baseVertices[i] = new Vector3[src.Length];
            System.Array.Copy(src, baseVertices[i], src.Length);
        }
    }


    private void LateUpdate()
    {
        if (!isUGUI) ApplyWiggle();
    }


    private void ApplyWiggle()
    {
        var textInfo = tmpText.textInfo;
        if (textInfo.characterCount == 0) return;


        if (dirty)
        {
            CacheBaseVertices();
            dirty = false;
            lastSampledTime = -1f; // テキスト変更後は強制再適用
        }
        if (baseVertices == null) return;

        bool inReveal = (revealGracePeriodMs > 0f) &&
                        ((Time.unscaledTime - lastTextChangeTime) < revealGracePeriodMs * 0.001f);


        float sampledTime;
        if (targetFps > 0f && !inReveal)
        {
            float step = 1f / targetFps;
            sampledTime = Mathf.Floor(Time.unscaledTime / step) * step;
        }
        else
        {
            sampledTime = Time.unscaledTime;
        }


        // 時刻が進んでいなければスキップ(メッシュは前フレーム状態を維持)
        if (sampledTime == lastSampledTime) return;
        lastSampledTime = sampledTime;


        float time = sampledTime * speed;


        for (int i = 0; i < textInfo.characterCount; i++)
        {
            var charInfo = textInfo.characterInfo[i];
            if (!charInfo.isVisible) continue;


            int matIdx = charInfo.materialReferenceIndex;
            int vertIdx = charInfo.vertexIndex;
            if (baseVertices[matIdx] == null || vertIdx + 3 >= baseVertices[matIdx].Length) continue;


            var verts = textInfo.meshInfo[matIdx].vertices;


            // キャッシュ済みのクリーンな頂点をソースに使用
            Vector3 v0 = baseVertices[matIdx][vertIdx + 0];
            Vector3 v1 = baseVertices[matIdx][vertIdx + 1];
            Vector3 v2 = baseVertices[matIdx][vertIdx + 2];
            Vector3 v3 = baseVertices[matIdx][vertIdx + 3];
            Vector3 center = (v0 + v2) * 0.5f;


            float phase = i * perCharacterPhase;
            float nx = (Mathf.PerlinNoise(i * 0.173f, time * noiseScale) - 0.5f) * 2f;
            float ny = (Mathf.PerlinNoise(i * 0.417f, 100f + time * noiseScale) - 0.5f) * 2f;
            float sx = Mathf.Sin(time * 1.13f + phase);
            float sy = Mathf.Cos(time * 1.41f + phase * 1.7f);
            float sr = Mathf.Sin(time * 0.91f + phase * 2.3f);


            Vector3 offset = new Vector3(
                (sx * 0.6f + nx * 0.4f) * positionAmplitude,
                (sy * 0.6f + ny * 0.4f) * positionAmplitude,
                0f
            );
            float angle = (sr * 0.7f + nx * 0.3f) * rotationAmplitude;
            Quaternion rot = Quaternion.Euler(0f, 0f, angle);


            verts[vertIdx + 0] = rot * (v0 - center) + center + offset;
            verts[vertIdx + 1] = rot * (v1 - center) + center + offset;
            verts[vertIdx + 2] = rot * (v2 - center) + center + offset;
            verts[vertIdx + 3] = rot * (v3 - center) + center + offset;
        }


        for (int i = 0; i < textInfo.meshInfo.Length; i++)
        {
            textInfo.meshInfo[i].mesh.vertices = textInfo.meshInfo[i].vertices;
            tmpText.UpdateGeometry(textInfo.meshInfo[i].mesh, i);
        }
    }
}


手順1: スクリプトを置く

 Assets/Projects/Scripts/Effects/CreepyTextWiggle.cs を作成して貼り付け(C# は UTF-8 BOM 付き)。

手順2: TMP オブジェクトに追加する

 TextMeshProUGUI(Canvas 上)でも TextMeshPro(ワールド空間)でもよい。[RequireComponent(typeof(TMP_Text))] なので TMP のないオブジェクトには付きません。


手順3: パラメータ設定

positionAmplitude

 位置のずれ幅のパラメータ

rotationAmplitude

 文字の回転角度のパラメータ

speed

 ウゴウゴする速さ

targetFps

 アニメーションのFps値を設定


手順4: 確認

  • 再生して文字がその場で揺れているか(じわじわ流れていくなら、キャッシュが取れていない=TEXT_CHANGED_EVENT が来ていない)
  • テキストを変えたときに一瞬でも「素の文字」が見えないか(見えるなら revealGracePeriodMs を上げる)
  • ポーズ中も動くか(意図通りか確認。止めたいなら Time.time へ)


 「揺らす」「歪める」演出はやり過ぎると読めなくなるので、まず本文と線を同じ fps に揃えて、振幅は小さめから始めるのがおすすめ。


 コードがあれば、これを基にAIであなたのプロジェクトにスムーズに組み込めるこの時代。

 少しでも役に立てれば幸いです。



HAYAO

記事一覧へ戻る

僕らが運営している雑談サーバです!
遊びに来てね

ユクーリしていってね

英訳、中国訳ともに労力もお金も大変です
ゲーム開発含めて応援してくれたら
とっても嬉しい!

Patreon

コメント (0)

コメントを残す

0/1000

まだコメントはありません。最初のコメントを投稿してください!