@bloopjs/toodle 0.1.3 → 0.1.5

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.
@@ -1,5 +1,7 @@
1
1
  import type { IRenderBackend } from "../backends/IRenderBackend";
2
2
  import type { ITextShader } from "../backends/ITextShader";
3
+ import type { WebGLBackend } from "../backends/webgl2/WebGLBackend";
4
+ import { WebGLFontPipeline } from "../backends/webgl2/WebGLFontPipeline";
3
5
  import { WebGLTextShader } from "../backends/webgl2/WebGLTextShader";
4
6
  import { FontPipeline } from "../backends/webgpu/FontPipeline";
5
7
  import { TextureComputeShader } from "../backends/webgpu/TextureComputeShader";
@@ -19,7 +21,11 @@ import type {
19
21
  TextureBundleOpts,
20
22
  TextureWithMetadata,
21
23
  } from "./types";
22
- import { getBitmapFromUrl, packBitmapsToAtlas } from "./util";
24
+ import {
25
+ getBitmapFromUrl,
26
+ packBitmapsToAtlas,
27
+ packBitmapsToAtlasCPU,
28
+ } from "./util";
23
29
 
24
30
  export type TextureId = string;
25
31
  export type BundleId = string;
@@ -349,8 +355,14 @@ export class AssetManager {
349
355
  );
350
356
  this.#fonts.set(id, textShader);
351
357
  } else {
352
- // WebGL: font loaded for measurement, but rendering will throw
353
- const textShader = new WebGLTextShader(font, limits.maxTextLength);
358
+ // WebGL: create font pipeline and text shader for rendering
359
+ const webglBackend = this.#backend as WebGLBackend;
360
+ const fontPipeline = WebGLFontPipeline.create(
361
+ webglBackend.gl,
362
+ font,
363
+ limits.maxTextLength,
364
+ );
365
+ const textShader = new WebGLTextShader(webglBackend, fontPipeline);
354
366
  this.#fonts.set(id, textShader);
355
367
  }
356
368
 
@@ -424,41 +436,58 @@ export class AssetManager {
424
436
  bundleId: BundleId,
425
437
  opts: TextureBundleOpts,
426
438
  ) {
427
- if (this.#backend.type !== "webgpu") {
428
- throw new Error(
429
- "Dynamic texture bundle registration is only supported with WebGPU backend. Use prebaked atlases instead.",
439
+ if (this.#backend.type === "webgpu") {
440
+ // WebGPU path: supports optional crop compute shader
441
+ const device = (this.#backend as WebGPUBackend).device;
442
+ const images = new Map<string, TextureWithMetadata>();
443
+
444
+ await Promise.all(
445
+ Object.entries(opts.textures).map(async ([id, url]) => {
446
+ const bitmap = await getBitmapFromUrl(url);
447
+ let textureWrapper: TextureWithMetadata = this.#wrapBitmapToTexture(
448
+ bitmap,
449
+ id,
450
+ );
451
+
452
+ if (opts.cropTransparentPixels && this.#cropComputeShader) {
453
+ textureWrapper =
454
+ await this.#cropComputeShader.processTexture(textureWrapper);
455
+ }
456
+ images.set(id, textureWrapper);
457
+ }),
430
458
  );
431
- }
432
459
 
433
- const device = (this.#backend as WebGPUBackend).device;
434
- const images = new Map<string, TextureWithMetadata>();
460
+ const atlases = await packBitmapsToAtlas(
461
+ images,
462
+ this.#backend.limits.textureSize,
463
+ device,
464
+ );
435
465
 
436
- let _networkLoadTime = 0;
437
- await Promise.all(
438
- Object.entries(opts.textures).map(async ([id, url]) => {
439
- const now = performance.now();
440
- const bitmap = await getBitmapFromUrl(url);
441
- _networkLoadTime += performance.now() - now;
442
- let textureWrapper: TextureWithMetadata = this.#wrapBitmapToTexture(
443
- bitmap,
444
- id,
466
+ this.bundles.registerDynamicBundle(bundleId, atlases);
467
+ } else {
468
+ // WebGL2 path: CPU-only packing without cropping support
469
+ if (opts.cropTransparentPixels) {
470
+ console.warn(
471
+ "cropTransparentPixels is not supported on WebGL2 backend and will be ignored.",
445
472
  );
473
+ }
446
474
 
447
- if (opts.cropTransparentPixels && this.#cropComputeShader) {
448
- textureWrapper =
449
- await this.#cropComputeShader.processTexture(textureWrapper);
450
- }
451
- images.set(id, textureWrapper);
452
- }),
453
- );
475
+ const images = new Map<string, { bitmap: ImageBitmap; id: string }>();
454
476
 
455
- const atlases = await packBitmapsToAtlas(
456
- images,
457
- this.#backend.limits.textureSize,
458
- device,
459
- );
477
+ await Promise.all(
478
+ Object.entries(opts.textures).map(async ([id, url]) => {
479
+ const bitmap = await getBitmapFromUrl(url);
480
+ images.set(id, { bitmap, id });
481
+ }),
482
+ );
460
483
 
461
- this.bundles.registerDynamicBundle(bundleId, atlases);
484
+ const atlases = await packBitmapsToAtlasCPU(
485
+ images,
486
+ this.#backend.limits.textureSize,
487
+ );
488
+
489
+ this.bundles.registerDynamicBundle(bundleId, atlases);
490
+ }
462
491
  }
463
492
 
464
493
  async #registerBundleFromAtlases(bundleId: BundleId, opts: AtlasBundleOpts) {
@@ -258,3 +258,143 @@ type Rectangle = {
258
258
  width: number;
259
259
  height: number;
260
260
  };
261
+
262
+ /**
263
+ * CPU-only version of packBitmapsToAtlas for WebGL2 backend.
264
+ * Uses OffscreenCanvas to composite packed bitmaps instead of GPU textures.
265
+ * Does not support transparent pixel cropping.
266
+ */
267
+ export async function packBitmapsToAtlasCPU(
268
+ images: Map<string, { bitmap: ImageBitmap; id: string }>,
269
+ textureSize: number,
270
+ ): Promise<CpuTextureAtlas[]> {
271
+ const cpuTextureAtlases: CpuTextureAtlas[] = [];
272
+ const packed: PackedTexture[] = [];
273
+ const spaces: Rectangle[] = [
274
+ { x: 0, y: 0, width: textureSize, height: textureSize },
275
+ ];
276
+
277
+ let atlasRegionMap = new Map<string, TextureRegion>();
278
+
279
+ for (const [id, { bitmap }] of images) {
280
+ // Find best fitting space using guillotine method
281
+ let bestSpace = -1;
282
+ let bestScore = Number.POSITIVE_INFINITY;
283
+
284
+ for (let i = 0; i < spaces.length; i++) {
285
+ const space = spaces[i];
286
+ if (bitmap.width <= space.width && bitmap.height <= space.height) {
287
+ // Score based on how well it fits (smaller score is better)
288
+ const score = Math.abs(
289
+ space.width * space.height - bitmap.width * bitmap.height,
290
+ );
291
+ if (score < bestScore) {
292
+ bestScore = score;
293
+ bestSpace = i;
294
+ }
295
+ }
296
+ }
297
+
298
+ if (bestSpace === -1) {
299
+ // Current atlas is full, finalize it and start a new one
300
+ const tex = createAtlasBitmapFromPacked(packed, textureSize);
301
+ cpuTextureAtlases.push({
302
+ texture: tex,
303
+ textureRegions: atlasRegionMap,
304
+ width: tex.width,
305
+ height: tex.height,
306
+ });
307
+
308
+ atlasRegionMap = new Map<string, TextureRegion>();
309
+ packed.length = 0;
310
+
311
+ spaces.length = 0;
312
+ spaces.push({
313
+ x: 0,
314
+ y: 0,
315
+ width: textureSize,
316
+ height: textureSize,
317
+ });
318
+ bestSpace = 0;
319
+ }
320
+
321
+ const space = spaces[bestSpace];
322
+
323
+ // Pack the image
324
+ packed.push({
325
+ texture: bitmap,
326
+ x: space.x,
327
+ y: space.y,
328
+ width: bitmap.width,
329
+ height: bitmap.height,
330
+ });
331
+
332
+ // Split remaining space into two new spaces
333
+ spaces.splice(bestSpace, 1);
334
+
335
+ if (space.width - bitmap.width > 0) {
336
+ spaces.push({
337
+ x: space.x + bitmap.width,
338
+ y: space.y,
339
+ width: space.width - bitmap.width,
340
+ height: bitmap.height,
341
+ });
342
+ }
343
+
344
+ if (space.height - bitmap.height > 0) {
345
+ spaces.push({
346
+ x: space.x,
347
+ y: space.y + bitmap.height,
348
+ width: space.width,
349
+ height: space.height - bitmap.height,
350
+ });
351
+ }
352
+
353
+ // Create atlas coords (no cropping for CPU path)
354
+ const uvScale = {
355
+ width: bitmap.width / textureSize,
356
+ height: bitmap.height / textureSize,
357
+ };
358
+ atlasRegionMap.set(id, {
359
+ uvOffset: {
360
+ x: space.x / textureSize,
361
+ y: space.y / textureSize,
362
+ },
363
+ uvScale,
364
+ uvScaleCropped: uvScale,
365
+ cropOffset: { x: 0, y: 0 },
366
+ originalSize: { width: bitmap.width, height: bitmap.height },
367
+ });
368
+ }
369
+
370
+ // Finalize the last atlas
371
+ const tex = createAtlasBitmapFromPacked(packed, textureSize);
372
+ cpuTextureAtlases.push({
373
+ texture: tex,
374
+ textureRegions: atlasRegionMap,
375
+ width: tex.width,
376
+ height: tex.height,
377
+ });
378
+
379
+ return cpuTextureAtlases;
380
+ }
381
+
382
+ /**
383
+ * Composites packed textures onto an OffscreenCanvas and returns an ImageBitmap.
384
+ */
385
+ function createAtlasBitmapFromPacked(
386
+ packed: PackedTexture[],
387
+ atlasSize: number,
388
+ ): ImageBitmap {
389
+ const canvas = new OffscreenCanvas(atlasSize, atlasSize);
390
+ const ctx = canvas.getContext("2d");
391
+ if (!ctx) {
392
+ throw new Error("Failed to get 2d context from OffscreenCanvas");
393
+ }
394
+
395
+ for (const texture of packed) {
396
+ ctx.drawImage(texture.texture, texture.x, texture.y);
397
+ }
398
+
399
+ return canvas.transferToImageBitmap();
400
+ }