@aethervtt/shard-text 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +3 -0
- package/dist/build.d.ts +43 -0
- package/dist/build.js +106 -0
- package/dist/charset.d.ts +8 -0
- package/dist/charset.js +43 -0
- package/dist/components.d.ts +54 -0
- package/dist/components.js +118 -0
- package/dist/font.d.ts +193 -0
- package/dist/font.js +377 -0
- package/dist/importer.d.ts +40 -0
- package/dist/importer.js +193 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +140 -0
- package/dist/kerning.d.ts +8 -0
- package/dist/kerning.js +168 -0
- package/dist/layout.d.ts +84 -0
- package/dist/layout.js +297 -0
- package/dist/locale.d.ts +133 -0
- package/dist/locale.js +574 -0
- package/dist/msdf.d.ts +52 -0
- package/dist/msdf.js +1147 -0
- package/dist/opentype.d.ts +36 -0
- package/dist/plugin.d.ts +2 -0
- package/dist/plugin.js +43 -0
- package/dist/preview.d.ts +2 -0
- package/dist/preview.js +76 -0
- package/dist/render.d.ts +114 -0
- package/dist/render.js +544 -0
- package/dist/shaders.d.ts +2 -0
- package/dist/shaders.js +158 -0
- package/dist/shape.d.ts +77 -0
- package/dist/shape.js +404 -0
- package/dist/source.d.ts +44 -0
- package/dist/source.js +108 -0
- package/package.json +40 -0
package/dist/render.js
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineResource,
|
|
3
|
+
defineSystem
|
|
4
|
+
} from "@aethervtt/shard-core";
|
|
5
|
+
import { GpuBuffer } from "@aethervtt/shard-gpu";
|
|
6
|
+
import {
|
|
7
|
+
ComputedVisibility,
|
|
8
|
+
cameraOf,
|
|
9
|
+
GpuAssetsResource,
|
|
10
|
+
RenderPhase,
|
|
11
|
+
Shaders,
|
|
12
|
+
sceneColor
|
|
13
|
+
} from "@aethervtt/shard-render";
|
|
14
|
+
import { Textures } from "@aethervtt/shard-texture";
|
|
15
|
+
import { GlobalTransform } from "@aethervtt/shard-transform";
|
|
16
|
+
import { Localized, ScreenText, Text } from "./components.js";
|
|
17
|
+
import { Fonts } from "./importer.js";
|
|
18
|
+
import { layoutText, TextLayout } from "./layout.js";
|
|
19
|
+
const GLYPH_FLOATS = 12;
|
|
20
|
+
const TEXT_FLOATS = 36;
|
|
21
|
+
const SPACE_WORLD = 0;
|
|
22
|
+
const SPACE_SCREEN = 1;
|
|
23
|
+
const ALIGNS = ["left", "center", "right"];
|
|
24
|
+
const CORNERS = [
|
|
25
|
+
[0, 0],
|
|
26
|
+
[0.5, 0],
|
|
27
|
+
[1, 0],
|
|
28
|
+
[0, 0.5],
|
|
29
|
+
[0.5, 0.5],
|
|
30
|
+
[1, 0.5],
|
|
31
|
+
[0, 1],
|
|
32
|
+
[0.5, 1],
|
|
33
|
+
[1, 1]
|
|
34
|
+
];
|
|
35
|
+
class TextStore {
|
|
36
|
+
states = /* @__PURE__ */ new Map();
|
|
37
|
+
glyphs = new Float32Array(GLYPH_FLOATS * 1024);
|
|
38
|
+
glyphU32 = new Uint32Array(this.glyphs.buffer);
|
|
39
|
+
glyphCount = 0;
|
|
40
|
+
texts = new Float32Array(TEXT_FLOATS * 64);
|
|
41
|
+
textU32 = new Uint32Array(this.texts.buffer);
|
|
42
|
+
textCount = 0;
|
|
43
|
+
groups = [];
|
|
44
|
+
groupCount = 0;
|
|
45
|
+
glyphBuffer;
|
|
46
|
+
textBuffer;
|
|
47
|
+
/** Layouts redone this frame, and characters nothing could draw. */
|
|
48
|
+
relayouts = 0;
|
|
49
|
+
missing = 0;
|
|
50
|
+
uploadedBytes = 0;
|
|
51
|
+
frame = 0;
|
|
52
|
+
freeIndices = [];
|
|
53
|
+
nextIndex = 0;
|
|
54
|
+
dirtyGlyphs = true;
|
|
55
|
+
constructor(gpu) {
|
|
56
|
+
const storage = GPUBufferUsage.STORAGE;
|
|
57
|
+
this.glyphBuffer = new GpuBuffer(gpu, { label: "text/glyphs", usage: storage, size: 4096 });
|
|
58
|
+
this.textBuffer = new GpuBuffer(gpu, { label: "text/records", usage: storage, size: 4096 });
|
|
59
|
+
}
|
|
60
|
+
beginFrame() {
|
|
61
|
+
this.frame++;
|
|
62
|
+
this.relayouts = 0;
|
|
63
|
+
this.uploadedBytes = 0;
|
|
64
|
+
}
|
|
65
|
+
state(entity, space) {
|
|
66
|
+
let s = this.states.get(entity);
|
|
67
|
+
if (!s) {
|
|
68
|
+
const index = this.freeIndices.pop() ?? this.nextIndex++;
|
|
69
|
+
s = {
|
|
70
|
+
entity,
|
|
71
|
+
space,
|
|
72
|
+
layout: new TextLayout(),
|
|
73
|
+
value: "",
|
|
74
|
+
font: void 0,
|
|
75
|
+
fontVersion: -1,
|
|
76
|
+
size: -1,
|
|
77
|
+
align: -1,
|
|
78
|
+
anchorX: 0,
|
|
79
|
+
anchorY: 0,
|
|
80
|
+
maxWidth: 0,
|
|
81
|
+
lineHeight: 0,
|
|
82
|
+
index,
|
|
83
|
+
seen: 0
|
|
84
|
+
};
|
|
85
|
+
this.states.set(entity, s);
|
|
86
|
+
this.dirtyGlyphs = true;
|
|
87
|
+
}
|
|
88
|
+
s.seen = this.frame;
|
|
89
|
+
if (this.texts.length < (s.index + 1) * TEXT_FLOATS) {
|
|
90
|
+
const grown = new Float32Array(Math.max(this.texts.length * 2, (s.index + 1) * TEXT_FLOATS));
|
|
91
|
+
grown.set(this.texts);
|
|
92
|
+
this.texts = grown;
|
|
93
|
+
this.textU32 = new Uint32Array(grown.buffer);
|
|
94
|
+
}
|
|
95
|
+
this.textCount = Math.max(this.textCount, s.index + 1);
|
|
96
|
+
return s;
|
|
97
|
+
}
|
|
98
|
+
/** Forgets texts not seen this frame (despawned, hidden, or their component removed). */
|
|
99
|
+
sweep() {
|
|
100
|
+
for (const [entity, s] of this.states) {
|
|
101
|
+
if (s.seen === this.frame) continue;
|
|
102
|
+
this.states.delete(entity);
|
|
103
|
+
this.freeIndices.push(s.index);
|
|
104
|
+
this.dirtyGlyphs = true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Group per (page, space) while rebuilding: [world, screen], -1 for none yet. */
|
|
108
|
+
groupOf = /* @__PURE__ */ new Map();
|
|
109
|
+
groupFor(page, space) {
|
|
110
|
+
let slots = this.groupOf.get(page);
|
|
111
|
+
if (!slots) {
|
|
112
|
+
slots = new Int32Array([-1, -1]);
|
|
113
|
+
this.groupOf.set(page, slots);
|
|
114
|
+
}
|
|
115
|
+
let g = slots[space];
|
|
116
|
+
if (g < 0) {
|
|
117
|
+
g = this.groupCount++;
|
|
118
|
+
slots[space] = g;
|
|
119
|
+
const group2 = this.groups[g];
|
|
120
|
+
if (group2) {
|
|
121
|
+
group2.page = page;
|
|
122
|
+
group2.space = space;
|
|
123
|
+
group2.first = 0;
|
|
124
|
+
group2.count = 0;
|
|
125
|
+
} else {
|
|
126
|
+
this.groups[g] = { page, space, first: 0, count: 0 };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return this.groups[g];
|
|
130
|
+
}
|
|
131
|
+
/** Rebuilds the glyph records, grouped by (page, space), from every layout. */
|
|
132
|
+
rebuild() {
|
|
133
|
+
this.dirtyGlyphs = false;
|
|
134
|
+
this.groupOf.clear();
|
|
135
|
+
this.groupCount = 0;
|
|
136
|
+
for (const s of this.states.values()) {
|
|
137
|
+
const l = s.layout;
|
|
138
|
+
for (let q = 0; q < l.count; q++) this.groupFor(l.pages[l.page[q]], s.space).count++;
|
|
139
|
+
}
|
|
140
|
+
let total = 0;
|
|
141
|
+
for (let g = 0; g < this.groupCount; g++) {
|
|
142
|
+
const group2 = this.groups[g];
|
|
143
|
+
group2.first = total;
|
|
144
|
+
total += group2.count;
|
|
145
|
+
group2.count = 0;
|
|
146
|
+
}
|
|
147
|
+
if (this.glyphs.length < total * GLYPH_FLOATS) {
|
|
148
|
+
this.glyphs = new Float32Array(Math.max(this.glyphs.length * 2, total * GLYPH_FLOATS));
|
|
149
|
+
this.glyphU32 = new Uint32Array(this.glyphs.buffer);
|
|
150
|
+
}
|
|
151
|
+
const f = this.glyphs;
|
|
152
|
+
const u = this.glyphU32;
|
|
153
|
+
for (const s of this.states.values()) {
|
|
154
|
+
const l = s.layout;
|
|
155
|
+
for (let q = 0; q < l.count; q++) {
|
|
156
|
+
const group2 = this.groupFor(l.pages[l.page[q]], s.space);
|
|
157
|
+
const o = (group2.first + group2.count++) * GLYPH_FLOATS;
|
|
158
|
+
for (let k = 0; k < 4; k++) {
|
|
159
|
+
f[o + k] = l.quads[q * 4 + k];
|
|
160
|
+
f[o + 4 + k] = l.uvs[q * 4 + k];
|
|
161
|
+
}
|
|
162
|
+
u[o + 8] = s.index;
|
|
163
|
+
const font = l.glyphs[q].font;
|
|
164
|
+
f[o + 9] = font.size / font.range;
|
|
165
|
+
f[o + 10] = font.range;
|
|
166
|
+
f[o + 11] = 0;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
this.glyphCount = total;
|
|
170
|
+
this.glyphBuffer.write(f, 0, 0, Math.max(GLYPH_FLOATS, total * GLYPH_FLOATS));
|
|
171
|
+
this.uploadedBytes += total * GLYPH_FLOATS * 4;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const TextRenderer = defineResource("text/Renderer", {
|
|
175
|
+
description: "Laid-out text on the GPU: glyph records by atlas page, and per-text styles."
|
|
176
|
+
});
|
|
177
|
+
const layoutOptions = { anchor: [0.5, 0.5] };
|
|
178
|
+
function textColumns(table, def) {
|
|
179
|
+
const c = (name) => table.column(def, name);
|
|
180
|
+
return {
|
|
181
|
+
// A localized text shows its resolved string (0038).
|
|
182
|
+
value: table.has(Localized) ? table.column(Localized, "value") : c("value"),
|
|
183
|
+
font: c("font"),
|
|
184
|
+
color: c("color"),
|
|
185
|
+
align: c("align"),
|
|
186
|
+
anchor: c("anchor"),
|
|
187
|
+
maxWidth: c("maxWidth"),
|
|
188
|
+
lineHeight: c("lineHeight"),
|
|
189
|
+
weight: c("weight"),
|
|
190
|
+
outline: c("outline"),
|
|
191
|
+
shadow: c("shadow"),
|
|
192
|
+
size: c("size"),
|
|
193
|
+
visible: table.column(ComputedVisibility, "visible")
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function prepareText(store, fonts, s, c, i, world, screen) {
|
|
197
|
+
const font = fonts.get(c.font[i]);
|
|
198
|
+
const size = c.size[i];
|
|
199
|
+
const value = c.value[i] ?? "";
|
|
200
|
+
if (font && (s.value !== value || s.font !== font || s.fontVersion !== font.version || s.size !== size || s.align !== c.align[i] || s.anchorX !== c.anchor[i * 2] || s.anchorY !== c.anchor[i * 2 + 1] || s.maxWidth !== c.maxWidth[i] || s.lineHeight !== c.lineHeight[i])) {
|
|
201
|
+
layoutOptions.size = 1;
|
|
202
|
+
layoutOptions.align = ALIGNS[c.align[i]] ?? "left";
|
|
203
|
+
layoutOptions.anchor[0] = c.anchor[i * 2];
|
|
204
|
+
layoutOptions.anchor[1] = c.anchor[i * 2 + 1];
|
|
205
|
+
layoutOptions.maxWidth = size > 0 ? c.maxWidth[i] / size : 0;
|
|
206
|
+
layoutOptions.lineHeight = c.lineHeight[i];
|
|
207
|
+
layoutText(font, value, layoutOptions, s.layout);
|
|
208
|
+
s.value = value;
|
|
209
|
+
s.font = font;
|
|
210
|
+
s.fontVersion = font.version;
|
|
211
|
+
s.size = size;
|
|
212
|
+
s.align = c.align[i];
|
|
213
|
+
s.anchorX = c.anchor[i * 2];
|
|
214
|
+
s.anchorY = c.anchor[i * 2 + 1];
|
|
215
|
+
s.maxWidth = c.maxWidth[i];
|
|
216
|
+
s.lineHeight = c.lineHeight[i];
|
|
217
|
+
store.relayouts++;
|
|
218
|
+
store.dirtyGlyphs = true;
|
|
219
|
+
} else if (!font && s.layout.count > 0) {
|
|
220
|
+
s.layout.count = 0;
|
|
221
|
+
s.font = void 0;
|
|
222
|
+
store.dirtyGlyphs = true;
|
|
223
|
+
}
|
|
224
|
+
if (!c.visible[i] && s.layout.count > 0) {
|
|
225
|
+
s.layout.count = 0;
|
|
226
|
+
s.value = "";
|
|
227
|
+
store.dirtyGlyphs = true;
|
|
228
|
+
}
|
|
229
|
+
store.missing += s.layout.missing;
|
|
230
|
+
const f = store.texts;
|
|
231
|
+
const u = store.textU32;
|
|
232
|
+
const o = s.index * TEXT_FLOATS;
|
|
233
|
+
if (world) for (let k = 0; k < 12; k++) f[o + k] = world[k];
|
|
234
|
+
else for (let k = 0; k < 12; k++) f[o + k] = k === 0 || k === 5 || k === 10 ? 1 : 0;
|
|
235
|
+
for (let k = 0; k < 4; k++) f[o + 12 + k] = c.color[i * 4 + k];
|
|
236
|
+
const outline = c.outline[i];
|
|
237
|
+
const shadow = c.shadow[i];
|
|
238
|
+
for (let k = 0; k < 4; k++) f[o + 16 + k] = outline.color[k];
|
|
239
|
+
for (let k = 0; k < 4; k++) f[o + 20 + k] = shadow.color[k];
|
|
240
|
+
f[o + 24] = size;
|
|
241
|
+
f[o + 25] = outline.width;
|
|
242
|
+
f[o + 26] = shadow.softness;
|
|
243
|
+
f[o + 27] = c.weight[i];
|
|
244
|
+
f[o + 28] = shadow.offset[0];
|
|
245
|
+
f[o + 29] = shadow.offset[1];
|
|
246
|
+
u[o + 30] = 0;
|
|
247
|
+
u[o + 31] = 0;
|
|
248
|
+
f[o + 32] = screen ? CORNERS[screen.corner][0] : 0;
|
|
249
|
+
f[o + 33] = screen ? CORNERS[screen.corner][1] : 0;
|
|
250
|
+
f[o + 34] = screen ? screen.x : 0;
|
|
251
|
+
f[o + 35] = screen ? screen.y : 0;
|
|
252
|
+
}
|
|
253
|
+
const prepareTexts = defineSystem({
|
|
254
|
+
name: "text/prepare",
|
|
255
|
+
description: "Lays out changed text and uploads glyphs and styles.",
|
|
256
|
+
setup: (world) => ({
|
|
257
|
+
worldQ: world.query({ with: [Text, GlobalTransform, ComputedVisibility] }),
|
|
258
|
+
screenQ: world.query({ with: [ScreenText, ComputedVisibility] }),
|
|
259
|
+
screen: { corner: 0, x: 0, y: 0 }
|
|
260
|
+
}),
|
|
261
|
+
run: ({ worldQ, screenQ, screen }, world) => {
|
|
262
|
+
const store = world.resource(TextRenderer);
|
|
263
|
+
const fonts = world.resource(Fonts);
|
|
264
|
+
store.beginFrame();
|
|
265
|
+
store.missing = 0;
|
|
266
|
+
for (const table of worldQ.tables) {
|
|
267
|
+
const c = textColumns(table, Text);
|
|
268
|
+
const g = table.column(GlobalTransform, "matrix");
|
|
269
|
+
const billboard = table.column(Text, "billboard");
|
|
270
|
+
for (let i = 0; i < table.count; i++) {
|
|
271
|
+
const s = store.state(table.entities[i], SPACE_WORLD);
|
|
272
|
+
prepareText(store, fonts, s, c, i, g.subarray(i * 12, i * 12 + 12), void 0);
|
|
273
|
+
store.textU32[s.index * TEXT_FLOATS + 30] = billboard[i] ? 1 : 0;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
for (const table of screenQ.tables) {
|
|
277
|
+
const c = textColumns(table, ScreenText);
|
|
278
|
+
const corner = table.column(ScreenText, "corner");
|
|
279
|
+
const position = table.column(ScreenText, "position");
|
|
280
|
+
for (let i = 0; i < table.count; i++) {
|
|
281
|
+
const s = store.state(table.entities[i], SPACE_SCREEN);
|
|
282
|
+
screen.corner = corner[i];
|
|
283
|
+
screen.x = position[i * 2];
|
|
284
|
+
screen.y = position[i * 2 + 1];
|
|
285
|
+
prepareText(store, fonts, s, c, i, void 0, screen);
|
|
286
|
+
store.textU32[s.index * TEXT_FLOATS + 31] = 1;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
store.sweep();
|
|
290
|
+
if (store.dirtyGlyphs) store.rebuild();
|
|
291
|
+
if (store.textCount > 0) {
|
|
292
|
+
store.textBuffer.write(store.texts, 0, 0, store.textCount * TEXT_FLOATS);
|
|
293
|
+
store.uploadedBytes += store.textCount * TEXT_FLOATS * 4;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
const TextCachesResource = defineResource("text/DrawCaches", {
|
|
298
|
+
description: "GPU objects of the text passes.",
|
|
299
|
+
init: () => ({ generation: -1, pipelines: /* @__PURE__ */ new Map(), groups: /* @__PURE__ */ new Map(), views: /* @__PURE__ */ new Map() })
|
|
300
|
+
});
|
|
301
|
+
const ids = /* @__PURE__ */ new WeakMap();
|
|
302
|
+
let nextId = 1;
|
|
303
|
+
function idOf(o) {
|
|
304
|
+
let id = ids.get(o);
|
|
305
|
+
if (id === void 0) {
|
|
306
|
+
id = nextId++;
|
|
307
|
+
ids.set(o, id);
|
|
308
|
+
}
|
|
309
|
+
return id;
|
|
310
|
+
}
|
|
311
|
+
function caches(ctx) {
|
|
312
|
+
const gpu = ctx.gpu;
|
|
313
|
+
const c = ctx.world.initResource(TextCachesResource);
|
|
314
|
+
if (c.generation !== gpu.generation) {
|
|
315
|
+
c.generation = gpu.generation;
|
|
316
|
+
c.layouts = void 0;
|
|
317
|
+
c.pipelines.clear();
|
|
318
|
+
c.groups.clear();
|
|
319
|
+
c.views.clear();
|
|
320
|
+
c.sampler = void 0;
|
|
321
|
+
}
|
|
322
|
+
if (!c.layouts) {
|
|
323
|
+
const V = GPUShaderStage.VERTEX;
|
|
324
|
+
const F = GPUShaderStage.FRAGMENT;
|
|
325
|
+
c.layouts = {
|
|
326
|
+
view: gpu.layouts.bindGroupLayout({
|
|
327
|
+
label: "text/view",
|
|
328
|
+
entries: [{ binding: 0, visibility: V, buffer: { type: "uniform" } }]
|
|
329
|
+
}),
|
|
330
|
+
data: gpu.layouts.bindGroupLayout({
|
|
331
|
+
label: "text/data",
|
|
332
|
+
entries: [
|
|
333
|
+
{ binding: 0, visibility: V | F, buffer: { type: "read-only-storage" } },
|
|
334
|
+
{ binding: 1, visibility: V | F, buffer: { type: "read-only-storage" } }
|
|
335
|
+
]
|
|
336
|
+
}),
|
|
337
|
+
page: gpu.layouts.bindGroupLayout({
|
|
338
|
+
label: "text/page",
|
|
339
|
+
entries: [
|
|
340
|
+
{ binding: 0, visibility: F, texture: { sampleType: "float" } },
|
|
341
|
+
{ binding: 1, visibility: F, sampler: { type: "filtering" } }
|
|
342
|
+
]
|
|
343
|
+
})
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
c.sampler ??= gpu.device.createSampler({
|
|
347
|
+
label: "text/msdf",
|
|
348
|
+
magFilter: "linear",
|
|
349
|
+
minFilter: "linear",
|
|
350
|
+
addressModeU: "clamp-to-edge",
|
|
351
|
+
addressModeV: "clamp-to-edge"
|
|
352
|
+
});
|
|
353
|
+
return c;
|
|
354
|
+
}
|
|
355
|
+
function group(ctx, slot, key, layout, entries) {
|
|
356
|
+
const c = caches(ctx);
|
|
357
|
+
let g = c.groups.get(slot);
|
|
358
|
+
if (!g || g.key !== key) {
|
|
359
|
+
g = { key, group: ctx.gpu.device.createBindGroup({ label: slot, layout, entries: entries() }) };
|
|
360
|
+
c.groups.set(slot, g);
|
|
361
|
+
}
|
|
362
|
+
return g.group;
|
|
363
|
+
}
|
|
364
|
+
const BLEND = {
|
|
365
|
+
color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
366
|
+
alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" }
|
|
367
|
+
};
|
|
368
|
+
function pipeline(ctx, screen, format, msaa) {
|
|
369
|
+
const gpu = ctx.gpu;
|
|
370
|
+
const c = caches(ctx);
|
|
371
|
+
const key = `${screen ? "s" : "w"}/${format}/${msaa}`;
|
|
372
|
+
const cached = c.pipelines.get(key);
|
|
373
|
+
if (cached) return cached;
|
|
374
|
+
const module = ctx.world.resource(Shaders).module(gpu, {
|
|
375
|
+
root: "shard::text",
|
|
376
|
+
defines: { SCREEN: screen, SRGB_TARGET: format.endsWith("-srgb") }
|
|
377
|
+
});
|
|
378
|
+
if (!module) {
|
|
379
|
+
gpu.pipelines.skipped++;
|
|
380
|
+
return void 0;
|
|
381
|
+
}
|
|
382
|
+
const l = c.layouts;
|
|
383
|
+
const p = gpu.pipelines.render({
|
|
384
|
+
label: `text/${key}`,
|
|
385
|
+
layout: gpu.layouts.pipelineLayout({
|
|
386
|
+
label: "text",
|
|
387
|
+
bindGroupLayouts: [l.view, l.data, l.page]
|
|
388
|
+
}),
|
|
389
|
+
vertex: { module, entryPoint: "vs" },
|
|
390
|
+
fragment: { module, entryPoint: "fs", targets: [{ format, blend: BLEND }] },
|
|
391
|
+
primitive: { topology: "triangle-list" },
|
|
392
|
+
depthStencil: screen ? void 0 : { format: "depth32float", depthWriteEnabled: false, depthCompare: "greater-equal" },
|
|
393
|
+
multisample: { count: msaa }
|
|
394
|
+
});
|
|
395
|
+
if (p) c.pipelines.set(key, p);
|
|
396
|
+
return p;
|
|
397
|
+
}
|
|
398
|
+
const viewScratch = new Float32Array(28);
|
|
399
|
+
function pageTexture(ctx, page) {
|
|
400
|
+
return page.texture ?? (page.ref ? ctx.world.resource(Textures).get(page.ref) : void 0);
|
|
401
|
+
}
|
|
402
|
+
function drawText(ctx, space) {
|
|
403
|
+
const cam = cameraOf(ctx.view);
|
|
404
|
+
const store = ctx.world.tryResource(TextRenderer);
|
|
405
|
+
if (!cam || !store) return;
|
|
406
|
+
const gpu = ctx.gpu;
|
|
407
|
+
const c = caches(ctx);
|
|
408
|
+
const screen = space === SPACE_SCREEN;
|
|
409
|
+
const format = screen ? ctx.texture("view-target").format : "rgba16float";
|
|
410
|
+
const p = pipeline(ctx, screen, format, screen ? 1 : cam.msaa);
|
|
411
|
+
if (!p) return;
|
|
412
|
+
let uniform = c.views.get(ctx.view.name);
|
|
413
|
+
if (!uniform) {
|
|
414
|
+
uniform = new GpuBuffer(gpu, {
|
|
415
|
+
label: `${ctx.view.name}/text`,
|
|
416
|
+
usage: GPUBufferUsage.UNIFORM,
|
|
417
|
+
size: 112
|
|
418
|
+
});
|
|
419
|
+
c.views.set(ctx.view.name, uniform);
|
|
420
|
+
}
|
|
421
|
+
writeView(cam);
|
|
422
|
+
uniform.write(viewScratch);
|
|
423
|
+
const u = uniform;
|
|
424
|
+
const pass = ctx.renderPass;
|
|
425
|
+
pass.setPipeline(p);
|
|
426
|
+
pass.setBindGroup(
|
|
427
|
+
0,
|
|
428
|
+
group(ctx, `${ctx.view.name}/text-view`, `${idOf(u.buffer)}`, c.layouts.view, () => [
|
|
429
|
+
{ binding: 0, resource: { buffer: u.buffer } }
|
|
430
|
+
])
|
|
431
|
+
);
|
|
432
|
+
pass.setBindGroup(
|
|
433
|
+
1,
|
|
434
|
+
group(
|
|
435
|
+
ctx,
|
|
436
|
+
"text/data",
|
|
437
|
+
`${idOf(store.glyphBuffer.buffer)}/${idOf(store.textBuffer.buffer)}`,
|
|
438
|
+
c.layouts.data,
|
|
439
|
+
() => [
|
|
440
|
+
{ binding: 0, resource: { buffer: store.glyphBuffer.buffer } },
|
|
441
|
+
{ binding: 1, resource: { buffer: store.textBuffer.buffer } }
|
|
442
|
+
]
|
|
443
|
+
)
|
|
444
|
+
);
|
|
445
|
+
const assets = ctx.world.resource(GpuAssetsResource);
|
|
446
|
+
for (let g = 0; g < store.groupCount; g++) {
|
|
447
|
+
const grp = store.groups[g];
|
|
448
|
+
if (grp.space !== space || grp.count === 0) continue;
|
|
449
|
+
const texture = pageTexture(ctx, grp.page);
|
|
450
|
+
const gt = texture ? assets.texture(texture) : void 0;
|
|
451
|
+
if (!gt) continue;
|
|
452
|
+
const sampler = c.sampler;
|
|
453
|
+
pass.setBindGroup(
|
|
454
|
+
2,
|
|
455
|
+
group(ctx, `text/page/${idOf(grp.page)}`, `${idOf(gt.texture)}`, c.layouts.page, () => [
|
|
456
|
+
{ binding: 0, resource: gt.linear },
|
|
457
|
+
{ binding: 1, resource: sampler }
|
|
458
|
+
])
|
|
459
|
+
);
|
|
460
|
+
pass.draw(6, grp.count, 0, grp.first);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
function writeView(cam) {
|
|
464
|
+
viewScratch.set(cam.viewProj, 0);
|
|
465
|
+
const v = cam.view;
|
|
466
|
+
viewScratch[16] = v[0];
|
|
467
|
+
viewScratch[17] = v[4];
|
|
468
|
+
viewScratch[18] = v[8];
|
|
469
|
+
viewScratch[19] = 0;
|
|
470
|
+
viewScratch[20] = v[1];
|
|
471
|
+
viewScratch[21] = v[5];
|
|
472
|
+
viewScratch[22] = v[9];
|
|
473
|
+
viewScratch[23] = 0;
|
|
474
|
+
viewScratch[24] = cam.displayWidth;
|
|
475
|
+
viewScratch[25] = cam.displayHeight;
|
|
476
|
+
viewScratch[26] = 1 / cam.displayWidth;
|
|
477
|
+
viewScratch[27] = 1 / cam.displayHeight;
|
|
478
|
+
}
|
|
479
|
+
function hasSpace(world, space) {
|
|
480
|
+
const store = world.tryResource(TextRenderer);
|
|
481
|
+
if (!store) return false;
|
|
482
|
+
for (let g = 0; g < store.groupCount; g++) {
|
|
483
|
+
const grp = store.groups[g];
|
|
484
|
+
if (grp.space === space && grp.count > 0) return true;
|
|
485
|
+
}
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
function textNode(world) {
|
|
489
|
+
return {
|
|
490
|
+
kind: "render",
|
|
491
|
+
phase: RenderPhase.Transparent + 10,
|
|
492
|
+
enabled: (view) => cameraOf(view) !== void 0 && hasSpace(world, SPACE_WORLD),
|
|
493
|
+
writes: ["scene-color", "scene-depth", "hdr"],
|
|
494
|
+
color: (view) => sceneColor(view),
|
|
495
|
+
depth: { resource: "scene-depth", readOnly: true },
|
|
496
|
+
run: (ctx) => drawText(ctx, SPACE_WORLD)
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
function screenTextNode(world) {
|
|
500
|
+
return {
|
|
501
|
+
kind: "render",
|
|
502
|
+
phase: RenderPhase.Overlay + 10,
|
|
503
|
+
enabled: (view) => cameraOf(view) !== void 0 && hasSpace(world, SPACE_SCREEN),
|
|
504
|
+
writes: ["view-target"],
|
|
505
|
+
color: [{ resource: "view-target" }],
|
|
506
|
+
run: (ctx) => drawText(ctx, SPACE_SCREEN)
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
function describeText(world) {
|
|
510
|
+
const store = world.tryResource(TextRenderer);
|
|
511
|
+
if (!store) return void 0;
|
|
512
|
+
const fonts = /* @__PURE__ */ new Set();
|
|
513
|
+
for (const s of store.states.values()) if (s.font) fonts.add(s.font);
|
|
514
|
+
let pages = 0;
|
|
515
|
+
let runtimePages = 0;
|
|
516
|
+
let runtimeGlyphs = 0;
|
|
517
|
+
const missing = [];
|
|
518
|
+
for (const f of fonts) {
|
|
519
|
+
pages += f.pages.length;
|
|
520
|
+
runtimePages += f.pages.filter((p) => p.runtime).length;
|
|
521
|
+
runtimeGlyphs += f.runtimeGlyphs;
|
|
522
|
+
for (const cp of f.missing) missing.push(String.fromCodePoint(cp));
|
|
523
|
+
}
|
|
524
|
+
return {
|
|
525
|
+
texts: store.states.size,
|
|
526
|
+
glyphs: store.glyphCount,
|
|
527
|
+
drawCalls: store.groupCount,
|
|
528
|
+
atlasPages: pages,
|
|
529
|
+
runtimePages,
|
|
530
|
+
runtimeGlyphs,
|
|
531
|
+
missing: missing.slice(0, 50),
|
|
532
|
+
relayouts: store.relayouts,
|
|
533
|
+
uploadedBytes: store.uploadedBytes
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
export {
|
|
537
|
+
TextCachesResource,
|
|
538
|
+
TextRenderer,
|
|
539
|
+
TextStore,
|
|
540
|
+
describeText,
|
|
541
|
+
prepareTexts,
|
|
542
|
+
screenTextNode,
|
|
543
|
+
textNode
|
|
544
|
+
};
|
package/dist/shaders.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
const TEXT_SHADERS = {
|
|
2
|
+
"shard::text": `
|
|
3
|
+
import shard::color::linear_to_srgb;
|
|
4
|
+
|
|
5
|
+
struct TextView {
|
|
6
|
+
view_proj: mat4x4f,
|
|
7
|
+
/** The camera's right and up in world space (billboards). */
|
|
8
|
+
right: vec4f,
|
|
9
|
+
up: vec4f,
|
|
10
|
+
/** width, height, 1 / width, 1 / height. */
|
|
11
|
+
viewport: vec4f,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
struct Glyph {
|
|
15
|
+
/** x, y (bottom left), width, height, in em. */
|
|
16
|
+
rect: vec4f,
|
|
17
|
+
/** u0, v0 (top left), u1, v1. */
|
|
18
|
+
uv: vec4f,
|
|
19
|
+
text: u32,
|
|
20
|
+
/** Atlas pixels per em \xF7 distance range: em \u2192 distance units. */
|
|
21
|
+
em_to_sd: f32,
|
|
22
|
+
/** Distance range in atlas pixels. */
|
|
23
|
+
range: f32,
|
|
24
|
+
_pad: f32,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
struct TextRecord {
|
|
28
|
+
row0: vec4f,
|
|
29
|
+
row1: vec4f,
|
|
30
|
+
row2: vec4f,
|
|
31
|
+
color: vec4f,
|
|
32
|
+
outline_color: vec4f,
|
|
33
|
+
shadow_color: vec4f,
|
|
34
|
+
/** size, outline width (em), shadow softness, weight. */
|
|
35
|
+
style: vec4f,
|
|
36
|
+
shadow_offset: vec2f,
|
|
37
|
+
billboard: u32,
|
|
38
|
+
screen: u32,
|
|
39
|
+
/** Screen text: corner factor (xy), offset in pixels (zw). */
|
|
40
|
+
placement: vec4f,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@group(0) @binding(0) var<uniform> text_view: TextView;
|
|
44
|
+
@group(1) @binding(0) var<storage, read> glyphs: array<Glyph>;
|
|
45
|
+
@group(1) @binding(1) var<storage, read> texts: array<TextRecord>;
|
|
46
|
+
@group(2) @binding(0) var atlas: texture_2d<f32>;
|
|
47
|
+
@group(2) @binding(1) var atlas_sampler: sampler;
|
|
48
|
+
|
|
49
|
+
struct TextOutput {
|
|
50
|
+
@builtin(position) clip: vec4f,
|
|
51
|
+
@location(0) uv: vec2f,
|
|
52
|
+
/** The glyph's uv rect: samples clamp to it, so neighbors in the atlas never show. */
|
|
53
|
+
@location(1) @interpolate(flat) bounds: vec4f,
|
|
54
|
+
/** uv per em (x, y), for the shadow offset. */
|
|
55
|
+
@location(2) @interpolate(flat) uv_per_em: vec2f,
|
|
56
|
+
@location(3) @interpolate(flat) glyph: u32,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
fn corner(k: u32) -> vec2f {
|
|
60
|
+
let c = array<vec2f, 6>(vec2f(0.0, 0.0), vec2f(0.0, 1.0), vec2f(1.0, 0.0), vec2f(1.0, 0.0), vec2f(0.0, 1.0), vec2f(1.0, 1.0));
|
|
61
|
+
return c[k];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@vertex fn vs(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> TextOutput {
|
|
65
|
+
let g = glyphs[i];
|
|
66
|
+
let t = texts[g.text];
|
|
67
|
+
let c = corner(v);
|
|
68
|
+
// Grow the quad so a shadow offset fits; uvs extrapolate and samples clamp to the glyph.
|
|
69
|
+
let pad = abs(t.shadow_offset) + vec2f(t.style.z * 0.1 + t.style.y);
|
|
70
|
+
let x = g.rect.x - pad.x + c.x * (g.rect.z + 2.0 * pad.x);
|
|
71
|
+
let y = g.rect.y + g.rect.w + pad.y - c.y * (g.rect.w + 2.0 * pad.y);
|
|
72
|
+
let fx = (x - g.rect.x) / max(g.rect.z, 1e-6);
|
|
73
|
+
let fy = (g.rect.y + g.rect.w - y) / max(g.rect.w, 1e-6);
|
|
74
|
+
var out: TextOutput;
|
|
75
|
+
out.uv = vec2f(mix(g.uv.x, g.uv.z, fx), mix(g.uv.y, g.uv.w, fy));
|
|
76
|
+
out.bounds = vec4f(min(g.uv.xy, g.uv.zw), max(g.uv.xy, g.uv.zw));
|
|
77
|
+
out.uv_per_em = vec2f((g.uv.z - g.uv.x) / max(g.rect.z, 1e-6), (g.uv.w - g.uv.y) / max(g.rect.w, 1e-6));
|
|
78
|
+
out.glyph = i;
|
|
79
|
+
let p = vec2f(x, y) * t.style.x;
|
|
80
|
+
@if(SCREEN) {
|
|
81
|
+
let origin = t.placement.xy * text_view.viewport.xy + t.placement.zw;
|
|
82
|
+
let px = origin + vec2f(p.x, -p.y);
|
|
83
|
+
out.clip = vec4f(px.x * text_view.viewport.z * 2.0 - 1.0, 1.0 - px.y * text_view.viewport.w * 2.0, 0.5, 1.0);
|
|
84
|
+
}
|
|
85
|
+
@if(!SCREEN) {
|
|
86
|
+
var world: vec3f;
|
|
87
|
+
if (t.billboard != 0u) {
|
|
88
|
+
let origin = vec3f(t.row0.w, t.row1.w, t.row2.w);
|
|
89
|
+
let scale = length(vec3f(t.row0.x, t.row1.x, t.row2.x));
|
|
90
|
+
world = origin + (text_view.right.xyz * p.x + text_view.up.xyz * p.y) * scale;
|
|
91
|
+
} else {
|
|
92
|
+
let l = vec4f(p, 0.0, 1.0);
|
|
93
|
+
world = vec3f(dot(t.row0, l), dot(t.row1, l), dot(t.row2, l));
|
|
94
|
+
}
|
|
95
|
+
out.clip = text_view.view_proj * vec4f(world, 1.0);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
fn median(c: vec3f) -> f32 {
|
|
101
|
+
return max(min(c.r, c.g), min(max(c.r, c.g), c.b));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The distance at a uv; outside the glyph's rect (the padded quad), fully outside. */
|
|
105
|
+
fn distance_at(uv: vec2f, bounds: vec4f) -> f32 {
|
|
106
|
+
let d = median(textureSampleLevel(atlas, atlas_sampler, clamp(uv, bounds.xy, bounds.zw), 0.0).rgb);
|
|
107
|
+
let inside = all(uv >= bounds.xy) && all(uv <= bounds.zw);
|
|
108
|
+
return select(0.0, d, inside);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Straight color \xD7 coverage, premultiplied. */
|
|
112
|
+
fn layer(c: vec4f, coverage: f32) -> vec4f {
|
|
113
|
+
let a = c.a * coverage;
|
|
114
|
+
return vec4f(c.rgb * a, a);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@fragment fn fs(in: TextOutput) -> @location(0) vec4f {
|
|
118
|
+
let g = glyphs[in.glyph];
|
|
119
|
+
let t = texts[g.text];
|
|
120
|
+
// Distance range in screen pixels: how many screen pixels one unit of distance spans.
|
|
121
|
+
let unit_range = vec2f(g.range) / vec2f(textureDimensions(atlas));
|
|
122
|
+
let screen_px = max(0.5 * dot(unit_range, 1.0 / fwidth(in.uv)), 1.0);
|
|
123
|
+
let sd = distance_at(in.uv, in.bounds);
|
|
124
|
+
let bias = t.style.w * g.em_to_sd;
|
|
125
|
+
let fill = clamp(screen_px * (sd - 0.5 + bias) + 0.5, 0.0, 1.0);
|
|
126
|
+
var outline = 0.0;
|
|
127
|
+
if (t.style.y > 0.0) {
|
|
128
|
+
// The field reaches half the range past the edge, and at small sizes one pixel spans much
|
|
129
|
+
// of it: the outline grows only as far as keeps zero distance at zero coverage.
|
|
130
|
+
let grow = min(t.style.y * g.em_to_sd, max(0.5 - 0.5 / screen_px - bias, 0.0));
|
|
131
|
+
outline = clamp(screen_px * (sd - 0.5 + bias + grow) + 0.5, 0.0, 1.0);
|
|
132
|
+
}
|
|
133
|
+
// Shadow (or glow, with no offset): the field shifted, and softened by widening its ramp.
|
|
134
|
+
let shift = vec2f(t.shadow_offset.x, -t.shadow_offset.y) * in.uv_per_em;
|
|
135
|
+
let sd_shadow = distance_at(in.uv - shift, in.bounds);
|
|
136
|
+
let ramp = max(t.style.z * 0.5, 0.5 / screen_px);
|
|
137
|
+
var shadow = 0.0;
|
|
138
|
+
if (t.shadow_color.a > 0.0 && (t.style.z > 0.0 || any(t.shadow_offset != vec2f(0.0)))) {
|
|
139
|
+
shadow = clamp((sd_shadow - 0.5 + bias) / (2.0 * ramp) + 0.5, 0.0, 1.0);
|
|
140
|
+
}
|
|
141
|
+
var c = layer(t.shadow_color, shadow);
|
|
142
|
+
let o = layer(t.outline_color, outline);
|
|
143
|
+
c = o + c * (1.0 - o.a);
|
|
144
|
+
let f = layer(t.color, fill);
|
|
145
|
+
c = f + c * (1.0 - f.a);
|
|
146
|
+
if (c.a <= 0.0) { discard; }
|
|
147
|
+
@if(SCREEN) {
|
|
148
|
+
@if(!SRGB_TARGET) {
|
|
149
|
+
let rgb = c.rgb / c.a;
|
|
150
|
+
c = vec4f(linear_to_srgb(rgb) * c.a, c.a);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return c;
|
|
154
|
+
}`
|
|
155
|
+
};
|
|
156
|
+
export {
|
|
157
|
+
TEXT_SHADERS
|
|
158
|
+
};
|