UniText 의 모디파이어는 방금 찍은 쿼드 네 정점만 고친다
label.Text = "HP <color=#FF4040>12</color> / 100";
글자는 화면에 사각형으로 그려집니다. 글리프 하나마다 정점 넷으로 된 사각형(쿼드) 하나를 만들고, 아틀라스에서 그 글리프가 든 칸을 입힙니다. 텍스트 한 덩어리의 메시는 이 쿼드를 글리프 수만큼 이어 붙인 것입니다.
색, 굵게, 기울임 같은 효과는 이 정점을 고쳐서 냅니다. 색은 정점 색을 바꾸고, 기울임은 위쪽 정점 둘을 옆으로 밉니다. 효과를 넣는 코드(모디파이어)마다 완성된 메시에서 자기 구간을 찾아다니게 하면, 효과 수만큼 정점 배열 전체를 훑고 그 대부분을 “내 구간이 아니다” 를 확인하는 데 씁니다.
UniText 의 메시 생성기는 글리프 쿼드를 한 장 찍을 때마다 OnGlyph 를 불러, 모디파이어가 방금 찍힌 정점 네 개만 고치게 합니다. ColorModifier 는 1 을 찍은 순간 불려 그 네 정점의 색만 바꾸고, HP 를 찍을 때는 한 칸 읽고 빠집니다. 기준은 1.0.0 브랜치의 커밋 2087eff 입니다.
쿼드를 한 장 찍고 바로 알린다
UniTextMeshGenerator 는 폰트마다 필요한 용량을 한 번 잡고, 글리프마다 정점 네 개(좌하, 좌상, 우상, 우하)와 삼각형 둘을 찍은 뒤 vertexCount 를 4 올리고 OnGlyph 를 부릅니다.
foreach (var glyph in glyphs)
{
// 이 글리프가 쓸 정점 네 칸
i0 = vertexCount;
i1 = i0 + 1;
i2 = i0 + 2;
i3 = i0 + 3;
verts[i0..i3] = 좌하, 좌상, 우상, 우하; // uv 와 색도 같은 칸에 쓴다
tris[..] = (i0, i1, i2), (i2, i3, i0); // 삼각형 둘이 네 정점을 나눠 쓴다
// 콜백이 읽을 공개 필드를 이 글리프 값으로 바꾼다
currentCluster = glyph.cluster;
x = glyph.x;
// 카운터를 먼저 올리고 알린다
vertexCount += 4;
triangleCount += 6;
OnGlyph?.Invoke(); // 모디파이어는 vertexCount - 4 부터 네 칸을 고친다
// 콜백이 버퍼를 키웠을 수 있으니 배열을 다시 읽는다
verts = vertices.data;
cols = colors.data;
}
HP 12 / 100 의 12 두 글자가 찍힐 때 모디파이어가 보는 값입니다.
| 글리프 | currentCluster |
콜백 때 vertexCount |
고칠 정점 |
|---|---|---|---|
1 |
3 | 4 | 0 ~ 3 |
2 |
4 | 8 | 4 ~ 7 |
currentCluster 는 태그를 걷어 낸 텍스트의 코드포인트 번호입니다. 카운터를 올린 뒤에 콜백을 부르므로, 방금 찍은 쿼드는 늘 vertexCount - 4 부터입니다.
OnGlyph 는 인자가 없는 콜백입니다. 모디파이어는 “지금 어느 글리프인가” 를 인자로 받지 않고, 생성기의 공개 필드(currentCluster, vertexCount, Vertices, Colors 등)에서 읽습니다. 생성기는 UniTextMeshGenerator.Current 로 얻습니다.
Current 는 스레드마다 값이 따로인 정적 필드([ThreadStatic])입니다. 메시 생성은 워커 스레드 여럿에서 동시에 돌 수 있는데, 그때도 각 스레드의 콜백은 자기 스레드가 만들고 있는 생성기를 봅니다.
이 구조에서는 모디파이어가 몇 개든 정점 배열을 한 번만 지나가고, 모디파이어끼리는 서로를 모릅니다. 라틴 2,000자(1,992글리프)의 메시만 다시 만든 시간입니다.
| 모디파이어 | 메시 단계 |
|---|---|
| 0개 | 0.267 ms |
1개 (<color>) |
0.293 ms |
3개 (<color><b><i>) |
0.359 ms |
모디파이어 하나가 글리프당 십여 나노초를 더했습니다.
이렇게 쓴다
콜백은 워커 스레드에서도 불립니다. 콜백 안에서 Time.time, transform 같은 메인 스레드 전용 유니티 API 를 부르면, 병렬로 도는 프레임에만 예외가 납니다. 에디터에서 멀쩡하던 것이 글자가 많은 특정 화면에서만 터집니다.
필요한 값은 메인 스레드에서 불리는 PrepareForParallel() 에서 필드에 담아 두고, 콜백은 그 필드만 읽습니다.
float time;
public override void PrepareForParallel()
{
time = Time.time; // 메인 스레드에서 미리 읽어 둔다
}
void OnGlyph()
{
float t = time; // 콜백은 필드만 읽는다
}
콜백 뒤에 배열을 다시 읽는다
밑줄은 선 쿼드를, 말줄임은 ... 쿼드를 더 찍습니다. 공간이 모자라면 모디파이어가 EnsureCapacity 를 부르고, 버퍼는 풀에서 새 배열을 빌리고 옛 배열을 풀에 반납합니다. 루프가 지역 변수로 들고 있던 배열이 반납된 것이라, 루프는 콜백이 끝날 때마다 필드에서 배열을 다시 읽습니다.
var verts = vertices.data; // 루프 전에 한 번 받아 둔다
foreach (var glyph in glyphs)
{
verts[vertexCount..+3] = 쿼드;
vertexCount += 4;
OnGlyph?.Invoke(); // 밑줄 모디파이어가 EnsureCapacity 를 불러 버퍼를 키울 수 있다
verts = vertices.data; // 그래서 콜백이 끝날 때마다 다시 읽는다
}
마지막 줄이 없으면 이렇게 됩니다. vertices.data 가 8칸짜리 배열 A 라고 합시다.
- 첫 글리프를 A 의 0 ~ 3 칸에 씁니다.
- 밑줄 모디파이어가 12칸을 더 요구합니다. 버퍼가 16칸짜리 새 배열 B 를 풀에서 빌려 복사하고, A 를 풀에 반납합니다.
vertices.data는 B 가 됩니다. - 루프의 지역 변수
verts는 여전히 A 를 가리킵니다. 둘째 글리프를 A 에 쓰려 하면 범위 밖 예외가 나거나, 다른 곳이 빌려 간 배열을 덮어씁니다.
다시 읽는 줄이 있으면 3 에서 verts 가 B 가 되어 둘째 글리프가 제자리에 찍힙니다. 버퍼는 글리프 수 × 4 로 미리 잡으므로 쿼드만 찍을 때는 커지지 않고, 모디파이어가 정점을 더할 때만 커집니다.
같은 함정이 모디파이어 쪽에도 있습니다. gen.Vertices 를 먼저 받아 두고 EnsureCapacity 를 부르면 그 지역 변수도 반납된 배열을 가리킵니다.
이렇게 쓴다
정점을 더하는 모디파이어는 공간을 먼저 확보하고, 그다음에 배열을 받습니다.
// 틀림: 받아 둔 배열이 EnsureCapacity 뒤에는 반납된 배열이다
var verts = gen.Vertices;
gen.EnsureCapacity(4, 6);
verts[gen.vertexCount] = ...;
// 맞음
gen.EnsureCapacity(4, 6); // 정점 4개, 인덱스 6개를 더 쓰겠다
var verts = gen.Vertices;
var tris = gen.Triangles;
삼각형이 가리키는 정점 번호는 배열 전체 기준이 아니라 정점 번호 - gen.CurrentSegmentVertexStart 로 적습니다. 메시가 폰트마다 잘려 넘어가기 때문에, 전체 기준 번호를 적으면 자를 때 어긋납니다.
파생 클래스에는 구멍 셋만 열려 있다
GlyphModifier<T> 는 OnEnable, OnDisable, OnDestroy, OnApply 를 sealed override 로 막아 구독, 해제, 버퍼 대여를 파생 클래스가 틀릴 수 없게 했습니다. 남은 것은 AttributeKey, DoApply, GetOnGlyphCallback 셋입니다. 모디파이어는 텍스트에 제 태그가 처음 나올 때 OnGlyph 에 붙으므로, 씬에 UniText 가 많아도 태그가 실제로 쓰인 곳만 값을 냅니다. 글리프마다 Y 를 흔드는 <wave=8> 을 만들면 이렇습니다.
using System;
using UnityEngine;
using LightSide;
[Serializable]
public sealed class WaveParseRule : TagParseRule
{
protected override string TagName => "wave";
protected override bool HasParameter => true;
}
[Serializable]
public sealed class WaveModifier : GlyphModifier<float>
{
float time; // 워커 스레드에서는 Time.time 을 못 읽는다
protected override string AttributeKey => "wave";
protected override Action GetOnGlyphCallback() => OnGlyph; // 메서드 그룹
public override void PrepareForParallel() => time = Time.time; // 메인 스레드에서 미리
protected override void DoApply(int start, int end, string parameter)
{
if (!float.TryParse(parameter, out var amplitude) || amplitude == 0f) return;
var data = attribute.buffer.data;
if (data == null) return;
var last = Math.Min(Math.Min(end, buffers.codepoints.count), data.Length);
for (var i = Math.Max(0, start); i < last; i++) data[i] = amplitude;
}
void OnGlyph()
{
var gen = UniTextMeshGenerator.Current;
var data = attribute.buffer.data;
var cluster = gen.currentCluster;
if (data == null || (uint)cluster >= (uint)data.Length || data[cluster] == 0f) return;
var offset = Mathf.Sin(time * 4f + cluster * 0.5f) * data[cluster];
var baseIdx = gen.vertexCount - 4; // 방금 찍힌 쿼드
var verts = gen.Vertices;
for (var k = 0; k < 4; k++) verts[baseIdx + k].y += offset;
}
}
<wave=8>hello</wave> 의 쿼드는 태그 없는 텍스트보다 글자마다 6.71, 3.81, -0.03, -3.87, -6.75 만큼 위아래로 어긋났습니다. 1초 뒤 메시만 다시 만들자 PrepareForParallel 이 새로 읽은 시간으로 값이 바뀌었습니다.
이렇게 쓴다
GetOnGlyphCallback 은 부를 때마다 같은 델리게이트를 돌려줘야 합니다. 켤 때와 끌 때 이 메서드를 따로 불러 += 와 -= 에 쓰기 때문입니다.
// 틀림: 부를 때마다 새 델리게이트라 -= 가 아무것도 빼지 못한다
protected override Action GetOnGlyphCallback() => () => OnGlyph();
// 맞음: 메서드 그룹은 같은 대상으로 비교된다
protected override Action GetOnGlyphCallback() => OnGlyph;
틀린 쪽은 예외도 경고도 없습니다. 컴포넌트를 껐다 켤 때마다 구독이 쌓여 효과가 두 번, 세 번 겹칩니다.
정리
UniText 의 모디파이어는 방금 찍은 쿼드 네 정점만 고칩니다.
- 생성기는 쿼드를 찍고
vertexCount를 4 올린 뒤OnGlyph를 부르고, 모디파이어는vertexCount - 4부터 네 칸을 봅니다. 정점 배열은 한 번만 지나갑니다. - 모디파이어가 버퍼를 키우면 옛 배열이 반납되므로, 루프는 콜백 뒤에 배열을 다시 읽고 모디파이어는
EnsureCapacity뒤에 배열을 받습니다. - 파생 클래스에는
AttributeKey,DoApply,GetOnGlyphCallback셋만 열려 있고, 콜백은 같은 델리게이트를 돌려줘야 풀립니다.
그래서 콜백에서는 유니티 API 를 부르지 않고, 정점을 더할 때는 EnsureCapacity 를 먼저 부르고, 콜백은 메서드 그룹으로 돌려줍니다.