akarisub 0.1.0

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.
@@ -0,0 +1,728 @@
1
+ /// <reference types="@webgpu/types" />
2
+
3
+ import type { RenderImage } from './types'
4
+
5
+ // Maximum images per batch
6
+ const MAX_IMAGES_PER_BATCH = 256
7
+
8
+ // WebGPU max texture array layers
9
+ const MAX_TEXTURE_ARRAY_LAYERS = 256
10
+
11
+ // WGSL Vertex Shader
12
+ const VERTEX_SHADER = /* wgsl */ `
13
+ struct VertexOutput {
14
+ @builtin(position) position: vec4f,
15
+ @location(0) @interpolate(flat) instanceIndex: u32,
16
+ @location(1) @interpolate(flat) destXY: vec2f,
17
+ @location(2) @interpolate(flat) texSize: vec2f,
18
+ }
19
+
20
+ struct Uniforms {
21
+ resolution: vec2f,
22
+ }
23
+
24
+ struct ImageData {
25
+ destRect: vec4f, // x, y, w, h
26
+ texInfo: vec4f, // texW, texH, texIndex, 0
27
+ }
28
+
29
+ @group(0) @binding(0) var<uniform> uniforms: Uniforms;
30
+ @group(0) @binding(1) var<storage, read> imageData: array<ImageData>;
31
+
32
+ // Quad vertices (two triangles)
33
+ const QUAD_POSITIONS = array<vec2f, 6>(
34
+ vec2f(0.0, 0.0),
35
+ vec2f(1.0, 0.0),
36
+ vec2f(0.0, 1.0),
37
+ vec2f(1.0, 0.0),
38
+ vec2f(1.0, 1.0),
39
+ vec2f(0.0, 1.0)
40
+ );
41
+
42
+ @vertex
43
+ fn vertexMain(
44
+ @builtin(vertex_index) vertexIndex: u32,
45
+ @builtin(instance_index) instanceIndex: u32
46
+ ) -> VertexOutput {
47
+ var output: VertexOutput;
48
+
49
+ let data = imageData[instanceIndex];
50
+ let quadPos = QUAD_POSITIONS[vertexIndex];
51
+ let wh = data.destRect.zw;
52
+
53
+ // Calculate pixel position
54
+ let pixelPos = data.destRect.xy + quadPos * wh;
55
+
56
+ // Convert to clip space (-1 to 1)
57
+ var clipPos = (pixelPos / uniforms.resolution) * 2.0 - 1.0;
58
+ clipPos.y = -clipPos.y;
59
+
60
+ output.position = vec4f(clipPos, 0.0, 1.0);
61
+ output.instanceIndex = instanceIndex;
62
+ output.destXY = data.destRect.xy;
63
+ output.texSize = data.texInfo.xy;
64
+
65
+ return output;
66
+ }
67
+ `
68
+
69
+ // WGSL Fragment Shader
70
+ const FRAGMENT_SHADER = /* wgsl */ `
71
+ @group(0) @binding(2) var texArray: texture_2d_array<f32>;
72
+
73
+ struct ImageData {
74
+ destRect: vec4f,
75
+ texInfo: vec4f,
76
+ }
77
+
78
+ @group(0) @binding(1) var<storage, read> imageData: array<ImageData>;
79
+
80
+ struct FragmentInput {
81
+ @builtin(position) fragCoord: vec4f,
82
+ @location(0) @interpolate(flat) instanceIndex: u32,
83
+ @location(1) @interpolate(flat) destXY: vec2f,
84
+ @location(2) @interpolate(flat) texSize: vec2f,
85
+ }
86
+
87
+ @fragment
88
+ fn fragmentMain(input: FragmentInput) -> @location(0) vec4f {
89
+ let data = imageData[input.instanceIndex];
90
+ let texIndex = u32(data.texInfo.z);
91
+
92
+ // Calculate texel coordinates
93
+ let texCoordF = floor(input.fragCoord.xy - input.destXY);
94
+ let texCoord = vec2i(texCoordF);
95
+
96
+ // Bounds check
97
+ let texSizeI = vec2i(input.texSize);
98
+ if (texCoord.x < 0 || texCoord.y < 0 || texCoord.x >= texSizeI.x || texCoord.y >= texSizeI.y) {
99
+ discard;
100
+ }
101
+
102
+ // Load from texture array
103
+ let color = textureLoad(texArray, texCoord, texIndex, 0);
104
+
105
+ // Premultiplied alpha output
106
+ return vec4f(color.rgb * color.a, color.a);
107
+ }
108
+ `
109
+
110
+ /**
111
+ * Check if WebGPU is supported in the current browser.
112
+ */
113
+ export function isWebGPUSupported(): boolean {
114
+ return typeof navigator !== 'undefined' && 'gpu' in navigator
115
+ }
116
+
117
+ /**
118
+ * High-performance WebGPU subtitle renderer for AkariSub.
119
+ */
120
+ export class WebGPURenderer {
121
+ private device: GPUDevice | null = null
122
+ private context: GPUCanvasContext | null = null
123
+ private pipeline: GPURenderPipeline | null = null
124
+ private bindGroupLayout: GPUBindGroupLayout | null = null
125
+
126
+ private uniformBuffer: GPUBuffer | null = null
127
+ private imageDataBuffer: GPUBuffer | null = null
128
+
129
+ // Texture array for batched rendering
130
+ private textureArray: GPUTexture | null = null
131
+ private textureArrayView: GPUTextureView | null = null
132
+ private textureArraySize = 0
133
+ private textureArrayWidth = 0
134
+ private textureArrayHeight = 0
135
+
136
+ private pendingDestroyTextures: GPUTexture[] = []
137
+
138
+ // Pre-allocated typed arrays (reused every frame - ZERO allocations in hot path)
139
+ private readonly imageDataArray: Float32Array
140
+ private readonly resolutionArray = new Float32Array(2)
141
+
142
+ // Reusable conversion buffer for RGBA->BGRA (grows as needed, never shrinks)
143
+ private conversionBuffer: Uint8Array | null = null
144
+ private conversionBufferSize = 0
145
+
146
+ // Bind group (recreated only when texture array changes)
147
+ private bindGroup: GPUBindGroup | null = null
148
+ private bindGroupDirty = true
149
+
150
+ // Track canvas size to avoid redundant updates
151
+ private lastCanvasWidth = 0
152
+ private lastCanvasHeight = 0
153
+
154
+ format: GPUTextureFormat = 'bgra8unorm'
155
+
156
+ private _canvas: HTMLCanvasElement | null = null
157
+ private _initPromise: Promise<void> | null = null
158
+ private _initialized = false
159
+
160
+ constructor() {
161
+ // Pre-allocate buffer for max images (8 floats per image: destRect + texInfo)
162
+ this.imageDataArray = new Float32Array(MAX_IMAGES_PER_BATCH * 8)
163
+ }
164
+
165
+ async init(): Promise<void> {
166
+ if (this._initPromise) return this._initPromise
167
+ this._initPromise = this._initDevice()
168
+ return this._initPromise
169
+ }
170
+
171
+ private async _initDevice(): Promise<void> {
172
+ if (!navigator.gpu) {
173
+ throw new Error('WebGPU not supported')
174
+ }
175
+
176
+ const adapter = await navigator.gpu.requestAdapter({
177
+ powerPreference: 'high-performance'
178
+ })
179
+
180
+ if (!adapter) {
181
+ throw new Error('No WebGPU adapter found')
182
+ }
183
+
184
+ this.device = await adapter.requestDevice()
185
+ this.format = navigator.gpu.getPreferredCanvasFormat()
186
+
187
+ const vertexModule = this.device.createShaderModule({ code: VERTEX_SHADER })
188
+ const fragmentModule = this.device.createShaderModule({ code: FRAGMENT_SHADER })
189
+
190
+ this.uniformBuffer = this.device.createBuffer({
191
+ size: 16,
192
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
193
+ })
194
+
195
+ // Large storage buffer for all image data
196
+ this.imageDataBuffer = this.device.createBuffer({
197
+ size: MAX_IMAGES_PER_BATCH * 8 * 4,
198
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
199
+ })
200
+
201
+ // Create initial texture array with reasonable defaults
202
+ this.createTextureArray(256, 256, 32)
203
+
204
+ this.bindGroupLayout = this.device.createBindGroupLayout({
205
+ entries: [
206
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: 'uniform' } },
207
+ {
208
+ binding: 1,
209
+ visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
210
+ buffer: { type: 'read-only-storage' }
211
+ },
212
+ {
213
+ binding: 2,
214
+ visibility: GPUShaderStage.FRAGMENT,
215
+ texture: { sampleType: 'unfilterable-float', viewDimension: '2d-array' }
216
+ }
217
+ ]
218
+ })
219
+
220
+ const pipelineLayout = this.device.createPipelineLayout({
221
+ bindGroupLayouts: [this.bindGroupLayout]
222
+ })
223
+
224
+ this.pipeline = this.device.createRenderPipeline({
225
+ layout: pipelineLayout,
226
+ vertex: { module: vertexModule, entryPoint: 'vertexMain' },
227
+ fragment: {
228
+ module: fragmentModule,
229
+ entryPoint: 'fragmentMain',
230
+ targets: [
231
+ {
232
+ format: this.format,
233
+ blend: {
234
+ color: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
235
+ alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' }
236
+ }
237
+ }
238
+ ]
239
+ },
240
+ primitive: { topology: 'triangle-list' }
241
+ })
242
+
243
+ this._initialized = true
244
+ }
245
+
246
+ // Round up to next power of 2 for GPU-friendly sizes
247
+ private nextPowerOf2(n: number): number {
248
+ n--
249
+ n |= n >> 1
250
+ n |= n >> 2
251
+ n |= n >> 4
252
+ n |= n >> 8
253
+ n |= n >> 16
254
+ return n + 1
255
+ }
256
+
257
+ private createTextureArray(width: number, height: number, layers: number): void {
258
+ if (this.textureArray) {
259
+ this.pendingDestroyTextures.push(this.textureArray)
260
+ }
261
+
262
+ // Use power-of-2 dimensions for better GPU performance
263
+ const w = this.nextPowerOf2(Math.max(width, 64))
264
+ const h = this.nextPowerOf2(Math.max(height, 64))
265
+ // Clamp layers to WebGPU max (256)
266
+ const l = Math.min(this.nextPowerOf2(Math.max(layers, 16)), MAX_TEXTURE_ARRAY_LAYERS)
267
+
268
+ this.textureArray = this.device!.createTexture({
269
+ size: [w, h, l],
270
+ format: this.format,
271
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
272
+ })
273
+ this.textureArrayView = this.textureArray.createView({ dimension: '2d-array' })
274
+ this.textureArrayWidth = w
275
+ this.textureArrayHeight = h
276
+ this.textureArraySize = l
277
+ this.bindGroupDirty = true
278
+
279
+ // Clear all texture layers to transparent to prevent garbage border artifacts
280
+ const commandEncoder = this.device!.createCommandEncoder()
281
+ for (let layer = 0; layer < l; layer++) {
282
+ const layerView = this.textureArray.createView({
283
+ dimension: '2d',
284
+ baseArrayLayer: layer,
285
+ arrayLayerCount: 1
286
+ })
287
+ const renderPass = commandEncoder.beginRenderPass({
288
+ colorAttachments: [
289
+ {
290
+ view: layerView,
291
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
292
+ loadOp: 'clear',
293
+ storeOp: 'store'
294
+ }
295
+ ]
296
+ })
297
+ renderPass.end()
298
+ }
299
+ this.device!.queue.submit([commandEncoder.finish()])
300
+ }
301
+
302
+ private ensureTextureArray(maxWidth: number, maxHeight: number, count: number): boolean {
303
+ // Clamp count to max layers
304
+ const clampedCount = Math.min(count, MAX_TEXTURE_ARRAY_LAYERS)
305
+
306
+ if (
307
+ maxWidth <= this.textureArrayWidth &&
308
+ maxHeight <= this.textureArrayHeight &&
309
+ clampedCount <= this.textureArraySize
310
+ ) {
311
+ return false
312
+ }
313
+
314
+ // Grow with some headroom to avoid frequent resizes, but cap at max layers
315
+ const newWidth = this.nextPowerOf2(Math.max(this.textureArrayWidth, maxWidth))
316
+ const newHeight = this.nextPowerOf2(Math.max(this.textureArrayHeight, maxHeight))
317
+ const newLayers = Math.min(
318
+ this.nextPowerOf2(Math.max(this.textureArraySize, clampedCount, clampedCount + 16)),
319
+ MAX_TEXTURE_ARRAY_LAYERS
320
+ )
321
+
322
+ this.createTextureArray(newWidth, newHeight, newLayers)
323
+ return true
324
+ }
325
+
326
+ private updateBindGroup(): void {
327
+ if (!this.bindGroupDirty || !this.device || !this.bindGroupLayout) return
328
+
329
+ this.bindGroup = this.device.createBindGroup({
330
+ layout: this.bindGroupLayout,
331
+ entries: [
332
+ { binding: 0, resource: { buffer: this.uniformBuffer! } },
333
+ { binding: 1, resource: { buffer: this.imageDataBuffer! } },
334
+ { binding: 2, resource: this.textureArrayView! }
335
+ ]
336
+ })
337
+ this.bindGroupDirty = false
338
+ }
339
+
340
+ private ensureConversionBuffer(size: number): Uint8Array {
341
+ if (this.conversionBufferSize < size) {
342
+ // Grow with 50% headroom to reduce reallocations
343
+ this.conversionBufferSize = Math.max(size, (this.conversionBufferSize * 1.5) | 0, 65536)
344
+ this.conversionBuffer = new Uint8Array(this.conversionBufferSize)
345
+ }
346
+ return this.conversionBuffer!
347
+ }
348
+
349
+ async setCanvas(canvas: HTMLCanvasElement, width: number, height: number): Promise<void> {
350
+ await this.init()
351
+
352
+ if (!this.device) throw new Error('WebGPU device not initialized')
353
+ if (width <= 0 || height <= 0) return
354
+
355
+ this._canvas = canvas
356
+ canvas.width = width
357
+ canvas.height = height
358
+
359
+ if (!this.context) {
360
+ this.context = canvas.getContext('webgpu')
361
+ if (!this.context) throw new Error('Could not get WebGPU context')
362
+
363
+ this.context.configure({
364
+ device: this.device,
365
+ format: this.format,
366
+ alphaMode: 'premultiplied'
367
+ })
368
+ }
369
+
370
+ this.resolutionArray[0] = width
371
+ this.resolutionArray[1] = height
372
+ this.device.queue.writeBuffer(this.uniformBuffer!, 0, this.resolutionArray)
373
+
374
+ this.lastCanvasWidth = width
375
+ this.lastCanvasHeight = height
376
+ }
377
+
378
+ updateSize(width: number, height: number): void {
379
+ if (!this.device || !this._canvas || width <= 0 || height <= 0) return
380
+ if (width === this.lastCanvasWidth && height === this.lastCanvasHeight) return
381
+
382
+ this._canvas.width = width
383
+ this._canvas.height = height
384
+ this.resolutionArray[0] = width
385
+ this.resolutionArray[1] = height
386
+ this.device.queue.writeBuffer(this.uniformBuffer!, 0, this.resolutionArray)
387
+
388
+ this.lastCanvasWidth = width
389
+ this.lastCanvasHeight = height
390
+ }
391
+
392
+ /**
393
+ * Render ImageBitmaps (async render mode)
394
+ * Handles batching when image count exceeds MAX_TEXTURE_ARRAY_LAYERS
395
+ */
396
+ renderBitmaps(
397
+ images: { image: ImageBitmap; x: number; y: number }[],
398
+ _canvasWidth: number,
399
+ _canvasHeight: number
400
+ ): void {
401
+ if (!this.device || !this.context || !this.pipeline) return
402
+
403
+ const len = images.length
404
+ if (len === 0) {
405
+ this.clear()
406
+ return
407
+ }
408
+
409
+ const currentTexture = this.context.getCurrentTexture()
410
+ if (currentTexture.width === 0 || currentTexture.height === 0) return
411
+
412
+ // Single pass: find max dimensions and count valid images
413
+ let maxW = 0,
414
+ maxH = 0,
415
+ validCount = 0
416
+ for (let i = 0; i < len; i++) {
417
+ const { image } = images[i]
418
+ const w = image.width,
419
+ h = image.height
420
+ if (w > 0 && h > 0) {
421
+ if (w > maxW) maxW = w
422
+ if (h > maxH) maxH = h
423
+ validCount++
424
+ }
425
+ }
426
+
427
+ if (validCount === 0) {
428
+ this.clear()
429
+ return
430
+ }
431
+
432
+ // Ensure texture array is large enough (capped at MAX_TEXTURE_ARRAY_LAYERS)
433
+ const batchSize = Math.min(validCount, MAX_TEXTURE_ARRAY_LAYERS)
434
+ this.ensureTextureArray(maxW, maxH, batchSize)
435
+ this.updateBindGroup()
436
+
437
+ const device = this.device
438
+ const queue = device.queue
439
+ const textureArray = this.textureArray!
440
+ const imageDataArray = this.imageDataArray
441
+ const textureView = currentTexture.createView()
442
+
443
+ // Process images in batches if needed
444
+ let imageIndex = 0
445
+ let isFirstBatch = true
446
+
447
+ while (imageIndex < len) {
448
+ let texIndex = 0
449
+
450
+ // Upload batch of textures
451
+ while (imageIndex < len && texIndex < MAX_TEXTURE_ARRAY_LAYERS) {
452
+ const img = images[imageIndex++]
453
+ const bitmap = img.image
454
+ const w = bitmap.width,
455
+ h = bitmap.height
456
+ if (w <= 0 || h <= 0) continue
457
+
458
+ // Copy to texture array layer
459
+ queue.copyExternalImageToTexture(
460
+ { source: bitmap, flipY: false },
461
+ { texture: textureArray, origin: [0, 0, texIndex], premultipliedAlpha: false },
462
+ { width: w, height: h }
463
+ )
464
+
465
+ // Fill pre-allocated array
466
+ const offset = texIndex << 3
467
+ imageDataArray[offset] = img.x
468
+ imageDataArray[offset + 1] = img.y
469
+ imageDataArray[offset + 2] = w
470
+ imageDataArray[offset + 3] = h
471
+ imageDataArray[offset + 4] = w
472
+ imageDataArray[offset + 5] = h
473
+ imageDataArray[offset + 6] = texIndex
474
+ imageDataArray[offset + 7] = 0
475
+
476
+ texIndex++
477
+ }
478
+
479
+ if (texIndex === 0) continue
480
+
481
+ // Upload buffer and draw batch
482
+ queue.writeBuffer(this.imageDataBuffer!, 0, imageDataArray.buffer, 0, texIndex << 5)
483
+
484
+ const commandEncoder = device.createCommandEncoder()
485
+ const renderPass = commandEncoder.beginRenderPass({
486
+ colorAttachments: [
487
+ {
488
+ view: textureView,
489
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
490
+ loadOp: isFirstBatch ? 'clear' : 'load',
491
+ storeOp: 'store'
492
+ }
493
+ ]
494
+ })
495
+
496
+ renderPass.setPipeline(this.pipeline)
497
+ renderPass.setBindGroup(0, this.bindGroup!)
498
+ renderPass.draw(6, texIndex)
499
+ renderPass.end()
500
+
501
+ queue.submit([commandEncoder.finish()])
502
+ isFirstBatch = false
503
+ }
504
+
505
+ this.cleanupPendingTextures()
506
+ }
507
+
508
+ /**
509
+ * Render from raw ArrayBuffer data (non-async render mode)
510
+ * Handles batching when image count exceeds MAX_TEXTURE_ARRAY_LAYERS
511
+ */
512
+ render(
513
+ images: RenderImage[],
514
+ _canvasWidth: number,
515
+ _canvasHeight: number
516
+ ): void {
517
+ if (!this.device || !this.context || !this.pipeline) return
518
+
519
+ const len = images.length
520
+ if (len === 0) {
521
+ this.clear()
522
+ return
523
+ }
524
+
525
+ const currentTexture = this.context.getCurrentTexture()
526
+ if (currentTexture.width === 0 || currentTexture.height === 0) return
527
+
528
+ // Single pass: find max dimensions and count valid images
529
+ let maxW = 0,
530
+ maxH = 0,
531
+ validCount = 0
532
+ for (let i = 0; i < len; i++) {
533
+ const { w, h } = images[i]
534
+ if (w > 0 && h > 0) {
535
+ if (w > maxW) maxW = w
536
+ if (h > maxH) maxH = h
537
+ validCount++
538
+ }
539
+ }
540
+
541
+ if (validCount === 0) {
542
+ this.clear()
543
+ return
544
+ }
545
+
546
+ // Ensure texture array is large enough (capped at MAX_TEXTURE_ARRAY_LAYERS)
547
+ const batchSize = Math.min(validCount, MAX_TEXTURE_ARRAY_LAYERS)
548
+ this.ensureTextureArray(maxW, maxH, batchSize)
549
+ this.updateBindGroup()
550
+
551
+ const device = this.device
552
+ const queue = device.queue
553
+ const textureArray = this.textureArray!
554
+ const imageDataArray = this.imageDataArray
555
+ const isBGRA = this.format === 'bgra8unorm'
556
+ const textureView = currentTexture.createView()
557
+
558
+ // Process images in batches if needed
559
+ let imageIndex = 0
560
+ let isFirstBatch = true
561
+
562
+ while (imageIndex < len) {
563
+ let texIndex = 0
564
+
565
+ // Upload batch of textures
566
+ while (imageIndex < len && texIndex < MAX_TEXTURE_ARRAY_LAYERS) {
567
+ const img = images[imageIndex++]
568
+ const w = img.w,
569
+ h = img.h
570
+ if (w <= 0 || h <= 0) continue
571
+
572
+ // Upload texture data
573
+ const imgData = img.image
574
+ if (imgData instanceof ImageBitmap) {
575
+ queue.copyExternalImageToTexture(
576
+ { source: imgData, flipY: false },
577
+ { texture: textureArray, origin: [0, 0, texIndex], premultipliedAlpha: false },
578
+ { width: w, height: h }
579
+ )
580
+ } else if (imgData instanceof ArrayBuffer) {
581
+ this.uploadTextureData(texIndex, imgData, w, h, isBGRA)
582
+ }
583
+
584
+ // Fill pre-allocated array
585
+ const offset = texIndex << 3
586
+ imageDataArray[offset] = img.x
587
+ imageDataArray[offset + 1] = img.y
588
+ imageDataArray[offset + 2] = w
589
+ imageDataArray[offset + 3] = h
590
+ imageDataArray[offset + 4] = w
591
+ imageDataArray[offset + 5] = h
592
+ imageDataArray[offset + 6] = texIndex
593
+ imageDataArray[offset + 7] = 0
594
+
595
+ texIndex++
596
+ }
597
+
598
+ if (texIndex === 0) continue
599
+
600
+ // Upload buffer and draw batch
601
+ queue.writeBuffer(this.imageDataBuffer!, 0, imageDataArray.buffer, 0, texIndex << 5)
602
+
603
+ const commandEncoder = device.createCommandEncoder()
604
+ const renderPass = commandEncoder.beginRenderPass({
605
+ colorAttachments: [
606
+ {
607
+ view: textureView,
608
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
609
+ loadOp: isFirstBatch ? 'clear' : 'load',
610
+ storeOp: 'store'
611
+ }
612
+ ]
613
+ })
614
+
615
+ renderPass.setPipeline(this.pipeline)
616
+ renderPass.setBindGroup(0, this.bindGroup!)
617
+ renderPass.draw(6, texIndex)
618
+ renderPass.end()
619
+
620
+ queue.submit([commandEncoder.finish()])
621
+ isFirstBatch = false
622
+ }
623
+
624
+ this.cleanupPendingTextures()
625
+ }
626
+
627
+ private uploadTextureData(
628
+ layerIndex: number,
629
+ rgbaBuffer: ArrayBuffer,
630
+ width: number,
631
+ height: number,
632
+ swapRB: boolean
633
+ ): void {
634
+ const size = width * height * 4
635
+
636
+ if (swapRB) {
637
+ // Use reusable conversion buffer
638
+ const uploadData = this.ensureConversionBuffer(size)
639
+ const src = new Uint8Array(rgbaBuffer)
640
+
641
+ // Unrolled loop for better performance
642
+ for (let j = 0; j < size; j += 4) {
643
+ uploadData[j] = src[j + 2] // B <- R
644
+ uploadData[j + 1] = src[j + 1] // G
645
+ uploadData[j + 2] = src[j] // R <- B
646
+ uploadData[j + 3] = src[j + 3] // A
647
+ }
648
+
649
+ this.device!.queue.writeTexture(
650
+ { texture: this.textureArray!, origin: [0, 0, layerIndex] },
651
+ uploadData.buffer,
652
+ { bytesPerRow: width * 4 },
653
+ { width, height }
654
+ )
655
+ } else {
656
+ this.device!.queue.writeTexture(
657
+ { texture: this.textureArray!, origin: [0, 0, layerIndex] },
658
+ rgbaBuffer,
659
+ { bytesPerRow: width * 4 },
660
+ { width, height }
661
+ )
662
+ }
663
+ }
664
+
665
+ private cleanupPendingTextures(): void {
666
+ const pending = this.pendingDestroyTextures
667
+ const len = pending.length
668
+ if (len === 0) return
669
+
670
+ for (let i = 0; i < len; i++) {
671
+ pending[i].destroy()
672
+ }
673
+ pending.length = 0
674
+ }
675
+
676
+ clear(): void {
677
+ if (!this.device || !this.context) return
678
+
679
+ try {
680
+ const currentTexture = this.context.getCurrentTexture()
681
+ if (currentTexture.width === 0 || currentTexture.height === 0) return
682
+
683
+ const commandEncoder = this.device.createCommandEncoder()
684
+ const renderPass = commandEncoder.beginRenderPass({
685
+ colorAttachments: [
686
+ {
687
+ view: currentTexture.createView(),
688
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
689
+ loadOp: 'clear',
690
+ storeOp: 'store'
691
+ }
692
+ ]
693
+ })
694
+ renderPass.end()
695
+ this.device.queue.submit([commandEncoder.finish()])
696
+ } catch {
697
+ // Ignore errors
698
+ }
699
+ }
700
+
701
+ get initialized(): boolean {
702
+ return this._initialized
703
+ }
704
+
705
+ destroy(): void {
706
+ this.cleanupPendingTextures()
707
+
708
+ this.textureArray?.destroy()
709
+ this.textureArray = null
710
+ this.textureArrayView = null
711
+
712
+ this.uniformBuffer?.destroy()
713
+ this.uniformBuffer = null
714
+ this.imageDataBuffer?.destroy()
715
+ this.imageDataBuffer = null
716
+
717
+ this.bindGroup = null
718
+ this.conversionBuffer = null
719
+ this.conversionBufferSize = 0
720
+
721
+ this.device?.destroy()
722
+ this.device = null
723
+ this.context = null
724
+ this._canvas = null
725
+ this._initialized = false
726
+ this._initPromise = null
727
+ }
728
+ }