@driftengine/ui2d 4.13.0 → 4.13.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  The 2D layer: quads with a texture on them, batched, in the order you drew them.
4
4
 
5
- **Cost: 10.1 KB gzipped on top of core.** Measured by `scripts/size-gate.test.mjs`, which fails if it
5
+ **Cost: 10.6 KB gzipped on top of core.** Measured by `scripts/size-gate.test.mjs`, which fails if it
6
6
  drifts more than 3% — the number is derived from the same floors that gate asserts, so a README
7
7
  quoting a stale one is a red suite rather than a thing somebody notices.
8
8
 
package/dist/index.d.ts CHANGED
@@ -15,7 +15,7 @@ export type { SpriteBatch, SpritePlacement, SpriteRun, UvRect } from './spriteBa
15
15
  export { DEFAULT_SPRITE_CAPACITY, DEFAULT_SPRITE_SLOTS, createSpritePass } from './spritePass.ts';
16
16
  export type { SpritePass, SpritePassOptions } from './spritePass.ts';
17
17
  export { DEFAULT_SPRITE_TEXTURE_OPTIONS } from './spriteTexture.ts';
18
- export type { SpriteImage, SpriteTextureOptions } from './spriteTexture.ts';
18
+ export type { SpriteImage, SpriteRegion, SpriteTextureOptions } from './spriteTexture.ts';
19
19
  export { createSpriteFrame, frameOf, gridSheet, namedSheet, sheetFrame, sheetFrameHeight, sheetFrameWidth, } from './spriteSheet.ts';
20
20
  export type { SheetEntry, SpriteFrame, SpriteSheet } from './spriteSheet.ts';
21
21
  export { TILE_EMPTY, createTilemap, drawTilemap, setTile, tileAt } from './tilemap.ts';
@@ -12,6 +12,21 @@ export interface Webgl2Sprites {
12
12
  export declare function createWebgl2Sprites(gl: WebGL2RenderingContext, capacity: number, slots: number, label: string): Webgl2Sprites;
13
13
  /** Replace a texture slot. The previous texture in that slot is deleted. */
14
14
  export declare function setWebgl2SpriteTexture(gl: WebGL2RenderingContext, sprites: Webgl2Sprites, slot: number, source: TexImageSource, options: SpriteTextureOptions): void;
15
+ /**
16
+ * Copy one rectangle of `source` into the slot's texture at the same place: the WebGL2 half of
17
+ * `SpritePass.updateTexture`, which checks that the slot holds a texture of `source`'s size and has
18
+ * clipped the rectangle to it. See `updateGpuSpriteTexture` for why it exists.
19
+ *
20
+ * **The rectangle is chosen by the unpack state, and the state is put back to zero.** WebGL2 reads a
21
+ * sub-rectangle of a canvas or a bitmap through `UNPACK_SKIP_PIXELS`, `UNPACK_SKIP_ROWS` and
22
+ * `UNPACK_ROW_LENGTH`, set to the image's own width so a row is read as a row of the image. That is
23
+ * context state every other upload on this context reads too, core's included, so leaving it set
24
+ * would have the next texture anywhere in the engine start at this rectangle's corner. A chain,
25
+ * where the slot has one, is generated again, since its lower levels mixed the texels just replaced.
26
+ */
27
+ export declare function updateWebgl2SpriteTexture(gl: WebGL2RenderingContext, sprites: Webgl2Sprites, slot: number, source: TexImageSource & {
28
+ readonly width: number;
29
+ }, x: number, y: number, width: number, height: number, mipmap: boolean): void;
15
30
  /**
16
31
  * Fill a slot with one opaque white texel, from an array rather than an image.
17
32
  *
package/dist/spriteGl.js CHANGED
@@ -132,6 +132,36 @@ export function setWebgl2SpriteTexture(gl, sprites, slot, source, options) {
132
132
  gl.activeTexture(gl.TEXTURE0);
133
133
  sprites.textures[slot] = texture;
134
134
  }
135
+ /**
136
+ * Copy one rectangle of `source` into the slot's texture at the same place: the WebGL2 half of
137
+ * `SpritePass.updateTexture`, which checks that the slot holds a texture of `source`'s size and has
138
+ * clipped the rectangle to it. See `updateGpuSpriteTexture` for why it exists.
139
+ *
140
+ * **The rectangle is chosen by the unpack state, and the state is put back to zero.** WebGL2 reads a
141
+ * sub-rectangle of a canvas or a bitmap through `UNPACK_SKIP_PIXELS`, `UNPACK_SKIP_ROWS` and
142
+ * `UNPACK_ROW_LENGTH`, set to the image's own width so a row is read as a row of the image. That is
143
+ * context state every other upload on this context reads too, core's included, so leaving it set
144
+ * would have the next texture anywhere in the engine start at this rectangle's corner. A chain,
145
+ * where the slot has one, is generated again, since its lower levels mixed the texels just replaced.
146
+ */
147
+ export function updateWebgl2SpriteTexture(gl, sprites, slot, source, x, y, width, height, mipmap) {
148
+ const texture = sprites.textures[slot];
149
+ if (texture === null || texture === undefined)
150
+ return;
151
+ gl.activeTexture(gl.TEXTURE0 + SPRITE_UNIT);
152
+ gl.bindTexture(gl.TEXTURE_2D, texture);
153
+ gl.pixelStorei(gl.UNPACK_ROW_LENGTH, source.width);
154
+ gl.pixelStorei(gl.UNPACK_SKIP_PIXELS, x);
155
+ gl.pixelStorei(gl.UNPACK_SKIP_ROWS, y);
156
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, x, y, width, height, gl.RGBA, gl.UNSIGNED_BYTE, source);
157
+ gl.pixelStorei(gl.UNPACK_ROW_LENGTH, 0);
158
+ gl.pixelStorei(gl.UNPACK_SKIP_PIXELS, 0);
159
+ gl.pixelStorei(gl.UNPACK_SKIP_ROWS, 0);
160
+ if (mipmap)
161
+ gl.generateMipmap(gl.TEXTURE_2D);
162
+ gl.bindTexture(gl.TEXTURE_2D, null);
163
+ gl.activeTexture(gl.TEXTURE0);
164
+ }
135
165
  /**
136
166
  * Fill a slot with one opaque white texel, from an array rather than an image.
137
167
  *
@@ -5,6 +5,9 @@ import type { SpriteImage, SpriteTextureOptions } from './spriteTexture.ts';
5
5
  export interface GpuSpriteSlot {
6
6
  readonly texture: GPUTexture;
7
7
  readonly bindGroup: GPUBindGroup;
8
+ /** Kept so a partial update can rebuild the chain it overwrote part of. */
9
+ readonly format: GPUTextureFormat;
10
+ readonly levels: number;
8
11
  }
9
12
  /** Pipelines by colour format, then depth format, then sample count: a lookup that allocates nothing. */
10
13
  type PipelinesByTarget = Map<GPUTextureFormat, Map<GPUTextureFormat, Map<number, GPURenderPipeline>>>;
@@ -49,6 +52,21 @@ export declare function createGpuSprites(device: GPUDevice, format: GPUTextureFo
49
52
  */
50
53
  export declare function spritePipelineFor(device: GPUDevice, sprites: GpuSprites, format: GPUTextureFormat, depthFormat: GPUTextureFormat, samples: number): GPURenderPipeline;
51
54
  export declare function setGpuSpriteTexture(device: GPUDevice, sprites: GpuSprites, slot: number, source: SpriteImage, options: SpriteTextureOptions): void;
55
+ /**
56
+ * Copy one rectangle of `source` into the slot's texture at the same place, keeping the texture and
57
+ * its bind group: the WebGPU half of `SpritePass.updateTexture`, which checks that the slot holds a
58
+ * texture of `source`'s size before it calls this, and has clipped the rectangle to it.
59
+ *
60
+ * **Why this exists.** `setGpuSpriteTexture` destroys the slot's texture, makes it again at full size
61
+ * and copies the whole image in, so an atlas painted one picture at a time sent every texel of the
62
+ * atlas for each picture: measured by a consumer at about 100 ms for a 5120-texel side. This sends
63
+ * the rectangle. A chain, where the slot has one, is rendered again from level zero, because a lower
64
+ * level mixes the texels just replaced with their neighbours; that is GPU work rather than an upload.
65
+ *
66
+ * **Ordered like `setTexture`, on the queue**: a copy issued after a draw that reads the slot was
67
+ * recorded, and before that frame is submitted, is what the draw sees. Paint before `drawPass`.
68
+ */
69
+ export declare function updateGpuSpriteTexture(device: GPUDevice, sprites: GpuSprites, slot: number, source: SpriteImage, x: number, y: number, width: number, height: number): void;
52
70
  /** The same one white texel, written straight into a texture. See the WebGL2 half for why. */
53
71
  export declare function setGpuWhiteTexture(device: GPUDevice, sprites: GpuSprites, slot: number): void;
54
72
  export declare function uploadGpuInstances(device: GPUDevice, sprites: GpuSprites, batch: SpriteBatch): void;
package/dist/spriteGpu.js CHANGED
@@ -243,8 +243,47 @@ export function setGpuSpriteTexture(device, sprites, slot, source, options) {
243
243
  { binding: FRAG.textures.uSpriteTexture.sampler, resource: sampler },
244
244
  ],
245
245
  });
246
- sprites.slots[slot] = { texture, bindGroup };
246
+ sprites.slots[slot] = { texture, bindGroup, format, levels };
247
247
  }
248
+ /**
249
+ * Copy one rectangle of `source` into the slot's texture at the same place, keeping the texture and
250
+ * its bind group: the WebGPU half of `SpritePass.updateTexture`, which checks that the slot holds a
251
+ * texture of `source`'s size before it calls this, and has clipped the rectangle to it.
252
+ *
253
+ * **Why this exists.** `setGpuSpriteTexture` destroys the slot's texture, makes it again at full size
254
+ * and copies the whole image in, so an atlas painted one picture at a time sent every texel of the
255
+ * atlas for each picture: measured by a consumer at about 100 ms for a 5120-texel side. This sends
256
+ * the rectangle. A chain, where the slot has one, is rendered again from level zero, because a lower
257
+ * level mixes the texels just replaced with their neighbours; that is GPU work rather than an upload.
258
+ *
259
+ * **Ordered like `setTexture`, on the queue**: a copy issued after a draw that reads the slot was
260
+ * recorded, and before that frame is submitted, is what the draw sees. Paint before `drawPass`.
261
+ */
262
+ export function updateGpuSpriteTexture(device, sprites, slot, source, x, y, width, height) {
263
+ const held = sprites.slots[slot];
264
+ if (held === null || held === undefined)
265
+ return;
266
+ /* Refilled rather than built: a timer's digits are an update every frame. */
267
+ COPY_SOURCE.source = source;
268
+ COPY_ORIGIN.x = x;
269
+ COPY_ORIGIN.y = y;
270
+ COPY_TARGET.texture = held.texture;
271
+ COPY_SIZE.width = width;
272
+ COPY_SIZE.height = height;
273
+ device.queue.copyExternalImageToTexture(COPY_SOURCE, COPY_TARGET, COPY_SIZE);
274
+ /* Let go of the caller's canvas until the next copy, rather than holding it alive here. */
275
+ COPY_SOURCE.source = EMPTY_SOURCE;
276
+ generateMipChain(device, sprites.mipPipelines, held.texture, held.format, held.levels);
277
+ }
278
+ /* The one origin both ends of the copy share: a rectangle lands where it was in the image. */
279
+ const COPY_ORIGIN = { x: 0, y: 0 };
280
+ const EMPTY_SOURCE = null;
281
+ const COPY_SOURCE = { source: EMPTY_SOURCE, origin: COPY_ORIGIN, flipY: false };
282
+ const COPY_TARGET = {
283
+ texture: null,
284
+ origin: COPY_ORIGIN,
285
+ };
286
+ const COPY_SIZE = { width: 0, height: 0 };
248
287
  /** The same one white texel, written straight into a texture. See the WebGL2 half for why. */
249
288
  export function setGpuWhiteTexture(device, sprites, slot) {
250
289
  const texture = device.createTexture({
@@ -265,7 +304,7 @@ export function setGpuWhiteTexture(device, sprites, slot) {
265
304
  { binding: FRAG.textures.uSpriteTexture.sampler, resource: sprites.samplers.nearest },
266
305
  ],
267
306
  });
268
- sprites.slots[slot] = { texture, bindGroup };
307
+ sprites.slots[slot] = { texture, bindGroup, format: 'rgba8unorm', levels: 1 };
269
308
  }
270
309
  export function uploadGpuInstances(device, sprites, batch) {
271
310
  if (batch.count === 0)
@@ -2,7 +2,7 @@
2
2
  import type { PassDefinition } from '@driftengine/core';
3
3
  import type { Affine2D } from './camera2d.ts';
4
4
  import type { SpriteBatch } from './spriteBatch.ts';
5
- import type { SpriteImage, SpriteTextureOptions } from './spriteTexture.ts';
5
+ import type { SpriteImage, SpriteRegion, SpriteTextureOptions } from './spriteTexture.ts';
6
6
  /** Sprites a frame, if the caller does not say. Four thousand quads is 224 KB of instance data. */
7
7
  export declare const DEFAULT_SPRITE_CAPACITY = 4096;
8
8
  /**
@@ -50,5 +50,27 @@ export interface SpritePass extends PassDefinition {
50
50
  setTransform(affine: Affine2D): void;
51
51
  /** Fill a texture slot. Safe before the pass is registered; applied when it gets a device. */
52
52
  setTexture(slot: number, source: SpriteImage, options?: SpriteTextureOptions): void;
53
+ /**
54
+ * Copy `region` of `source` into the slot's texture at the same place, keeping the rest: what an
55
+ * atlas painted one picture at a time sends, instead of all of itself.
56
+ *
57
+ * The slot must hold a texture made from an image of `source`'s size, through `setTexture`; it
58
+ * keeps the options it was made with. Otherwise, or before the pass has a device, this does what
59
+ * `setTexture` does with the slot's options (the default ones for an empty slot), so a caller
60
+ * never has to ask which. The region is clipped to the image, and an empty one copies nothing. A
61
+ * slot with a mip chain has the chain rebuilt.
62
+ *
63
+ * **The saving, measured by a consumer painting a 2D layer into atlases**: `setTexture` sends the
64
+ * whole image and, on WebGPU, makes the texture again, about 100 ms for a 5120-texel side, while
65
+ * the pictures new on that frame covered about 6 of its 26 million texels and a typical later
66
+ * frame changes a timer's digits. Call it before `drawPass` in a frame, as `setTexture` is.
67
+ *
68
+ * **Paint such an atlas into a 2D context made with `willReadFrequently: true`.** On WebGPU a
69
+ * canvas drawn on the GPU is transferred whole before any part of it is copied, measured at 66 ms
70
+ * for one 256 by 64 rectangle of a 5120-texel atlas against 0.4 ms from a canvas kept in memory;
71
+ * WebGL2 copies a rectangle quickly from either. What the option gives up is painting on the GPU,
72
+ * which matters for a canvas redrawn whole every frame and not for an atlas painted in pieces.
73
+ */
74
+ updateTexture(slot: number, source: SpriteImage, region: SpriteRegion): void;
53
75
  }
54
76
  export declare function createSpritePass(options?: SpritePassOptions): SpritePass;
@@ -3,8 +3,8 @@ import { interfaceGrade } from '@driftengine/core';
3
3
  import { SPRITE_BINDINGS } from './shaders/generated/sprite.wgsl.js';
4
4
  import { createAffine2D } from './camera2d.js';
5
5
  import { createSpriteBatch, resetSpriteBatch } from './spriteBatch.js';
6
- import { createWebgl2Sprites, disposeWebgl2Sprites, drawWebgl2Sprites, setWebgl2SpriteTexture, setWebgl2WhiteTexture, uploadWebgl2Instances, } from './spriteGl.js';
7
- import { createGpuSprites, disposeGpuSprites, drawGpuSprites, spritePipelineFor, setGpuSpriteTexture, setGpuWhiteTexture, uploadGpuInstances, } from './spriteGpu.js';
6
+ import { createWebgl2Sprites, disposeWebgl2Sprites, drawWebgl2Sprites, setWebgl2SpriteTexture, updateWebgl2SpriteTexture, setWebgl2WhiteTexture, uploadWebgl2Instances, } from './spriteGl.js';
7
+ import { createGpuSprites, disposeGpuSprites, drawGpuSprites, spritePipelineFor, setGpuSpriteTexture, updateGpuSpriteTexture, setGpuWhiteTexture, uploadGpuInstances, } from './spriteGpu.js';
8
8
  import { DEFAULT_SPRITE_TEXTURE_OPTIONS } from './spriteTexture.js';
9
9
  const VERT = SPRITE_BINDINGS.SPRITE_VERT;
10
10
  const FRAG = SPRITE_BINDINGS.SPRITE_FRAG;
@@ -37,6 +37,21 @@ export function createSpritePass(options = {}) {
37
37
  let device = null;
38
38
  let clipCorrection = IDENTITY_CLIP;
39
39
  const pending = [];
40
+ /* What each slot was made from, so an update knows whether a partial copy fits it. */
41
+ const held = Array.from({ length: slots }, () => null);
42
+ function setTexture(slot, source, textureOptions) {
43
+ if (slot < 0 || slot >= slots) {
44
+ throw new RangeError(`${label}: slot ${slot} is outside the ${slots} this pass has`);
45
+ }
46
+ const options = textureOptions ?? DEFAULT_SPRITE_TEXTURE_OPTIONS;
47
+ held[slot] = { width: source.width, height: source.height, options };
48
+ const entry = { slot, source, options };
49
+ if (gl === null && gpu === null) {
50
+ pending.push(entry);
51
+ return;
52
+ }
53
+ applyTexture(entry);
54
+ }
40
55
  const applyTexture = (entry) => {
41
56
  if (gl !== null && context !== null) {
42
57
  setWebgl2SpriteTexture(context, gl, entry.slot, entry.source, entry.options);
@@ -56,20 +71,32 @@ export function createSpritePass(options = {}) {
56
71
  setTransform(affine) {
57
72
  toNdc.set(affine);
58
73
  },
59
- setTexture(slot, source, textureOptions) {
60
- if (slot < 0 || slot >= slots) {
61
- throw new RangeError(`${label}: slot ${slot} is outside the ${slots} this pass has`);
74
+ setTexture,
75
+ updateTexture(slot, source, region) {
76
+ const was = slot >= 0 && slot < slots ? held[slot] : null;
77
+ if (was === null ||
78
+ was === undefined ||
79
+ was.width !== source.width ||
80
+ was.height !== source.height ||
81
+ (gl === null && gpu === null)) {
82
+ setTexture(slot, source, was?.options);
83
+ return;
62
84
  }
63
- const entry = {
64
- slot,
65
- source,
66
- options: textureOptions ?? DEFAULT_SPRITE_TEXTURE_OPTIONS,
67
- };
68
- if (gl === null && gpu === null) {
69
- pending.push(entry);
85
+ /* Whole texels inside the image: a rectangle reaching past an edge copies what is inside it. */
86
+ const x0 = Math.max(0, Math.floor(region.x));
87
+ const y0 = Math.max(0, Math.floor(region.y));
88
+ const x1 = Math.min(source.width, Math.ceil(region.x + region.width));
89
+ const y1 = Math.min(source.height, Math.ceil(region.y + region.height));
90
+ if (x1 <= x0 || y1 <= y0)
91
+ return;
92
+ if (gl !== null && context !== null) {
93
+ const mipmap = was.options.mipmap === true;
94
+ updateWebgl2SpriteTexture(context, gl, slot, source, x0, y0, x1 - x0, y1 - y0, mipmap);
70
95
  return;
71
96
  }
72
- applyTexture(entry);
97
+ if (gpu !== null && device !== null) {
98
+ updateGpuSpriteTexture(device, gpu, slot, source, x0, y0, x1 - x0, y1 - y0);
99
+ }
73
100
  },
74
101
  init(passDevice) {
75
102
  /*
@@ -12,6 +12,16 @@
12
12
  * ask the caller how big the image it just handed over is.
13
13
  */
14
14
  export type SpriteImage = ImageBitmap | HTMLCanvasElement | OffscreenCanvas;
15
+ /**
16
+ * A rectangle of a sprite image, in its texels from the top left: what `updateTexture` copies, from
17
+ * the image into the slot's texture at the same place.
18
+ */
19
+ export interface SpriteRegion {
20
+ readonly x: number;
21
+ readonly y: number;
22
+ readonly width: number;
23
+ readonly height: number;
24
+ }
15
25
  export interface SpriteTextureOptions {
16
26
  /**
17
27
  * `srgb` is right for anything painted to be looked at, which is every sprite sheet — the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/ui2d",
3
- "version": "4.13.0",
3
+ "version": "4.13.1",
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.13.0"
30
+ "@driftengine/core": "4.13.1"
31
31
  },
32
32
  "author": "Drift Technologies",
33
33
  "repository": {
package/src/index.ts CHANGED
@@ -25,7 +25,7 @@ export { DEFAULT_SPRITE_CAPACITY, DEFAULT_SPRITE_SLOTS, createSpritePass } from
25
25
  export type { SpritePass, SpritePassOptions } from './spritePass.ts';
26
26
 
27
27
  export { DEFAULT_SPRITE_TEXTURE_OPTIONS } from './spriteTexture.ts';
28
- export type { SpriteImage, SpriteTextureOptions } from './spriteTexture.ts';
28
+ export type { SpriteImage, SpriteRegion, SpriteTextureOptions } from './spriteTexture.ts';
29
29
 
30
30
  export {
31
31
  createSpriteFrame,
package/src/spriteGl.ts CHANGED
@@ -169,6 +169,45 @@ export function setWebgl2SpriteTexture(
169
169
  sprites.textures[slot] = texture;
170
170
  }
171
171
 
172
+ /**
173
+ * Copy one rectangle of `source` into the slot's texture at the same place: the WebGL2 half of
174
+ * `SpritePass.updateTexture`, which checks that the slot holds a texture of `source`'s size and has
175
+ * clipped the rectangle to it. See `updateGpuSpriteTexture` for why it exists.
176
+ *
177
+ * **The rectangle is chosen by the unpack state, and the state is put back to zero.** WebGL2 reads a
178
+ * sub-rectangle of a canvas or a bitmap through `UNPACK_SKIP_PIXELS`, `UNPACK_SKIP_ROWS` and
179
+ * `UNPACK_ROW_LENGTH`, set to the image's own width so a row is read as a row of the image. That is
180
+ * context state every other upload on this context reads too, core's included, so leaving it set
181
+ * would have the next texture anywhere in the engine start at this rectangle's corner. A chain,
182
+ * where the slot has one, is generated again, since its lower levels mixed the texels just replaced.
183
+ */
184
+ export function updateWebgl2SpriteTexture(
185
+ gl: WebGL2RenderingContext,
186
+ sprites: Webgl2Sprites,
187
+ slot: number,
188
+ source: TexImageSource & { readonly width: number },
189
+ x: number,
190
+ y: number,
191
+ width: number,
192
+ height: number,
193
+ mipmap: boolean,
194
+ ): void {
195
+ const texture = sprites.textures[slot];
196
+ if (texture === null || texture === undefined) return;
197
+ gl.activeTexture(gl.TEXTURE0 + SPRITE_UNIT);
198
+ gl.bindTexture(gl.TEXTURE_2D, texture);
199
+ gl.pixelStorei(gl.UNPACK_ROW_LENGTH, source.width);
200
+ gl.pixelStorei(gl.UNPACK_SKIP_PIXELS, x);
201
+ gl.pixelStorei(gl.UNPACK_SKIP_ROWS, y);
202
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, x, y, width, height, gl.RGBA, gl.UNSIGNED_BYTE, source);
203
+ gl.pixelStorei(gl.UNPACK_ROW_LENGTH, 0);
204
+ gl.pixelStorei(gl.UNPACK_SKIP_PIXELS, 0);
205
+ gl.pixelStorei(gl.UNPACK_SKIP_ROWS, 0);
206
+ if (mipmap) gl.generateMipmap(gl.TEXTURE_2D);
207
+ gl.bindTexture(gl.TEXTURE_2D, null);
208
+ gl.activeTexture(gl.TEXTURE0);
209
+ }
210
+
172
211
  /**
173
212
  * Fill a slot with one opaque white texel, from an array rather than an image.
174
213
  *
package/src/spriteGpu.ts CHANGED
@@ -37,6 +37,9 @@ const TEXTURE_USAGE = 4 | 2 | 16; // TEXTURE_BINDING | COPY_DST | RENDER_ATTACHM
37
37
  export interface GpuSpriteSlot {
38
38
  readonly texture: GPUTexture;
39
39
  readonly bindGroup: GPUBindGroup;
40
+ /** Kept so a partial update can rebuild the chain it overwrote part of. */
41
+ readonly format: GPUTextureFormat;
42
+ readonly levels: number;
40
43
  }
41
44
 
42
45
  /** Pipelines by colour format, then depth format, then sample count: a lookup that allocates nothing. */
@@ -331,9 +334,62 @@ export function setGpuSpriteTexture(
331
334
  { binding: FRAG.textures.uSpriteTexture.sampler, resource: sampler },
332
335
  ],
333
336
  });
334
- sprites.slots[slot] = { texture, bindGroup };
337
+ sprites.slots[slot] = { texture, bindGroup, format, levels };
335
338
  }
336
339
 
340
+ /**
341
+ * Copy one rectangle of `source` into the slot's texture at the same place, keeping the texture and
342
+ * its bind group: the WebGPU half of `SpritePass.updateTexture`, which checks that the slot holds a
343
+ * texture of `source`'s size before it calls this, and has clipped the rectangle to it.
344
+ *
345
+ * **Why this exists.** `setGpuSpriteTexture` destroys the slot's texture, makes it again at full size
346
+ * and copies the whole image in, so an atlas painted one picture at a time sent every texel of the
347
+ * atlas for each picture: measured by a consumer at about 100 ms for a 5120-texel side. This sends
348
+ * the rectangle. A chain, where the slot has one, is rendered again from level zero, because a lower
349
+ * level mixes the texels just replaced with their neighbours; that is GPU work rather than an upload.
350
+ *
351
+ * **Ordered like `setTexture`, on the queue**: a copy issued after a draw that reads the slot was
352
+ * recorded, and before that frame is submitted, is what the draw sees. Paint before `drawPass`.
353
+ */
354
+ export function updateGpuSpriteTexture(
355
+ device: GPUDevice,
356
+ sprites: GpuSprites,
357
+ slot: number,
358
+ source: SpriteImage,
359
+ x: number,
360
+ y: number,
361
+ width: number,
362
+ height: number,
363
+ ): void {
364
+ const held = sprites.slots[slot];
365
+ if (held === null || held === undefined) return;
366
+ /* Refilled rather than built: a timer's digits are an update every frame. */
367
+ COPY_SOURCE.source = source as GPUCopyExternalImageSource;
368
+ COPY_ORIGIN.x = x;
369
+ COPY_ORIGIN.y = y;
370
+ COPY_TARGET.texture = held.texture;
371
+ COPY_SIZE.width = width;
372
+ COPY_SIZE.height = height;
373
+ device.queue.copyExternalImageToTexture(COPY_SOURCE, COPY_TARGET, COPY_SIZE);
374
+ /* Let go of the caller's canvas until the next copy, rather than holding it alive here. */
375
+ COPY_SOURCE.source = EMPTY_SOURCE;
376
+ generateMipChain(device, sprites.mipPipelines, held.texture, held.format, held.levels);
377
+ }
378
+
379
+ /* The one origin both ends of the copy share: a rectangle lands where it was in the image. */
380
+ const COPY_ORIGIN = { x: 0, y: 0 };
381
+ const EMPTY_SOURCE = null as unknown as GPUCopyExternalImageSource;
382
+ const COPY_SOURCE: {
383
+ source: GPUCopyExternalImageSource;
384
+ origin: typeof COPY_ORIGIN;
385
+ flipY: boolean;
386
+ } = { source: EMPTY_SOURCE, origin: COPY_ORIGIN, flipY: false };
387
+ const COPY_TARGET: { texture: GPUTexture; origin: typeof COPY_ORIGIN } = {
388
+ texture: null as unknown as GPUTexture,
389
+ origin: COPY_ORIGIN,
390
+ };
391
+ const COPY_SIZE = { width: 0, height: 0 };
392
+
337
393
  /** The same one white texel, written straight into a texture. See the WebGL2 half for why. */
338
394
  export function setGpuWhiteTexture(device: GPUDevice, sprites: GpuSprites, slot: number): void {
339
395
  const texture = device.createTexture({
@@ -359,7 +415,7 @@ export function setGpuWhiteTexture(device: GPUDevice, sprites: GpuSprites, slot:
359
415
  { binding: FRAG.textures.uSpriteTexture.sampler, resource: sprites.samplers.nearest },
360
416
  ],
361
417
  });
362
- sprites.slots[slot] = { texture, bindGroup };
418
+ sprites.slots[slot] = { texture, bindGroup, format: 'rgba8unorm', levels: 1 };
363
419
  }
364
420
 
365
421
  export function uploadGpuInstances(
package/src/spritePass.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  disposeWebgl2Sprites,
14
14
  drawWebgl2Sprites,
15
15
  setWebgl2SpriteTexture,
16
+ updateWebgl2SpriteTexture,
16
17
  setWebgl2WhiteTexture,
17
18
  uploadWebgl2Instances,
18
19
  } from './spriteGl.ts';
@@ -23,12 +24,13 @@ import {
23
24
  drawGpuSprites,
24
25
  spritePipelineFor,
25
26
  setGpuSpriteTexture,
27
+ updateGpuSpriteTexture,
26
28
  setGpuWhiteTexture,
27
29
  uploadGpuInstances,
28
30
  } from './spriteGpu.ts';
29
31
  import type { GpuSprites } from './spriteGpu.ts';
30
32
  import { DEFAULT_SPRITE_TEXTURE_OPTIONS } from './spriteTexture.ts';
31
- import type { SpriteImage, SpriteTextureOptions } from './spriteTexture.ts';
33
+ import type { SpriteImage, SpriteRegion, SpriteTextureOptions } from './spriteTexture.ts';
32
34
 
33
35
  const VERT = SPRITE_BINDINGS.SPRITE_VERT;
34
36
  const FRAG = SPRITE_BINDINGS.SPRITE_FRAG;
@@ -86,6 +88,34 @@ export interface SpritePass extends PassDefinition {
86
88
  setTransform(affine: Affine2D): void;
87
89
  /** Fill a texture slot. Safe before the pass is registered; applied when it gets a device. */
88
90
  setTexture(slot: number, source: SpriteImage, options?: SpriteTextureOptions): void;
91
+ /**
92
+ * Copy `region` of `source` into the slot's texture at the same place, keeping the rest: what an
93
+ * atlas painted one picture at a time sends, instead of all of itself.
94
+ *
95
+ * The slot must hold a texture made from an image of `source`'s size, through `setTexture`; it
96
+ * keeps the options it was made with. Otherwise, or before the pass has a device, this does what
97
+ * `setTexture` does with the slot's options (the default ones for an empty slot), so a caller
98
+ * never has to ask which. The region is clipped to the image, and an empty one copies nothing. A
99
+ * slot with a mip chain has the chain rebuilt.
100
+ *
101
+ * **The saving, measured by a consumer painting a 2D layer into atlases**: `setTexture` sends the
102
+ * whole image and, on WebGPU, makes the texture again, about 100 ms for a 5120-texel side, while
103
+ * the pictures new on that frame covered about 6 of its 26 million texels and a typical later
104
+ * frame changes a timer's digits. Call it before `drawPass` in a frame, as `setTexture` is.
105
+ *
106
+ * **Paint such an atlas into a 2D context made with `willReadFrequently: true`.** On WebGPU a
107
+ * canvas drawn on the GPU is transferred whole before any part of it is copied, measured at 66 ms
108
+ * for one 256 by 64 rectangle of a 5120-texel atlas against 0.4 ms from a canvas kept in memory;
109
+ * WebGL2 copies a rectangle quickly from either. What the option gives up is painting on the GPU,
110
+ * which matters for a canvas redrawn whole every frame and not for an atlas painted in pieces.
111
+ */
112
+ updateTexture(slot: number, source: SpriteImage, region: SpriteRegion): void;
113
+ }
114
+
115
+ interface HeldTexture {
116
+ width: number;
117
+ height: number;
118
+ options: SpriteTextureOptions;
89
119
  }
90
120
 
91
121
  interface PendingTexture {
@@ -112,6 +142,26 @@ export function createSpritePass(options: SpritePassOptions = {}): SpritePass {
112
142
  let device: GPUDevice | null = null;
113
143
  let clipCorrection: Float32Array = IDENTITY_CLIP;
114
144
  const pending: PendingTexture[] = [];
145
+ /* What each slot was made from, so an update knows whether a partial copy fits it. */
146
+ const held: (HeldTexture | null)[] = Array.from({ length: slots }, () => null);
147
+
148
+ function setTexture(
149
+ slot: number,
150
+ source: SpriteImage,
151
+ textureOptions?: SpriteTextureOptions,
152
+ ): void {
153
+ if (slot < 0 || slot >= slots) {
154
+ throw new RangeError(`${label}: slot ${slot} is outside the ${slots} this pass has`);
155
+ }
156
+ const options = textureOptions ?? DEFAULT_SPRITE_TEXTURE_OPTIONS;
157
+ held[slot] = { width: source.width, height: source.height, options };
158
+ const entry: PendingTexture = { slot, source, options };
159
+ if (gl === null && gpu === null) {
160
+ pending.push(entry);
161
+ return;
162
+ }
163
+ applyTexture(entry);
164
+ }
115
165
 
116
166
  const applyTexture = (entry: PendingTexture): void => {
117
167
  if (gl !== null && context !== null) {
@@ -136,20 +186,34 @@ export function createSpritePass(options: SpritePassOptions = {}): SpritePass {
136
186
  toNdc.set(affine);
137
187
  },
138
188
 
139
- setTexture(slot: number, source: SpriteImage, textureOptions?: SpriteTextureOptions): void {
140
- if (slot < 0 || slot >= slots) {
141
- throw new RangeError(`${label}: slot ${slot} is outside the ${slots} this pass has`);
189
+ setTexture,
190
+
191
+ updateTexture(slot: number, source: SpriteImage, region: SpriteRegion): void {
192
+ const was = slot >= 0 && slot < slots ? held[slot] : null;
193
+ if (
194
+ was === null ||
195
+ was === undefined ||
196
+ was.width !== source.width ||
197
+ was.height !== source.height ||
198
+ (gl === null && gpu === null)
199
+ ) {
200
+ setTexture(slot, source, was?.options);
201
+ return;
142
202
  }
143
- const entry: PendingTexture = {
144
- slot,
145
- source,
146
- options: textureOptions ?? DEFAULT_SPRITE_TEXTURE_OPTIONS,
147
- };
148
- if (gl === null && gpu === null) {
149
- pending.push(entry);
203
+ /* Whole texels inside the image: a rectangle reaching past an edge copies what is inside it. */
204
+ const x0 = Math.max(0, Math.floor(region.x));
205
+ const y0 = Math.max(0, Math.floor(region.y));
206
+ const x1 = Math.min(source.width, Math.ceil(region.x + region.width));
207
+ const y1 = Math.min(source.height, Math.ceil(region.y + region.height));
208
+ if (x1 <= x0 || y1 <= y0) return;
209
+ if (gl !== null && context !== null) {
210
+ const mipmap = was.options.mipmap === true;
211
+ updateWebgl2SpriteTexture(context, gl, slot, source, x0, y0, x1 - x0, y1 - y0, mipmap);
150
212
  return;
151
213
  }
152
- applyTexture(entry);
214
+ if (gpu !== null && device !== null) {
215
+ updateGpuSpriteTexture(device, gpu, slot, source, x0, y0, x1 - x0, y1 - y0);
216
+ }
153
217
  },
154
218
 
155
219
  init(passDevice: PassDevice): void {
@@ -14,6 +14,17 @@
14
14
  */
15
15
  export type SpriteImage = ImageBitmap | HTMLCanvasElement | OffscreenCanvas;
16
16
 
17
+ /**
18
+ * A rectangle of a sprite image, in its texels from the top left: what `updateTexture` copies, from
19
+ * the image into the slot's texture at the same place.
20
+ */
21
+ export interface SpriteRegion {
22
+ readonly x: number;
23
+ readonly y: number;
24
+ readonly width: number;
25
+ readonly height: number;
26
+ }
27
+
17
28
  export interface SpriteTextureOptions {
18
29
  /**
19
30
  * `srgb` is right for anything painted to be looked at, which is every sprite sheet — the