経緯
SRDebuggerのProfilerのグラフを描画している箇所がobsoleteになっていたので、新しい方の関数を使うように修正するついでに、他の形式のグラフにできないか試した際に、折れ線グラフを作成したので、ついでにuGUI対応のLineRendererを作ってみました。
やりたかったこととしては、二点以上の座標リストからある程度破綻しないように線を描画することです。
その為にいろいろ調べていたのですが、キーワードとして、「マイター結合」「ベベル結合」という言葉がよく出てはきますが詳しい実装ロジックはなかなか見つかりませんでしたので、この記事で実際のコードと共に解説していきます。
作成環境
Unity6.2.7f1
はじめに
まずはuGUI上になんでもいいので描画するクラスを作成します。
非常に大雑把に書くとこのようになります。
using UnityEngine;
using UnityEngine.UI;
[RequireComponent(typeof(CanvasRenderer))]
public class LineRendererForUGUI : MaskableGraphic
{
}
デフォルトだとマテリアルを使用しないとテクスチャの設定ができないので、もしテクスチャを設定したいのであれば以下のように変更します。
using UnityEngine;
using UnityEngine.UI;
[RequireComponent(typeof(CanvasRenderer))]
public class LineRendererForUGUI : MaskableGraphic
{
[SerializeField]
private Sprite sprite;
public override Texture mainTexture => sprite == null ? null : sprite.texture;
}
線を描画していく
線を描くために必要なデータ類を追加していきます。
とりあえず線の位置を決定するために必要な座標リストと、線の太さを定義しておきます。
そのまま描画するメッシュ情報の更新関数もオーバーライドして、実直に線を書いていきます。
/* 〜省略 */
[SerializeField]
private Vector2[] points = new Vector2[0]; // ローカル座標系になるので注意
[SerializeField]
private float thickness = 1f;
/* 〜省略〜 */
protected override void OnPopulateMesh(VertexHelper vh)
{
vh.Clear();
// 座標が線を引くのに足りない場合は処理を行わない
if (points == null || points.Length < 2)
return;
var vertexIndex = 0;
for (var index = 0; index < points.Length - 1; index++)
{
var currentPoint = points[index];
var nextPoint = points[index + 1];
var direction = (nextPoint - currentPoint).normalized;
// 法線は、方向ベクトルに対して、xとyの要素を入れ替えて、xの方の値をマイナスにすると取得できる
var normal = new Vector2(direction.y, -direction.x);
var halfThickness = thickness / 2f;
var vertex = UIVertex.simpleVert;
vertex.color = color;
/*
* v1 ---- v3
* v2 ---- v4
* とする
*/
vertex.position = currentPoint + normal * halfThickness;
vh.AddVert(vertex);
vertex.position = currentPoint - normal * halfThickness;
vh.AddVert(vertex);
vertex.position = nextPoint + normal * halfThickness;
vh.AddVert(vertex);
vertex.position = nextPoint - normal * halfThickness;
vh.AddVert(vertex);
vh.AddTriangle(vertexIndex, vertexIndex + 2, vertexIndex + 1);
vh.AddTriangle(vertexIndex + 1, vertexIndex + 2, vertexIndex + 3);
vertexIndex += 4;
}
}
一点増やして三点にするとこのような感じ

線と線の繋ぎ目に隙間が開いてしまって綺麗ではないですね。
ひとまず対応するのに前後の線の角度の内積をとって、太さを変えずにくっつけてみようと思います。
手順
- 先頭のポイントは二番目のポイントに向けたベクトルに対して垂直の辺を作成する
- 端ではないポイントでは前後のベクトルを足したベクトルを正規化したベクトル方向に線を広げる
- 終端のポイントは一つ前のポイントから最後のポイントに向けたベクトルに対して垂直の辺を作成する
実際のコードはこのようになります。
LineRendererForUGUI.cs
using System.Linq;
using UnityEngine;
using UnityEngine.UI;
[RequireComponent(typeof(CanvasRenderer))]
public class LineRendererForUGUI : MaskableGraphic
{
/* 〜省略〜 */
protected override void OnPopulateMesh(VertexHelper vh)
{
vh.Clear();
// 座標が線を引くのに足りない場合は処理を行わない
if (points == null || points.Length < 2)
return;
var vertexIndex = 0;
var halfThickness = thickness / 2f;
var vertex = UIVertex.simpleVert;
vertex.color = color;
for (var index = 0; index < points.Length; index++)
{
// 先頭
if (index == 0)
{
ProcessStartPoint(vh, index, halfThickness);
continue;
}
// 終端
if (index == points.Length - 1)
{
ProcessEndPoint(vh, index, vertexIndex, halfThickness);
continue;
}
// 端以外の処理
var prevPoint = points[index - 1];
var currentPoint = points[index];
var nextPoint = points[index + 1];
var prevDirection = (currentPoint - prevPoint).normalized;
var prevNormal = new Vector2(prevDirection.y, -prevDirection.x);
var nextDirection = (nextPoint - currentPoint).normalized;
var nextNormal = new Vector2(nextDirection.y, -nextDirection.x);
var dot = (prevNormal + nextNormal).normalized;
vertex.position = currentPoint + dot * halfThickness;
vh.AddVert(vertex);
vertex.position = currentPoint - dot * halfThickness;
vh.AddVert(vertex);
vh.AddTriangle(vertexIndex, vertexIndex + 2, vertexIndex + 1);
vh.AddTriangle(vertexIndex + 1, vertexIndex + 2, vertexIndex + 3);
// 中間点は共通で使用するので、2つずつインデックスを進める
vertexIndex += 2;
}
}
private void ProcessStartPoint(VertexHelper vh, int index, float halfThickness)
{
var currentPoint = points[index];
var nextPoint = points[index + 1];
var direction = (nextPoint - currentPoint).normalized;
var normal = new Vector2(direction.y, -direction.x);
var vertex = UIVertex.simpleVert;
vertex.color = color;
vertex.position = currentPoint + normal * halfThickness;
vh.AddVert(vertex);
vertex.position = currentPoint - normal * halfThickness;
vh.AddVert(vertex);
}
private void ProcessEndPoint(VertexHelper vh, int index, int vertexIndex, float halfThickness)
{
var currentPoint = points[index];
var prevPoint = points[index - 1];
var direction = (currentPoint - prevPoint).normalized;
var normal = new Vector2(direction.y, -direction.x);
var vertex = UIVertex.simpleVert;
vertex.position = currentPoint + normal * halfThickness;
vh.AddVert(vertex);
vertex.position = currentPoint - normal * halfThickness;
vh.AddVert(vertex);
vh.AddTriangle(vertexIndex, vertexIndex + 2, vertexIndex + 1);
vh.AddTriangle(vertexIndex + 1, vertexIndex + 2, vertexIndex + 3);
}
}
長くなり、ローカル変数の扱いが面倒になるので関数を分けています。
この状態での三点の場合の表示はこのようになります。
先ほどよりマシですが、角度が急になると線が細くなってしまいますね。
ちなみにZ字を書かせるとこうなります。

ここで、角付近が細くならないようにするため、マイター結合という手法を使います。
これは、角の結合手法の一つで、これとベベル結合と合わせてフォントの描画に用いられている手法です。
マイター結合とは
二つのメッシュやパスを一定角度でカットして接続する方式です。
ゲーム開発などでは、以下のような場面で使用されます。
- モデル(建物の角・道路の繋ぎなど)のエッジ合わせ
- ラインレンダラーのジョイン処理 ← 今回の用途
- 法線ブレンドでのスムーズな頂点接続
詰まるところ「異なる方向を持つ二つのジオメトリを、自然な角度で一つに見せる」ための幾何学ロジックです。
基本ロジック
1. 接続角度の半分を切る
最終的な角度θを作りたい場合、各メッシュのカット角は以下のようになります。
\displaylines {
α = \frac{θ}{2}
}
つまり、直角(90°)を作る場合は両方を45°でカットするということになります。
2. マイター結合の面の法線は、2方向の法線ベクトルを加算して正規化すれば求まります
こちらは先ほど結合する頂点を求めるのに使用した手法と同じですね。
この角度をマイター方向といいます。
3. マイター突出長を求める
線を「太く」描くと、単なる一本の線ではなく、両端に幅を持つ四角形の帯になります。
その帯を「角」で曲げるとき、内側は短く・外側は長くなります。
そのとき、外側の線がどれだけ長く伸びるかを計算するのがマイター突出長です。
← 外側が尖る
/\
/ \
/ \
+------+----→ 線の中心
この「/\」の部分がマイター部分。
角が鋭くなるほど、外側がグッと伸びるのがわかりますね。
線の太さをw、角の内角をθとすると、外側にどれだけ「はみ出すか」を表す距離がマイター突出長です。
理論的には次の式で表せます。
\displaylines{
Lm = \frac{w/2}{sin(θ/2)}
}
これはつまり、「線の幅の半分を、角度に応じてどれだけ補正して伸ばすか」を計算しています。
ここまでを実際のプログラムに落とし込むとこのようになります。
// 端以外の処理
var prevPoint = points[index - 1];
var currentPoint = points[index];
var nextPoint = points[index + 1];
var prevDirection = (currentPoint - prevPoint).normalized;
var prevNormal = new Vector2(prevDirection.y, -prevDirection.x);
var nextDirection = (nextPoint - currentPoint).normalized;
var nextNormal = new Vector2(nextDirection.y, -nextDirection.x);
var miterDirection = (prevNormal + nextNormal).normalized; // マイター方向の算出
// マイター突出長の算出
var dotToNextNormal = Vector2.Dot(miterDirection, nextNormal);
var miterLength = halfThickness / dotToNextNormal;
// 使用するのはマイター方向とマイター突出長を使用して頂点位置を算出する
vertex.position = currentPoint + miterDirection * miterLength;
vh.AddVert(vertex);
vertex.position = currentPoint - miterDirection * miterLength;
vh.AddVert(vertex);
vh.AddTriangle(vertexIndex, vertexIndex + 2, vertexIndex + 1);
vh.AddTriangle(vertexIndex + 1, vertexIndex + 2, vertexIndex + 3);
// 中間点は共通で使用するので、2つずつインデックスを進める
vertexIndex += 2;
とても綺麗になりましたね!
ただし、このままでは角度が急になってきた際にこのようになってしまいます。

これは、マイター突出長がθが鋭角になればなるほど無限大に近づいていくことが原因で発生します。
例えば、内角が10°だと、sin(5°) ≈ 0.0087となるので、10倍以上に伸びます。
そのため、一定の長さを超えた場合に別の手法、今回はベベル結合という手法に切り替える必要が出てきます。
ベベル結合
ベベル結合とは、線の角部分をそのまま尖らせずにまっすぐ切り落としてつなぐ結合方法です。
ベベル結合の手順としては、まず2つのエッジ方向から、曲がり方向(左折/右折)を判定し、
外側となる側の法線方向へオフセットした2点(外側点A・外側点B)を求めます。
この2点を直線で結ぶことで、角を切り落としたベベル結合が形成されます。
メッシュ化においては、各ストロークの帯(四角形)とは別に、この外側2点と
必要に応じて内側の接続点を用いて三角形を生成し、帯の終端同士を繋ぎます。
実装上の注意点として、ベベル結合が発生するとポリゴンの生成順や頂点の共有関係が
通常とは変化するため、曲がり方向(前フレームの曲がり方向も含む)に応じて
インデックスの付け方を切り替える必要があります。
直接組み込むと非常に複雑になるので、まずはベベル結合だけで線を引く仕組みを作ってみます。
LineRendererForUGUIBevel.cs
using UnityEditor;
using UnityEngine;
using UnityEngine.UI;
[RequireComponent(typeof(CanvasRenderer))]
public class LineRendererForUGUIBevel : MaskableGraphic
{
[SerializeField] private Sprite sprite;
[SerializeField] private Vector2[] points = new Vector2[0]; // ローカル座標系になるので注意
[SerializeField] private float thickness = 1f;
public override Texture mainTexture => sprite == null ? null : sprite.texture;
protected override void OnPopulateMesh(VertexHelper vh)
{
vh.Clear();
if (points == null || points.Length < 2)
return;
var vertexIndex = 0;
var halfThickness = thickness * 0.5f;
var prevIsLeftTurn = false;
var vertex = UIVertex.simpleVert;
vertex.color = color;
for (var index = 0; index < points.Length; index++)
{
if (index == 0)
{
ProcessStartPoint(vh, index, halfThickness);
continue;
}
if (index == points.Length - 1)
{
ProcessEndPoint(vh, index, vertexIndex, halfThickness, prevIsLeftTurn);
continue;
}
var currentPoint = points[index];
var prevPoint = points[index - 1];
var nextPoint = points[index + 1];
// 方向ベクトル
var directionPrev = (currentPoint - prevPoint).normalized;
var directionNext = (nextPoint - currentPoint).normalized;
// 各方向の法線
var normalPrev = new Vector2(directionPrev.y, -directionPrev.x);
var normalNext = new Vector2(directionNext.y, -directionNext.x);
// 左折判定
var isLeftTurn = Cross(directionPrev, directionNext) > 0; // 左折か?
// 外側法線(前方向/次方向)
var outerPrevNormal = isLeftTurn ? normalPrev : -normalPrev;
var outerNextNormal = isLeftTurn ? normalNext : -normalNext;
// 外側頂点
var outerPrev = currentPoint + outerPrevNormal * halfThickness;
var outerNext = currentPoint + outerNextNormal * halfThickness;
// 内側法線
var innerPrevNormal = -outerPrevNormal;
var innerNextNormal = -outerNextNormal;
// 内側線のオフセット基点
var innerPrevOffsetPoint = currentPoint + innerPrevNormal * halfThickness;
var innerNextOffsetPoint = currentPoint + innerNextNormal * halfThickness;
// 交点(内側同士)
var innerIntersection = Intersection(innerPrevOffsetPoint, directionPrev, innerNextOffsetPoint,
directionNext, out var intersectionFound);
if (!intersectionFound)
{
innerIntersection = currentPoint;
}
vertex.position = outerPrev; // 前セグメント側外頂点
vh.AddVert(vertex);
vertex.position = innerIntersection; // 内側交点
vh.AddVert(vertex);
vertex.position = outerNext; // 次セグメント側外頂点
vh.AddVert(vertex);
if (!isLeftTurn)
{
if (!prevIsLeftTurn)
{
vh.AddTriangle(vertexIndex, vertexIndex + 1, vertexIndex + 2);
vh.AddTriangle(vertexIndex, vertexIndex + 2, vertexIndex + 3);
vh.AddTriangle(vertexIndex + 2, vertexIndex + 3, vertexIndex + 4);
}
else
{
vh.AddTriangle(vertexIndex, vertexIndex + 1, vertexIndex + 2);
vh.AddTriangle(vertexIndex + 1, vertexIndex + 2, vertexIndex + 3);
vh.AddTriangle(vertexIndex + 2, vertexIndex + 3, vertexIndex + 4);
}
}
else
{
if (!prevIsLeftTurn)
{
vh.AddTriangle(vertexIndex, vertexIndex + 1, vertexIndex + 2);
vh.AddTriangle(vertexIndex + 1, vertexIndex + 2, vertexIndex + 3);
vh.AddTriangle(vertexIndex + 2, vertexIndex + 3, vertexIndex + 4);
}
else
{
vh.AddTriangle(vertexIndex, vertexIndex + 1, vertexIndex + 2);
vh.AddTriangle(vertexIndex, vertexIndex + 2, vertexIndex + 3);
vh.AddTriangle(vertexIndex + 2, vertexIndex + 3, vertexIndex + 4);
}
}
vertexIndex += 3;
prevIsLeftTurn = isLeftTurn;
}
}
private void ProcessStartPoint(VertexHelper vh, int index, float halfThickness)
{
var currentPoint = points[index];
var nextPoint = points[index + 1];
var direction = (nextPoint - currentPoint).normalized;
var normal = new Vector2(direction.y, -direction.x);
var vertex = UIVertex.simpleVert;
vertex.color = color;
vertex.position = currentPoint + normal * halfThickness;
vh.AddVert(vertex);
vertex.position = currentPoint - normal * halfThickness;
vh.AddVert(vertex);
}
private void ProcessEndPoint(VertexHelper vh, int index, int vertexIndex, float halfThickness, bool prevIsLeftTurn)
{
var currentPoint = points[index];
var prevPoint = points[index - 1];
var direction = (currentPoint - prevPoint).normalized;
var normal = new Vector2(direction.y, -direction.x);
var vertex = UIVertex.simpleVert;
vertex.color = color;
vertex.position = currentPoint + normal * halfThickness;
vh.AddVert(vertex);
vertex.position = currentPoint - normal * halfThickness;
vh.AddVert(vertex);
if (points.Length == 2)
{
vh.AddTriangle(vertexIndex, vertexIndex + 2, vertexIndex + 1);
vh.AddTriangle(vertexIndex + 1, vertexIndex + 2, vertexIndex + 3);
}
else
{
if (!prevIsLeftTurn)
{
vh.AddTriangle(vertexIndex, vertexIndex + 1, vertexIndex + 2);
vh.AddTriangle(vertexIndex + 1, vertexIndex + 2, vertexIndex + 3);
}
else
{
vh.AddTriangle(vertexIndex, vertexIndex + 1, vertexIndex + 2);
vh.AddTriangle(vertexIndex, vertexIndex + 2, vertexIndex + 3);
}
}
}
private Vector2 Intersection(Vector2 prevPoint, Vector2 directionPrev, Vector2 nextPoint, Vector2 directionNext,
out bool found)
{
// 二つの半直線 (originA + t * directionA) と (originB + u * directionB) の交点を求める
// directionA / directionB は正規化されている前提ではないがクロス判定のみなので不要
var crossDirection = Cross(directionPrev, directionNext); // 方向ベクトル同士の外積(平行判定)
if (Mathf.Abs(crossDirection) < float.Epsilon)
{
found = false; // 平行もしくは同一直線で交点が一意に定まらない
return default;
}
var deltaOrigins = nextPoint - prevPoint; // 始点間ベクトル
var tAlongA = Cross(deltaOrigins, directionNext) / crossDirection; // originA から交点までのスカラー値
found = true;
return prevPoint + directionPrev * tAlongA;
}
/// <summary>
/// 外積
/// </summary>
private float Cross(Vector2 a, Vector2 b) => a.x * b.y - a.y * b.x;
}
先ほどと同様に、Z字を書かせてみるとこのようになります。

角が切り落とされていますね。
今度はマイター結合とベベル結合を使い分けるように合体させていきます。
ただし、このままだとパフォーマンス的・処理的にしんどくなってきますので、一度頂点情報とインデックスの追加方法を整理します。
それぞれ以下のように変更します。
LineRendererForUGUI.cs
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
[RequireComponent(typeof(CanvasRenderer))]
public class LineRendererForUGUI : MaskableGraphic
{
[SerializeField] private Sprite sprite;
[SerializeField] private Vector2[] points = new Vector2[0]; // ローカル座標系になるので注意
[SerializeField] private float thickness = 1f;
public override Texture mainTexture => sprite == null ? null : sprite.texture;
private List<UIVertex> vertices = new();
private List<int> indices = new();
protected override void OnPopulateMesh(VertexHelper vh)
{
vh.Clear();
vertices.Clear();
indices.Clear();
// 座標が線を引くのに足りない場合は処理を行わない
if (points == null || points.Length < 2)
return;
var vertexIndex = 0;
var halfThickness = thickness / 2f;
for (var index = 0; index < points.Length; index++)
{
// 先頭
if (index == 0)
{
ProcessStartPoint(index, halfThickness);
continue;
}
// 終端
if (index == points.Length - 1)
{
ProcessEndPoint(index, vertexIndex, halfThickness);
continue;
}
// 端以外の処理
ProcessMiddlePoint(index, vertexIndex, halfThickness);
// 中間点は共通で使用するので、2つずつインデックスを進める
vertexIndex += 2;
}
vh.AddUIVertexStream(vertices, indices);
}
private void ProcessStartPoint(int index, float halfThickness)
{
var currentPoint = points[index];
var nextPoint = points[index + 1];
var direction = (nextPoint - currentPoint).normalized;
var normal = new Vector2(direction.y, -direction.x);
var vertex = UIVertex.simpleVert;
vertex.color = color;
vertex.position = currentPoint + normal * halfThickness;
vertices.Add(vertex);
vertex.position = currentPoint - normal * halfThickness;
vertices.Add(vertex);
}
private void ProcessEndPoint(int index, int vertexIndex, float halfThickness)
{
var currentPoint = points[index];
var prevPoint = points[index - 1];
var direction = (currentPoint - prevPoint).normalized;
var normal = new Vector2(direction.y, -direction.x);
var vertex = UIVertex.simpleVert;
vertex.position = currentPoint + normal * halfThickness;
vertices.Add(vertex);
vertex.position = currentPoint - normal * halfThickness;
vertices.Add(vertex);
AddTriangle(vertexIndex, vertexIndex + 2, vertexIndex + 1);
AddTriangle(vertexIndex + 1, vertexIndex + 2, vertexIndex + 3);
}
private void ProcessMiddlePoint(int index, int vertexIndex, float halfThickness)
{
var prevPoint = points[index - 1];
var currentPoint = points[index];
var nextPoint = points[index + 1];
var prevDirection = (currentPoint - prevPoint).normalized;
var prevNormal = new Vector2(prevDirection.y, -prevDirection.x);
var nextDirection = (nextPoint - currentPoint).normalized;
var nextNormal = new Vector2(nextDirection.y, -nextDirection.x);
var miterDirection = (prevNormal + nextNormal).normalized; // マイター方向の算出
// マイター突出長の算出
var dotToNextNormal = Vector2.Dot(miterDirection, nextNormal);
var miterLength = halfThickness / dotToNextNormal;
// 使用するのはマイター方向とマイター突出長を使用して頂点位置を算出する
var vertex = UIVertex.simpleVert;
vertex.color = color;
vertex.position = currentPoint + miterDirection * miterLength;
vertices.Add(vertex);
vertex.position = currentPoint - miterDirection * miterLength;
vertices.Add(vertex);
AddTriangle(vertexIndex, vertexIndex + 2, vertexIndex + 1);
AddTriangle(vertexIndex + 1, vertexIndex + 2, vertexIndex + 3);
}
private void AddTriangle(int a, int b, int c)
{
indices.Add(a);
indices.Add(b);
indices.Add(c);
}
}
LineRendererForUGUIBevel.cs
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using UnityEngine.UI;
[RequireComponent(typeof(CanvasRenderer))]
public class LineRendererForUGUIBevel : MaskableGraphic
{
[SerializeField] private Sprite sprite;
[SerializeField] private Vector2[] points = new Vector2[0]; // ローカル座標系になるので注意
[SerializeField] private float thickness = 1f;
public override Texture mainTexture => sprite == null ? null : sprite.texture;
private List<UIVertex> vertices = new();
private List<int> indices = new();
protected override void OnPopulateMesh(VertexHelper vh)
{
vh.Clear();
vertices.Clear();
indices.Clear();
if (points == null || points.Length < 2)
return;
var vertexIndex = 0;
var halfThickness = thickness * 0.5f;
var prevIsLeftTurn = false;
for (var index = 0; index < points.Length; index++)
{
if (index == 0)
{
ProcessStartPoint(index, halfThickness);
continue;
}
if (index == points.Length - 1)
{
ProcessEndPoint(index, vertexIndex, halfThickness, prevIsLeftTurn);
continue;
}
prevIsLeftTurn = ProcessBevel(index, vertexIndex, halfThickness, prevIsLeftTurn);
vertexIndex += 3;
}
vh.AddUIVertexStream(vertices, indices);
}
private void ProcessStartPoint(int index, float halfThickness)
{
var currentPoint = points[index];
var nextPoint = points[index + 1];
var direction = (nextPoint - currentPoint).normalized;
var normal = new Vector2(direction.y, -direction.x);
var vertex = UIVertex.simpleVert;
vertex.color = color;
vertex.position = currentPoint + normal * halfThickness;
vertices.Add(vertex);
vertex.position = currentPoint - normal * halfThickness;
vertices.Add(vertex);
}
private void ProcessEndPoint(int index, int vertexIndex, float halfThickness, bool prevIsLeftTurn)
{
var currentPoint = points[index];
var prevPoint = points[index - 1];
var direction = (currentPoint - prevPoint).normalized;
var normal = new Vector2(direction.y, -direction.x);
var vertex = UIVertex.simpleVert;
vertex.color = color;
vertex.position = currentPoint + normal * halfThickness;
vertices.Add(vertex);
vertex.position = currentPoint - normal * halfThickness;
vertices.Add(vertex);
if (points.Length == 2)
{
AddTriangle(vertexIndex, vertexIndex + 2, vertexIndex + 1);
AddTriangle(vertexIndex + 1, vertexIndex + 2, vertexIndex + 3);
}
else
{
if (!prevIsLeftTurn)
{
AddTriangle(vertexIndex, vertexIndex + 1, vertexIndex + 2);
AddTriangle(vertexIndex + 1, vertexIndex + 2, vertexIndex + 3);
}
else
{
AddTriangle(vertexIndex, vertexIndex + 1, vertexIndex + 2);
AddTriangle(vertexIndex, vertexIndex + 2, vertexIndex + 3);
}
}
}
private bool ProcessBevel(int index, int vertexIndex, float halfThickness, bool prevIsLeftTurn)
{
var currentPoint = points[index];
var prevPoint = points[index - 1];
var nextPoint = points[index + 1];
// 方向ベクトル
var directionPrev = (currentPoint - prevPoint).normalized;
var directionNext = (nextPoint - currentPoint).normalized;
// 各方向の法線
var normalPrev = new Vector2(directionPrev.y, -directionPrev.x);
var normalNext = new Vector2(directionNext.y, -directionNext.x);
// 左折判定
var isLeftTurn = Cross(directionPrev, directionNext) > 0; // 左折か?
// 外側法線(前方向/次方向)
var outerPrevNormal = isLeftTurn ? normalPrev : -normalPrev;
var outerNextNormal = isLeftTurn ? normalNext : -normalNext;
// 外側頂点
var outerPrev = currentPoint + outerPrevNormal * halfThickness;
var outerNext = currentPoint + outerNextNormal * halfThickness;
// 内側法線
var innerPrevNormal = -outerPrevNormal;
var innerNextNormal = -outerNextNormal;
// 内側線のオフセット基点
var innerPrevOffsetPoint = currentPoint + innerPrevNormal * halfThickness;
var innerNextOffsetPoint = currentPoint + innerNextNormal * halfThickness;
// 交点(内側同士)
var innerIntersection = Intersection(innerPrevOffsetPoint, directionPrev, innerNextOffsetPoint,
directionNext, out var intersectionFound);
if (!intersectionFound)
{
innerIntersection = currentPoint;
}
var vertex = UIVertex.simpleVert;
vertex.color = color;
vertex.position = outerPrev; // 前セグメント側外頂点
vertices.Add(vertex);
vertex.position = innerIntersection; // 内側交点
vertices.Add(vertex);
vertex.position = outerNext; // 次セグメント側外頂点
vertices.Add(vertex);
if (!isLeftTurn)
{
if (!prevIsLeftTurn)
{
AddTriangle(vertexIndex, vertexIndex + 1, vertexIndex + 2);
AddTriangle(vertexIndex, vertexIndex + 2, vertexIndex + 3);
AddTriangle(vertexIndex + 2, vertexIndex + 3, vertexIndex + 4);
}
else
{
AddTriangle(vertexIndex, vertexIndex + 1, vertexIndex + 2);
AddTriangle(vertexIndex + 1, vertexIndex + 2, vertexIndex + 3);
AddTriangle(vertexIndex + 2, vertexIndex + 3, vertexIndex + 4);
}
}
else
{
if (!prevIsLeftTurn)
{
AddTriangle(vertexIndex, vertexIndex + 1, vertexIndex + 2);
AddTriangle(vertexIndex + 1, vertexIndex + 2, vertexIndex + 3);
AddTriangle(vertexIndex + 2, vertexIndex + 3, vertexIndex + 4);
}
else
{
AddTriangle(vertexIndex, vertexIndex + 1, vertexIndex + 2);
AddTriangle(vertexIndex, vertexIndex + 2, vertexIndex + 3);
AddTriangle(vertexIndex + 2, vertexIndex + 3, vertexIndex + 4);
}
}
return isLeftTurn;
}
private Vector2 Intersection(Vector2 prevPoint, Vector2 directionPrev, Vector2 nextPoint, Vector2 directionNext,
out bool found)
{
// 二つの半直線 (originA + t * directionA) と (originB + u * directionB) の交点を求める
// directionA / directionB は正規化されている前提ではないがクロス判定のみなので不要
var crossDirection = Cross(directionPrev, directionNext); // 方向ベクトル同士の外積(平行判定)
if (Mathf.Abs(crossDirection) < float.Epsilon)
{
found = false; // 平行もしくは同一直線で交点が一意に定まらない
return default;
}
var deltaOrigins = nextPoint - prevPoint; // 始点間ベクトル
var tAlongA = Cross(deltaOrigins, directionNext) / crossDirection; // originA から交点までのスカラー値
found = true;
return prevPoint + directionPrev * tAlongA;
}
/// <summary>
/// 外積
/// </summary>
private float Cross(Vector2 a, Vector2 b) => a.x * b.y - a.y * b.x;
private void AddTriangle(int a, int b, int c)
{
indices.Add(a);
indices.Add(b);
indices.Add(c);
}
}
マイター結合とベベル結合の使い分け
マイター結合の問題点は、角が鋭角すぎるとマイター突出長が長くなりすぎて、大幅に尖ってしまうことです。
では、それを解消するにはマイター突出長が一定以上の長さになったら、ベベル結合に切り替えることです。
まずはマイター突出長の制限を設けるためのメンバ変数を用意します。
[SerializeField]
private float miterLimit = 4f; // 大体4くらいで丁度いいと思います。
次に、端以外の頂点の計算を、マイター結合とベベル結合で切り替えられるようにします。
ただし、今度は一つ前の角の結合方法がベベル結合になったかどうかのフラグが必要になります。
最終的なコードはこちらになります
LineRendererForUGUI.cs
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using UnityEngine.UI;
[RequireComponent(typeof(CanvasRenderer))]
public class LineRendererForUGUI : MaskableGraphic
{
[SerializeField] private Sprite sprite;
[SerializeField] private Vector2[] points = new Vector2[0]; // ローカル座標系になるので注意
[SerializeField] private float thickness = 1f; // 線の太さ
[SerializeField] private float miterLimit = 4f; // マイター(角の突出)の最大長
/// <summary>セグメントの長さが短すぎる場合の閾値(線の太さに依存)</summary>
private float SegmentEpsilon => Mathf.Max(0.001f, thickness * 0.05f);
/// <summary>2つのセグメントが平行かどうか判定するための閾値</summary>
private float ParallelEpsilon(float lengthA, float lengthB) => 1e-5f * (lengthA * lengthB + 1f);
/// <summary>交点計算時の後退許容値(線の太さに依存)</summary>
private float BackwardTolerance => Mathf.Max(0.00015f, thickness * 0.02f);
/// <summary>
/// 使用するテクスチャ(spriteが設定されていればそのテクスチャ、なければnull)
/// </summary>
public override Texture mainTexture => sprite == null ? null : sprite.texture;
private List<UIVertex> vertices = new(); // 頂点リスト
private List<int> indices = new(); // インデックスリスト
/// <summary>
/// メッシュ生成処理(頂点・インデックスを計算してUIに描画)
/// </summary>
protected override void OnPopulateMesh(VertexHelper vh)
{
vh.Clear();
vertices.Clear();
indices.Clear();
// 座標が線を引くのに足りない場合は処理を行わない
if (points == null || points.Length < 2)
return;
var vertexIndex = 0;
var halfThickness = thickness / 2f;
var isPrevBevel = false;
var isPrevTurnLeft = false;
for (var index = 0; index < points.Length; index++)
{
// 先頭
if (index == 0)
{
ProcessStartPoint(index, halfThickness);
continue;
}
// 終端
if (index == points.Length - 1)
{
ProcessEndPoint(index, vertexIndex, halfThickness, isPrevBevel, isPrevTurnLeft);
continue;
}
// 端以外の処理
(isPrevBevel, isPrevTurnLeft) =
ProcessMiddlePoint(index, vertexIndex, halfThickness, isPrevBevel, isPrevTurnLeft);
// 中間点は共通で使用するので、2つずつインデックスを進める
vertexIndex += isPrevBevel ? 3 : 2;
}
vh.AddUIVertexStream(vertices, indices);
}
/// <summary>
/// 線の始点の頂点を計算・追加
/// </summary>
private void ProcessStartPoint(int index, float halfThickness)
{
var currentPoint = points[index];
var nextPoint = points[index + 1];
var direction = (nextPoint - currentPoint).normalized;
var normal = new Vector2(direction.y, -direction.x);
var vertex = UIVertex.simpleVert;
vertex.color = color;
vertex.position = currentPoint + normal * halfThickness;
vertices.Add(vertex);
vertex.position = currentPoint - normal * halfThickness;
vertices.Add(vertex);
}
/// <summary>
/// 線の終点の頂点を計算・追加
/// </summary>
private void ProcessEndPoint(int index, int vertexIndex, float halfThickness, bool isPrevBevel, bool isPrevLeftTurn)
{
var currentPoint = points[index];
var prevPoint = points[index - 1];
var direction = (currentPoint - prevPoint).normalized;
var normal = new Vector2(direction.y, -direction.x);
var vertex = UIVertex.simpleVert;
vertex.position = currentPoint + normal * halfThickness;
vertices.Add(vertex);
vertex.position = currentPoint - normal * halfThickness;
vertices.Add(vertex);
if (!isPrevBevel)
{
AddTriangle(vertexIndex, vertexIndex + 2, vertexIndex + 1);
AddTriangle(vertexIndex + 1, vertexIndex + 2, vertexIndex + 3);
return;
}
if (!isPrevLeftTurn)
{
AddTriangle(vertexIndex, vertexIndex + 1, vertexIndex + 2);
AddTriangle(vertexIndex + 1, vertexIndex + 2, vertexIndex + 3);
}
else
{
AddTriangle(vertexIndex, vertexIndex + 1, vertexIndex + 2);
AddTriangle(vertexIndex, vertexIndex + 2, vertexIndex + 3);
}
}
/// <summary>
/// 線の中間点(角部分)の頂点を計算・追加
/// </summary>
private (bool isBevel, bool isLeftTurn) ProcessMiddlePoint(int index, int vertexIndex, float halfThickness,
bool isPrevBevel, bool isPrevLeftTurn)
{
var prevPoint = points[index - 1];
var currentPoint = points[index];
var nextPoint = points[index + 1];
var prevDirection = (currentPoint - prevPoint).normalized;
var prevNormal = new Vector2(prevDirection.y, -prevDirection.x);
var nextDirection = (nextPoint - currentPoint).normalized;
var nextNormal = new Vector2(nextDirection.y, -nextDirection.x);
var isLeftTurn = Cross(prevDirection, nextDirection) > 0f; // 左折か?
var miterDirection = (prevNormal + nextNormal).normalized; // マイター方向の算出
// マイター突出長の算出
var dotToNextNormal = Vector2.Dot(miterDirection, nextNormal);
var miterLength = halfThickness / dotToNextNormal;
if (Mathf.Abs(dotToNextNormal) < float.Epsilon ||
Mathf.Abs(miterLength) > halfThickness * Mathf.Max(1f, miterLimit))
{
isLeftTurn = ProcessBevel(index, vertexIndex, halfThickness, isPrevLeftTurn);
return (true, isLeftTurn);
}
// 使用するのはマイター方向とマイター突出長を使用して頂点位置を算出する
var vertex = UIVertex.simpleVert;
vertex.color = color;
vertex.position = currentPoint + miterDirection * miterLength;
vertices.Add(vertex);
vertex.position = currentPoint - miterDirection * miterLength;
vertices.Add(vertex);
if (!isPrevBevel)
{
AddTriangle(vertexIndex, vertexIndex + 2, vertexIndex + 1);
AddTriangle(vertexIndex + 1, vertexIndex + 2, vertexIndex + 3);
}
else
{
if (isPrevLeftTurn)
{
AddTriangle(vertexIndex, vertexIndex + 2, vertexIndex + 1);
AddTriangle(vertexIndex, vertexIndex + 2, vertexIndex + 3);
}
else
{
AddTriangle(vertexIndex, vertexIndex + 1, vertexIndex + 2);
AddTriangle(vertexIndex + 1, vertexIndex + 2, vertexIndex + 3);
}
}
return (false, isLeftTurn);
}
/// <summary>
/// ベベル(角の丸め処理)を行い、頂点を追加
/// </summary>
private bool ProcessBevel(int index, int vertexIndex, float halfThickness, bool prevIsLeftTurn)
{
var currentPoint = points[index];
var prevPoint = points[index - 1];
var nextPoint = points[index + 1];
var prevToCurrent = (currentPoint - prevPoint);
var currentToNext = (nextPoint - currentPoint);
// 方向ベクトル
var directionPrev = prevToCurrent.normalized;
var directionNext = currentToNext.normalized;
// 各方向の法線
var normalPrev = new Vector2(directionPrev.y, -directionPrev.x);
var normalNext = new Vector2(directionNext.y, -directionNext.x);
// 左折判定
var isLeftTurn = Cross(directionPrev, directionNext) > 0; // 左折か?
// 外側法線(前方向/次方向)
var outerPrevNormal = isLeftTurn ? normalPrev : -normalPrev;
var outerNextNormal = isLeftTurn ? normalNext : -normalNext;
// 外側頂点
var outerPrev = currentPoint + outerPrevNormal * halfThickness;
var outerNext = currentPoint + outerNextNormal * halfThickness;
// 内側法線
var innerPrevNormal = -outerPrevNormal;
var innerNextNormal = -outerNextNormal;
// 内側線のオフセット基点
var innerPrevOffsetPoint = currentPoint + innerPrevNormal * halfThickness;
var innerNextOffsetPoint = currentPoint + innerNextNormal * halfThickness;
// 交点(内側同士)
var innerIntersection = Intersection(innerPrevOffsetPoint, directionPrev, innerNextOffsetPoint,
directionNext, prevToCurrent.magnitude, currentToNext.magnitude, out var intersectionFound);
if (!intersectionFound)
{
innerIntersection = currentPoint;
}
var vertex = UIVertex.simpleVert;
vertex.color = color;
vertex.position = outerPrev; // 前セグメント側外頂点
vertices.Add(vertex);
vertex.position = innerIntersection; // 内側交点
vertices.Add(vertex);
vertex.position = outerNext; // 次セグメント側外頂点
vertices.Add(vertex);
if (!isLeftTurn)
{
if (!prevIsLeftTurn)
{
AddTriangle(vertexIndex, vertexIndex + 1, vertexIndex + 2);
AddTriangle(vertexIndex, vertexIndex + 2, vertexIndex + 3);
}
else
{
AddTriangle(vertexIndex, vertexIndex + 1, vertexIndex + 2);
AddTriangle(vertexIndex + 1, vertexIndex + 2, vertexIndex + 3);
}
}
else
{
if (!prevIsLeftTurn)
{
AddTriangle(vertexIndex, vertexIndex + 1, vertexIndex + 2);
AddTriangle(vertexIndex + 1, vertexIndex + 2, vertexIndex + 3);
}
else
{
AddTriangle(vertexIndex, vertexIndex + 1, vertexIndex + 2);
AddTriangle(vertexIndex, vertexIndex + 2, vertexIndex + 3);
}
}
AddTriangle(vertexIndex + 2, vertexIndex + 3, vertexIndex + 4);
return isLeftTurn;
}
/// <summary>
/// 2つの線分の交点を計算
/// </summary>
private Vector2 Intersection(Vector2 prevPoint, Vector2 directionPrev, Vector2 nextPoint, Vector2 directionNext,
float prevToCurrentLength, float currentToNextLength, out bool found)
{
if (prevToCurrentLength < SegmentEpsilon || currentToNextLength < SegmentEpsilon)
{
Debug.LogWarning("セグメントの長さが短すぎます。フォールバック頂点を使用します。", this);
found = false;
return default;
}
// 二つの半直線 (originA + t * directionA) と (originB + u * directionB) の交点を求める
// directionA / directionB は正規化されている前提ではないがクロス判定のみなので不要
var crossDirection = Cross(directionPrev, directionNext); // 方向ベクトル同士の外積(平行判定)
if (Mathf.Abs(crossDirection) < ParallelEpsilon(prevToCurrentLength, currentToNextLength))
{
Debug.LogWarning("セグメントが平行もしくは同一直線です。フォールバック頂点を使用します。", this);
found = false; // 平行もしくは同一直線で交点が一意に定まらない
return default;
}
var deltaOrigins = nextPoint - prevPoint; // 始点間ベクトル
var tAlongA = Cross(deltaOrigins, directionNext) / crossDirection; // originA から交点までのスカラー値
if (tAlongA < -BackwardTolerance || tAlongA > prevToCurrentLength + BackwardTolerance)
{
found = false;
Debug.LogWarning("幾何的縮退を検知しました。フォールバック頂点を使用します。", this);
return default;
}
found = true;
return prevPoint + directionPrev * tAlongA;
}
/// <summary>
/// 外積計算(2Dベクトル用)
/// </summary>
private float Cross(Vector2 a, Vector2 b) => a.x * b.y - a.y * b.x;
/// <summary>
/// 三角形をインデックスリストに追加
/// </summary>
private void AddTriangle(int a, int b, int c)
{
indices.Add(a);
indices.Add(b);
indices.Add(c);
}
}
とんでもなく長いコードですね。
しかも共通処理が共通化されていなかったり、同じ処理を何度も繰り返している箇所があるので、このままではパフォーマンス的に厳しいものがありますので、座標に関する計算周りを別クラスに切り出すことで、軽量化しました。
最終的なコードはこちらです。
LineRendererForUGUI.cs
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using UnityEngine.UI;
[RequireComponent(typeof(CanvasRenderer))]
public class LineRendererForUGUI : MaskableGraphic
{
[SerializeField] private Sprite sprite;
[SerializeField] private Vector2[] points = new Vector2[0]; // ローカル座標系になるので注意
[SerializeField] private float thickness = 1f; // 線の太さ
[SerializeField] private float miterLimit = 4f; // マイター(角の突出)の最大長
/// <summary>セグメントの長さが短すぎる場合の閾値(線の太さに依存)</summary>
private float SegmentEpsilon => Mathf.Max(0.001f, thickness * 0.05f);
/// <summary>2つのセグメントが平行かどうか判定するための閾値</summary>
private float ParallelEpsilon(float lengthA, float lengthB) => 1e-5f * (lengthA * lengthB + 1f);
/// <summary>交点計算時の後退許容値(線の太さに依存)</summary>
private float BackwardTolerance => Mathf.Max(0.00015f, thickness * 0.02f);
/// <summary>
/// 使用するテクスチャ(spriteが設定されていればそのテクスチャ、なければnull)
/// </summary>
public override Texture mainTexture => sprite == null ? null : sprite.texture;
private readonly List<UIVertex> vertices = new(); // 頂点リスト
private readonly List<int> indices = new(); // インデックスリスト
private UIVertex vertex;
/// <summary>
/// メッシュ生成処理(頂点・インデックスを計算してUIに描画)
/// </summary>
protected override void OnPopulateMesh(VertexHelper vh)
{
vh.Clear();
vertices.Clear();
indices.Clear();
// 座標が線を引くのに足りない場合は処理を行わない
if (points == null || points.Length < 2)
return;
var vertexIndex = 0;
var halfThickness = thickness / 2f;
var isPrevBevel = false;
var isPrevTurnLeft = false;
vertex = UIVertex.simpleVert;
vertex.color = color;
for (var index = 0; index < points.Length; index++)
{
var pointData = new LineJoinContext(index, points);
// 先頭
if (pointData.IsFirst)
{
ProcessStartPoint(pointData, halfThickness);
continue;
}
// 終端
if (pointData.IsLast)
{
ProcessEndPoint(pointData, vertexIndex, halfThickness, isPrevBevel, isPrevTurnLeft);
continue;
}
// 端以外の処理
isPrevBevel = ProcessMiddlePoint(pointData, vertexIndex, halfThickness, isPrevBevel, isPrevTurnLeft);
isPrevTurnLeft = pointData.IsLeftTurn;
// 中間点は共通で使用するので、2つずつインデックスを進める
vertexIndex += isPrevBevel ? 3 : 2;
}
vh.AddUIVertexStream(vertices, indices);
}
/// <summary>
/// 線の始点の頂点を計算・追加
/// </summary>
private void ProcessStartPoint(LineJoinContext pointData, float halfThickness)
{
vertex.position = pointData.Current + pointData.NextNormal * halfThickness;
vertices.Add(vertex);
vertex.position = pointData.Current - pointData.NextNormal * halfThickness;
vertices.Add(vertex);
}
/// <summary>
/// 線の終点の頂点を計算・追加
/// </summary>
private void ProcessEndPoint(LineJoinContext pointData, int vertexIndex, float halfThickness, bool isPrevBevel,
bool isPrevLeftTurn)
{
vertex.position = pointData.Current + pointData.PrevNormal * halfThickness;
vertices.Add(vertex);
vertex.position = pointData.Current - pointData.PrevNormal * halfThickness;
vertices.Add(vertex);
AddTrianglesForEnd(vertexIndex, isPrevBevel, isPrevLeftTurn);
}
/// <summary>
/// 線の中間点(角部分)の頂点を計算・追加
/// </summary>
private bool ProcessMiddlePoint(LineJoinContext pointData, int vertexIndex, float halfThickness,
bool isPrevBevel, bool isPrevLeftTurn)
{
var miterDirection = pointData.MiterVector;
// Miter 原始ベクトルがゼロに近い場合はベベルへフォールバック
if (miterDirection.sqrMagnitude < 1e-8f)
{
ProcessBevel(pointData, vertexIndex, halfThickness, isPrevLeftTurn);
return true;
}
// マイター突出長の算出
var dotToNextNormal = Vector2.Dot(miterDirection, pointData.NextNormal);
// 分母が極小のときは不安定なのでベベルへ
const float dotEps = 1e-4f;
if (Mathf.Abs(dotToNextNormal) < dotEps)
{
ProcessBevel(pointData, vertexIndex, halfThickness, isPrevLeftTurn);
return true;
}
var miterLength = halfThickness / dotToNextNormal;
// マイター長が許容範囲外ならベベル
if (Mathf.Abs(miterLength) > halfThickness * Mathf.Max(1f, miterLimit))
{
ProcessBevel(pointData, vertexIndex, halfThickness, isPrevLeftTurn);
return true;
}
// 使用するのはマイター方向とマイター突出長を使用して頂点位置を算出する
vertex.position = pointData.Current + miterDirection * miterLength;
vertices.Add(vertex);
vertex.position = pointData.Current - miterDirection * miterLength;
vertices.Add(vertex);
if (!isPrevBevel)
{
AddTrianglesForMiter(vertexIndex, false, isPrevLeftTurn);
}
else
{
AddTrianglesForMiter(vertexIndex, true, isPrevLeftTurn);
}
return false;
}
/// <summary>
/// ベベル(角の丸め処理)を行い、頂点を追加
/// </summary>
private void ProcessBevel(LineJoinContext pointData, int vertexIndex, float halfThickness, bool prevIsLeftTurn)
{
var isLeftTurn = pointData.IsLeftTurn;
// 外側法線(方向ベクトルではなく法線を使用)
var outerPrevNormal = isLeftTurn ? pointData.PrevNormal : -pointData.PrevNormal;
var outerNextNormal = isLeftTurn ? pointData.NextNormal : -pointData.NextNormal;
// 外側頂点
var outerPrev = pointData.Current + outerPrevNormal * halfThickness;
var outerNext = pointData.Current + outerNextNormal * halfThickness;
// 内側法線
var innerPrevNormal = -outerPrevNormal;
var innerNextNormal = -outerNextNormal;
var innerPrevOffsetPoint = pointData.Current + innerPrevNormal * halfThickness;
var innerNextOffsetPoint = pointData.Current + innerNextNormal * halfThickness;
// 交点(内側同士)
var innerIntersection = Intersection(pointData, innerPrevOffsetPoint, innerNextOffsetPoint,
out var intersectionFound);
if (!intersectionFound)
{
// フォールバック: currentPoint ではなく 2 オフセットの中点で段差緩和
innerIntersection = (innerPrevOffsetPoint + innerNextOffsetPoint) * 0.5f;
}
vertex.position = outerPrev;
vertices.Add(vertex);
vertex.position = innerIntersection;
vertices.Add(vertex);
vertex.position = outerNext;
vertices.Add(vertex);
// 三角形構築(左右で若干の貼り方差異)
if (!isLeftTurn)
{
AddTrianglesForBevel(vertexIndex, prevIsLeftTurn, false);
}
else
{
AddTrianglesForBevel(vertexIndex, prevIsLeftTurn, true);
}
AddFinalBevelTriangle(vertexIndex);
}
/// <summary>
/// 2つの線分の交点を計算
/// </summary>
private Vector2 Intersection(LineJoinContext pointData, Vector2 prevPoint, Vector2 nextPoint, out bool found)
{
if (pointData.PrevLength < SegmentEpsilon || pointData.NextLength < SegmentEpsilon)
{
Debug.LogWarning("セグメントの長さが短すぎます。フォールバック頂点を使用します。", this);
found = false;
return default;
}
var crossDirection = Cross(pointData.PrevDirection, pointData.NextDirection);
if (Mathf.Abs(crossDirection) < ParallelEpsilon(pointData.PrevLength, pointData.NextLength))
{
Debug.LogWarning("セグメントが平行もしくは同一直線です。フォールバック頂点を使用します。", this);
found = false;
return default;
}
var deltaOrigins = nextPoint - prevPoint;
var tAlongA = Cross(deltaOrigins, pointData.NextDirection) / crossDirection;
// 範囲判定を PrevLength に訂正(進む方向は PrevDirection)
if (tAlongA < -BackwardTolerance || tAlongA > pointData.PrevLength + BackwardTolerance)
{
found = false;
Debug.LogWarning("幾何的縮退を検知しました。フォールバック頂点を使用します。", this);
return default;
}
found = true;
return prevPoint + pointData.PrevDirection * tAlongA;
}
/// <summary>
/// 外積計算(2Dベクトル用)
/// </summary>
private float Cross(Vector2 a, Vector2 b) => a.x * b.y - a.y * b.x;
/// <summary>
/// 三角形をインデックスリストに追加
/// </summary>
private void AddTriangle(int a, int b, int c)
{
indices.Add(a);
indices.Add(b);
indices.Add(c);
}
private void AddTrianglesForMiter(int baseIndex, bool prevWasBevel, bool prevLeftTurn)
{
if (!prevWasBevel)
{
// 通常 -> 通常
AddTriangle(baseIndex, baseIndex + 2, baseIndex + 1);
AddTriangle(baseIndex + 1, baseIndex + 2, baseIndex + 3);
return;
}
// ベベル -> 通常 (前ベベルの左折/右折で貼り分け)
if (prevLeftTurn)
{
AddTriangle(baseIndex, baseIndex + 2, baseIndex + 1);
AddTriangle(baseIndex, baseIndex + 2, baseIndex + 3);
}
else
{
AddTriangle(baseIndex, baseIndex + 1, baseIndex + 2);
AddTriangle(baseIndex + 1, baseIndex + 2, baseIndex + 3);
}
}
private void AddTrianglesForBevel(int baseIndex, bool prevLeftTurn, bool currentLeftTurn)
{
// currentLeftTurn で左右を入れ替えるが分岐パターンは左右対称
if (!currentLeftTurn)
{
if (!prevLeftTurn)
{
AddTriangle(baseIndex, baseIndex + 1, baseIndex + 2);
AddTriangle(baseIndex, baseIndex + 2, baseIndex + 3);
}
else
{
AddTriangle(baseIndex, baseIndex + 1, baseIndex + 2);
AddTriangle(baseIndex + 1, baseIndex + 2, baseIndex + 3);
}
}
else
{
if (!prevLeftTurn)
{
AddTriangle(baseIndex, baseIndex + 1, baseIndex + 2);
AddTriangle(baseIndex + 1, baseIndex + 2, baseIndex + 3);
}
else
{
AddTriangle(baseIndex, baseIndex + 1, baseIndex + 2);
AddTriangle(baseIndex, baseIndex + 2, baseIndex + 3);
}
}
}
private void AddFinalBevelTriangle(int baseIndex)
{
AddTriangle(baseIndex + 2, baseIndex + 3, baseIndex + 4);
}
private void AddTrianglesForEnd(int baseIndex, bool prevWasBevel, bool prevLeftTurn)
{
if (!prevWasBevel)
{
AddTriangle(baseIndex, baseIndex + 2, baseIndex + 1);
AddTriangle(baseIndex + 1, baseIndex + 2, baseIndex + 3);
return;
}
if (!prevLeftTurn)
{
AddTriangle(baseIndex, baseIndex + 1, baseIndex + 2);
AddTriangle(baseIndex + 1, baseIndex + 2, baseIndex + 3);
}
else
{
AddTriangle(baseIndex, baseIndex + 1, baseIndex + 2);
AddTriangle(baseIndex, baseIndex + 2, baseIndex + 3);
}
}
#if UNITY_EDITOR
[CustomEditor(typeof(LineRendererForUGUI))]
private class LineRendererForUGUIEditor : Editor
{
public override void OnInspectorGUI()
{
base.OnInspectorGUI();
if (GUILayout.Button("サンプル作成"))
{
var length = Random.Range(3, 10);
var pts = new Vector2[length];
for (var idx = 0; idx < length; idx++)
{
pts[idx] = new Vector2(Random.Range(-100f, 100f), Random.Range(-100f, 100f));
}
if (target is LineRendererForUGUI lr)
{
lr.points = pts;
lr.SetVerticesDirty();
}
}
}
}
#endif
}
LineJoinContext.cs
using UnityEngine;
/// <summary>
/// ラインのジョイン(角)の計算に必要な前・現在・次の点および派生情報をまとめたコンテキスト
/// </summary>
public struct LineJoinContext
{
/// <summary>極小長さ判定に用いる閾値</summary>
private const float Epsilon = 1e-6f;
/// <summary>前の点(先頭の場合は自身)</summary>
public Vector2 Prev { get; }
/// <summary>現在の点</summary>
public Vector2 Current { get; }
/// <summary>次の点(終端の場合は自身)</summary>
public Vector2 Next { get; }
/// <summary>前の点から現在の点へのベクトル</summary>
public Vector2 PrevVector { get; }
/// <summary>現在の点から次の点へのベクトル</summary>
public Vector2 NextVector { get; }
/// <summary>前ベクトルの正規化方向</summary>
public Vector2 PrevDirection { get; }
/// <summary>次ベクトルの正規化方向</summary>
public Vector2 NextDirection { get; }
/// <summary>前方向を左に90度回転させた法線</summary>
public Vector2 PrevNormal { get; }
/// <summary>次方向を左に90度回転させた法線</summary>
public Vector2 NextNormal { get; }
/// <summary>マイター方向(前法線 + 次法線 を正規化したもの)</summary>
public Vector2 MiterVector => (PrevNormal + NextNormal).normalized;
/// <summary>前方向から次方向へ左折しているか</summary>
public bool IsLeftTurn { get; }
/// <summary>前ベクトルの長さ</summary>
public float PrevLength { get; }
/// <summary>次ベクトルの長さ</summary>
public float NextLength { get; }
/// <summary>配列先頭かどうか</summary>
public bool IsFirst { get; }
/// <summary>配列終端かどうか</summary>
public bool IsLast { get; }
/// <summary>
/// 指定インデックス位置の幾何情報を構築する
/// </summary>
/// <param name="index">対象インデックス</param>
/// <param name="allPoints">全ポイント配列</param>
public LineJoinContext(int index, Vector2[] allPoints)
{
Current = allPoints[index];
Prev = Current;
Next = Current;
PrevVector = Vector2.zero;
NextVector = Vector2.zero;
PrevLength = 0f;
NextLength = 0f;
PrevDirection = Vector2.right;
NextDirection = Vector2.right;
PrevNormal = new Vector2(PrevDirection.y, -PrevDirection.x);
NextNormal = new Vector2(NextDirection.y, -NextDirection.x);
IsLeftTurn = false;
IsFirst = index == 0;
IsLast = index == allPoints.Length - 1;
if (!IsFirst)
{
Prev = allPoints[index - 1];
PrevVector = Current - Prev;
PrevLength = PrevVector.magnitude;
if (PrevLength > Epsilon)
PrevDirection = PrevVector / PrevLength;
PrevNormal = new Vector2(PrevDirection.y, -PrevDirection.x);
}
if (!IsLast)
{
Next = allPoints[index + 1];
NextVector = Next - Current;
NextLength = NextVector.magnitude;
if (NextLength > Epsilon)
NextDirection = NextVector / NextLength;
NextNormal = new Vector2(NextDirection.y, -NextDirection.x);
}
if (!IsFirst && !IsLast)
{
IsLeftTurn = Cross(PrevDirection, NextDirection) > 0f;
}
}
/// <summary>2次元ベクトルの外積(面積符号付き)</summary>
private float Cross(Vector2 a, Vector2 b) => a.x * b.y - a.y * b.x;
}
最終的にはこのような感じになります。
デバッグ機能として、適当な座標リストを作成して表示を試せるようにしてあります。
表示が崩れている箇所は、幾何的に生成不可能な形になってしまっている場合に発生するので、座標が近すぎたり角度がほぼ180°だったりした場合に発生します。





