@multiplekex/shallot 0.1.12 → 0.2.0

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.
Files changed (62) hide show
  1. package/package.json +3 -4
  2. package/src/core/builder.ts +71 -32
  3. package/src/core/component.ts +25 -11
  4. package/src/core/index.ts +14 -13
  5. package/src/core/math.ts +135 -0
  6. package/src/core/runtime.ts +0 -1
  7. package/src/core/state.ts +9 -68
  8. package/src/core/xml.ts +381 -265
  9. package/src/editor/format.ts +5 -0
  10. package/src/editor/index.ts +101 -0
  11. package/src/extras/arrows/index.ts +28 -69
  12. package/src/extras/gradient/index.ts +36 -52
  13. package/src/extras/lines/index.ts +51 -122
  14. package/src/extras/orbit/index.ts +40 -15
  15. package/src/extras/text/font.ts +546 -0
  16. package/src/extras/text/index.ts +158 -204
  17. package/src/extras/text/sdf.ts +429 -0
  18. package/src/standard/activity/index.ts +172 -0
  19. package/src/standard/compute/graph.ts +23 -23
  20. package/src/standard/compute/index.ts +76 -61
  21. package/src/standard/defaults.ts +8 -5
  22. package/src/standard/index.ts +1 -0
  23. package/src/standard/input/index.ts +30 -19
  24. package/src/standard/loading/index.ts +18 -13
  25. package/src/standard/render/bvh/blas.ts +752 -0
  26. package/src/standard/render/bvh/radix.ts +476 -0
  27. package/src/standard/render/bvh/structs.ts +167 -0
  28. package/src/standard/render/bvh/tlas.ts +886 -0
  29. package/src/standard/render/bvh/traverse.ts +467 -0
  30. package/src/standard/render/camera.ts +302 -27
  31. package/src/standard/render/data.ts +93 -0
  32. package/src/standard/render/depth.ts +117 -0
  33. package/src/standard/render/forward/index.ts +259 -0
  34. package/src/standard/render/forward/raster.ts +228 -0
  35. package/src/standard/render/index.ts +443 -70
  36. package/src/standard/render/indirect.ts +40 -0
  37. package/src/standard/render/instance.ts +214 -0
  38. package/src/standard/render/intersection.ts +72 -0
  39. package/src/standard/render/light.ts +16 -16
  40. package/src/standard/render/mesh/index.ts +67 -75
  41. package/src/standard/render/mesh/unified.ts +96 -0
  42. package/src/standard/render/{transparent.ts → overlay.ts} +14 -15
  43. package/src/standard/render/pass.ts +10 -4
  44. package/src/standard/render/postprocess.ts +142 -64
  45. package/src/standard/render/ray.ts +61 -0
  46. package/src/standard/render/scene.ts +38 -164
  47. package/src/standard/render/shaders.ts +484 -0
  48. package/src/standard/render/surface/compile.ts +3 -10
  49. package/src/standard/render/surface/index.ts +60 -30
  50. package/src/standard/render/surface/noise.ts +45 -0
  51. package/src/standard/render/surface/structs.ts +60 -19
  52. package/src/standard/render/surface/wgsl.ts +573 -0
  53. package/src/standard/render/triangle.ts +84 -0
  54. package/src/standard/transforms/index.ts +4 -6
  55. package/src/standard/tween/index.ts +10 -1
  56. package/src/standard/tween/sequence.ts +24 -16
  57. package/src/standard/tween/tween.ts +67 -16
  58. package/src/core/types.ts +0 -37
  59. package/src/standard/compute/inspect.ts +0 -201
  60. package/src/standard/compute/pass.ts +0 -23
  61. package/src/standard/compute/timing.ts +0 -139
  62. package/src/standard/render/forward.ts +0 -273
@@ -1,142 +1,88 @@
1
- import TinySDF from "@mapbox/tiny-sdf";
1
+ import { type Font, loadFont } from "./font";
2
+ import { SDFGenerator } from "./sdf";
2
3
  import {
3
4
  MAX_ENTITIES,
4
5
  resource,
5
6
  registerPostLoadHook,
7
+ createFieldProxy,
6
8
  type Plugin,
7
9
  type State,
8
10
  type System,
9
11
  type PostLoadContext,
12
+ type FieldProxy,
10
13
  } from "../../core";
11
- import { setTraits, type FieldAccessor } from "../../core/component";
14
+ import { setTraits } from "../../core/component";
12
15
  import { Compute, ComputePlugin } from "../../standard/compute";
13
16
  import {
14
17
  Render,
15
18
  RenderPlugin,
16
- DEPTH_FORMAT,
17
19
  Pass,
18
20
  registerDraw,
19
21
  type Draw,
20
22
  type SharedPassContext,
21
23
  } from "../../standard/render";
24
+ import { DEPTH_FORMAT } from "../../standard/render/scene";
25
+ import { SCENE_STRUCT_WGSL } from "../../standard/render/shaders";
22
26
  import { Transform } from "../../standard/transforms";
23
27
 
24
28
  const MAX_GLYPHS = 50000;
25
29
  const GLYPH_FLOATS = 16;
30
+ const SDF_SIZE = 64;
26
31
  const SDF_EXPONENT = 9;
27
- const SDF_CUTOFF = 0.5;
28
-
29
- let customFontFamily: string | null = null;
30
- let customFontWeight: string = "normal";
31
-
32
- function encodeExponentialSdf(linearData: Uint8Array): Uint8Array {
33
- const encoded = new Uint8Array(linearData.length);
34
- for (let i = 0; i < linearData.length; i++) {
35
- // TinySDF outputs: 0=outside, 255=inside, cutoff*255=edge
36
- // Convert to signed distance: positive=outside, negative=inside
37
- const raw = linearData[i] / 255;
38
- const signedDist = (SDF_CUTOFF - raw) / SDF_CUTOFF;
39
-
40
- // Apply exponential encoding (Troika formula)
41
- const absDist = Math.min(1, Math.abs(signedDist));
42
- let alpha = Math.pow(1 - absDist, SDF_EXPONENT) / 2;
43
- if (signedDist < 0) {
44
- alpha = 1 - alpha;
45
- }
46
- encoded[i] = Math.round(Math.max(0, Math.min(255, alpha * 255)));
47
- }
48
- return encoded;
32
+
33
+ const fontUrls: string[] = [];
34
+ const loadedFonts: (Font | null)[] = [];
35
+
36
+ export function font(url: string): number {
37
+ const id = fontUrls.length;
38
+ fontUrls.push(url);
39
+ loadedFonts.push(null);
40
+ return id;
41
+ }
42
+
43
+ export function getFont(id: number): Font | null {
44
+ return loadedFonts[id] ?? null;
49
45
  }
50
46
 
51
- export async function setTextFont(fontFamily: string, weight: number = 400): Promise<void> {
52
- const fontSpec = `${weight} 128px "${fontFamily}"`;
53
- await document.fonts.load(fontSpec);
54
- customFontFamily = fontFamily;
55
- customFontWeight = weight >= 600 ? "bold" : "normal";
47
+ export function clearFonts(): void {
48
+ fontUrls.length = 0;
49
+ loadedFonts.length = 0;
50
+ }
51
+
52
+ async function loadAllFonts(): Promise<void> {
53
+ await Promise.all(
54
+ fontUrls.map(async (url, id) => {
55
+ loadedFonts[id] = await loadFont(url);
56
+ })
57
+ );
56
58
  }
57
59
 
58
60
  export const TextData = {
59
61
  data: new Float32Array(MAX_ENTITIES * 12),
62
+ fonts: new Uint32Array(MAX_ENTITIES),
60
63
  };
61
64
 
62
- interface TextProxy extends Array<number>, FieldAccessor {}
63
-
64
- function textProxy(offset: number): TextProxy {
65
- const data = TextData.data;
66
-
67
- function getValue(eid: number): number {
68
- return data[eid * 12 + offset];
69
- }
70
-
71
- function setValue(eid: number, value: number): void {
72
- data[eid * 12 + offset] = value;
73
- }
74
-
75
- return new Proxy([] as unknown as TextProxy, {
76
- get(_, prop) {
77
- if (prop === "get") return getValue;
78
- if (prop === "set") return setValue;
79
- const eid = Number(prop);
80
- if (Number.isNaN(eid)) return undefined;
81
- return getValue(eid);
82
- },
83
- set(_, prop, value) {
84
- const eid = Number(prop);
85
- if (Number.isNaN(eid)) return false;
86
- setValue(eid, value);
87
- return true;
88
- },
89
- });
90
- }
91
-
92
- function colorProxy(): TextProxy {
93
- const data = TextData.data;
65
+ const data = TextData.data;
66
+ const fonts = TextData.fonts;
94
67
 
68
+ function packedColorProxy(data: Float32Array, stride: number, offset: number): FieldProxy {
95
69
  function getValue(eid: number): number {
96
- const offset = eid * 12 + 8;
97
- const r = Math.round(data[offset] * 255);
98
- const g = Math.round(data[offset + 1] * 255);
99
- const b = Math.round(data[offset + 2] * 255);
70
+ const o = eid * stride + offset;
71
+ const r = Math.round(data[o] * 255);
72
+ const g = Math.round(data[o + 1] * 255);
73
+ const b = Math.round(data[o + 2] * 255);
100
74
  return (r << 16) | (g << 8) | b;
101
75
  }
102
76
 
103
77
  function setValue(eid: number, value: number): void {
104
- const offset = eid * 12 + 8;
105
- data[offset] = ((value >> 16) & 0xff) / 255;
106
- data[offset + 1] = ((value >> 8) & 0xff) / 255;
107
- data[offset + 2] = (value & 0xff) / 255;
108
- data[offset + 3] = 1;
109
- }
110
-
111
- return new Proxy([] as unknown as TextProxy, {
112
- get(_, prop) {
113
- if (prop === "get") return getValue;
114
- if (prop === "set") return setValue;
115
- const eid = Number(prop);
116
- if (Number.isNaN(eid)) return undefined;
117
- return getValue(eid);
118
- },
119
- set(_, prop, value) {
120
- const eid = Number(prop);
121
- if (Number.isNaN(eid)) return false;
122
- setValue(eid, value);
123
- return true;
124
- },
125
- });
126
- }
127
-
128
- function colorChannelProxy(channelIndex: number): TextProxy {
129
- const data = TextData.data;
130
-
131
- function getValue(eid: number): number {
132
- return data[eid * 12 + 8 + channelIndex];
133
- }
134
-
135
- function setValue(eid: number, value: number): void {
136
- data[eid * 12 + 8 + channelIndex] = value;
78
+ const o = eid * stride + offset;
79
+ data[o] = ((value >> 16) & 0xff) / 255;
80
+ data[o + 1] = ((value >> 8) & 0xff) / 255;
81
+ data[o + 2] = (value & 0xff) / 255;
82
+ data[o + 3] = 1;
137
83
  }
138
84
 
139
- return new Proxy([] as unknown as TextProxy, {
85
+ return new Proxy([] as unknown as FieldProxy, {
140
86
  get(_, prop) {
141
87
  if (prop === "get") return getValue;
142
88
  if (prop === "set") return setValue;
@@ -181,15 +127,16 @@ function contentProxy(): TextContentProxy {
181
127
 
182
128
  export const Text = {
183
129
  content: contentProxy(),
184
- fontSize: textProxy(0),
185
- opacity: textProxy(1),
186
- visible: textProxy(2),
187
- anchorX: textProxy(3),
188
- anchorY: textProxy(4),
189
- color: colorProxy(),
190
- colorR: colorChannelProxy(0),
191
- colorG: colorChannelProxy(1),
192
- colorB: colorChannelProxy(2),
130
+ font: fonts,
131
+ fontSize: createFieldProxy(data, 12, 0),
132
+ opacity: createFieldProxy(data, 12, 1),
133
+ visible: createFieldProxy(data, 12, 2),
134
+ anchorX: createFieldProxy(data, 12, 3),
135
+ anchorY: createFieldProxy(data, 12, 4),
136
+ color: packedColorProxy(data, 12, 8),
137
+ colorR: createFieldProxy(data, 12, 8),
138
+ colorG: createFieldProxy(data, 12, 9),
139
+ colorB: createFieldProxy(data, 12, 10),
193
140
  };
194
141
 
195
142
  interface PendingText {
@@ -223,6 +170,7 @@ function finalizePendingText(_state: State, _context: PostLoadContext): void {
223
170
 
224
171
  setTraits(Text, {
225
172
  defaults: () => ({
173
+ font: 0,
226
174
  fontSize: 1,
227
175
  opacity: 1,
228
176
  visible: 1,
@@ -238,6 +186,7 @@ setTraits(Text, {
238
186
  pendingTextContent.push({ eid, content: parsed.content });
239
187
  }
240
188
 
189
+ if (parsed.font) result.font = parseInt(parsed.font, 10);
241
190
  if (parsed["font-size"]) result.fontSize = parseFloat(parsed["font-size"]);
242
191
  if (parsed.fontSize) result.fontSize = parseFloat(parsed.fontSize);
243
192
  if (parsed.opacity) result.opacity = parseFloat(parsed.opacity);
@@ -259,17 +208,6 @@ setTraits(Text, {
259
208
 
260
209
  return result;
261
210
  },
262
- accessors: {
263
- fontSize: Text.fontSize,
264
- opacity: Text.opacity,
265
- visible: Text.visible,
266
- anchorX: Text.anchorX,
267
- anchorY: Text.anchorY,
268
- color: Text.color,
269
- colorR: Text.colorR,
270
- colorG: Text.colorG,
271
- colorB: Text.colorB,
272
- },
273
211
  });
274
212
 
275
213
  interface GlyphMetrics {
@@ -295,30 +233,25 @@ interface GlyphAtlas {
295
233
  rowHeight: number;
296
234
  cursorX: number;
297
235
  cursorY: number;
298
- sdf: TinySDF;
299
- sdfFontSize: number;
236
+ font: Font;
237
+ sdfGenerator: SDFGenerator;
300
238
  }
301
239
 
302
- function createGlyphAtlas(device: GPUDevice): GlyphAtlas {
240
+ function createGlyphAtlas(device: GPUDevice, font: Font): GlyphAtlas {
303
241
  const width = 2048;
304
242
  const height = 2048;
305
- const fontSize = 128;
306
243
 
307
244
  const texture = device.createTexture({
308
245
  size: { width, height },
309
246
  format: "r8unorm",
310
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
247
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
311
248
  label: "glyphAtlas",
312
249
  });
313
250
 
314
- const sdf = new TinySDF({
315
- fontSize,
316
- fontFamily: customFontFamily ?? "system-ui, sans-serif",
317
- fontWeight: customFontWeight,
318
- fontStyle: "normal",
319
- buffer: 16,
320
- radius: 48,
321
- cutoff: 0.5,
251
+ const sdfGenerator = new SDFGenerator({
252
+ device,
253
+ sdfSize: SDF_SIZE,
254
+ exponent: SDF_EXPONENT,
322
255
  });
323
256
 
324
257
  return {
@@ -330,63 +263,71 @@ function createGlyphAtlas(device: GPUDevice): GlyphAtlas {
330
263
  rowHeight: 0,
331
264
  cursorX: 0,
332
265
  cursorY: 0,
333
- sdf,
334
- sdfFontSize: fontSize,
266
+ font,
267
+ sdfGenerator,
335
268
  };
336
269
  }
337
270
 
338
- function ensureGlyph(device: GPUDevice, atlas: GlyphAtlas, char: string): GlyphMetrics {
271
+ function ensureGlyph(atlas: GlyphAtlas, char: string): GlyphMetrics | null {
339
272
  const existing = atlas.glyphs.get(char);
340
273
  if (existing) return existing;
341
274
 
342
- const glyph = atlas.sdf.draw(char);
275
+ const path = atlas.font.glyphPath(char);
276
+ const bounds = atlas.font.glyphBounds(char);
277
+ const advance = atlas.font.advance(char);
278
+
279
+ if (!path || !bounds) return null;
280
+
281
+ const [xMin, yMin, xMax, yMax] = bounds;
282
+ const unitsPerEm = atlas.font.unitsPerEm;
283
+
284
+ const padding = unitsPerEm * 0.1;
285
+ const paddedBounds: [number, number, number, number] = [
286
+ xMin - padding,
287
+ yMin - padding,
288
+ xMax + padding,
289
+ yMax + padding,
290
+ ];
291
+
292
+ const glyphWidth = paddedBounds[2] - paddedBounds[0];
293
+ const glyphHeight = paddedBounds[3] - paddedBounds[1];
343
294
 
344
- if (atlas.cursorX + glyph.width > atlas.width) {
295
+ if (atlas.cursorX + SDF_SIZE > atlas.width) {
345
296
  atlas.cursorX = 0;
346
297
  atlas.cursorY += atlas.rowHeight;
347
298
  atlas.rowHeight = 0;
348
299
  }
349
300
 
350
- if (atlas.cursorY + glyph.height > atlas.height) {
301
+ if (atlas.cursorY + SDF_SIZE > atlas.height) {
351
302
  throw new Error("Glyph atlas full");
352
303
  }
353
304
 
354
- const glyphData = encodeExponentialSdf(new Uint8Array(glyph.data));
355
-
356
- device.queue.writeTexture(
357
- {
358
- texture: atlas.texture,
359
- origin: { x: atlas.cursorX, y: atlas.cursorY },
360
- },
361
- glyphData as Uint8Array<ArrayBuffer>,
362
- { bytesPerRow: glyph.width },
363
- { width: glyph.width, height: glyph.height }
364
- );
305
+ atlas.sdfGenerator.generate(path, paddedBounds, atlas.texture, atlas.cursorX, atlas.cursorY);
365
306
 
366
307
  const metrics: GlyphMetrics = {
367
- width: glyph.width,
368
- height: glyph.height,
369
- glyphWidth: glyph.glyphWidth,
370
- glyphHeight: glyph.glyphHeight,
371
- glyphTop: glyph.glyphTop,
372
- glyphLeft: glyph.glyphLeft,
373
- advance: glyph.glyphAdvance,
308
+ width: SDF_SIZE,
309
+ height: SDF_SIZE,
310
+ glyphWidth: glyphWidth / unitsPerEm,
311
+ glyphHeight: glyphHeight / unitsPerEm,
312
+ glyphTop: paddedBounds[3] / unitsPerEm,
313
+ glyphLeft: paddedBounds[0] / unitsPerEm,
314
+ advance: advance / unitsPerEm,
374
315
  u0: atlas.cursorX / atlas.width,
375
316
  v0: atlas.cursorY / atlas.height,
376
- u1: (atlas.cursorX + glyph.width) / atlas.width,
377
- v1: (atlas.cursorY + glyph.height) / atlas.height,
317
+ u1: (atlas.cursorX + SDF_SIZE) / atlas.width,
318
+ v1: (atlas.cursorY + SDF_SIZE) / atlas.height,
378
319
  };
379
320
 
380
321
  atlas.glyphs.set(char, metrics);
381
- atlas.cursorX += glyph.width;
382
- atlas.rowHeight = Math.max(atlas.rowHeight, glyph.height);
322
+ atlas.cursorX += SDF_SIZE;
323
+ atlas.rowHeight = Math.max(atlas.rowHeight, SDF_SIZE);
383
324
 
384
325
  return metrics;
385
326
  }
386
327
 
387
- function ensureString(device: GPUDevice, atlas: GlyphAtlas, text: string): void {
328
+ function ensureString(atlas: GlyphAtlas, text: string): void {
388
329
  for (const char of text) {
389
- ensureGlyph(device, atlas, char);
330
+ ensureGlyph(atlas, char);
390
331
  }
391
332
  }
392
333
 
@@ -411,15 +352,20 @@ interface LayoutResult {
411
352
 
412
353
  function layoutText(text: string, atlas: GlyphAtlas, fontSize: number): LayoutResult {
413
354
  const glyphs: LayoutGlyph[] = [];
414
- const scale = fontSize / atlas.sdfFontSize;
355
+ const scale = fontSize;
415
356
 
416
357
  let cursorX = 0;
417
358
  let maxHeight = 0;
359
+ let prevChar: string | null = null;
418
360
 
419
361
  for (const char of text) {
420
362
  const metrics = atlas.glyphs.get(char);
421
363
  if (!metrics) continue;
422
364
 
365
+ if (prevChar) {
366
+ cursorX += atlas.font.kerning(prevChar, char) / atlas.font.unitsPerEm * scale;
367
+ }
368
+
423
369
  const glyphW = metrics.glyphWidth * scale;
424
370
  const glyphH = metrics.glyphHeight * scale;
425
371
  const advance = metrics.advance * scale;
@@ -442,6 +388,7 @@ function layoutText(text: string, atlas: GlyphAtlas, fontSize: number): LayoutRe
442
388
 
443
389
  cursorX += advance;
444
390
  maxHeight = Math.max(maxHeight, glyphH);
391
+ prevChar = char;
445
392
  }
446
393
 
447
394
  return {
@@ -452,16 +399,7 @@ function layoutText(text: string, atlas: GlyphAtlas, fontSize: number): LayoutRe
452
399
  }
453
400
 
454
401
  const textShader = /* wgsl */ `
455
- struct Scene {
456
- viewProj: mat4x4<f32>,
457
- cameraWorld: mat4x4<f32>,
458
- ambientColor: vec4<f32>,
459
- sunDirection: vec4<f32>,
460
- sunColor: vec4<f32>,
461
- cameraMode: f32,
462
- cameraSize: f32,
463
- viewport: vec2<f32>,
464
- }
402
+ ${SCENE_STRUCT_WGSL}
465
403
 
466
404
  struct GlyphInstance {
467
405
  posX: f32,
@@ -490,7 +428,7 @@ struct VertexOutput {
490
428
  @location(0) uv: vec2<f32>,
491
429
  @location(1) color: vec4<f32>,
492
430
  @location(2) localUV: vec2<f32>,
493
- @location(3) texelSize: vec2<f32>,
431
+ @location(3) glyphDimensions: vec2<f32>,
494
432
  }
495
433
 
496
434
  @vertex
@@ -506,27 +444,27 @@ fn vs(@builtin(vertex_index) vid: u32) -> VertexOutput {
506
444
  switch cornerIdx {
507
445
  case 0u: {
508
446
  localPos = vec2(0.0, 0.0);
509
- uv = vec2(glyph.u0, glyph.v1);
447
+ uv = vec2(glyph.u0, glyph.v0);
510
448
  }
511
449
  case 1u: {
512
450
  localPos = vec2(1.0, 0.0);
513
- uv = vec2(glyph.u1, glyph.v1);
451
+ uv = vec2(glyph.u1, glyph.v0);
514
452
  }
515
453
  case 2u: {
516
454
  localPos = vec2(1.0, 1.0);
517
- uv = vec2(glyph.u1, glyph.v0);
455
+ uv = vec2(glyph.u1, glyph.v1);
518
456
  }
519
457
  case 3u: {
520
458
  localPos = vec2(0.0, 0.0);
521
- uv = vec2(glyph.u0, glyph.v1);
459
+ uv = vec2(glyph.u0, glyph.v0);
522
460
  }
523
461
  case 4u: {
524
462
  localPos = vec2(1.0, 1.0);
525
- uv = vec2(glyph.u1, glyph.v0);
463
+ uv = vec2(glyph.u1, glyph.v1);
526
464
  }
527
465
  case 5u: {
528
466
  localPos = vec2(0.0, 1.0);
529
- uv = vec2(glyph.u0, glyph.v0);
467
+ uv = vec2(glyph.u0, glyph.v1);
530
468
  }
531
469
  default: {
532
470
  localPos = vec2(0.0);
@@ -548,7 +486,7 @@ fn vs(@builtin(vertex_index) vid: u32) -> VertexOutput {
548
486
  out.uv = uv;
549
487
  out.color = glyph.color;
550
488
  out.localUV = localPos;
551
- out.texelSize = vec2(glyph.texelWidth, glyph.texelHeight);
489
+ out.glyphDimensions = vec2(glyph.width, glyph.height);
552
490
  return out;
553
491
  }
554
492
 
@@ -561,21 +499,17 @@ struct FragmentOutput {
561
499
  fn fs(input: VertexOutput) -> FragmentOutput {
562
500
  let sdfValue = textureSample(atlasTexture, atlasSampler, input.uv).r;
563
501
 
564
- // Decode exponential SDF (inverse of Troika-style encoding)
565
502
  let sdfExponent = 9.0;
566
503
  let isOutside = sdfValue < 0.5;
567
504
  let processedAlpha = select(1.0 - sdfValue, sdfValue, isOutside);
568
505
  let normalizedDist = 1.0 - pow(2.0 * processedAlpha, 1.0 / sdfExponent);
569
- let signedDist = select(-normalizedDist, normalizedDist, isOutside);
570
506
 
571
- // Troika-style AA using screen-space derivatives
572
- let texelFootprint = fwidth(input.localUV * input.texelSize);
573
- let aaRadius = length(texelFootprint) * 0.5;
574
- let smoothing = clamp(aaRadius * 0.5, 0.01, 0.25);
507
+ let maxDimension = max(input.glyphDimensions.x, input.glyphDimensions.y);
508
+ let absDist = normalizedDist * maxDimension;
509
+ let signedDist = select(-absDist, absDist, isOutside);
575
510
 
576
- // Edge at signedDist = 0, positive offset = thicker text
577
- let edgeOffset = 0.02;
578
- let alpha = smoothstep(edgeOffset + smoothing, edgeOffset - smoothing, signedDist);
511
+ let aaDist = length(fwidth(input.localUV * input.glyphDimensions)) * 0.5;
512
+ let alpha = smoothstep(aaDist, -aaDist, signedDist);
579
513
 
580
514
  if alpha < 0.01 {
581
515
  discard;
@@ -653,7 +587,7 @@ function createTextDraw(config: TextConfig): Draw {
653
587
 
654
588
  return {
655
589
  id: "text",
656
- pass: Pass.Transparent,
590
+ pass: Pass.Overlay,
657
591
  order: 2,
658
592
 
659
593
  execute() {},
@@ -686,26 +620,26 @@ function createTextDraw(config: TextConfig): Draw {
686
620
  };
687
621
  }
688
622
 
689
- export interface TextState {
690
- atlas: GlyphAtlas;
623
+ export interface Glyphs {
624
+ atlases: GlyphAtlas[];
691
625
  sampler: GPUSampler;
692
626
  buffer: GPUBuffer;
693
627
  staging: Float32Array;
694
628
  count: number;
695
629
  }
696
630
 
697
- export const TextResource = resource<TextState>("text");
631
+ export const Glyphs = resource<Glyphs>("glyphs");
698
632
 
699
633
  const TextSystem: System = {
700
634
  group: "draw",
701
635
 
702
636
  update(state: State) {
703
637
  const compute = Compute.from(state);
704
- const text = TextResource.from(state);
638
+ const text = Glyphs.from(state);
705
639
  if (!compute || !text) return;
706
640
 
707
641
  const { device } = compute;
708
- const { atlas, staging } = text;
642
+ const { atlases, staging } = text;
709
643
  const stagingU32 = new Uint32Array(staging.buffer);
710
644
 
711
645
  let glyphCount = 0;
@@ -716,7 +650,11 @@ const TextSystem: System = {
716
650
  const content = textContent.get(eid);
717
651
  if (!content) continue;
718
652
 
719
- ensureString(device, atlas, content);
653
+ const fontId = Text.font[eid];
654
+ const atlas = atlases[fontId] ?? atlases[0];
655
+ if (!atlas) continue;
656
+
657
+ ensureString(atlas, content);
720
658
 
721
659
  const fontSize = Text.fontSize[eid];
722
660
  const layout = layoutText(content, atlas, fontSize);
@@ -780,23 +718,39 @@ export const TextPlugin: Plugin = {
780
718
  components: { Text },
781
719
  dependencies: [ComputePlugin, RenderPlugin],
782
720
 
783
- initialize(state: State) {
721
+ async initialize(state: State) {
784
722
  registerPostLoadHook(finalizePendingText);
785
723
 
786
724
  const compute = Compute.from(state);
787
725
  const render = Render.from(state);
788
726
  if (!compute || !render) return;
789
727
 
728
+ if (fontUrls.length === 0) {
729
+ return;
730
+ }
731
+
732
+ await loadAllFonts();
733
+
790
734
  const { device } = compute;
791
735
 
792
- const atlas = createGlyphAtlas(device);
736
+ const atlases: GlyphAtlas[] = [];
737
+ for (const loadedFont of loadedFonts) {
738
+ if (loadedFont) {
739
+ atlases.push(createGlyphAtlas(device, loadedFont));
740
+ }
741
+ }
742
+
743
+ if (atlases.length === 0) {
744
+ return;
745
+ }
746
+
793
747
  const sampler = device.createSampler({
794
748
  magFilter: "linear",
795
749
  minFilter: "linear",
796
750
  });
797
751
 
798
- const textState: TextState = {
799
- atlas,
752
+ const textState: Glyphs = {
753
+ atlases,
800
754
  sampler,
801
755
  buffer: device.createBuffer({
802
756
  label: "glyphs",
@@ -807,14 +761,14 @@ export const TextPlugin: Plugin = {
807
761
  count: 0,
808
762
  };
809
763
 
810
- state.setResource(TextResource, textState);
764
+ state.setResource(Glyphs, textState);
811
765
 
812
766
  registerDraw(
813
767
  state,
814
768
  createTextDraw({
815
769
  scene: render.scene,
816
770
  glyphs: textState.buffer,
817
- atlas: atlas.textureView,
771
+ atlas: atlases[0].textureView,
818
772
  sampler,
819
773
  matrices: render.matrices,
820
774
  getCount: () => textState.count,