@aethervtt/shard-ui 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/dist/render.js ADDED
@@ -0,0 +1,715 @@
1
+ import {
2
+ defineResource,
3
+ defineSystem,
4
+ Last
5
+ } from "@aethervtt/shard-core";
6
+ import { GpuBuffer } from "@aethervtt/shard-gpu";
7
+ import {
8
+ cameraOf,
9
+ DebugOverlays,
10
+ defineOverlay,
11
+ Gpu,
12
+ GpuAssetsResource,
13
+ Graph,
14
+ isOverlayOn,
15
+ Picking,
16
+ RenderDescribers,
17
+ RenderPhase,
18
+ RenderSet,
19
+ Shaders
20
+ } from "@aethervtt/shard-render";
21
+ import { TextureAtlases } from "@aethervtt/shard-sprite";
22
+ import { Textures } from "@aethervtt/shard-texture";
23
+ import { UiImage, UiInteraction, UiNode, UiSlider, UiTextInput, UiToggle } from "./components.js";
24
+ import { hitTest, UiPointer } from "./interaction.js";
25
+ import { describeExtras } from "./methods.js";
26
+ import { UI_SHADERS } from "./shaders.js";
27
+ import { ResolvedStyle, resolveStyle, State } from "./style.js";
28
+ import { measureTextNode, textColumns, UiState } from "./tree.js";
29
+ const QUAD_FLOATS = 28;
30
+ const KIND_BOX = 0;
31
+ const KIND_IMAGE = 1;
32
+ const KIND_IMAGE_PREMULTIPLIED = 2;
33
+ const KIND_GLYPH = 3;
34
+ class UiRenderStore {
35
+ records = new Float32Array(QUAD_FLOATS * 256);
36
+ recordU32 = new Uint32Array(this.records.buffer);
37
+ count = 0;
38
+ draws = [];
39
+ drawCount = 0;
40
+ buffer;
41
+ /** Root visual versions the records were built from. */
42
+ built = /* @__PURE__ */ new Map();
43
+ overlay = false;
44
+ /** This frame's rebuilds and bytes uploaded, and the frame total. */
45
+ rebuilds = 0;
46
+ uploadedBytes = 0;
47
+ totalUploads = 0;
48
+ constructor(gpu) {
49
+ this.buffer = new GpuBuffer(gpu, {
50
+ label: "ui/quads",
51
+ usage: GPUBufferUsage.STORAGE,
52
+ size: QUAD_FLOATS * 4 * 64
53
+ });
54
+ }
55
+ /** Whether any root changed since the records were built. */
56
+ stale(roots, overlay) {
57
+ if (overlay !== this.overlay || roots.length !== this.built.size) return true;
58
+ for (const r of roots) if (this.built.get(r.entity) !== r.visualVersion) return true;
59
+ return false;
60
+ }
61
+ markBuilt(roots, overlay) {
62
+ this.built.clear();
63
+ for (const r of roots) this.built.set(r.entity, r.visualVersion);
64
+ this.overlay = overlay;
65
+ }
66
+ push() {
67
+ const n = this.count++;
68
+ if (this.records.length < this.count * QUAD_FLOATS) {
69
+ const grown = new Float32Array(this.records.length * 2);
70
+ grown.set(this.records);
71
+ this.records = grown;
72
+ this.recordU32 = new Uint32Array(grown.buffer);
73
+ }
74
+ return n * QUAD_FLOATS;
75
+ }
76
+ }
77
+ const UiRenderer = defineResource("ui/UiRenderer", {
78
+ description: "UI quads on the GPU: records in draw order and texture runs per camera."
79
+ });
80
+ const style = new ResolvedStyle();
81
+ let opacity = new Float32Array(64);
82
+ function quad(store, camera, x, y, w, h, kind, source, srgb, clip, co) {
83
+ let d = store.drawCount > 0 ? store.draws[store.drawCount - 1] : void 0;
84
+ const needs = kind !== KIND_BOX;
85
+ if (!d || d.camera !== camera || needs && d.source !== null && (d.source !== source || d.srgb !== srgb)) {
86
+ d = store.draws[store.drawCount];
87
+ if (!d) {
88
+ d = { camera, first: 0, count: 0, source: null, page: false, srgb: false };
89
+ store.draws[store.drawCount] = d;
90
+ }
91
+ store.drawCount++;
92
+ d.camera = camera;
93
+ d.first = store.count;
94
+ d.count = 0;
95
+ d.source = null;
96
+ d.page = false;
97
+ d.srgb = false;
98
+ }
99
+ if (needs && d.source === null) {
100
+ d.source = source;
101
+ d.page = kind === KIND_GLYPH;
102
+ d.srgb = srgb;
103
+ }
104
+ d.count++;
105
+ const o = store.push();
106
+ const f = store.records;
107
+ f[o] = x;
108
+ f[o + 1] = y;
109
+ f[o + 2] = w;
110
+ f[o + 3] = h;
111
+ for (let k = 4; k < 20; k++) f[o + k] = 0;
112
+ f[o + 20] = clip[co];
113
+ f[o + 21] = clip[co + 1];
114
+ f[o + 22] = clip[co + 2];
115
+ f[o + 23] = clip[co + 3];
116
+ f[o + 24] = 0;
117
+ store.recordU32[o + 25] = kind;
118
+ f[o + 26] = 0;
119
+ f[o + 27] = 0;
120
+ return o;
121
+ }
122
+ function premultiplied(f, o, c, alpha) {
123
+ const a = c[3] * alpha;
124
+ f[o] = c[0] * a;
125
+ f[o + 1] = c[1] * a;
126
+ f[o + 2] = c[2] * a;
127
+ f[o + 3] = a;
128
+ }
129
+ const clipPx = new Float32Array(4);
130
+ function buildRoot(world, store, r, focused, outlines) {
131
+ const F = r.factor;
132
+ if (opacity.length < r.count) opacity = new Float32Array(r.count * 2);
133
+ const cam = r.camera;
134
+ for (let p = 0; p < r.count; p++) {
135
+ const i = r.paint[p];
136
+ const parent = r.parent[i];
137
+ const e = r.entities[i];
138
+ if (!r.visible[i] || !world.isAlive(e)) {
139
+ opacity[i] = 0;
140
+ continue;
141
+ }
142
+ const table = world.entityTable(e);
143
+ const row = world.entityRow(e);
144
+ let state = 0;
145
+ if (table.has(UiInteraction)) {
146
+ const s2 = table.column(UiInteraction, "state")[row];
147
+ if (s2 === 1) state |= State.Hovered;
148
+ if (s2 === 2) state |= State.Hovered | State.Pressed;
149
+ if (s2 === 3) state |= State.Disabled;
150
+ if (table.column(UiInteraction, "focused")[row]) state |= State.Focused;
151
+ }
152
+ if (table.has(UiToggle) && table.column(UiToggle, "on")[row]) state |= State.On;
153
+ const styleName = table.column(UiNode, "style")[row] ?? "";
154
+ resolveStyle(style, textColumns(table), row, r.theme, styleName, state);
155
+ const alpha = (parent >= 0 ? opacity[parent] : 1) * style.opacity;
156
+ opacity[i] = alpha;
157
+ if (alpha <= 0) continue;
158
+ const x = r.x[i] * F;
159
+ const y = r.y[i] * F;
160
+ const w = r.w[i] * F;
161
+ const h = r.h[i] * F;
162
+ const s = r.scale[i] * F;
163
+ clipPx[0] = r.clip[i * 4] * F;
164
+ clipPx[1] = r.clip[i * 4 + 1] * F;
165
+ clipPx[2] = r.clip[i * 4 + 2] * F;
166
+ clipPx[3] = r.clip[i * 4 + 3] * F;
167
+ if (clipPx[2] <= clipPx[0] || clipPx[3] <= clipPx[1]) continue;
168
+ const angle = r.angle[i];
169
+ if (style.background[3] > 0 || style.borderWidth > 0 && style.borderColor[3] > 0) {
170
+ const o = quad(store, cam, x, y, w, h, KIND_BOX, null, false, clipPx, 0);
171
+ premultiplied(store.records, o + 8, style.background, alpha);
172
+ premultiplied(store.records, o + 12, style.borderColor, alpha);
173
+ for (let k = 0; k < 4; k++) store.records[o + 16 + k] = style.radius[k] * s;
174
+ store.records[o + 24] = style.borderWidth * s;
175
+ store.records[o + 27] = angle;
176
+ }
177
+ if (table.has(UiImage)) buildImage(world, store, r, table, row, x, y, w, h, s, alpha, angle);
178
+ if (table.has(UiSlider)) {
179
+ const min = table.column(UiSlider, "min")[row];
180
+ const max = table.column(UiSlider, "max")[row];
181
+ const value = table.column(UiSlider, "value")[row];
182
+ const t = max !== min ? Math.min(1, Math.max(0, (value - min) / (max - min))) : 0;
183
+ const pad = r.tree.padding;
184
+ const px = x + pad[i * 4 + 3] * s;
185
+ const py = y + pad[i * 4] * s;
186
+ const pw = Math.max(0, w - (pad[i * 4 + 1] + pad[i * 4 + 3]) * s);
187
+ const ph = Math.max(0, h - (pad[i * 4] + pad[i * 4 + 2]) * s);
188
+ if (t > 0 && pw > 0 && ph > 0) {
189
+ const o = quad(store, cam, px, py, pw * t, ph, KIND_BOX, null, false, clipPx, 0);
190
+ const fill = table.column(UiSlider, "fill");
191
+ premultiplied(store.records, o + 8, fill.subarray(row * 4, row * 4 + 4), alpha);
192
+ const inner = Math.max(
193
+ 0,
194
+ Math.min(style.radius[0], style.radius[3]) * s - pad[i * 4 + 3] * s
195
+ );
196
+ for (let k = 0; k < 4; k++) store.records[o + 16 + k] = inner;
197
+ }
198
+ }
199
+ const text = r.texts[i];
200
+ if (text?.font && text.value !== "") {
201
+ const pad = r.tree.padding;
202
+ const contentW = Math.max(0, r.tree.w[i] - pad[i * 4 + 1] - pad[i * 4 + 3]);
203
+ if (text.laidWidth !== contentW || text.laidVersion !== text.fontVersion) {
204
+ measureTextNode(text, contentW, measured, text.layout);
205
+ text.laidWidth = contentW;
206
+ text.laidVersion = text.fontVersion;
207
+ }
208
+ const l = text.layout;
209
+ const size = text.size;
210
+ const leading = (text.lineHeight * size - (text.font.ascent - text.font.descent) * size) / 2;
211
+ const offsetX = style.align === 1 ? (contentW - l.width) / 2 : style.align === 2 ? contentW - l.width : 0;
212
+ const ox = x + (pad[i * 4 + 3] + offsetX) * s;
213
+ const oy = y + (pad[i * 4] + leading) * s;
214
+ const dim = table.has(UiTextInput) && (table.column(UiTextInput, "value")[row] ?? "") === "";
215
+ const ta = alpha * (dim ? 0.45 : 1);
216
+ for (let q = 0; q < l.count; q++) {
217
+ const page = l.pages[l.page[q]];
218
+ const qx = l.quads[q * 4];
219
+ const qy = l.quads[q * 4 + 1];
220
+ const qw = l.quads[q * 4 + 2];
221
+ const qh = l.quads[q * 4 + 3];
222
+ const o = quad(
223
+ store,
224
+ cam,
225
+ ox + qx * s,
226
+ oy - (qy + qh) * s,
227
+ qw * s,
228
+ qh * s,
229
+ KIND_GLYPH,
230
+ page,
231
+ false,
232
+ clipPx,
233
+ 0
234
+ );
235
+ for (let k = 0; k < 4; k++) store.records[o + 4 + k] = l.uvs[q * 4 + k];
236
+ premultiplied(store.records, o + 8, style.color, ta);
237
+ store.records[o + 26] = l.glyphs[q].font.range;
238
+ }
239
+ if (focused === e && table.has(UiTextInput)) {
240
+ const last = Math.max(0, l.lineCount - 1);
241
+ const lineW = dim ? 0 : l.lineWidth[last];
242
+ const lineOffset = style.align === 1 ? (l.width - lineW) / 2 : style.align === 2 ? l.width - lineW : 0;
243
+ const cx = ox + (lineOffset + lineW + 1) * s;
244
+ const cy = y + (pad[i * 4] + last * text.lineHeight * size) * s;
245
+ const o = quad(
246
+ store,
247
+ cam,
248
+ cx,
249
+ cy,
250
+ Math.max(1, 2 * s / F),
251
+ text.lineHeight * size * s,
252
+ KIND_BOX,
253
+ null,
254
+ false,
255
+ clipPx,
256
+ 0
257
+ );
258
+ premultiplied(store.records, o + 8, style.color, alpha);
259
+ }
260
+ } else if (text && focused === e && table.has(UiTextInput)) {
261
+ const pad = r.tree.padding;
262
+ const o = quad(
263
+ store,
264
+ cam,
265
+ x + pad[i * 4 + 3] * s,
266
+ y + pad[i * 4] * s,
267
+ 2,
268
+ text.lineHeight * text.size * s,
269
+ KIND_BOX,
270
+ null,
271
+ false,
272
+ clipPx,
273
+ 0
274
+ );
275
+ premultiplied(store.records, o + 8, style.color, alpha);
276
+ }
277
+ }
278
+ if (outlines) buildOutlines(store, r);
279
+ }
280
+ const measured = new Float32Array(2);
281
+ const image = {
282
+ camera: null,
283
+ kind: 0,
284
+ texture: void 0,
285
+ srgb: false,
286
+ tint: new Float32Array(4),
287
+ alpha: 1,
288
+ tw: 1,
289
+ th: 1,
290
+ angle: 0
291
+ };
292
+ const sliceX = new Float32Array(4);
293
+ const sliceY = new Float32Array(4);
294
+ const sliceU = new Float32Array(4);
295
+ const sliceV = new Float32Array(4);
296
+ function imageQuad(store, qx, qy, qw, qh, u0, v0, u1, v1, radius) {
297
+ if (qw <= 0 || qh <= 0) return;
298
+ const o = quad(
299
+ store,
300
+ image.camera,
301
+ qx,
302
+ qy,
303
+ qw,
304
+ qh,
305
+ image.kind,
306
+ image.texture,
307
+ image.srgb,
308
+ clipPx,
309
+ 0
310
+ );
311
+ const f = store.records;
312
+ f[o + 4] = u0 / image.tw;
313
+ f[o + 5] = v0 / image.th;
314
+ f[o + 6] = u1 / image.tw;
315
+ f[o + 7] = v1 / image.th;
316
+ premultiplied(f, o + 8, image.tint, image.alpha);
317
+ for (let k = 0; k < 4; k++) f[o + 16 + k] = style.radius[k] * radius;
318
+ f[o + 27] = image.angle;
319
+ }
320
+ function buildImage(world, store, r, table, row, x, y, w, h, s, alpha, angle) {
321
+ const atlasRef = table.column(UiImage, "atlas")[row];
322
+ const region = table.column(UiImage, "region")[row] ?? "";
323
+ const atlas = atlasRef ? world.tryResource(TextureAtlases)?.get(atlasRef) : void 0;
324
+ const ri = atlas && region ? atlas.region(region) : -1;
325
+ const textures = world.tryResource(Textures);
326
+ let texture;
327
+ let rx = 0;
328
+ let ry = 0;
329
+ let rw = 0;
330
+ let rh = 0;
331
+ if (atlas && ri >= 0) {
332
+ texture = textures?.get(atlas.texture);
333
+ rx = atlas.rects[ri * 4];
334
+ ry = atlas.rects[ri * 4 + 1];
335
+ rw = atlas.rects[ri * 4 + 2];
336
+ rh = atlas.rects[ri * 4 + 3];
337
+ } else {
338
+ texture = textures?.get(table.column(UiImage, "texture")[row]);
339
+ if (texture) {
340
+ rw = texture.width;
341
+ rh = texture.height;
342
+ }
343
+ }
344
+ if (!texture || rw <= 0 || rh <= 0) return;
345
+ const tint = table.column(UiImage, "tint");
346
+ image.camera = r.camera;
347
+ image.kind = texture.premultiplied ? KIND_IMAGE_PREMULTIPLIED : KIND_IMAGE;
348
+ image.texture = texture;
349
+ image.srgb = texture.usage === "color";
350
+ for (let k = 0; k < 4; k++) image.tint[k] = tint[row * 4 + k];
351
+ image.alpha = alpha;
352
+ image.tw = texture.width;
353
+ image.th = texture.height;
354
+ image.angle = angle;
355
+ const slice = table.column(UiImage, "slice");
356
+ const sT = slice[row * 4];
357
+ const sR = slice[row * 4 + 1];
358
+ const sB = slice[row * 4 + 2];
359
+ const sL = slice[row * 4 + 3];
360
+ if (sT > 0 || sR > 0 || sB > 0 || sL > 0) {
361
+ sliceX[0] = x;
362
+ sliceX[1] = x + sL * s;
363
+ sliceX[2] = x + w - sR * s;
364
+ sliceX[3] = x + w;
365
+ sliceY[0] = y;
366
+ sliceY[1] = y + sT * s;
367
+ sliceY[2] = y + h - sB * s;
368
+ sliceY[3] = y + h;
369
+ sliceU[0] = rx;
370
+ sliceU[1] = rx + sL;
371
+ sliceU[2] = rx + rw - sR;
372
+ sliceU[3] = rx + rw;
373
+ sliceV[0] = ry;
374
+ sliceV[1] = ry + sT;
375
+ sliceV[2] = ry + rh - sB;
376
+ sliceV[3] = ry + rh;
377
+ for (let b = 0; b < 3; b++) {
378
+ for (let a = 0; a < 3; a++) {
379
+ imageQuad(
380
+ store,
381
+ sliceX[a],
382
+ sliceY[b],
383
+ sliceX[a + 1] - sliceX[a],
384
+ sliceY[b + 1] - sliceY[b],
385
+ sliceU[a],
386
+ sliceV[b],
387
+ sliceU[a + 1],
388
+ sliceV[b + 1],
389
+ 0
390
+ );
391
+ }
392
+ }
393
+ return;
394
+ }
395
+ const fit = table.column(UiImage, "fit")[row];
396
+ if (fit === 0) {
397
+ imageQuad(store, x, y, w, h, rx, ry, rx + rw, ry + rh, s);
398
+ return;
399
+ }
400
+ const scale = fit === 1 ? Math.min(w / rw, h / rh) : Math.max(w / rw, h / rh);
401
+ if (fit === 1) {
402
+ const dw = rw * scale;
403
+ const dh = rh * scale;
404
+ imageQuad(store, x + (w - dw) / 2, y + (h - dh) / 2, dw, dh, rx, ry, rx + rw, ry + rh, s);
405
+ } else {
406
+ const cw = w / scale;
407
+ const ch = h / scale;
408
+ const cx = rx + (rw - cw) / 2;
409
+ const cy = ry + (rh - ch) / 2;
410
+ imageQuad(store, x, y, w, h, cx, cy, cx + cw, cy + ch, s);
411
+ }
412
+ }
413
+ const OUTLINE = [0.1, 0.9, 1, 0.9];
414
+ const PADDING = [1, 0.6, 0.1, 0.7];
415
+ const MARGIN = [1, 0.2, 0.6, 0.5];
416
+ const fullClip = new Float32Array([-1e9, -1e9, 1e9, 1e9]);
417
+ function outline(store, camera, x, y, w, h, color) {
418
+ if (w <= 0 || h <= 0) return;
419
+ const o = quad(store, camera, x, y, w, h, KIND_BOX, null, false, fullClip, 0);
420
+ premultiplied(store.records, o + 12, color, 1);
421
+ store.records[o + 24] = 1;
422
+ }
423
+ function buildOutlines(store, r) {
424
+ const F = r.factor;
425
+ const t = r.tree;
426
+ for (let i = 0; i < r.count; i++) {
427
+ if (!r.visible[i]) continue;
428
+ const s = r.scale[i] * F;
429
+ const x = r.x[i] * F;
430
+ const y = r.y[i] * F;
431
+ const w = r.w[i] * F;
432
+ const h = r.h[i] * F;
433
+ outline(store, r.camera, x, y, w, h, OUTLINE);
434
+ const pT = t.padding[i * 4] * s;
435
+ const pR = t.padding[i * 4 + 1] * s;
436
+ const pB = t.padding[i * 4 + 2] * s;
437
+ const pL = t.padding[i * 4 + 3] * s;
438
+ if (pT || pR || pB || pL)
439
+ outline(store, r.camera, x + pL, y + pT, w - pL - pR, h - pT - pB, PADDING);
440
+ const mT = t.margin[i * 4] * s;
441
+ const mR = t.margin[i * 4 + 1] * s;
442
+ const mB = t.margin[i * 4 + 2] * s;
443
+ const mL = t.margin[i * 4 + 3] * s;
444
+ if (mT || mR || mB || mL)
445
+ outline(store, r.camera, x - mL, y - mT, w + mL + mR, h + mT + mB, MARGIN);
446
+ }
447
+ }
448
+ const uiLayoutOverlay = defineOverlay({
449
+ name: "ui-layout",
450
+ description: "UI node rects: border boxes (cyan), padding (orange), and margins (pink).",
451
+ draw: () => {
452
+ }
453
+ });
454
+ const prepareUi = defineSystem({
455
+ name: "ui/prepare",
456
+ description: "Builds and uploads UI quads when layout or looks changed.",
457
+ run: (_, world) => {
458
+ const store = world.tryResource(UiRenderer);
459
+ const ui = world.tryResource(UiState);
460
+ if (!store || !ui) return;
461
+ store.rebuilds = 0;
462
+ store.uploadedBytes = 0;
463
+ const overlays = world.tryResource(DebugOverlays);
464
+ const outlines = overlays ? isOverlayOn(overlays, "ui-layout") : false;
465
+ if (!store.stale(ui.roots, outlines)) return;
466
+ store.count = 0;
467
+ store.drawCount = 0;
468
+ const focused = world.tryResource(UiPointer)?.focused ?? null;
469
+ for (const r of ui.roots) buildRoot(world, store, r, focused, outlines);
470
+ store.markBuilt(ui.roots, outlines);
471
+ store.rebuilds++;
472
+ const floats = Math.max(QUAD_FLOATS, store.count * QUAD_FLOATS);
473
+ store.buffer.write(store.records, 0, 0, floats);
474
+ store.uploadedBytes = store.count * QUAD_FLOATS * 4;
475
+ store.totalUploads++;
476
+ }
477
+ });
478
+ const UiCachesResource = defineResource("ui/DrawCaches", {
479
+ description: "GPU objects of the UI pass.",
480
+ init: () => ({ generation: -1, pipelines: /* @__PURE__ */ new Map(), groups: /* @__PURE__ */ new Map(), views: /* @__PURE__ */ new Map() })
481
+ });
482
+ const ids = /* @__PURE__ */ new WeakMap();
483
+ let nextId = 1;
484
+ function idOf(o) {
485
+ let id = ids.get(o);
486
+ if (id === void 0) {
487
+ id = nextId++;
488
+ ids.set(o, id);
489
+ }
490
+ return id;
491
+ }
492
+ function caches(ctx) {
493
+ const gpu = ctx.gpu;
494
+ const c = ctx.world.initResource(UiCachesResource);
495
+ if (c.generation !== gpu.generation) {
496
+ c.generation = gpu.generation;
497
+ c.layouts = void 0;
498
+ c.pipelines.clear();
499
+ c.groups.clear();
500
+ c.views.clear();
501
+ c.sampler = void 0;
502
+ c.white = void 0;
503
+ }
504
+ if (!c.layouts) {
505
+ const V = GPUShaderStage.VERTEX;
506
+ const F = GPUShaderStage.FRAGMENT;
507
+ c.layouts = {
508
+ view: gpu.layouts.bindGroupLayout({
509
+ label: "ui/view",
510
+ entries: [
511
+ { binding: 0, visibility: V, buffer: { type: "uniform" } },
512
+ { binding: 1, visibility: V | F, buffer: { type: "read-only-storage" } }
513
+ ]
514
+ }),
515
+ texture: gpu.layouts.bindGroupLayout({
516
+ label: "ui/texture",
517
+ entries: [
518
+ { binding: 0, visibility: F, texture: { sampleType: "float" } },
519
+ { binding: 1, visibility: F, sampler: { type: "filtering" } }
520
+ ]
521
+ })
522
+ };
523
+ }
524
+ c.sampler ??= gpu.device.createSampler({
525
+ label: "ui/linear",
526
+ magFilter: "linear",
527
+ minFilter: "linear",
528
+ mipmapFilter: "linear",
529
+ addressModeU: "clamp-to-edge",
530
+ addressModeV: "clamp-to-edge"
531
+ });
532
+ if (!c.white) {
533
+ const texture = gpu.device.createTexture({
534
+ label: "ui/white",
535
+ size: [1, 1],
536
+ format: "rgba8unorm",
537
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
538
+ });
539
+ gpu.device.queue.writeTexture(
540
+ { texture },
541
+ new Uint8Array([255, 255, 255, 255]),
542
+ { bytesPerRow: 4 },
543
+ [1, 1]
544
+ );
545
+ c.white = texture.createView();
546
+ }
547
+ return c;
548
+ }
549
+ function group(ctx, slot, key, layout, entries) {
550
+ const c = caches(ctx);
551
+ let g = c.groups.get(slot);
552
+ if (!g || g.key !== key) {
553
+ g = { key, group: ctx.gpu.device.createBindGroup({ label: slot, layout, entries: entries() }) };
554
+ c.groups.set(slot, g);
555
+ }
556
+ return g.group;
557
+ }
558
+ const BLEND = {
559
+ color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
560
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" }
561
+ };
562
+ function pipeline(ctx, format) {
563
+ const gpu = ctx.gpu;
564
+ const c = caches(ctx);
565
+ const cached = c.pipelines.get(format);
566
+ if (cached) return cached;
567
+ const module = ctx.world.resource(Shaders).module(gpu, {
568
+ root: "shard::ui",
569
+ defines: { SRGB_TARGET: format.endsWith("-srgb") }
570
+ });
571
+ if (!module) {
572
+ gpu.pipelines.skipped++;
573
+ return void 0;
574
+ }
575
+ const l = c.layouts;
576
+ const p = gpu.pipelines.render({
577
+ label: `ui/${format}`,
578
+ layout: gpu.layouts.pipelineLayout({ label: "ui", bindGroupLayouts: [l.view, l.texture] }),
579
+ vertex: { module, entryPoint: "vs" },
580
+ fragment: { module, entryPoint: "fs", targets: [{ format, blend: BLEND }] },
581
+ primitive: { topology: "triangle-list" },
582
+ multisample: { count: 1 }
583
+ });
584
+ if (p) c.pipelines.set(format, p);
585
+ return p;
586
+ }
587
+ function pageTexture(world, page) {
588
+ return page.texture ?? (page.ref ? world.resource(Textures).get(page.ref) : void 0);
589
+ }
590
+ const viewScratch = new Float32Array(4);
591
+ function drawUi(ctx) {
592
+ const cam = cameraOf(ctx.view);
593
+ const store = ctx.world.tryResource(UiRenderer);
594
+ if (!cam || !store || store.drawCount === 0) return;
595
+ const gpu = ctx.gpu;
596
+ const c = caches(ctx);
597
+ const p = pipeline(ctx, ctx.texture("view-target").format);
598
+ if (!p) return;
599
+ let uniform = c.views.get(ctx.view.name);
600
+ if (!uniform) {
601
+ uniform = new GpuBuffer(gpu, {
602
+ label: `${ctx.view.name}/ui`,
603
+ usage: GPUBufferUsage.UNIFORM,
604
+ size: 16
605
+ });
606
+ c.views.set(ctx.view.name, uniform);
607
+ }
608
+ viewScratch[0] = cam.displayWidth;
609
+ viewScratch[1] = cam.displayHeight;
610
+ viewScratch[2] = 1 / cam.displayWidth;
611
+ viewScratch[3] = 1 / cam.displayHeight;
612
+ uniform.write(viewScratch);
613
+ const u = uniform;
614
+ const pass = ctx.renderPass;
615
+ pass.setPipeline(p);
616
+ pass.setBindGroup(
617
+ 0,
618
+ group(
619
+ ctx,
620
+ `${ctx.view.name}/ui-view`,
621
+ `${idOf(u.buffer)}/${idOf(store.buffer.buffer)}`,
622
+ c.layouts.view,
623
+ () => [
624
+ { binding: 0, resource: { buffer: u.buffer } },
625
+ { binding: 1, resource: { buffer: store.buffer.buffer } }
626
+ ]
627
+ )
628
+ );
629
+ const assets = ctx.world.resource(GpuAssetsResource);
630
+ const sampler = c.sampler;
631
+ for (let d = 0; d < store.drawCount; d++) {
632
+ const draw = store.draws[d];
633
+ if (draw.count === 0 || draw.camera !== null && draw.camera !== cam.entity) continue;
634
+ let view = c.white;
635
+ let key = "white";
636
+ if (draw.source) {
637
+ const texture = draw.page ? pageTexture(ctx.world, draw.source) : draw.source;
638
+ const gt = texture ? assets.texture(texture) : void 0;
639
+ if (!gt) continue;
640
+ view = draw.srgb ? gt.srgb : gt.linear;
641
+ key = `${idOf(gt.texture)}/${draw.srgb ? "s" : "l"}`;
642
+ }
643
+ const v = view;
644
+ pass.setBindGroup(
645
+ 1,
646
+ group(ctx, `ui/tex/${d}`, key, c.layouts.texture, () => [
647
+ { binding: 0, resource: v },
648
+ { binding: 1, resource: sampler }
649
+ ])
650
+ );
651
+ pass.draw(6, draw.count, 0, draw.first);
652
+ }
653
+ }
654
+ function uiNode(world) {
655
+ return {
656
+ kind: "render",
657
+ phase: RenderPhase.Overlay + 20,
658
+ enabled: (view) => {
659
+ const cam = cameraOf(view);
660
+ const store = world.tryResource(UiRenderer);
661
+ if (!cam || !store || store.drawCount === 0) return false;
662
+ for (let d = 0; d < store.drawCount; d++) {
663
+ const draw = store.draws[d];
664
+ if (draw.count > 0 && (draw.camera === null || draw.camera === cam.entity)) return true;
665
+ }
666
+ return false;
667
+ },
668
+ writes: ["view-target"],
669
+ color: [{ resource: "view-target" }],
670
+ run: (ctx) => drawUi(ctx)
671
+ };
672
+ }
673
+ function describeUiRender(world) {
674
+ const store = world.tryResource(UiRenderer);
675
+ if (!store) return void 0;
676
+ let draws = 0;
677
+ for (let d = 0; d < store.drawCount; d++) if (store.draws[d].count > 0) draws++;
678
+ return {
679
+ quads: store.count,
680
+ drawCalls: draws,
681
+ rebuilds: store.rebuilds,
682
+ uploadedBytes: store.uploadedBytes
683
+ };
684
+ }
685
+ function installUiRenderer(app) {
686
+ const world = app.world;
687
+ const graph = world.tryResource(Graph);
688
+ const gpu = world.tryResource(Gpu);
689
+ if (!graph || !gpu) return;
690
+ app.insertResource(UiRenderer, new UiRenderStore(gpu));
691
+ const shaders = world.resource(Shaders);
692
+ for (const [path, source] of Object.entries(UI_SHADERS))
693
+ shaders.register(path, source, `engine:${path}`);
694
+ graph.addNode("ui", uiNode(world));
695
+ app.addSystems(Last, prepareUi.inSet(RenderSet.Prepare));
696
+ world.initResource(RenderDescribers).set("ui", (w) => describeUiRender(w));
697
+ describeExtras.set("render", (w) => describeUiRender(w) ?? null);
698
+ world.initResource(Picking).blockers.set("ui", (w, view, x, y) => {
699
+ const ui = w.tryResource(UiState);
700
+ if (!ui) return false;
701
+ const camera = view.startsWith("camera:") ? Number(view.slice(7)) : void 0;
702
+ return hitTest(ui, x, y, camera).node >= 0;
703
+ });
704
+ }
705
+ export {
706
+ QUAD_FLOATS,
707
+ UiCachesResource,
708
+ UiRenderStore,
709
+ UiRenderer,
710
+ describeUiRender,
711
+ installUiRenderer,
712
+ prepareUi,
713
+ uiLayoutOverlay,
714
+ uiNode
715
+ };
@@ -0,0 +1,2 @@
1
+ /** WGSL for UI quads: rounded bordered boxes (SDF), images, and MSDF glyphs (see render.ts). */
2
+ export declare const UI_SHADERS: Record<string, string>;