@driftengine/ui2d 4.8.0 → 4.8.2

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.
@@ -6,8 +6,20 @@ export interface GpuSpriteSlot {
6
6
  readonly texture: GPUTexture;
7
7
  readonly bindGroup: GPUBindGroup;
8
8
  }
9
+ /** Pipelines by colour format, then depth format, then sample count: a lookup that allocates nothing. */
10
+ type PipelinesByTarget = Map<GPUTextureFormat, Map<GPUTextureFormat, Map<number, GPURenderPipeline>>>;
9
11
  export interface GpuSprites {
10
- readonly pipeline: GPURenderPipeline;
12
+ /**
13
+ * One pipeline per pass the sprites have been drawn into, chosen at the draw by
14
+ * `spritePipelineFor`. A pipeline must match its pass's attachments exactly, and a 2D layer is
15
+ * drawn in the frame and over it, which are two different passes: the scene's target at the
16
+ * scene's samples, and the canvas at one.
17
+ */
18
+ readonly pipelines: PipelinesByTarget;
19
+ readonly pipelineLayout: GPUPipelineLayout;
20
+ readonly vertexModule: GPUShaderModule;
21
+ readonly fragmentModule: GPUShaderModule;
22
+ readonly label: string;
11
23
  readonly layout: GPUBindGroupLayout;
12
24
  readonly vertexUniforms: GPUBuffer;
13
25
  readonly fragmentUniforms: GPUBuffer;
@@ -28,9 +40,20 @@ export interface GpuSprites {
28
40
  readonly fragmentInts: Int32Array;
29
41
  }
30
42
  export declare function createGpuSprites(device: GPUDevice, format: GPUTextureFormat, depthFormat: GPUTextureFormat, samples: number, capacity: number, slots: number, label: string): GpuSprites;
43
+ /**
44
+ * The pipeline for a pass with these attachments, built the first time it is asked for.
45
+ *
46
+ * Chosen at the draw from what the pass is (`PassContext.format`, `depthFormat`, `samples`), so a
47
+ * 2D layer drawn over the finished frame, on the canvas at one sample, is not refused for having
48
+ * been built for the scene target. A lookup through three maps on every later draw, no allocation.
49
+ */
50
+ export declare function spritePipelineFor(device: GPUDevice, sprites: GpuSprites, format: GPUTextureFormat, depthFormat: GPUTextureFormat, samples: number): GPURenderPipeline;
31
51
  export declare function setGpuSpriteTexture(device: GPUDevice, sprites: GpuSprites, slot: number, source: SpriteImage, options: SpriteTextureOptions): void;
32
52
  /** The same one white texel, written straight into a texture. See the WebGL2 half for why. */
33
53
  export declare function setGpuWhiteTexture(device: GPUDevice, sprites: GpuSprites, slot: number): void;
34
54
  export declare function uploadGpuInstances(device: GPUDevice, sprites: GpuSprites, batch: SpriteBatch): void;
35
- export declare function drawGpuSprites(pass: GPURenderPassEncoder, sprites: GpuSprites, batch: SpriteBatch): void;
55
+ export declare function drawGpuSprites(pass: GPURenderPassEncoder, sprites: GpuSprites, batch: SpriteBatch,
56
+ /** The pipeline for this pass's attachments: `spritePipelineFor`. */
57
+ pipeline: GPURenderPipeline): void;
36
58
  export declare function disposeGpuSprites(sprites: GpuSprites): void;
59
+ export {};
package/dist/spriteGpu.js CHANGED
@@ -37,52 +37,14 @@ export function createGpuSprites(device, format, depthFormat, samples, capacity,
37
37
  },
38
38
  ],
39
39
  });
40
- const pipeline = device.createRenderPipeline({
41
- label,
42
- layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }),
43
- vertex: {
44
- module: device.createShaderModule({ label: `${label}.vert`, code: SPRITE_VERT_WGSL }),
45
- entryPoint: 'main',
46
- buffers: [
47
- {
48
- arrayStride: STRIDE,
49
- stepMode: 'instance',
50
- attributes: [
51
- { shaderLocation: 0, offset: 0, format: 'float32x4' },
52
- { shaderLocation: 1, offset: 16, format: 'float32x4' },
53
- { shaderLocation: 2, offset: 32, format: 'float32x4' },
54
- { shaderLocation: 3, offset: 48, format: 'float32x2' },
55
- ],
56
- },
57
- ],
58
- },
59
- fragment: {
60
- module: device.createShaderModule({ label: `${label}.frag`, code: SPRITE_FRAG_WGSL }),
61
- entryPoint: 'main',
62
- targets: [
63
- {
64
- format,
65
- /* Premultiplied `over`, matching the WebGL2 half exactly. */
66
- blend: {
67
- color: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
68
- alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
69
- },
70
- },
71
- ],
72
- },
73
- /*
74
- * Nothing is culled: a sprite is mirrored by giving it a negative width, which reverses its
75
- * winding, and a character facing left is exactly that.
76
- */
77
- primitive: { topology: 'triangle-list', cullMode: 'none' },
78
- multisample: { count: samples },
79
- /*
80
- * Neither tested nor written. The 2D layer's order *is* its layering — there is no z to sort
81
- * by — and a pass that depth-tested would let whatever 3D geometry is in the frame punch holes
82
- * in an overlay drawn over it. Declared all the same, because a pipeline in a render pass that
83
- * has a depth attachment must name its format.
84
- */
85
- depthStencil: { format: depthFormat, depthWriteEnabled: false, depthCompare: 'always' },
40
+ const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [layout] });
41
+ const vertexModule = device.createShaderModule({
42
+ label: `${label}.vert`,
43
+ code: SPRITE_VERT_WGSL,
44
+ });
45
+ const fragmentModule = device.createShaderModule({
46
+ label: `${label}.frag`,
47
+ code: SPRITE_FRAG_WGSL,
86
48
  });
87
49
  const vertexUniforms = device.createBuffer({
88
50
  label: `${label}.vertexUniforms`,
@@ -101,8 +63,12 @@ export function createGpuSprites(device, format, depthFormat, samples, capacity,
101
63
  });
102
64
  const vertexScratch = new ArrayBuffer(VERT.uniformSize);
103
65
  const fragmentScratch = new ArrayBuffer(FRAG.uniformSize);
104
- return {
105
- pipeline,
66
+ const sprites = {
67
+ pipelines: new Map(),
68
+ pipelineLayout,
69
+ vertexModule,
70
+ fragmentModule,
71
+ label,
106
72
  layout,
107
73
  vertexUniforms,
108
74
  fragmentUniforms,
@@ -138,6 +104,80 @@ export function createGpuSprites(device, format, depthFormat, samples, capacity,
138
104
  fragmentFloats: new Float32Array(fragmentScratch),
139
105
  fragmentInts: new Int32Array(fragmentScratch),
140
106
  };
107
+ /* The frame's own target, built now, so the common case compiles before its first draw. */
108
+ spritePipelineFor(device, sprites, format, depthFormat, samples);
109
+ return sprites;
110
+ }
111
+ /**
112
+ * The pipeline for a pass with these attachments, built the first time it is asked for.
113
+ *
114
+ * Chosen at the draw from what the pass is (`PassContext.format`, `depthFormat`, `samples`), so a
115
+ * 2D layer drawn over the finished frame, on the canvas at one sample, is not refused for having
116
+ * been built for the scene target. A lookup through three maps on every later draw, no allocation.
117
+ */
118
+ export function spritePipelineFor(device, sprites, format, depthFormat, samples) {
119
+ let byDepth = sprites.pipelines.get(format);
120
+ if (byDepth === undefined) {
121
+ byDepth = new Map();
122
+ sprites.pipelines.set(format, byDepth);
123
+ }
124
+ let bySamples = byDepth.get(depthFormat);
125
+ if (bySamples === undefined) {
126
+ bySamples = new Map();
127
+ byDepth.set(depthFormat, bySamples);
128
+ }
129
+ const found = bySamples.get(samples);
130
+ if (found !== undefined)
131
+ return found;
132
+ const pipeline = device.createRenderPipeline({
133
+ label: `${sprites.label}:${format}x${samples}`,
134
+ layout: sprites.pipelineLayout,
135
+ vertex: {
136
+ module: sprites.vertexModule,
137
+ entryPoint: 'main',
138
+ buffers: [
139
+ {
140
+ arrayStride: STRIDE,
141
+ stepMode: 'instance',
142
+ attributes: [
143
+ { shaderLocation: 0, offset: 0, format: 'float32x4' },
144
+ { shaderLocation: 1, offset: 16, format: 'float32x4' },
145
+ { shaderLocation: 2, offset: 32, format: 'float32x4' },
146
+ { shaderLocation: 3, offset: 48, format: 'float32x2' },
147
+ ],
148
+ },
149
+ ],
150
+ },
151
+ fragment: {
152
+ module: sprites.fragmentModule,
153
+ entryPoint: 'main',
154
+ targets: [
155
+ {
156
+ format,
157
+ /* Premultiplied `over`, matching the WebGL2 half exactly. */
158
+ blend: {
159
+ color: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
160
+ alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
161
+ },
162
+ },
163
+ ],
164
+ },
165
+ /*
166
+ * Nothing is culled: a sprite is mirrored by giving it a negative width, which reverses its
167
+ * winding, and a character facing left is exactly that.
168
+ */
169
+ primitive: { topology: 'triangle-list', cullMode: 'none' },
170
+ multisample: { count: samples },
171
+ /*
172
+ * Neither tested nor written. The 2D layer's order *is* its layering — there is no z to sort
173
+ * by — and a pass that depth-tested would let whatever 3D geometry is in the frame punch holes
174
+ * in an overlay drawn over it. Declared all the same, because a pipeline in a render pass that
175
+ * has a depth attachment must name its format.
176
+ */
177
+ depthStencil: { format: depthFormat, depthWriteEnabled: false, depthCompare: 'always' },
178
+ });
179
+ bySamples.set(samples, pipeline);
180
+ return pipeline;
141
181
  }
142
182
  /**
143
183
  * One pipeline per format, kept on the sprite pass rather than in the renderer's cache.
@@ -231,10 +271,12 @@ export function uploadGpuInstances(device, sprites, batch) {
231
271
  return;
232
272
  device.queue.writeBuffer(sprites.instances, 0, batch.instances.buffer, batch.instances.byteOffset, batch.count * STRIDE);
233
273
  }
234
- export function drawGpuSprites(pass, sprites, batch) {
274
+ export function drawGpuSprites(pass, sprites, batch,
275
+ /** The pipeline for this pass's attachments: `spritePipelineFor`. */
276
+ pipeline) {
235
277
  if (batch.count === 0)
236
278
  return;
237
- pass.setPipeline(sprites.pipeline);
279
+ pass.setPipeline(pipeline);
238
280
  for (let run = 0; run < batch.runCount; run += 1) {
239
281
  const at = run * 3;
240
282
  const slot = sprites.slots[batch.runs[at]];
@@ -3,7 +3,7 @@ import { SPRITE_BINDINGS } from './shaders/generated/sprite.wgsl.js';
3
3
  import { createAffine2D } from './camera2d.js';
4
4
  import { createSpriteBatch, resetSpriteBatch } from './spriteBatch.js';
5
5
  import { createWebgl2Sprites, disposeWebgl2Sprites, drawWebgl2Sprites, setWebgl2SpriteTexture, setWebgl2WhiteTexture, uploadWebgl2Instances, } from './spriteGl.js';
6
- import { createGpuSprites, disposeGpuSprites, drawGpuSprites, setGpuSpriteTexture, setGpuWhiteTexture, uploadGpuInstances, } from './spriteGpu.js';
6
+ import { createGpuSprites, disposeGpuSprites, drawGpuSprites, spritePipelineFor, setGpuSpriteTexture, setGpuWhiteTexture, uploadGpuInstances, } from './spriteGpu.js';
7
7
  import { DEFAULT_SPRITE_TEXTURE_OPTIONS } from './spriteTexture.js';
8
8
  const VERT = SPRITE_BINDINGS.SPRITE_VERT;
9
9
  const FRAG = SPRITE_BINDINGS.SPRITE_FRAG;
@@ -126,7 +126,8 @@ export function createSpritePass(options = {}) {
126
126
  device.queue.writeBuffer(gpu.vertexUniforms, 0, gpu.vertexScratch);
127
127
  device.queue.writeBuffer(gpu.fragmentUniforms, 0, gpu.fragmentScratch);
128
128
  uploadGpuInstances(device, gpu, batch);
129
- drawGpuSprites(ctx.pass, gpu, batch);
129
+ /* For the pass this lands in, which after `endFrame` is the canvas: see `spritePipelineFor`. */
130
+ drawGpuSprites(ctx.pass, gpu, batch, spritePipelineFor(device, gpu, ctx.format, ctx.depthFormat, ctx.samples));
130
131
  },
131
132
  dispose() {
132
133
  if (gl !== null && context !== null)
package/dist/uiDraw.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /** Turn a laid-out interface tree into quads, parents first so children land on top. */
2
2
  import { drawSprite } from './spriteBatch.js';
3
3
  import { createSpriteFrame } from './spriteSheet.js';
4
+ import { layersPresent } from './uiLayer.js';
4
5
  /* One placement, refilled per node. This is a per-frame path and these are the only objects in it. */
5
6
  const PLACEMENT = {
6
7
  x: 0,
@@ -30,11 +31,26 @@ const CUT = createSpriteFrame();
30
31
  * left at.
31
32
  */
32
33
  export function drawUiTree(batch, root, white, sink) {
33
- return drawWithin(batch, root, white, sink, -Infinity, -Infinity, Infinity, Infinity);
34
+ /*
35
+ * **Layer by layer, lowest first, and tree order within one.** A raised node — a dropdown that
36
+ * must cover the panel its control sits in — was drawn in tree order with everything else, so a
37
+ * later sibling covered it. Each layer is a walk of the whole tree with the clips it would have
38
+ * had anyway, so a raised node is still cut by a clipping ancestor; a tree with no raised node is
39
+ * one walk, as before.
40
+ */
41
+ const count = layersPresent(root, LAYERS);
42
+ let drawn = 0;
43
+ for (let at = 0; at < count; at += 1) {
44
+ drawn += drawWithin(batch, root, white, sink, -Infinity, -Infinity, Infinity, Infinity, 0, LAYERS[at] ?? 0);
45
+ }
46
+ return drawn;
34
47
  }
35
- function drawWithin(batch, node, white, sink, left, top, right, bottom) {
48
+ /** The layers of the tree being drawn, reused: this runs per frame. */
49
+ const LAYERS = [];
50
+ function drawWithin(batch, node, white, sink, left, top, right, bottom, inherited, only) {
36
51
  if (node.hidden)
37
52
  return 0;
53
+ const layer = node.layer === 0 ? inherited : node.layer;
38
54
  const rect = node.rect;
39
55
  const clipped = left !== -Infinity;
40
56
  const x0 = Math.max(rect.x, left);
@@ -47,7 +63,7 @@ function drawWithin(batch, node, white, sink, left, top, right, bottom) {
47
63
  * a node is drawn as it always was, whatever its size. Its children are cut one by one either way,
48
64
  * since a child can lie outside its parent's box and still inside the clip.
49
65
  */
50
- if (!clipped || (x1 > x0 && y1 > y0)) {
66
+ if (layer === only && (!clipped || (x1 > x0 && y1 > y0))) {
51
67
  PLACEMENT.x = clipped ? x0 : rect.x;
52
68
  PLACEMENT.y = clipped ? y0 : rect.y;
53
69
  PLACEMENT.w = clipped ? x1 - x0 : rect.w;
@@ -75,8 +91,9 @@ function drawWithin(batch, node, white, sink, left, top, right, bottom) {
75
91
  const ct = keep ? Math.max(top, rect.y) : top;
76
92
  const cr = keep ? Math.min(right, rect.x + rect.w) : right;
77
93
  const cb = keep ? Math.min(bottom, rect.y + rect.h) : bottom;
78
- for (const child of node.children)
79
- drawn += drawWithin(batch, child, white, sink, cl, ct, cr, cb);
94
+ for (const child of node.children) {
95
+ drawn += drawWithin(batch, child, white, sink, cl, ct, cr, cb, layer, only);
96
+ }
80
97
  return drawn;
81
98
  }
82
99
  /** The part of a node's frame its visible box shows, or the whole frame when nothing was cut. */
package/dist/uiFocus.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /** Which node a point is on, and which node a keyboard should be talking to. */
2
2
  import { uiRectHolds } from './uiNode.js';
3
+ import { layersPresent } from './uiLayer.js';
3
4
  /**
4
5
  * The interactive node under a point, or `null`.
5
6
  *
@@ -14,18 +15,32 @@ import { uiRectHolds } from './uiNode.js';
14
15
  * Allocates nothing.
15
16
  */
16
17
  export function uiHitTest(root, x, y) {
17
- if (root.hidden)
18
+ /* The highest layer first, as it is drawn last: a raised popup takes the pointer over a later
19
+ sibling it covers. See `drawUiTree`. */
20
+ const count = layersPresent(root, HIT_LAYERS);
21
+ for (let at = count - 1; at >= 0; at -= 1) {
22
+ const hit = hitOn(root, x, y, 0, HIT_LAYERS[at] ?? 0);
23
+ if (hit !== null)
24
+ return hit;
25
+ }
26
+ return null;
27
+ }
28
+ /** The layers of the tree being pointed at, reused: a hit test runs per pointer event. */
29
+ const HIT_LAYERS = [];
30
+ function hitOn(node, x, y, inherited, only) {
31
+ if (node.hidden)
18
32
  return null;
19
33
  /* A clipping node bounds its descendants here as it does in the picture: a row scrolled out of a
20
34
  list is not under the pointer, whatever is drawn where it would have been. */
21
- if (root.clip && !uiRectHolds(root, x, y))
35
+ if (node.clip && !uiRectHolds(node, x, y))
22
36
  return null;
23
- for (let i = root.children.length - 1; i >= 0; i -= 1) {
24
- const hit = uiHitTest(root.children[i], x, y);
37
+ const layer = node.layer === 0 ? inherited : node.layer;
38
+ for (let i = node.children.length - 1; i >= 0; i -= 1) {
39
+ const hit = hitOn(node.children[i], x, y, layer, only);
25
40
  if (hit !== null)
26
41
  return hit;
27
42
  }
28
- return root.interactive && uiRectHolds(root, x, y) ? root : null;
43
+ return layer === only && node.interactive && uiRectHolds(node, x, y) ? node : null;
29
44
  }
30
45
  /**
31
46
  * Every focusable node, in tree order, appended to `out`.
package/dist/uiLayer.d.ts CHANGED
@@ -16,3 +16,10 @@
16
16
  import type { UiNode } from './uiNode.ts';
17
17
  /** Fill `out` with the tree in draw order and return how many entries were written. */
18
18
  export declare function layerOrder(root: UiNode, out: UiNode[]): number;
19
+ /**
20
+ * The layers a tree draws on, ascending, into `out`; how many there are.
21
+ *
22
+ * What `drawUiTree` and `uiHitTest` walk a tree once per: a tree with no raised node answers one
23
+ * layer and is walked once, exactly as it was before either read a layer.
24
+ */
25
+ export declare function layersPresent(root: UiNode, out: number[]): number;
package/dist/uiLayer.js CHANGED
@@ -28,3 +28,27 @@ export function layerOrder(root, out) {
28
28
  out.length = count;
29
29
  return count;
30
30
  }
31
+ /** Ascending, without allocating a comparator per call. */
32
+ const ASCENDING = (a, b) => a - b;
33
+ /**
34
+ * The layers a tree draws on, ascending, into `out`; how many there are.
35
+ *
36
+ * What `drawUiTree` and `uiHitTest` walk a tree once per: a tree with no raised node answers one
37
+ * layer and is walked once, exactly as it was before either read a layer.
38
+ */
39
+ export function layersPresent(root, out) {
40
+ out.length = 0;
41
+ collect(root, 0, out);
42
+ if (out.length > 1)
43
+ out.sort(ASCENDING);
44
+ return out.length;
45
+ }
46
+ function collect(node, inherited, out) {
47
+ if (node.hidden)
48
+ return;
49
+ const layer = node.layer === 0 ? inherited : node.layer;
50
+ if (!out.includes(layer))
51
+ out.push(layer);
52
+ for (const child of node.children)
53
+ collect(child, layer, out);
54
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/ui2d",
3
- "version": "4.8.0",
3
+ "version": "4.8.2",
4
4
  "description": "The 2D layer: batched sprites, sheets, tilemaps, and a retained interface tree over them",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  ],
28
28
  "sideEffects": false,
29
29
  "peerDependencies": {
30
- "@driftengine/core": "4.8.0"
30
+ "@driftengine/core": "4.8.2"
31
31
  },
32
32
  "author": "Drift Technologies",
33
33
  "repository": {
package/src/spriteGpu.ts CHANGED
@@ -34,8 +34,24 @@ export interface GpuSpriteSlot {
34
34
  readonly bindGroup: GPUBindGroup;
35
35
  }
36
36
 
37
+ /** Pipelines by colour format, then depth format, then sample count: a lookup that allocates nothing. */
38
+ type PipelinesByTarget = Map<
39
+ GPUTextureFormat,
40
+ Map<GPUTextureFormat, Map<number, GPURenderPipeline>>
41
+ >;
42
+
37
43
  export interface GpuSprites {
38
- readonly pipeline: GPURenderPipeline;
44
+ /**
45
+ * One pipeline per pass the sprites have been drawn into, chosen at the draw by
46
+ * `spritePipelineFor`. A pipeline must match its pass's attachments exactly, and a 2D layer is
47
+ * drawn in the frame and over it, which are two different passes: the scene's target at the
48
+ * scene's samples, and the canvas at one.
49
+ */
50
+ readonly pipelines: PipelinesByTarget;
51
+ readonly pipelineLayout: GPUPipelineLayout;
52
+ readonly vertexModule: GPUShaderModule;
53
+ readonly fragmentModule: GPUShaderModule;
54
+ readonly label: string;
39
55
  readonly layout: GPUBindGroupLayout;
40
56
  readonly vertexUniforms: GPUBuffer;
41
57
  readonly fragmentUniforms: GPUBuffer;
@@ -83,52 +99,14 @@ export function createGpuSprites(
83
99
  ],
84
100
  });
85
101
 
86
- const pipeline = device.createRenderPipeline({
87
- label,
88
- layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }),
89
- vertex: {
90
- module: device.createShaderModule({ label: `${label}.vert`, code: SPRITE_VERT_WGSL }),
91
- entryPoint: 'main',
92
- buffers: [
93
- {
94
- arrayStride: STRIDE,
95
- stepMode: 'instance',
96
- attributes: [
97
- { shaderLocation: 0, offset: 0, format: 'float32x4' },
98
- { shaderLocation: 1, offset: 16, format: 'float32x4' },
99
- { shaderLocation: 2, offset: 32, format: 'float32x4' },
100
- { shaderLocation: 3, offset: 48, format: 'float32x2' },
101
- ],
102
- },
103
- ],
104
- },
105
- fragment: {
106
- module: device.createShaderModule({ label: `${label}.frag`, code: SPRITE_FRAG_WGSL }),
107
- entryPoint: 'main',
108
- targets: [
109
- {
110
- format,
111
- /* Premultiplied `over`, matching the WebGL2 half exactly. */
112
- blend: {
113
- color: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
114
- alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
115
- },
116
- },
117
- ],
118
- },
119
- /*
120
- * Nothing is culled: a sprite is mirrored by giving it a negative width, which reverses its
121
- * winding, and a character facing left is exactly that.
122
- */
123
- primitive: { topology: 'triangle-list', cullMode: 'none' },
124
- multisample: { count: samples },
125
- /*
126
- * Neither tested nor written. The 2D layer's order *is* its layering — there is no z to sort
127
- * by — and a pass that depth-tested would let whatever 3D geometry is in the frame punch holes
128
- * in an overlay drawn over it. Declared all the same, because a pipeline in a render pass that
129
- * has a depth attachment must name its format.
130
- */
131
- depthStencil: { format: depthFormat, depthWriteEnabled: false, depthCompare: 'always' },
102
+ const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [layout] });
103
+ const vertexModule = device.createShaderModule({
104
+ label: `${label}.vert`,
105
+ code: SPRITE_VERT_WGSL,
106
+ });
107
+ const fragmentModule = device.createShaderModule({
108
+ label: `${label}.frag`,
109
+ code: SPRITE_FRAG_WGSL,
132
110
  });
133
111
 
134
112
  const vertexUniforms = device.createBuffer({
@@ -150,8 +128,12 @@ export function createGpuSprites(
150
128
  const vertexScratch = new ArrayBuffer(VERT.uniformSize);
151
129
  const fragmentScratch = new ArrayBuffer(FRAG.uniformSize);
152
130
 
153
- return {
154
- pipeline,
131
+ const sprites: GpuSprites = {
132
+ pipelines: new Map(),
133
+ pipelineLayout,
134
+ vertexModule,
135
+ fragmentModule,
136
+ label,
155
137
  layout,
156
138
  vertexUniforms,
157
139
  fragmentUniforms,
@@ -187,6 +169,86 @@ export function createGpuSprites(
187
169
  fragmentFloats: new Float32Array(fragmentScratch),
188
170
  fragmentInts: new Int32Array(fragmentScratch),
189
171
  };
172
+ /* The frame's own target, built now, so the common case compiles before its first draw. */
173
+ spritePipelineFor(device, sprites, format, depthFormat, samples);
174
+ return sprites;
175
+ }
176
+
177
+ /**
178
+ * The pipeline for a pass with these attachments, built the first time it is asked for.
179
+ *
180
+ * Chosen at the draw from what the pass is (`PassContext.format`, `depthFormat`, `samples`), so a
181
+ * 2D layer drawn over the finished frame, on the canvas at one sample, is not refused for having
182
+ * been built for the scene target. A lookup through three maps on every later draw, no allocation.
183
+ */
184
+ export function spritePipelineFor(
185
+ device: GPUDevice,
186
+ sprites: GpuSprites,
187
+ format: GPUTextureFormat,
188
+ depthFormat: GPUTextureFormat,
189
+ samples: number,
190
+ ): GPURenderPipeline {
191
+ let byDepth = sprites.pipelines.get(format);
192
+ if (byDepth === undefined) {
193
+ byDepth = new Map();
194
+ sprites.pipelines.set(format, byDepth);
195
+ }
196
+ let bySamples = byDepth.get(depthFormat);
197
+ if (bySamples === undefined) {
198
+ bySamples = new Map();
199
+ byDepth.set(depthFormat, bySamples);
200
+ }
201
+ const found = bySamples.get(samples);
202
+ if (found !== undefined) return found;
203
+ const pipeline = device.createRenderPipeline({
204
+ label: `${sprites.label}:${format}x${samples}`,
205
+ layout: sprites.pipelineLayout,
206
+ vertex: {
207
+ module: sprites.vertexModule,
208
+ entryPoint: 'main',
209
+ buffers: [
210
+ {
211
+ arrayStride: STRIDE,
212
+ stepMode: 'instance',
213
+ attributes: [
214
+ { shaderLocation: 0, offset: 0, format: 'float32x4' },
215
+ { shaderLocation: 1, offset: 16, format: 'float32x4' },
216
+ { shaderLocation: 2, offset: 32, format: 'float32x4' },
217
+ { shaderLocation: 3, offset: 48, format: 'float32x2' },
218
+ ],
219
+ },
220
+ ],
221
+ },
222
+ fragment: {
223
+ module: sprites.fragmentModule,
224
+ entryPoint: 'main',
225
+ targets: [
226
+ {
227
+ format,
228
+ /* Premultiplied `over`, matching the WebGL2 half exactly. */
229
+ blend: {
230
+ color: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
231
+ alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
232
+ },
233
+ },
234
+ ],
235
+ },
236
+ /*
237
+ * Nothing is culled: a sprite is mirrored by giving it a negative width, which reverses its
238
+ * winding, and a character facing left is exactly that.
239
+ */
240
+ primitive: { topology: 'triangle-list', cullMode: 'none' },
241
+ multisample: { count: samples },
242
+ /*
243
+ * Neither tested nor written. The 2D layer's order *is* its layering — there is no z to sort
244
+ * by — and a pass that depth-tested would let whatever 3D geometry is in the frame punch holes
245
+ * in an overlay drawn over it. Declared all the same, because a pipeline in a render pass that
246
+ * has a depth attachment must name its format.
247
+ */
248
+ depthStencil: { format: depthFormat, depthWriteEnabled: false, depthCompare: 'always' },
249
+ });
250
+ bySamples.set(samples, pipeline);
251
+ return pipeline;
190
252
  }
191
253
 
192
254
  /**
@@ -313,9 +375,11 @@ export function drawGpuSprites(
313
375
  pass: GPURenderPassEncoder,
314
376
  sprites: GpuSprites,
315
377
  batch: SpriteBatch,
378
+ /** The pipeline for this pass's attachments: `spritePipelineFor`. */
379
+ pipeline: GPURenderPipeline,
316
380
  ): void {
317
381
  if (batch.count === 0) return;
318
- pass.setPipeline(sprites.pipeline);
382
+ pass.setPipeline(pipeline);
319
383
  for (let run = 0; run < batch.runCount; run += 1) {
320
384
  const at = run * 3;
321
385
  const slot = sprites.slots[batch.runs[at] as number];
package/src/spritePass.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  createGpuSprites,
21
21
  disposeGpuSprites,
22
22
  drawGpuSprites,
23
+ spritePipelineFor,
23
24
  setGpuSpriteTexture,
24
25
  setGpuWhiteTexture,
25
26
  uploadGpuInstances,
@@ -216,7 +217,13 @@ export function createSpritePass(options: SpritePassOptions = {}): SpritePass {
216
217
  device.queue.writeBuffer(gpu.vertexUniforms, 0, gpu.vertexScratch);
217
218
  device.queue.writeBuffer(gpu.fragmentUniforms, 0, gpu.fragmentScratch);
218
219
  uploadGpuInstances(device, gpu, batch);
219
- drawGpuSprites(ctx.pass, gpu, batch);
220
+ /* For the pass this lands in, which after `endFrame` is the canvas: see `spritePipelineFor`. */
221
+ drawGpuSprites(
222
+ ctx.pass,
223
+ gpu,
224
+ batch,
225
+ spritePipelineFor(device, gpu, ctx.format, ctx.depthFormat, ctx.samples),
226
+ );
220
227
  },
221
228
 
222
229
  dispose(): void {
package/src/uiDraw.ts CHANGED
@@ -5,6 +5,7 @@ import type { SpriteBatch, SpritePlacement } from './spriteBatch.ts';
5
5
  import { createSpriteFrame } from './spriteSheet.ts';
6
6
  import type { SpriteFrame } from './spriteSheet.ts';
7
7
  import type { UiNode, UiRect } from './uiNode.ts';
8
+ import { layersPresent } from './uiLayer.ts';
8
9
 
9
10
  /**
10
11
  * Where a caller draws what a quad cannot be.
@@ -61,9 +62,35 @@ export function drawUiTree(
61
62
  white: number,
62
63
  sink: UiContentSink | null,
63
64
  ): number {
64
- return drawWithin(batch, root, white, sink, -Infinity, -Infinity, Infinity, Infinity);
65
+ /*
66
+ * **Layer by layer, lowest first, and tree order within one.** A raised node — a dropdown that
67
+ * must cover the panel its control sits in — was drawn in tree order with everything else, so a
68
+ * later sibling covered it. Each layer is a walk of the whole tree with the clips it would have
69
+ * had anyway, so a raised node is still cut by a clipping ancestor; a tree with no raised node is
70
+ * one walk, as before.
71
+ */
72
+ const count = layersPresent(root, LAYERS);
73
+ let drawn = 0;
74
+ for (let at = 0; at < count; at += 1) {
75
+ drawn += drawWithin(
76
+ batch,
77
+ root,
78
+ white,
79
+ sink,
80
+ -Infinity,
81
+ -Infinity,
82
+ Infinity,
83
+ Infinity,
84
+ 0,
85
+ LAYERS[at] ?? 0,
86
+ );
87
+ }
88
+ return drawn;
65
89
  }
66
90
 
91
+ /** The layers of the tree being drawn, reused: this runs per frame. */
92
+ const LAYERS: number[] = [];
93
+
67
94
  function drawWithin(
68
95
  batch: SpriteBatch,
69
96
  node: UiNode,
@@ -73,8 +100,11 @@ function drawWithin(
73
100
  top: number,
74
101
  right: number,
75
102
  bottom: number,
103
+ inherited: number,
104
+ only: number,
76
105
  ): number {
77
106
  if (node.hidden) return 0;
107
+ const layer = node.layer === 0 ? inherited : node.layer;
78
108
  const rect = node.rect;
79
109
  const clipped = left !== -Infinity;
80
110
  const x0 = Math.max(rect.x, left);
@@ -87,7 +117,7 @@ function drawWithin(
87
117
  * a node is drawn as it always was, whatever its size. Its children are cut one by one either way,
88
118
  * since a child can lie outside its parent's box and still inside the clip.
89
119
  */
90
- if (!clipped || (x1 > x0 && y1 > y0)) {
120
+ if (layer === only && (!clipped || (x1 > x0 && y1 > y0))) {
91
121
  PLACEMENT.x = clipped ? x0 : rect.x;
92
122
  PLACEMENT.y = clipped ? y0 : rect.y;
93
123
  PLACEMENT.w = clipped ? x1 - x0 : rect.w;
@@ -115,7 +145,9 @@ function drawWithin(
115
145
  const ct = keep ? Math.max(top, rect.y) : top;
116
146
  const cr = keep ? Math.min(right, rect.x + rect.w) : right;
117
147
  const cb = keep ? Math.min(bottom, rect.y + rect.h) : bottom;
118
- for (const child of node.children) drawn += drawWithin(batch, child, white, sink, cl, ct, cr, cb);
148
+ for (const child of node.children) {
149
+ drawn += drawWithin(batch, child, white, sink, cl, ct, cr, cb, layer, only);
150
+ }
119
151
  return drawn;
120
152
  }
121
153
 
package/src/uiFocus.ts CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { uiRectHolds } from './uiNode.ts';
4
4
  import type { UiNode } from './uiNode.ts';
5
+ import { layersPresent } from './uiLayer.ts';
5
6
 
6
7
  /**
7
8
  * The interactive node under a point, or `null`.
@@ -17,15 +18,30 @@ import type { UiNode } from './uiNode.ts';
17
18
  * Allocates nothing.
18
19
  */
19
20
  export function uiHitTest(root: UiNode, x: number, y: number): UiNode | null {
20
- if (root.hidden) return null;
21
+ /* The highest layer first, as it is drawn last: a raised popup takes the pointer over a later
22
+ sibling it covers. See `drawUiTree`. */
23
+ const count = layersPresent(root, HIT_LAYERS);
24
+ for (let at = count - 1; at >= 0; at -= 1) {
25
+ const hit = hitOn(root, x, y, 0, HIT_LAYERS[at] ?? 0);
26
+ if (hit !== null) return hit;
27
+ }
28
+ return null;
29
+ }
30
+
31
+ /** The layers of the tree being pointed at, reused: a hit test runs per pointer event. */
32
+ const HIT_LAYERS: number[] = [];
33
+
34
+ function hitOn(node: UiNode, x: number, y: number, inherited: number, only: number): UiNode | null {
35
+ if (node.hidden) return null;
21
36
  /* A clipping node bounds its descendants here as it does in the picture: a row scrolled out of a
22
37
  list is not under the pointer, whatever is drawn where it would have been. */
23
- if (root.clip && !uiRectHolds(root, x, y)) return null;
24
- for (let i = root.children.length - 1; i >= 0; i -= 1) {
25
- const hit = uiHitTest(root.children[i] as UiNode, x, y);
38
+ if (node.clip && !uiRectHolds(node, x, y)) return null;
39
+ const layer = node.layer === 0 ? inherited : node.layer;
40
+ for (let i = node.children.length - 1; i >= 0; i -= 1) {
41
+ const hit = hitOn(node.children[i] as UiNode, x, y, layer, only);
26
42
  if (hit !== null) return hit;
27
43
  }
28
- return root.interactive && uiRectHolds(root, x, y) ? root : null;
44
+ return layer === only && node.interactive && uiRectHolds(node, x, y) ? node : null;
29
45
  }
30
46
 
31
47
  /**
package/src/uiLayer.ts CHANGED
@@ -45,3 +45,26 @@ export function layerOrder(root: UiNode, out: UiNode[]): number {
45
45
  out.length = count;
46
46
  return count;
47
47
  }
48
+
49
+ /** Ascending, without allocating a comparator per call. */
50
+ const ASCENDING = (a: number, b: number): number => a - b;
51
+
52
+ /**
53
+ * The layers a tree draws on, ascending, into `out`; how many there are.
54
+ *
55
+ * What `drawUiTree` and `uiHitTest` walk a tree once per: a tree with no raised node answers one
56
+ * layer and is walked once, exactly as it was before either read a layer.
57
+ */
58
+ export function layersPresent(root: UiNode, out: number[]): number {
59
+ out.length = 0;
60
+ collect(root, 0, out);
61
+ if (out.length > 1) out.sort(ASCENDING);
62
+ return out.length;
63
+ }
64
+
65
+ function collect(node: UiNode, inherited: number, out: number[]): void {
66
+ if (node.hidden) return;
67
+ const layer = node.layer === 0 ? inherited : node.layer;
68
+ if (!out.includes(layer)) out.push(layer);
69
+ for (const child of node.children) collect(child, layer, out);
70
+ }