@multiplekex/shallot 0.1.7 → 0.1.9
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/package.json +2 -2
- package/src/core/component.ts +7 -17
- package/src/core/index.ts +2 -1
- package/src/core/math.ts +30 -5
- package/src/core/state.ts +2 -2
- package/src/core/types.ts +2 -2
- package/src/core/xml.ts +83 -33
- package/src/extras/arrows/index.ts +73 -95
- package/src/extras/lines/index.ts +97 -56
- package/src/extras/orbit/index.ts +1 -1
- package/src/extras/text/index.ts +245 -82
- package/src/standard/compute/index.ts +26 -2
- package/src/standard/compute/inspect.ts +15 -1
- package/src/standard/render/camera.ts +8 -1
- package/src/standard/render/forward.ts +54 -3
- package/src/standard/render/index.ts +52 -3
- package/src/standard/render/material/index.ts +92 -0
- package/src/standard/render/mesh/index.ts +66 -10
- package/src/standard/render/opaque.ts +44 -0
- package/src/standard/render/postprocess.ts +10 -2
- package/src/standard/render/scene.ts +12 -1
- package/src/standard/render/transparent.ts +94 -0
- package/src/standard/tween/sequence.ts +2 -2
- package/src/standard/tween/tween.ts +31 -13
package/src/extras/text/index.ts
CHANGED
|
@@ -1,17 +1,58 @@
|
|
|
1
1
|
import TinySDF from "@mapbox/tiny-sdf";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
MAX_ENTITIES,
|
|
4
|
+
resource,
|
|
5
|
+
registerPostLoadHook,
|
|
6
|
+
type Plugin,
|
|
7
|
+
type State,
|
|
8
|
+
type System,
|
|
9
|
+
type PostLoadContext,
|
|
10
|
+
} from "../../core";
|
|
3
11
|
import { setTraits, type FieldAccessor } from "../../core/component";
|
|
12
|
+
import { Compute, ComputePlugin } from "../../standard/compute";
|
|
4
13
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
14
|
+
Render,
|
|
15
|
+
RenderPlugin,
|
|
16
|
+
DEPTH_FORMAT,
|
|
17
|
+
registerDrawContributor,
|
|
18
|
+
type DrawContributor,
|
|
19
|
+
type DrawContext,
|
|
20
|
+
} from "../../standard/render";
|
|
11
21
|
import { Transform } from "../../standard/transforms";
|
|
12
22
|
|
|
13
23
|
const MAX_GLYPHS = 50000;
|
|
14
24
|
const GLYPH_FLOATS = 16;
|
|
25
|
+
const SDF_EXPONENT = 9;
|
|
26
|
+
const SDF_CUTOFF = 0.5;
|
|
27
|
+
|
|
28
|
+
let customFontFamily: string | null = null;
|
|
29
|
+
let customFontWeight: string = "normal";
|
|
30
|
+
|
|
31
|
+
function encodeExponentialSdf(linearData: Uint8Array): Uint8Array {
|
|
32
|
+
const encoded = new Uint8Array(linearData.length);
|
|
33
|
+
for (let i = 0; i < linearData.length; i++) {
|
|
34
|
+
// TinySDF outputs: 0=outside, 255=inside, cutoff*255=edge
|
|
35
|
+
// Convert to signed distance: positive=outside, negative=inside
|
|
36
|
+
const raw = linearData[i] / 255;
|
|
37
|
+
const signedDist = (SDF_CUTOFF - raw) / SDF_CUTOFF;
|
|
38
|
+
|
|
39
|
+
// Apply exponential encoding (Troika formula)
|
|
40
|
+
const absDist = Math.min(1, Math.abs(signedDist));
|
|
41
|
+
let alpha = Math.pow(1 - absDist, SDF_EXPONENT) / 2;
|
|
42
|
+
if (signedDist < 0) {
|
|
43
|
+
alpha = 1 - alpha;
|
|
44
|
+
}
|
|
45
|
+
encoded[i] = Math.round(Math.max(0, Math.min(255, alpha * 255)));
|
|
46
|
+
}
|
|
47
|
+
return encoded;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function setTextFont(fontFamily: string, weight: number = 400): Promise<void> {
|
|
51
|
+
const fontSpec = `${weight} 128px "${fontFamily}"`;
|
|
52
|
+
await document.fonts.load(fontSpec);
|
|
53
|
+
customFontFamily = fontFamily;
|
|
54
|
+
customFontWeight = weight >= 600 ? "bold" : "normal";
|
|
55
|
+
}
|
|
15
56
|
|
|
16
57
|
export const TextData = {
|
|
17
58
|
data: new Float32Array(MAX_ENTITIES * 12),
|
|
@@ -83,22 +124,102 @@ function colorProxy(): TextProxy {
|
|
|
83
124
|
});
|
|
84
125
|
}
|
|
85
126
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
127
|
+
function colorChannelProxy(channelIndex: number): TextProxy {
|
|
128
|
+
const data = TextData.data;
|
|
129
|
+
|
|
130
|
+
function getValue(eid: number): number {
|
|
131
|
+
return data[eid * 12 + 8 + channelIndex];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function setValue(eid: number, value: number): void {
|
|
135
|
+
data[eid * 12 + 8 + channelIndex] = value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return new Proxy([] as unknown as TextProxy, {
|
|
139
|
+
get(_, prop) {
|
|
140
|
+
if (prop === "get") return getValue;
|
|
141
|
+
if (prop === "set") return setValue;
|
|
142
|
+
const eid = Number(prop);
|
|
143
|
+
if (Number.isNaN(eid)) return undefined;
|
|
144
|
+
return getValue(eid);
|
|
145
|
+
},
|
|
146
|
+
set(_, prop, value) {
|
|
147
|
+
const eid = Number(prop);
|
|
148
|
+
if (Number.isNaN(eid)) return false;
|
|
149
|
+
setValue(eid, value);
|
|
150
|
+
return true;
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const textContent = new Map<number, string>();
|
|
156
|
+
|
|
157
|
+
interface TextContentProxy {
|
|
158
|
+
[eid: number]: string | undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function contentProxy(): TextContentProxy {
|
|
162
|
+
return new Proxy({} as TextContentProxy, {
|
|
163
|
+
get(_, prop) {
|
|
164
|
+
const eid = Number(prop);
|
|
165
|
+
if (Number.isNaN(eid)) return undefined;
|
|
166
|
+
return textContent.get(eid);
|
|
167
|
+
},
|
|
168
|
+
set(_, prop, value) {
|
|
169
|
+
const eid = Number(prop);
|
|
170
|
+
if (Number.isNaN(eid)) return false;
|
|
171
|
+
if (value === undefined || value === null) {
|
|
172
|
+
textContent.delete(eid);
|
|
173
|
+
} else {
|
|
174
|
+
textContent.set(eid, value);
|
|
175
|
+
}
|
|
176
|
+
return true;
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export const Text = {
|
|
182
|
+
content: contentProxy(),
|
|
94
183
|
fontSize: textProxy(0),
|
|
95
184
|
opacity: textProxy(1),
|
|
96
185
|
visible: textProxy(2),
|
|
97
186
|
anchorX: textProxy(3),
|
|
98
187
|
anchorY: textProxy(4),
|
|
99
188
|
color: colorProxy(),
|
|
189
|
+
colorR: colorChannelProxy(0),
|
|
190
|
+
colorG: colorChannelProxy(1),
|
|
191
|
+
colorB: colorChannelProxy(2),
|
|
100
192
|
};
|
|
101
193
|
|
|
194
|
+
interface PendingText {
|
|
195
|
+
readonly eid: number;
|
|
196
|
+
readonly content: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let pendingTextContent: PendingText[] = [];
|
|
200
|
+
|
|
201
|
+
function parseTextAttrs(attrs: Record<string, string>): Record<string, string> {
|
|
202
|
+
if (attrs._value) {
|
|
203
|
+
const parsed: Record<string, string> = {};
|
|
204
|
+
for (const part of attrs._value.split(";")) {
|
|
205
|
+
const colonIdx = part.indexOf(":");
|
|
206
|
+
if (colonIdx === -1) continue;
|
|
207
|
+
const key = part.slice(0, colonIdx).trim();
|
|
208
|
+
const value = part.slice(colonIdx + 1).trim();
|
|
209
|
+
if (key && value) parsed[key] = value;
|
|
210
|
+
}
|
|
211
|
+
return parsed;
|
|
212
|
+
}
|
|
213
|
+
return attrs;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function finalizePendingText(_state: State, _context: PostLoadContext): void {
|
|
217
|
+
for (const pending of pendingTextContent) {
|
|
218
|
+
Text.content[pending.eid] = pending.content;
|
|
219
|
+
}
|
|
220
|
+
pendingTextContent = [];
|
|
221
|
+
}
|
|
222
|
+
|
|
102
223
|
setTraits(Text, {
|
|
103
224
|
defaults: () => ({
|
|
104
225
|
fontSize: 1,
|
|
@@ -108,6 +229,35 @@ setTraits(Text, {
|
|
|
108
229
|
anchorY: 0,
|
|
109
230
|
color: 0xffffff,
|
|
110
231
|
}),
|
|
232
|
+
adapter: (attrs: Record<string, string>, eid: number) => {
|
|
233
|
+
const parsed = parseTextAttrs(attrs);
|
|
234
|
+
const result: Record<string, number> = {};
|
|
235
|
+
|
|
236
|
+
if (parsed.content) {
|
|
237
|
+
pendingTextContent.push({ eid, content: parsed.content });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (parsed["font-size"]) result.fontSize = parseFloat(parsed["font-size"]);
|
|
241
|
+
if (parsed.fontSize) result.fontSize = parseFloat(parsed.fontSize);
|
|
242
|
+
if (parsed.opacity) result.opacity = parseFloat(parsed.opacity);
|
|
243
|
+
if (parsed.visible) result.visible = parseFloat(parsed.visible);
|
|
244
|
+
if (parsed["anchor-x"]) result.anchorX = parseFloat(parsed["anchor-x"]);
|
|
245
|
+
if (parsed.anchorX) result.anchorX = parseFloat(parsed.anchorX);
|
|
246
|
+
if (parsed["anchor-y"]) result.anchorY = parseFloat(parsed["anchor-y"]);
|
|
247
|
+
if (parsed.anchorY) result.anchorY = parseFloat(parsed.anchorY);
|
|
248
|
+
if (parsed.color) {
|
|
249
|
+
const colorStr = parsed.color;
|
|
250
|
+
if (colorStr.startsWith("0x") || colorStr.startsWith("0X")) {
|
|
251
|
+
result.color = parseInt(colorStr, 16);
|
|
252
|
+
} else if (colorStr.startsWith("#")) {
|
|
253
|
+
result.color = parseInt(colorStr.slice(1), 16);
|
|
254
|
+
} else {
|
|
255
|
+
result.color = parseInt(colorStr, 10);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return result;
|
|
260
|
+
},
|
|
111
261
|
accessors: {
|
|
112
262
|
fontSize: Text.fontSize,
|
|
113
263
|
opacity: Text.opacity,
|
|
@@ -115,6 +265,9 @@ setTraits(Text, {
|
|
|
115
265
|
anchorX: Text.anchorX,
|
|
116
266
|
anchorY: Text.anchorY,
|
|
117
267
|
color: Text.color,
|
|
268
|
+
colorR: Text.colorR,
|
|
269
|
+
colorG: Text.colorG,
|
|
270
|
+
colorB: Text.colorB,
|
|
118
271
|
},
|
|
119
272
|
});
|
|
120
273
|
|
|
@@ -146,9 +299,9 @@ interface GlyphAtlas {
|
|
|
146
299
|
}
|
|
147
300
|
|
|
148
301
|
function createGlyphAtlas(device: GPUDevice): GlyphAtlas {
|
|
149
|
-
const width =
|
|
150
|
-
const height =
|
|
151
|
-
const fontSize =
|
|
302
|
+
const width = 2048;
|
|
303
|
+
const height = 2048;
|
|
304
|
+
const fontSize = 128;
|
|
152
305
|
|
|
153
306
|
const texture = device.createTexture({
|
|
154
307
|
size: { width, height },
|
|
@@ -159,11 +312,11 @@ function createGlyphAtlas(device: GPUDevice): GlyphAtlas {
|
|
|
159
312
|
|
|
160
313
|
const sdf = new TinySDF({
|
|
161
314
|
fontSize,
|
|
162
|
-
fontFamily: "
|
|
163
|
-
fontWeight:
|
|
315
|
+
fontFamily: customFontFamily ?? "system-ui, sans-serif",
|
|
316
|
+
fontWeight: customFontWeight,
|
|
164
317
|
fontStyle: "normal",
|
|
165
|
-
buffer:
|
|
166
|
-
radius:
|
|
318
|
+
buffer: 16,
|
|
319
|
+
radius: 48,
|
|
167
320
|
cutoff: 0.5,
|
|
168
321
|
});
|
|
169
322
|
|
|
@@ -197,15 +350,14 @@ function ensureGlyph(device: GPUDevice, atlas: GlyphAtlas, char: string): GlyphM
|
|
|
197
350
|
throw new Error("Glyph atlas full");
|
|
198
351
|
}
|
|
199
352
|
|
|
200
|
-
const glyphData = new Uint8Array(glyph.data
|
|
201
|
-
glyphData.set(glyph.data);
|
|
353
|
+
const glyphData = encodeExponentialSdf(new Uint8Array(glyph.data));
|
|
202
354
|
|
|
203
355
|
device.queue.writeTexture(
|
|
204
356
|
{
|
|
205
357
|
texture: atlas.texture,
|
|
206
358
|
origin: { x: atlas.cursorX, y: atlas.cursorY },
|
|
207
359
|
},
|
|
208
|
-
glyphData
|
|
360
|
+
glyphData as Uint8Array<ArrayBuffer>,
|
|
209
361
|
{ bytesPerRow: glyph.width },
|
|
210
362
|
{ width: glyph.width, height: glyph.height }
|
|
211
363
|
);
|
|
@@ -242,6 +394,8 @@ interface LayoutGlyph {
|
|
|
242
394
|
y: number;
|
|
243
395
|
width: number;
|
|
244
396
|
height: number;
|
|
397
|
+
texelWidth: number;
|
|
398
|
+
texelHeight: number;
|
|
245
399
|
u0: number;
|
|
246
400
|
v0: number;
|
|
247
401
|
u1: number;
|
|
@@ -277,6 +431,8 @@ function layoutText(text: string, atlas: GlyphAtlas, fontSize: number): LayoutRe
|
|
|
277
431
|
y,
|
|
278
432
|
width: glyphW,
|
|
279
433
|
height: glyphH,
|
|
434
|
+
texelWidth: metrics.width,
|
|
435
|
+
texelHeight: metrics.height,
|
|
280
436
|
u0: metrics.u0,
|
|
281
437
|
v0: metrics.v0,
|
|
282
438
|
u1: metrics.u1,
|
|
@@ -298,6 +454,12 @@ const textShader = /* wgsl */ `
|
|
|
298
454
|
struct Scene {
|
|
299
455
|
viewProj: mat4x4<f32>,
|
|
300
456
|
cameraWorld: mat4x4<f32>,
|
|
457
|
+
ambientColor: vec4<f32>,
|
|
458
|
+
sunDirection: vec4<f32>,
|
|
459
|
+
sunColor: vec4<f32>,
|
|
460
|
+
cameraMode: f32,
|
|
461
|
+
cameraSize: f32,
|
|
462
|
+
viewport: vec2<f32>,
|
|
301
463
|
}
|
|
302
464
|
|
|
303
465
|
struct GlyphInstance {
|
|
@@ -307,7 +469,8 @@ struct GlyphInstance {
|
|
|
307
469
|
entityId: u32,
|
|
308
470
|
width: f32,
|
|
309
471
|
height: f32,
|
|
310
|
-
|
|
472
|
+
texelWidth: f32,
|
|
473
|
+
texelHeight: f32,
|
|
311
474
|
u0: f32,
|
|
312
475
|
v0: f32,
|
|
313
476
|
u1: f32,
|
|
@@ -325,10 +488,10 @@ struct VertexOutput {
|
|
|
325
488
|
@builtin(position) position: vec4<f32>,
|
|
326
489
|
@location(0) uv: vec2<f32>,
|
|
327
490
|
@location(1) color: vec4<f32>,
|
|
491
|
+
@location(2) localUV: vec2<f32>,
|
|
492
|
+
@location(3) texelSize: vec2<f32>,
|
|
328
493
|
}
|
|
329
494
|
|
|
330
|
-
const SDF_SMOOTHING: f32 = 0.1;
|
|
331
|
-
|
|
332
495
|
@vertex
|
|
333
496
|
fn vs(@builtin(vertex_index) vid: u32) -> VertexOutput {
|
|
334
497
|
let glyphIdx = vid / 6u;
|
|
@@ -383,23 +546,52 @@ fn vs(@builtin(vertex_index) vid: u32) -> VertexOutput {
|
|
|
383
546
|
out.position = scene.viewProj * worldPos;
|
|
384
547
|
out.uv = uv;
|
|
385
548
|
out.color = glyph.color;
|
|
549
|
+
out.localUV = localPos;
|
|
550
|
+
out.texelSize = vec2(glyph.texelWidth, glyph.texelHeight);
|
|
386
551
|
return out;
|
|
387
552
|
}
|
|
388
553
|
|
|
554
|
+
struct FragmentOutput {
|
|
555
|
+
@location(0) color: vec4<f32>,
|
|
556
|
+
@location(1) mask: f32,
|
|
557
|
+
}
|
|
558
|
+
|
|
389
559
|
@fragment
|
|
390
|
-
fn fs(input: VertexOutput) ->
|
|
560
|
+
fn fs(input: VertexOutput) -> FragmentOutput {
|
|
391
561
|
let sdfValue = textureSample(atlasTexture, atlasSampler, input.uv).r;
|
|
392
|
-
|
|
562
|
+
|
|
563
|
+
// Decode exponential SDF (inverse of Troika-style encoding)
|
|
564
|
+
let sdfExponent = 9.0;
|
|
565
|
+
let isOutside = sdfValue < 0.5;
|
|
566
|
+
let processedAlpha = select(1.0 - sdfValue, sdfValue, isOutside);
|
|
567
|
+
let normalizedDist = 1.0 - pow(2.0 * processedAlpha, 1.0 / sdfExponent);
|
|
568
|
+
let signedDist = select(-normalizedDist, normalizedDist, isOutside);
|
|
569
|
+
|
|
570
|
+
// Troika-style AA using screen-space derivatives
|
|
571
|
+
let texelFootprint = fwidth(input.localUV * input.texelSize);
|
|
572
|
+
let aaRadius = length(texelFootprint) * 0.5;
|
|
573
|
+
let smoothing = clamp(aaRadius * 0.5, 0.01, 0.25);
|
|
574
|
+
|
|
575
|
+
// Edge at signedDist = 0, positive offset = thicker text
|
|
576
|
+
let edgeOffset = 0.02;
|
|
577
|
+
let alpha = smoothstep(edgeOffset + smoothing, edgeOffset - smoothing, signedDist);
|
|
393
578
|
|
|
394
579
|
if alpha < 0.01 {
|
|
395
580
|
discard;
|
|
396
581
|
}
|
|
397
582
|
|
|
398
|
-
|
|
583
|
+
var out: FragmentOutput;
|
|
584
|
+
out.color = vec4(input.color.rgb, input.color.a * alpha);
|
|
585
|
+
out.mask = select(0.0, 1.0, alpha > 0.01);
|
|
586
|
+
return out;
|
|
399
587
|
}
|
|
400
588
|
`;
|
|
401
589
|
|
|
402
|
-
function createTextPipeline(
|
|
590
|
+
function createTextPipeline(
|
|
591
|
+
device: GPUDevice,
|
|
592
|
+
format: GPUTextureFormat,
|
|
593
|
+
maskFormat: GPUTextureFormat
|
|
594
|
+
): GPURenderPipeline {
|
|
403
595
|
const module = device.createShaderModule({ code: textShader });
|
|
404
596
|
|
|
405
597
|
return device.createRenderPipeline({
|
|
@@ -427,6 +619,10 @@ function createTextPipeline(device: GPUDevice, format: GPUTextureFormat): GPURen
|
|
|
427
619
|
},
|
|
428
620
|
},
|
|
429
621
|
},
|
|
622
|
+
{
|
|
623
|
+
format: maskFormat,
|
|
624
|
+
writeMask: GPUColorWrite.RED,
|
|
625
|
+
},
|
|
430
626
|
],
|
|
431
627
|
},
|
|
432
628
|
primitive: {
|
|
@@ -450,29 +646,24 @@ export interface TextConfig {
|
|
|
450
646
|
getCount: () => number;
|
|
451
647
|
}
|
|
452
648
|
|
|
453
|
-
|
|
649
|
+
function createTextContributor(config: TextConfig): DrawContributor {
|
|
454
650
|
let pipeline: GPURenderPipeline | null = null;
|
|
455
651
|
let bindGroup: GPUBindGroup | null = null;
|
|
456
652
|
|
|
457
653
|
return {
|
|
458
654
|
id: "text",
|
|
459
|
-
|
|
460
|
-
inputs: [],
|
|
461
|
-
outputs: [],
|
|
655
|
+
order: 2,
|
|
462
656
|
|
|
463
|
-
|
|
657
|
+
draw(pass: GPURenderPassEncoder, ctx: DrawContext) {
|
|
464
658
|
const count = config.getCount();
|
|
465
659
|
if (count === 0) return;
|
|
466
660
|
|
|
467
|
-
const { device, encoder, format } = ctx;
|
|
468
|
-
const targetView = ctx.getTextureView("scene") ?? ctx.canvasView;
|
|
469
|
-
|
|
470
661
|
if (!pipeline) {
|
|
471
|
-
pipeline = createTextPipeline(device, format);
|
|
662
|
+
pipeline = createTextPipeline(ctx.device, ctx.format, ctx.maskFormat);
|
|
472
663
|
}
|
|
473
664
|
|
|
474
665
|
if (!bindGroup) {
|
|
475
|
-
bindGroup = device.createBindGroup({
|
|
666
|
+
bindGroup = ctx.device.createBindGroup({
|
|
476
667
|
layout: pipeline.getBindGroupLayout(0),
|
|
477
668
|
entries: [
|
|
478
669
|
{ binding: 0, resource: { buffer: config.scene } },
|
|
@@ -484,33 +675,14 @@ export function createTextNode(config: TextConfig): ComputeNode {
|
|
|
484
675
|
});
|
|
485
676
|
}
|
|
486
677
|
|
|
487
|
-
const depthView = ctx.getTextureView("depth")!;
|
|
488
|
-
|
|
489
|
-
const pass = encoder.beginRenderPass({
|
|
490
|
-
colorAttachments: [
|
|
491
|
-
{
|
|
492
|
-
view: targetView,
|
|
493
|
-
loadOp: "load" as const,
|
|
494
|
-
storeOp: "store" as const,
|
|
495
|
-
},
|
|
496
|
-
],
|
|
497
|
-
depthStencilAttachment: {
|
|
498
|
-
view: depthView,
|
|
499
|
-
depthLoadOp: "load" as const,
|
|
500
|
-
depthStoreOp: "store" as const,
|
|
501
|
-
},
|
|
502
|
-
});
|
|
503
|
-
|
|
504
678
|
pass.setPipeline(pipeline);
|
|
505
679
|
pass.setBindGroup(0, bindGroup);
|
|
506
680
|
pass.draw(count * 6);
|
|
507
|
-
pass.end();
|
|
508
681
|
},
|
|
509
682
|
};
|
|
510
683
|
}
|
|
511
684
|
|
|
512
685
|
export interface TextState {
|
|
513
|
-
content: Map<number, string>;
|
|
514
686
|
atlas: GlyphAtlas;
|
|
515
687
|
sampler: GPUSampler;
|
|
516
688
|
buffer: GPUBuffer;
|
|
@@ -520,17 +692,6 @@ export interface TextState {
|
|
|
520
692
|
|
|
521
693
|
export const TextResource = resource<TextState>("text");
|
|
522
694
|
|
|
523
|
-
export function setTextContent(state: State, eid: number, content: string): void {
|
|
524
|
-
const text = TextResource.from(state);
|
|
525
|
-
if (!text) return;
|
|
526
|
-
text.content.set(eid, content);
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
export function getTextContent(state: State, eid: number): string | undefined {
|
|
530
|
-
const text = TextResource.from(state);
|
|
531
|
-
return text?.content.get(eid);
|
|
532
|
-
}
|
|
533
|
-
|
|
534
695
|
const TextSystem: System = {
|
|
535
696
|
group: "draw",
|
|
536
697
|
|
|
@@ -540,7 +701,7 @@ const TextSystem: System = {
|
|
|
540
701
|
if (!compute || !text) return;
|
|
541
702
|
|
|
542
703
|
const { device } = compute;
|
|
543
|
-
const { atlas, staging
|
|
704
|
+
const { atlas, staging } = text;
|
|
544
705
|
const stagingU32 = new Uint32Array(staging.buffer);
|
|
545
706
|
|
|
546
707
|
let glyphCount = 0;
|
|
@@ -548,13 +709,13 @@ const TextSystem: System = {
|
|
|
548
709
|
for (const eid of state.query([Text, Transform])) {
|
|
549
710
|
if (!Text.visible[eid]) continue;
|
|
550
711
|
|
|
551
|
-
const
|
|
552
|
-
if (!
|
|
712
|
+
const content = textContent.get(eid);
|
|
713
|
+
if (!content) continue;
|
|
553
714
|
|
|
554
|
-
ensureString(device, atlas,
|
|
715
|
+
ensureString(device, atlas, content);
|
|
555
716
|
|
|
556
717
|
const fontSize = Text.fontSize[eid];
|
|
557
|
-
const layout = layoutText(
|
|
718
|
+
const layout = layoutText(content, atlas, fontSize);
|
|
558
719
|
|
|
559
720
|
const anchorX = Text.anchorX[eid];
|
|
560
721
|
const anchorY = Text.anchorY[eid];
|
|
@@ -579,8 +740,8 @@ const TextSystem: System = {
|
|
|
579
740
|
|
|
580
741
|
staging[offset + 4] = glyph.width;
|
|
581
742
|
staging[offset + 5] = glyph.height;
|
|
582
|
-
staging[offset + 6] =
|
|
583
|
-
staging[offset + 7] =
|
|
743
|
+
staging[offset + 6] = glyph.texelWidth;
|
|
744
|
+
staging[offset + 7] = glyph.texelHeight;
|
|
584
745
|
|
|
585
746
|
staging[offset + 8] = glyph.u0;
|
|
586
747
|
staging[offset + 9] = glyph.v0;
|
|
@@ -616,6 +777,8 @@ export const TextPlugin: Plugin = {
|
|
|
616
777
|
dependencies: [ComputePlugin, RenderPlugin],
|
|
617
778
|
|
|
618
779
|
initialize(state: State) {
|
|
780
|
+
registerPostLoadHook(finalizePendingText);
|
|
781
|
+
|
|
619
782
|
const compute = Compute.from(state);
|
|
620
783
|
const render = Render.from(state);
|
|
621
784
|
if (!compute || !render) return;
|
|
@@ -629,7 +792,6 @@ export const TextPlugin: Plugin = {
|
|
|
629
792
|
});
|
|
630
793
|
|
|
631
794
|
const textState: TextState = {
|
|
632
|
-
content: new Map(),
|
|
633
795
|
atlas,
|
|
634
796
|
sampler,
|
|
635
797
|
buffer: device.createBuffer({
|
|
@@ -643,8 +805,9 @@ export const TextPlugin: Plugin = {
|
|
|
643
805
|
|
|
644
806
|
state.setResource(TextResource, textState);
|
|
645
807
|
|
|
646
|
-
|
|
647
|
-
|
|
808
|
+
registerDrawContributor(
|
|
809
|
+
state,
|
|
810
|
+
createTextContributor({
|
|
648
811
|
scene: render.scene,
|
|
649
812
|
glyphs: textState.buffer,
|
|
650
813
|
atlas: atlas.textureView,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resource, type Plugin, type State, type System } from "../../core";
|
|
2
2
|
import { ComputeGraph, type ExecutionContext, type ResourceId } from "./graph";
|
|
3
|
+
import { createTimingCollector, type FrameTiming } from "./timing";
|
|
3
4
|
|
|
4
5
|
export * from "./graph";
|
|
5
6
|
export * from "./inspect";
|
|
@@ -42,7 +43,13 @@ export async function requestGPU(): Promise<GPUDevice> {
|
|
|
42
43
|
throw new Error("No GPU adapter found");
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
const maxTextureDimension2D = adapter.limits.maxTextureDimension2D;
|
|
47
|
+
|
|
48
|
+
return adapter.requestDevice({
|
|
49
|
+
requiredLimits: {
|
|
50
|
+
maxTextureDimension2D,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
export interface ComputeResources {
|
|
@@ -57,6 +64,8 @@ export interface ComputeState {
|
|
|
57
64
|
readonly format: GPUTextureFormat;
|
|
58
65
|
readonly graph: ComputeGraph;
|
|
59
66
|
readonly resources: ComputeResources;
|
|
67
|
+
lastFrameTiming: FrameTiming | null;
|
|
68
|
+
frameIndex: number;
|
|
60
69
|
}
|
|
61
70
|
|
|
62
71
|
export const Compute = resource<ComputeState>("compute");
|
|
@@ -116,11 +125,18 @@ export const ComputeSystem: System = {
|
|
|
116
125
|
},
|
|
117
126
|
};
|
|
118
127
|
|
|
128
|
+
const collector = createTimingCollector();
|
|
129
|
+
|
|
119
130
|
for (const node of plan.sorted) {
|
|
131
|
+
collector.beginNode(node.id);
|
|
120
132
|
node.execute(ctx);
|
|
133
|
+
collector.endNode(node.id);
|
|
121
134
|
}
|
|
122
135
|
|
|
123
136
|
device.queue.submit([encoder.finish()]);
|
|
137
|
+
|
|
138
|
+
compute.lastFrameTiming = collector.finish(compute.frameIndex);
|
|
139
|
+
compute.frameIndex++;
|
|
124
140
|
},
|
|
125
141
|
|
|
126
142
|
dispose() {
|
|
@@ -159,6 +175,14 @@ export const ComputePlugin: Plugin = {
|
|
|
159
175
|
buffers: new Map(),
|
|
160
176
|
};
|
|
161
177
|
|
|
162
|
-
state.setResource(Compute, {
|
|
178
|
+
state.setResource(Compute, {
|
|
179
|
+
device,
|
|
180
|
+
context,
|
|
181
|
+
format,
|
|
182
|
+
graph,
|
|
183
|
+
resources,
|
|
184
|
+
lastFrameTiming: null,
|
|
185
|
+
frameIndex: 0,
|
|
186
|
+
});
|
|
163
187
|
},
|
|
164
188
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ComputeNode, NodeId, Phase, ResourceId, ResourceRef } from "./graph";
|
|
2
|
+
import type { FrameTiming } from "./timing";
|
|
2
3
|
|
|
3
4
|
export interface NodeInfo {
|
|
4
5
|
readonly id: NodeId;
|
|
@@ -147,12 +148,13 @@ function topoSortIds(nodes: readonly ComputeNode[]): NodeId[] {
|
|
|
147
148
|
return sorted;
|
|
148
149
|
}
|
|
149
150
|
|
|
150
|
-
export function formatGraph(info: GraphInspection): string {
|
|
151
|
+
export function formatGraph(info: GraphInspection, timing?: FrameTiming): string {
|
|
151
152
|
const lines: string[] = [];
|
|
152
153
|
lines.push("=== Compute Graph ===");
|
|
153
154
|
lines.push("");
|
|
154
155
|
|
|
155
156
|
const nodeMap = new Map(info.nodes.map((n) => [n.id, n]));
|
|
157
|
+
const timingMap = new Map(timing?.nodes.map((t) => [t.nodeId, t]));
|
|
156
158
|
|
|
157
159
|
for (const phase of PHASE_ORDER) {
|
|
158
160
|
const nodeIds = info.byPhase.get(phase) ?? [];
|
|
@@ -173,6 +175,18 @@ export function formatGraph(info: GraphInspection): string {
|
|
|
173
175
|
lines.push(` ${i + 1}. ${id}`);
|
|
174
176
|
});
|
|
175
177
|
|
|
178
|
+
if (timing) {
|
|
179
|
+
lines.push("");
|
|
180
|
+
lines.push("Timing:");
|
|
181
|
+
for (const nodeId of info.executionOrder) {
|
|
182
|
+
const nodeTiming = timingMap.get(nodeId);
|
|
183
|
+
if (nodeTiming) {
|
|
184
|
+
lines.push(` ${nodeId}: ${nodeTiming.cpuMs.toFixed(2)}ms`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
lines.push(` Total: ${timing.totalCpuMs.toFixed(2)}ms`);
|
|
188
|
+
}
|
|
189
|
+
|
|
176
190
|
return lines.join("\n");
|
|
177
191
|
}
|
|
178
192
|
|
|
@@ -79,13 +79,15 @@ export function uploadCamera(
|
|
|
79
79
|
device: GPUDevice,
|
|
80
80
|
buffer: GPUBuffer,
|
|
81
81
|
eid: number,
|
|
82
|
-
|
|
82
|
+
width: number,
|
|
83
|
+
height: number
|
|
83
84
|
): void {
|
|
84
85
|
const color = unpackColor(Camera.clearColor[eid]);
|
|
85
86
|
clearColor.r = color.r;
|
|
86
87
|
clearColor.g = color.g;
|
|
87
88
|
clearColor.b = color.b;
|
|
88
89
|
|
|
90
|
+
const aspect = width / height;
|
|
89
91
|
const proj =
|
|
90
92
|
Camera.mode[eid] === CameraMode.Orthographic
|
|
91
93
|
? orthographic(Camera.size[eid], aspect, Camera.near[eid], Camera.far[eid])
|
|
@@ -96,4 +98,9 @@ export function uploadCamera(
|
|
|
96
98
|
|
|
97
99
|
device.queue.writeBuffer(buffer, 0, viewProj as Float32Array<ArrayBuffer>);
|
|
98
100
|
device.queue.writeBuffer(buffer, 64, world as Float32Array<ArrayBuffer>);
|
|
101
|
+
device.queue.writeBuffer(
|
|
102
|
+
buffer,
|
|
103
|
+
176,
|
|
104
|
+
new Float32Array([Camera.mode[eid], Camera.size[eid], width, height])
|
|
105
|
+
);
|
|
99
106
|
}
|