どうもプログラマーの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
コメント (0)
コメントを残す
まだコメントはありません。最初のコメントを投稿してください!