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.
- package/LICENSE +23 -0
- package/README.md +388 -0
- package/dist/COPYRIGHT +951 -0
- package/dist/akarisub-worker.js +39 -0
- package/dist/akarisub-worker.wasm +0 -0
- package/dist/akarisub.umd.js +159 -0
- package/dist/default.woff2 +0 -0
- package/dist/index.js +147 -0
- package/package.json +63 -0
- package/src/ts/akarisub.ts +1159 -0
- package/src/ts/types.ts +391 -0
- package/src/ts/utils.ts +512 -0
- package/src/ts/webgl2-renderer.ts +415 -0
- package/src/ts/webgpu-renderer.ts +728 -0
- package/src/ts/worker.ts +1866 -0
|
@@ -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
|
+
}
|