概要
本記事では、Unity uGUIに円筒状の曲面表現を適用し、さらに切り欠き角を持つ枠線に回転する光の流れを表現する方法を解説する。実装はCanvas全体の曲率制御、各Graphicメッシュの頂点曲げ、そして極座標とマスクテクスチャを組み合わせたシェーダーの3つで構成される。
Canvas全体の曲率設定
Canvasにアタッチし、弧の中心角や品質を管理する設定コンポーネントを作成する。以下は機能を最小構成に再構成した例で、Canvasコンポーネントを要求し、必要な半径を計算する。
using UnityEngine;
[RequireComponent(typeof(Canvas))]
public class BendCanvasSettings : MonoBehaviour
{
[SerializeField, Range(1, 180)] int bendAngle = 90;
[SerializeField, Range(0.01f, 10f)] float meshQuality = 1f;
[SerializeField] bool keepAspect = true;
Canvas _canvas;
RectTransform _rt;
float _cachedRadius;
void Start()
{
_canvas = GetComponent<Canvas>();
_rt = transform as RectTransform;
RecalcRadius();
}
public int BendAngle => bendAngle;
public float Quality => meshQuality;
public bool KeepAspect => keepAspect;
void RecalcRadius()
{
float width = _rt.rect.size.x;
_cachedRadius = keepAspect
? width / (2f * Mathf.PI * (bendAngle / 360f))
: (width * 0.5f) / Mathf.Sin(Mathf.Clamp(bendAngle, -180f, 180f) * 0.5f * Mathf.Deg2Rad);
if (bendAngle == 0) _cachedRadius = 0;
}
public float GetRadius()
{
if (_cachedRadius == 0) RecalcRadius();
return _cachedRadius;
}
}
各Graphicの頂点曲げ
Canvas配下のImageやTextにアタッチし、BaseMeshEffectを継承して頂点を曲面に沿って再配置する。以下は最小限の再構成例である。
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class BendVertexEffect : BaseMeshEffect
{
BendCanvasSettings _settings;
protected override void OnEnable()
{
base.OnEnable();
_settings = GetComponentInParent<BendCanvasSettings>();
}
public override void ModifyMesh(VertexHelper vh)
{
if (!IsActive() || _settings == null) return;
var stream = new List<UIVertex>();
vh.GetUIVertexStream(stream);
if (stream.Count == 0) return;
BendVertices(stream);
vh.Clear();
vh.AddUIVertexTriangleStream(stream);
}
void BendVertices(List<UIVertex> verts)
{
var canvasSize = (_settings.transform as RectTransform).rect.size;
float radius = _settings.GetRadius();
float angle = _settings.BendAngle;
for (int i = 0; i < verts.Count; i++)
{
var v = verts[i];
v.position = CurvePoint(v.position, canvasSize, angle, radius);
verts[i] = v;
}
}
Vector3 CurvePoint(Vector3 localPos, Vector2 canvasSize, float angleDeg, float radius)
{
if (Mathf.Approximately(angleDeg, 0)) return localPos;
float theta = (localPos.x / canvasSize.x) * angleDeg * Mathf.Deg2Rad;
float r = radius + localPos.z;
localPos.x = Mathf.Sin(theta) * r;
localPos.z += Mathf.Cos(theta) * r - r;
return localPos;
}
}
切り欠き枠線の流れる光シェーダー
実装方針
- UVを-1〜1に正規化し、極座標の角度(atan2)を取得する。
- 時間で回転する行列を掛け、光が回転するようにする。
- smoothstepで扇形マスクを作り、枠線マスク(R/Bチャンネル)を掛ける。
- 最終的な発光色を出力する。
プロパティ定義
Properties
{
[PerRendererData] _MainTex ("Sprite", 2D) = "white" {}
_BorderMask ("Border Mask (R=細/B=太)", 2D) = "white" {}
[HDR] _GlowColor ("Glow Color", Color) = (1,1,1,1)
_Spread ("Fan Spread", Range(0,1)) = 0.5
_Sharpen ("Edge Sharpness", Range(0,1)) = 0.5
_RotateSpeed ("Rotation Speed", Range(-20,20)) = 1
_LineWidth ("Line Width", Range(0,1)) = 0.003
[HideInInspector] _StencilComp ("Stencil Comparison", Float) = 8
[HideInInspector] _Stencil ("Stencil ID", Float) = 0
[HideInInspector] _StencilOp ("Stencil Operation", Float) = 0
[HideInInspector] _StencilWriteMask ("Stencil Write Mask", Float) = 255
[HideInInspector] _StencilReadMask ("Stencil Read Mask", Float) = 255
[HideInInspector] _ColorMask ("Color Mask", Float) = 15
}
極座標と回転行列
float2 RotateUV(float2 p, float a)
{
float s = sin(a), c = cos(a);
return mul(float2x2(c, -s, s, c), p);
}
half4 frag(v2f i) : SV_Target
{
float2 centered = i.uv * 2.0 - 1.0;
float rotation = _Time.y * _RotateSpeed;
centered = RotateUV(centered, rotation);
float fan = 1.0 - abs(atan2(centered.y, centered.x) / UNITY_PI);
half fanMask = smoothstep(_Spread, 1.0, saturate(fan));
half edge = smoothstep(0.0, abs(_Sharpen - 1.0), fanMask);
fixed4 border = i.color * tex2D(_BorderMask, i.uv);
float line = lerp(border.r, border.b, _LineWidth);
float3 glow = line * _GlowColor.rgb * edge;
return half4(glow, 1.0);
}
完全なシェーーコード
Shader "Custom/CurvedBorderGlow"
{
Properties
{
[Header(Glow)]
[PerRendererData] _MainTex ("Sprite", 2D) = "white" {}
_BorderMask ("Border Mask (R=thin B=thick)", 2D) = "white" {}
[HDR] _GlowColor ("Glow Color", Color) = (1,1,1,1)
_Spread ("Fan Spread", Range(0,1)) = 0.5
_Sharpen ("Edge Sharpness", Range(0,1)) = 0.5
_RotateSpeed ("Rotation Speed", Range(-20,20)) = 1
_LineWidth ("Line Width", Range(0,1)) = 0.003
[HideInInspector] _StencilComp ("Stencil Comparison", Float) = 8
[HideInInspector] _Stencil ("Stencil ID", Float) = 0
[HideInInspector] _StencilOp ("Stencil Operation", Float) = 0
[HideInInspector] _StencilWriteMask ("Stencil Write Mask", Float) = 255
[HideInInspector] _StencilReadMask ("Stencil Read Mask", Float) = 255
[HideInInspector] _ColorMask ("Color Mask", Float) = 15
}
SubShader
{
Tags
{
"Queue"="Transparent"
"IgnoreProjector"="True"
"RenderType"="Transparent"
"CanUseSpriteAtlas"="True"
}
Stencil
{
Ref [_Stencil]
Comp [_StencilComp]
Pass [_StencilOp]
ReadMask [_StencilReadMask]
WriteMask [_StencilWriteMask]
}
Cull Off
Lighting Off
ZTest Always
ZWrite Off
ColorMask [_ColorMask]
Blend One One
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
#include "UnityUI.cginc"
sampler2D _MainTex;
sampler2D _BorderMask;
half4 _GlowColor;
float _Spread;
float _Sharpen;
float _RotateSpeed;
float _LineWidth;
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
fixed4 color : COLOR;
};
struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
fixed4 color : COLOR;
};
float2 RotateUV(float2 p, float a)
{
float s = sin(a), c = cos(a);
return mul(float2x2(c, -s, s, c), p);
}
v2f vert(appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
o.color = v.color;
return o;
}
half4 frag(v2f i) : SV_Target
{
float2 centered = i.uv * 2.0 - 1.0;
float rotation = _Time.y * _RotateSpeed;
centered = RotateUV(centered, rotation);
float fan = 1.0 - abs(atan2(centered.y, centered.x) / UNITY_PI);
half fanMask = smoothstep(_Spread, 1.0, saturate(fan));
half edge = smoothstep(0.0, abs(_Sharpen - 1.0), fanMask);
fixed4 border = i.color * tex2D(_BorderMask, i.uv);
float line = lerp(border.r, border.b, _LineWidth);
float3 glow = line * _GlowColor.rgb * edge;
return half4(glow, 1.0);
}
ENDCG
}
}
}