@bloopjs/toodle 0.0.104 → 0.1.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.
Files changed (122) hide show
  1. package/dist/Toodle.d.ts +41 -19
  2. package/dist/Toodle.d.ts.map +1 -1
  3. package/dist/backends/IBackendShader.d.ts +48 -0
  4. package/dist/backends/IBackendShader.d.ts.map +1 -0
  5. package/dist/backends/IRenderBackend.d.ts +92 -0
  6. package/dist/backends/IRenderBackend.d.ts.map +1 -0
  7. package/dist/backends/ITextureAtlas.d.ts +34 -0
  8. package/dist/backends/ITextureAtlas.d.ts.map +1 -0
  9. package/dist/backends/detection.d.ts +16 -0
  10. package/dist/backends/detection.d.ts.map +1 -0
  11. package/dist/backends/mod.d.ts +9 -0
  12. package/dist/backends/mod.d.ts.map +1 -0
  13. package/dist/backends/webgl2/WebGLBackend.d.ts +51 -0
  14. package/dist/backends/webgl2/WebGLBackend.d.ts.map +1 -0
  15. package/dist/backends/webgl2/WebGLQuadShader.d.ts +17 -0
  16. package/dist/backends/webgl2/WebGLQuadShader.d.ts.map +1 -0
  17. package/dist/backends/webgl2/glsl/quad.glsl.d.ts +12 -0
  18. package/dist/backends/webgl2/glsl/quad.glsl.d.ts.map +1 -0
  19. package/dist/backends/webgl2/mod.d.ts +3 -0
  20. package/dist/backends/webgl2/mod.d.ts.map +1 -0
  21. package/dist/backends/webgpu/ShaderDescriptor.d.ts.map +1 -0
  22. package/dist/{textures → backends/webgpu}/TextureComputeShader.d.ts +1 -1
  23. package/dist/backends/webgpu/TextureComputeShader.d.ts.map +1 -0
  24. package/dist/backends/webgpu/WebGPUBackend.d.ts +67 -0
  25. package/dist/backends/webgpu/WebGPUBackend.d.ts.map +1 -0
  26. package/dist/backends/webgpu/WebGPUQuadShader.d.ts +18 -0
  27. package/dist/backends/webgpu/WebGPUQuadShader.d.ts.map +1 -0
  28. package/dist/backends/webgpu/mod.d.ts +3 -0
  29. package/dist/backends/webgpu/mod.d.ts.map +1 -0
  30. package/dist/backends/webgpu/parser.d.ts.map +1 -0
  31. package/dist/{shaders → backends/webgpu}/postprocess/blur.d.ts +1 -1
  32. package/dist/backends/webgpu/postprocess/blur.d.ts.map +1 -0
  33. package/dist/{shaders → backends/webgpu}/postprocess/mod.d.ts +1 -1
  34. package/dist/backends/webgpu/postprocess/mod.d.ts.map +1 -0
  35. package/dist/backends/webgpu/samplers.d.ts.map +1 -0
  36. package/dist/backends/webgpu/wgsl/example.wgsl.d.ts.map +1 -0
  37. package/dist/backends/webgpu/wgsl/hello.wgsl.d.ts.map +1 -0
  38. package/dist/backends/webgpu/wgsl/helloInstanced.wgsl.d.ts.map +1 -0
  39. package/dist/backends/webgpu/wgsl/pixel-scraping.wgsl.d.ts.map +1 -0
  40. package/dist/backends/webgpu/wgsl/quad.wgsl.d.ts.map +1 -0
  41. package/dist/coreTypes/EngineUniform.d.ts.map +1 -0
  42. package/dist/mod.d.ts +3 -2
  43. package/dist/mod.d.ts.map +1 -1
  44. package/dist/mod.js +7247 -6663
  45. package/dist/mod.js.map +27 -22
  46. package/dist/scene/Batcher.d.ts +2 -2
  47. package/dist/scene/Batcher.d.ts.map +1 -1
  48. package/dist/scene/QuadNode.d.ts +3 -2
  49. package/dist/scene/QuadNode.d.ts.map +1 -1
  50. package/dist/scene/RenderComponent.d.ts +2 -2
  51. package/dist/scene/RenderComponent.d.ts.map +1 -1
  52. package/dist/text/TextShader.d.ts +8 -6
  53. package/dist/text/TextShader.d.ts.map +1 -1
  54. package/dist/textures/AssetManager.d.ts +21 -5
  55. package/dist/textures/AssetManager.d.ts.map +1 -1
  56. package/dist/textures/util.d.ts +0 -4
  57. package/dist/textures/util.d.ts.map +1 -1
  58. package/dist/utils/boilerplate.d.ts +1 -1
  59. package/dist/utils/boilerplate.d.ts.map +1 -1
  60. package/package.json +1 -2
  61. package/src/Toodle.ts +124 -156
  62. package/src/backends/IBackendShader.ts +52 -0
  63. package/src/backends/IRenderBackend.ts +118 -0
  64. package/src/backends/ITextureAtlas.ts +35 -0
  65. package/src/backends/detection.ts +46 -0
  66. package/src/backends/mod.ts +29 -0
  67. package/src/backends/webgl2/WebGLBackend.ts +256 -0
  68. package/src/backends/webgl2/WebGLQuadShader.ts +278 -0
  69. package/src/backends/webgl2/glsl/quad.glsl.ts +114 -0
  70. package/src/backends/webgl2/mod.ts +2 -0
  71. package/src/{textures → backends/webgpu}/TextureComputeShader.ts +2 -48
  72. package/src/backends/webgpu/WebGPUBackend.ts +350 -0
  73. package/src/{shaders/QuadShader.ts → backends/webgpu/WebGPUQuadShader.ts} +226 -170
  74. package/src/backends/webgpu/mod.ts +2 -0
  75. package/src/{shaders → backends/webgpu}/parser.ts +2 -2
  76. package/src/{shaders → backends/webgpu}/postprocess/blur.ts +2 -2
  77. package/src/{shaders → backends/webgpu}/postprocess/mod.ts +1 -1
  78. package/src/mod.ts +3 -2
  79. package/src/scene/Batcher.ts +3 -3
  80. package/src/scene/QuadNode.ts +6 -2
  81. package/src/scene/RenderComponent.ts +2 -2
  82. package/src/text/TextShader.ts +17 -11
  83. package/src/textures/AssetManager.ts +117 -93
  84. package/src/textures/util.ts +0 -92
  85. package/src/utils/boilerplate.ts +1 -1
  86. package/dist/shaders/EngineUniform.d.ts.map +0 -1
  87. package/dist/shaders/IShader.d.ts +0 -15
  88. package/dist/shaders/IShader.d.ts.map +0 -1
  89. package/dist/shaders/QuadShader.d.ts +0 -18
  90. package/dist/shaders/QuadShader.d.ts.map +0 -1
  91. package/dist/shaders/ShaderDescriptor.d.ts.map +0 -1
  92. package/dist/shaders/mod.d.ts +0 -6
  93. package/dist/shaders/mod.d.ts.map +0 -1
  94. package/dist/shaders/parser.d.ts.map +0 -1
  95. package/dist/shaders/postprocess/blur.d.ts.map +0 -1
  96. package/dist/shaders/postprocess/mod.d.ts.map +0 -1
  97. package/dist/shaders/samplers.d.ts.map +0 -1
  98. package/dist/shaders/wgsl/example.wgsl.d.ts.map +0 -1
  99. package/dist/shaders/wgsl/hello.wgsl.d.ts.map +0 -1
  100. package/dist/shaders/wgsl/helloInstanced.wgsl.d.ts.map +0 -1
  101. package/dist/shaders/wgsl/quad.wgsl.d.ts.map +0 -1
  102. package/dist/textures/TextureComputeShader.d.ts.map +0 -1
  103. package/dist/textures/pixel-scraping.wgsl.d.ts.map +0 -1
  104. package/src/shaders/IShader.ts +0 -20
  105. package/src/shaders/mod.ts +0 -5
  106. /package/dist/{shaders → backends/webgpu}/ShaderDescriptor.d.ts +0 -0
  107. /package/dist/{shaders → backends/webgpu}/parser.d.ts +0 -0
  108. /package/dist/{shaders → backends/webgpu}/samplers.d.ts +0 -0
  109. /package/dist/{shaders → backends/webgpu}/wgsl/example.wgsl.d.ts +0 -0
  110. /package/dist/{shaders → backends/webgpu}/wgsl/hello.wgsl.d.ts +0 -0
  111. /package/dist/{shaders → backends/webgpu}/wgsl/helloInstanced.wgsl.d.ts +0 -0
  112. /package/dist/{textures → backends/webgpu/wgsl}/pixel-scraping.wgsl.d.ts +0 -0
  113. /package/dist/{shaders → backends/webgpu}/wgsl/quad.wgsl.d.ts +0 -0
  114. /package/dist/{shaders → coreTypes}/EngineUniform.d.ts +0 -0
  115. /package/src/{shaders → backends/webgpu}/ShaderDescriptor.ts +0 -0
  116. /package/src/{shaders → backends/webgpu}/samplers.ts +0 -0
  117. /package/src/{shaders → backends/webgpu}/wgsl/example.wgsl.ts +0 -0
  118. /package/src/{shaders → backends/webgpu}/wgsl/hello.wgsl.ts +0 -0
  119. /package/src/{shaders → backends/webgpu}/wgsl/helloInstanced.wgsl.ts +0 -0
  120. /package/src/{textures → backends/webgpu/wgsl}/pixel-scraping.wgsl.ts +0 -0
  121. /package/src/{shaders → backends/webgpu}/wgsl/quad.wgsl.ts +0 -0
  122. /package/src/{shaders → coreTypes}/EngineUniform.ts +0 -0
@@ -0,0 +1,114 @@
1
+ export const vertexShader = /*glsl*/ `#version 300 es
2
+ precision highp float;
3
+
4
+ // Engine uniforms
5
+ uniform mat3 u_viewProjection;
6
+ uniform vec2 u_resolution;
7
+
8
+ // Instance data attributes
9
+ // location 0-2 are the model matrix for this instanced quad (mat3 as 3 vec3s)
10
+ layout(location = 0) in vec4 a_model0;
11
+ layout(location = 1) in vec4 a_model1;
12
+ layout(location = 2) in vec4 a_model2;
13
+ // location 3 is the tint color
14
+ layout(location = 3) in vec4 a_tint;
15
+ // location 4 is the uv offset and scale
16
+ layout(location = 4) in vec4 a_uvOffsetAndScale;
17
+ // location 5 is the crop offset and scale
18
+ layout(location = 5) in vec4 a_cropOffsetAndScale;
19
+ // location 6 is the atlas index (integer attribute)
20
+ layout(location = 6) in uint a_atlasIndex;
21
+
22
+ // Outputs to fragment shader
23
+ out vec4 v_uv; // xy = atlas uv, zw = original uv
24
+ out vec4 v_tint;
25
+ flat out int v_atlasIndex;
26
+
27
+ // Lookup tables for unit quad positions and UVs
28
+ const vec2 posLookup[4] = vec2[4](
29
+ vec2(-0.5, 0.5),
30
+ vec2(-0.5, -0.5),
31
+ vec2(0.5, 0.5),
32
+ vec2(0.5, -0.5)
33
+ );
34
+
35
+ const vec2 uvLookup[4] = vec2[4](
36
+ vec2(0.0, 0.0),
37
+ vec2(0.0, 1.0),
38
+ vec2(1.0, 0.0),
39
+ vec2(1.0, 1.0)
40
+ );
41
+
42
+ void main() {
43
+ // Reconstruct model matrix from instance data
44
+ mat3 modelMatrix = mat3(a_model0.xyz, a_model1.xyz, a_model2.xyz);
45
+
46
+ // Transform vertex position
47
+ vec2 localPosition = posLookup[gl_VertexID];
48
+ vec2 cropOffset = a_cropOffsetAndScale.xy;
49
+ vec2 cropScale = a_cropOffsetAndScale.zw;
50
+ vec2 croppedPosition = localPosition * cropScale + cropOffset;
51
+ vec3 worldPosition = modelMatrix * vec3(croppedPosition, 1.0);
52
+ vec3 clipPosition = u_viewProjection * worldPosition;
53
+ gl_Position = vec4(clipPosition.xy, 0.0, 1.0);
54
+
55
+ // Set UV coordinates
56
+ vec2 originalUv = uvLookup[gl_VertexID];
57
+ vec2 atlasUv = originalUv * a_uvOffsetAndScale.zw * cropScale + a_uvOffsetAndScale.xy;
58
+ v_uv = vec4(atlasUv, originalUv);
59
+
60
+ // Pass through tint and atlas index
61
+ v_tint = a_tint;
62
+ v_atlasIndex = int(a_atlasIndex);
63
+ }
64
+
65
+ `;
66
+
67
+ /**
68
+ * Default fragment shader for WebGL2 quad rendering.
69
+ * Custom fragment shaders must follow the same contract:
70
+ * - Required uniforms: u_resolution, u_textureArray
71
+ * - Required inputs: v_uv (vec4), v_tint (vec4), v_atlasIndex (flat int)
72
+ * - Required output: fragColor (vec4)
73
+ */
74
+ export const fragmentShader = /*glsl*/ `#version 300 es
75
+ precision highp float;
76
+ precision highp sampler2DArray;
77
+
78
+ // Engine uniforms
79
+ uniform vec2 u_resolution;
80
+
81
+ // Texture array sampler
82
+ uniform sampler2DArray u_textureArray;
83
+
84
+ // Inputs from vertex shader
85
+ in vec4 v_uv; // xy = atlas uv, zw = original uv
86
+ in vec4 v_tint;
87
+ flat in int v_atlasIndex;
88
+
89
+ // Output color
90
+ out vec4 fragColor;
91
+
92
+ void main() {
93
+ vec2 atlasUv = v_uv.xy;
94
+ vec2 originalUv = v_uv.zw;
95
+
96
+ if (v_atlasIndex == 1000) {
97
+ // Rectangle - return solid color
98
+ fragColor = vec4(1.0, 1.0, 1.0, 1.0) * v_tint;
99
+ } else if (v_atlasIndex == 1001) {
100
+ // Circle
101
+ float edgeWidth = 4.0 / max(u_resolution.x, u_resolution.y);
102
+ float centerDistance = 2.0 * distance(vec2(0.5, 0.5), originalUv);
103
+ float alpha = 1.0 - smoothstep(1.0 - edgeWidth, 1.0 + edgeWidth, centerDistance);
104
+ fragColor = vec4(v_tint.rgb, alpha * v_tint.a);
105
+ } else {
106
+ // Texture - sample from texture array
107
+ vec4 color = texture(u_textureArray, vec3(atlasUv, float(v_atlasIndex)));
108
+ fragColor = color * v_tint;
109
+ }
110
+ }
111
+ `;
112
+
113
+ /** Alias for users who want to extend the default shader */
114
+ export const defaultFragmentShader = fragmentShader;
@@ -0,0 +1,2 @@
1
+ export { WebGLBackend, type WebGLBackendOptions } from "./WebGLBackend";
2
+ export { WebGLQuadShader } from "./WebGLQuadShader";
@@ -1,12 +1,11 @@
1
- import computeShader from "./pixel-scraping.wgsl";
2
- import type { TextureWithMetadata } from "./types";
1
+ import type { TextureWithMetadata } from "../../textures/types";
2
+ import computeShader from "./wgsl/pixel-scraping.wgsl";
3
3
 
4
4
  // Constants
5
5
  const BOUNDING_BOX_SIZE = 4 * Uint32Array.BYTES_PER_ELEMENT;
6
6
  const WORKGROUP_SIZE = 8;
7
7
  const MAX_BOUND = 0xffffffff;
8
8
  const MIN_BOUND = 0x00000000;
9
- const BYTES_PER_PIXEL = 4;
10
9
 
11
10
  /**
12
11
  * The data returned by the compute shader that represents the opaque pixels in a texture.
@@ -260,51 +259,6 @@ export class TextureComputeShader {
260
259
  };
261
260
  }
262
261
 
263
- /**
264
- * Converts a GPUTexture to an ImageBitmap for display or further use.
265
- */
266
- async #textureToBitmap(
267
- texture: GPUTexture,
268
- width: number,
269
- height: number,
270
- ): Promise<ImageBitmap> {
271
- const paddedBytesPerRow = Math.ceil((width * BYTES_PER_PIXEL) / 256) * 256;
272
- const bufferSize = paddedBytesPerRow * height;
273
-
274
- const readBuffer = this.#device.createBuffer({
275
- size: bufferSize,
276
- usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
277
- });
278
-
279
- const encoder = this.#device.createCommandEncoder();
280
- encoder.copyTextureToBuffer(
281
- { texture },
282
- { buffer: readBuffer, bytesPerRow: paddedBytesPerRow },
283
- { width, height, depthOrArrayLayers: 1 },
284
- );
285
- this.#device.queue.submit([encoder.finish()]);
286
-
287
- await readBuffer.mapAsync(GPUMapMode.READ);
288
- const raw = readBuffer.getMappedRange();
289
- const rawArray = new Uint8Array(raw);
290
-
291
- const pixelData = new Uint8ClampedArray(width * height * 4);
292
- for (let y = 0; y < height; y++) {
293
- const src = y * paddedBytesPerRow;
294
- const dst = y * width * 4;
295
- pixelData.set(rawArray.subarray(src, src + width * 4), dst);
296
- }
297
- readBuffer.unmap();
298
-
299
- const canvas = document.createElement("canvas");
300
- canvas.width = width;
301
- canvas.height = height;
302
- const ctx = canvas.getContext("2d")!;
303
- ctx.putImageData(new ImageData(pixelData, width, height), 0, 0);
304
-
305
- return await createImageBitmap(canvas);
306
- }
307
-
308
262
  // Bind group helpers
309
263
 
310
264
  #boundsBindGroup(inputTexture: GPUTexture): GPUBindGroup {
@@ -0,0 +1,350 @@
1
+ import type { Color } from "../../coreTypes/Color";
2
+ import type { EngineUniform } from "../../coreTypes/EngineUniform";
3
+ import type { Size } from "../../coreTypes/Size";
4
+ import type { Limits, LimitsOptions } from "../../limits";
5
+ import { DEFAULT_LIMITS } from "../../limits";
6
+ import type { CpuTextureAtlas } from "../../textures/types";
7
+ import { assert } from "../../utils/assert";
8
+ import type { IBackendShader, QuadShaderCreationOpts } from "../IBackendShader";
9
+ import type { IRenderBackend } from "../IRenderBackend";
10
+ import type {
11
+ ITextureAtlas,
12
+ TextureAtlasFormat,
13
+ TextureAtlasOptions,
14
+ } from "../ITextureAtlas";
15
+ import type { PostProcess } from "./postprocess/mod";
16
+ import { WebGPUQuadShader } from "./WebGPUQuadShader";
17
+
18
+ export type WebGPUBackendOptions = {
19
+ limits?: LimitsOptions;
20
+ format?: "rgba8unorm" | "rg8unorm";
21
+ };
22
+
23
+ /**
24
+ * WebGPU implementation of the render backend.
25
+ */
26
+ export class WebGPUBackend implements IRenderBackend {
27
+ readonly type = "webgpu" as const;
28
+ readonly limits: Limits;
29
+ readonly atlasSize: Size;
30
+ readonly defaultAtlasId = "default";
31
+
32
+ #atlases = new Map<string, ITextureAtlas>();
33
+
34
+ #device: GPUDevice;
35
+ #context: GPUCanvasContext;
36
+ #presentationFormat: GPUTextureFormat;
37
+ #encoder: GPUCommandEncoder | null = null;
38
+ #renderPass: GPURenderPassEncoder | null = null;
39
+ #postprocess: PostProcess | null = null;
40
+ #pingpong: [GPUTexture, GPUTexture] | null = null;
41
+ #canvas: HTMLCanvasElement;
42
+
43
+ private constructor(
44
+ device: GPUDevice,
45
+ context: GPUCanvasContext,
46
+ presentationFormat: GPUTextureFormat,
47
+ limits: Limits,
48
+ canvas: HTMLCanvasElement,
49
+ ) {
50
+ this.#device = device;
51
+ this.#context = context;
52
+ this.#presentationFormat = presentationFormat;
53
+ this.limits = limits;
54
+ this.atlasSize = {
55
+ width: limits.textureSize,
56
+ height: limits.textureSize,
57
+ };
58
+ this.#canvas = canvas;
59
+ }
60
+
61
+ /**
62
+ * Create a WebGPU backend attached to a canvas.
63
+ */
64
+ static async create(
65
+ canvas: HTMLCanvasElement,
66
+ options: WebGPUBackendOptions = {},
67
+ ): Promise<WebGPUBackend> {
68
+ const adapter = await navigator.gpu.requestAdapter();
69
+ if (!adapter) {
70
+ throw new Error("WebGPU not supported: no adapter found");
71
+ }
72
+
73
+ const device = await adapter.requestDevice();
74
+ device.lost.then((info) => {
75
+ console.error("GPU Device lost", info);
76
+ });
77
+
78
+ const context = canvas.getContext("webgpu");
79
+ assert(context, "Could not get WebGPU context from canvas");
80
+
81
+ const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
82
+
83
+ context.configure({
84
+ device,
85
+ format: presentationFormat,
86
+ });
87
+
88
+ const limits: Limits = {
89
+ ...DEFAULT_LIMITS,
90
+ ...options.limits,
91
+ };
92
+
93
+ const backend = new WebGPUBackend(
94
+ device,
95
+ context,
96
+ presentationFormat,
97
+ limits,
98
+ canvas,
99
+ );
100
+
101
+ // Create the default texture atlas
102
+ backend.createTextureAtlas("default", {
103
+ format: options.format ?? "rgba8unorm",
104
+ layers: limits.textureArrayLayers,
105
+ size: limits.textureSize,
106
+ });
107
+
108
+ return backend;
109
+ }
110
+
111
+ startFrame(clearColor: Color, loadOp: "clear" | "load"): void {
112
+ this.#encoder = this.#device.createCommandEncoder();
113
+
114
+ // If postprocessing, render to ping-pong texture; otherwise render to canvas
115
+ const target = this.#postprocess
116
+ ? this.#pingpong![0]
117
+ : this.#context.getCurrentTexture();
118
+
119
+ this.#renderPass = this.#encoder.beginRenderPass({
120
+ label: "toodle frame",
121
+ colorAttachments: [
122
+ {
123
+ view: target.createView(),
124
+ clearValue: clearColor,
125
+ loadOp,
126
+ storeOp: "store",
127
+ },
128
+ ],
129
+ });
130
+ }
131
+
132
+ endFrame(): void {
133
+ assert(this.#renderPass, "No render pass - did you call startFrame?");
134
+ assert(this.#encoder, "No encoder - did you call startFrame?");
135
+
136
+ this.#renderPass.end();
137
+
138
+ // Run postprocessing if set
139
+ if (this.#postprocess && this.#pingpong) {
140
+ this.#postprocess.process(
141
+ this.#device.queue,
142
+ this.#encoder,
143
+ this.#pingpong,
144
+ this.#context.getCurrentTexture(),
145
+ );
146
+ }
147
+
148
+ this.#device.queue.submit([this.#encoder.finish()]);
149
+
150
+ this.#renderPass = null;
151
+ this.#encoder = null;
152
+ }
153
+
154
+ updateEngineUniform(_uniform: EngineUniform): void {
155
+ // Uniforms are updated per-shader in WebGPU, not at the backend level
156
+ // This is handled in WebGPUQuadShader.startFrame
157
+ }
158
+
159
+ async uploadAtlas(
160
+ atlas: CpuTextureAtlas,
161
+ layerIndex: number,
162
+ atlasId?: string,
163
+ ): Promise<void> {
164
+ const targetAtlas = this.getTextureAtlas(atlasId ?? "default");
165
+ assert(targetAtlas, `Atlas "${atlasId ?? "default"}" not found`);
166
+ const texture = targetAtlas.handle as GPUTexture;
167
+
168
+ if (atlas.rg8Bytes) {
169
+ const w = texture.width;
170
+ const h = texture.height;
171
+
172
+ // WebGPU requires 256-byte bytesPerRow
173
+ const rowBytes = w * 2;
174
+ assert(rowBytes % 256 === 0, "rowBytes must be a multiple of 256");
175
+
176
+ this.#device.queue.writeTexture(
177
+ {
178
+ texture,
179
+ origin: { x: 0, y: 0, z: layerIndex },
180
+ },
181
+ atlas.rg8Bytes,
182
+ { bytesPerRow: rowBytes, rowsPerImage: h },
183
+ { width: w, height: h, depthOrArrayLayers: 1 },
184
+ );
185
+ } else {
186
+ this.#device.queue.copyExternalImageToTexture(
187
+ {
188
+ source: atlas.texture,
189
+ },
190
+ {
191
+ texture,
192
+ origin: [0, 0, layerIndex],
193
+ },
194
+ [atlas.texture.width, atlas.texture.height, 1],
195
+ );
196
+ }
197
+ }
198
+
199
+ createQuadShader(opts: QuadShaderCreationOpts): IBackendShader {
200
+ return new WebGPUQuadShader(
201
+ opts.label,
202
+ this,
203
+ opts.instanceCount,
204
+ opts.userCode,
205
+ opts.blendMode,
206
+ opts.atlasId,
207
+ );
208
+ }
209
+
210
+ createTextureAtlas(id: string, options?: TextureAtlasOptions): ITextureAtlas {
211
+ if (this.#atlases.has(id)) {
212
+ throw new Error(`Atlas "${id}" already exists`);
213
+ }
214
+
215
+ const format: TextureAtlasFormat = options?.format ?? "rgba8unorm";
216
+ const layers = options?.layers ?? this.limits.textureArrayLayers;
217
+ const size = options?.size ?? this.limits.textureSize;
218
+
219
+ const texture = this.#device.createTexture({
220
+ label: `Toodle Atlas "${id}"`,
221
+ size: [size, size, layers],
222
+ format,
223
+ usage:
224
+ GPUTextureUsage.TEXTURE_BINDING |
225
+ GPUTextureUsage.COPY_DST |
226
+ GPUTextureUsage.RENDER_ATTACHMENT,
227
+ });
228
+
229
+ const atlas: ITextureAtlas = { id, format, layers, size, handle: texture };
230
+ this.#atlases.set(id, atlas);
231
+ return atlas;
232
+ }
233
+
234
+ getTextureAtlas(id?: string): ITextureAtlas | null {
235
+ return this.#atlases.get(id ?? "default") ?? null;
236
+ }
237
+
238
+ destroyTextureAtlas(id: string): void {
239
+ const atlas = this.#atlases.get(id);
240
+ if (atlas) {
241
+ (atlas.handle as GPUTexture).destroy();
242
+ this.#atlases.delete(id);
243
+ }
244
+ }
245
+
246
+ resize(_width: number, _height: number): void {
247
+ // Canvas resize is handled automatically by WebGPU context
248
+ // The presentation size updates on next getCurrentTexture()
249
+
250
+ // Recreate ping-pong textures if postprocessing is active
251
+ if (this.#postprocess && this.#pingpong) {
252
+ this.#destroyPingPongTextures();
253
+ this.#createPingPongTextures();
254
+ }
255
+ }
256
+
257
+ destroy(): void {
258
+ this.#destroyPingPongTextures();
259
+ // Destroy all atlases
260
+ for (const atlas of this.#atlases.values()) {
261
+ (atlas.handle as GPUTexture).destroy();
262
+ }
263
+ this.#atlases.clear();
264
+ this.#device.destroy();
265
+ }
266
+
267
+ /**
268
+ * Set a post-processor for screen effects.
269
+ * Setting a post-processor will cause the main render to go to an offscreen texture.
270
+ * Note: Ping-pong textures are not destroyed when setting to null to avoid
271
+ * race conditions with in-flight command buffers. They are cleaned up on destroy().
272
+ */
273
+ setPostprocess(processor: PostProcess | null): void {
274
+ this.#postprocess = processor;
275
+ if (processor && !this.#pingpong) {
276
+ this.#createPingPongTextures();
277
+ }
278
+ // Don't destroy pingpong textures when setting to null - they may still be
279
+ // referenced by in-flight command buffers. They'll be cleaned up on destroy().
280
+ }
281
+
282
+ /**
283
+ * Get the current post-processor.
284
+ */
285
+ getPostprocess(): PostProcess | null {
286
+ return this.#postprocess;
287
+ }
288
+
289
+ #createPingPongTextures(): void {
290
+ const width = this.#canvas.width;
291
+ const height = this.#canvas.height;
292
+
293
+ const createTexture = (label: string) =>
294
+ this.#device.createTexture({
295
+ label,
296
+ size: [width, height],
297
+ format: this.#presentationFormat,
298
+ usage:
299
+ GPUTextureUsage.RENDER_ATTACHMENT |
300
+ GPUTextureUsage.TEXTURE_BINDING |
301
+ GPUTextureUsage.COPY_SRC,
302
+ });
303
+
304
+ this.#pingpong = [
305
+ createTexture("toodle pingpong 0"),
306
+ createTexture("toodle pingpong 1"),
307
+ ];
308
+ }
309
+
310
+ #destroyPingPongTextures(): void {
311
+ if (this.#pingpong) {
312
+ this.#pingpong[0].destroy();
313
+ this.#pingpong[1].destroy();
314
+ this.#pingpong = null;
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Get the GPU device for advanced operations.
320
+ */
321
+ get device(): GPUDevice {
322
+ return this.#device;
323
+ }
324
+
325
+ /**
326
+ * Get the canvas context.
327
+ */
328
+ get context(): GPUCanvasContext {
329
+ return this.#context;
330
+ }
331
+
332
+ /**
333
+ * Get the presentation format.
334
+ */
335
+ get presentationFormat(): GPUTextureFormat {
336
+ return this.#presentationFormat;
337
+ }
338
+
339
+ /**
340
+ * Get the current render pass encoder.
341
+ * Only available between startFrame() and endFrame().
342
+ */
343
+ get renderPass(): GPURenderPassEncoder {
344
+ assert(
345
+ this.#renderPass,
346
+ "No render pass available - did you call startFrame?",
347
+ );
348
+ return this.#renderPass;
349
+ }
350
+ }