diff --git a/src/core/CoreTextureManager.test.ts b/src/core/CoreTextureManager.test.ts new file mode 100644 index 0000000..b347264 --- /dev/null +++ b/src/core/CoreTextureManager.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect } from 'vitest'; +import { ConcurrencyGate } from './CoreTextureManager.js'; + +/** + * Flush the microtask queue a few times so acquire()/release() continuations + * (which resolve across several `await` hops) have all settled before we + * assert. Deliberately microtask-only — no timers. + */ +const tick = async (): Promise => { + for (let i = 0; i < 5; i++) { + await Promise.resolve(); + } +}; + +describe('ConcurrencyGate', () => { + it('resolves acquisitions immediately up to the limit', async () => { + const gate = new ConcurrencyGate(2); + let resolved = 0; + + void gate.acquire().then(() => { + resolved++; + }); + void gate.acquire().then(() => { + resolved++; + }); + + await tick(); + expect(resolved).toBe(2); + }); + + it('blocks acquisitions beyond the limit until a slot is released', async () => { + const gate = new ConcurrencyGate(1); + let secondResolved = false; + + // Fast path takes the only slot. + await gate.acquire(); + + void gate.acquire().then(() => { + secondResolved = true; + }); + + await tick(); + expect(secondResolved).toBe(false); + + gate.release(); + await tick(); + expect(secondResolved).toBe(true); + }); + + it('hands released slots to waiters in FIFO order', async () => { + const gate = new ConcurrencyGate(1); + const order: number[] = []; + + // Hold the only slot, then queue three waiters. + await gate.acquire(); + void gate.acquire().then(() => order.push(1)); + void gate.acquire().then(() => order.push(2)); + void gate.acquire().then(() => order.push(3)); + + await tick(); + expect(order).toEqual([]); + + gate.release(); + await tick(); + expect(order).toEqual([1]); + + gate.release(); + await tick(); + expect(order).toEqual([1, 2]); + + gate.release(); + await tick(); + expect(order).toEqual([1, 2, 3]); + }); + + it('frees a slot when releasing with no waiters queued', async () => { + const gate = new ConcurrencyGate(1); + let resolved = false; + + await gate.acquire(); + gate.release(); + + void gate.acquire().then(() => { + resolved = true; + }); + + await tick(); + expect(resolved).toBe(true); + }); + + it('never runs more than the limit concurrently under a burst', async () => { + const limit = 3; + const gate = new ConcurrencyGate(limit); + const holds: Array<() => void> = []; + let active = 0; + let peak = 0; + + const run = async (): Promise => { + await gate.acquire(); + active++; + if (active > peak) { + peak = active; + } + // Hold the slot until externally released. + await new Promise((resolve) => { + holds.push(resolve); + }); + active--; + gate.release(); + }; + + const tasks: Array> = []; + for (let i = 0; i < 10; i++) { + tasks.push(run()); + } + + await tick(); + expect(active).toBe(limit); + expect(peak).toBe(limit); + + // Drain holds one at a time; each release lets exactly one waiter in. + while (holds.length > 0) { + const releaseHold = holds.shift()!; + releaseHold(); + await tick(); + expect(active).toBeLessThanOrEqual(limit); + } + + await Promise.all(tasks); + expect(peak).toBe(limit); + expect(active).toBe(0); + }); + + it('applies no ceiling below its limit', async () => { + const gate = new ConcurrencyGate(4); + let resolved = 0; + + for (let i = 0; i < 4; i++) { + void gate.acquire().then(() => { + resolved++; + }); + } + + await tick(); + expect(resolved).toBe(4); + }); +}); diff --git a/src/core/CoreTextureManager.ts b/src/core/CoreTextureManager.ts index ea48935..57dbe2c 100644 --- a/src/core/CoreTextureManager.ts +++ b/src/core/CoreTextureManager.ts @@ -6,6 +6,7 @@ import { NoiseTexture } from './textures/NoiseTexture.js'; import { SubTexture } from './textures/SubTexture.js'; import { RenderTexture } from './textures/RenderTexture.js'; import { Texture, TextureType } from './textures/Texture.js'; +import type { TextureData } from './textures/Texture.js'; import { EventEmitter } from '../common/EventEmitter.js'; import type { Stage } from './Stage.js'; import { @@ -51,6 +52,10 @@ export interface TextureManagerSettings { // 'auto' = detect via probe; boolean = force the value and skip the probe. premultiplyAlphaHonored: boolean | 'auto'; maxRetryCount: number; + // Upper bound on concurrent main-thread fetch+decode operations when no + // image worker manager exists (numImageWorkers === 0). See CoreTextureManager + // for how the gate is applied. `0` disables the gate (unbounded). + imageDecodeConcurrency: number; } export type ResizeModeOptions = @@ -230,6 +235,49 @@ class TextureUploadQueue { } } +/** + * Bounds how many async operations run concurrently. + * + * Used to cap main-thread fetch+decode (`getTextureData`) when there are no + * image workers: on such devices every `createImageBitmap`/decode runs on the + * main thread, so a scroll that makes dozens of image nodes renderable at once + * would otherwise fire dozens of decodes back-to-back and starve the render + * loop. A caller `await`s {@link acquire} before the work and calls + * {@link release} in a `finally` after it. + * + * The in-flight slot is handed straight from a releaser to the next waiter, so + * the active count never dips below `limit` while work is pending. + * + * Exported for unit testing; not part of the public renderer surface. + */ +export class ConcurrencyGate { + private inFlight = 0; + private readonly waiters: Array<() => void> = []; + + constructor(private readonly limit: number) {} + + acquire(): Promise { + if (this.inFlight < this.limit) { + this.inFlight++; + return Promise.resolve(); + } + return new Promise((resolve) => { + this.waiters.push(resolve); + }); + } + + release(): void { + const next = this.waiters.shift(); + if (next !== undefined) { + // Transfer the slot to the next waiter without touching the count — + // one operation left, one is starting, so `inFlight` is unchanged. + next(); + return; + } + this.inFlight--; + } +} + export class CoreTextureManager extends EventEmitter { /** * Map of textures by cache key @@ -246,6 +294,13 @@ export class CoreTextureManager extends EventEmitter { private initialized = false; private stage: Stage; private numImageWorkers: number; + private imageDecodeConcurrency: number; + /** + * Caps concurrent main-thread fetch+decode. Non-null only when there is no + * image worker manager (workers already bound concurrency by pool size). + * Assigned in {@link initialize} once worker availability is known. + */ + private decodeGate: ConcurrencyGate | null = null; public platform: Platform; @@ -291,12 +346,14 @@ export class CoreTextureManager extends EventEmitter { createImageBitmapSupport, premultiplyAlphaHonored, maxRetryCount, + imageDecodeConcurrency, } = settings; this.stage = stage; this.platform = stage.platform; this.numImageWorkers = numImageWorkers; this.maxRetryCount = maxRetryCount; + this.imageDecodeConcurrency = imageDecodeConcurrency; if (createImageBitmapSupport === 'auto') { validateCreateImageBitmap(this.platform) @@ -404,6 +461,23 @@ export class CoreTextureManager extends EventEmitter { ); } + // Without an image worker manager, every fetch+decode runs on the main + // thread. Bound how many run at once so a burst (e.g. a scroll that makes + // many image nodes renderable in one tick) can't serialize dozens of + // decodes and starve the render loop. With workers, the pool already caps + // concurrency, so the gate stays null (no main-thread ceiling needed). + // + // Scope: this bounds the `createImageBitmap` decode, which happens inside + // `getTextureData` (see `loadTexture`). On the `` fallback path + // (`hasCreateImageBitmap === false`) the image decodes lazily and the real + // main-thread cost is the synchronous `texImage2D` at upload, which the + // gate does not cover — the per-frame upload budget (`processUntil`) paces + // that instead. The gate is therefore most effective on the + // createImageBitmap(-polyfill) path. + if (this.imageWorkerManager === null && this.imageDecodeConcurrency > 0) { + this.decodeGate = new ConcurrencyGate(this.imageDecodeConcurrency); + } + this.initialized = true; this.emit('initialized'); @@ -494,15 +568,30 @@ export class CoreTextureManager extends EventEmitter { texture.setState('loading'); + // Bound concurrent main-thread fetch+decode. Priority (on-screen) textures + // bypass the gate so they never wait behind off-screen prefetch decodes. + // `decodeGate` is null when image workers handle decoding off-thread. + const gate = priority === true ? null : this.decodeGate; + if (gate !== null) { + await gate.acquire(); + } + // Get texture data - early return on failure - const textureDataResult = await texture.getTextureData().catch((err) => { - console.error(err); - texture.setState( - 'failed', - new TextureError(TextureErrorCode.TEXTURE_DATA_NULL), - ); - return null; - }); + let textureDataResult: TextureData | null; + try { + textureDataResult = await texture.getTextureData().catch((err) => { + console.error(err); + texture.setState( + 'failed', + new TextureError(TextureErrorCode.TEXTURE_DATA_NULL), + ); + return null; + }); + } finally { + if (gate !== null) { + gate.release(); + } + } // Early return if texture data fetch failed if (textureDataResult === null || texture.state === 'failed') { diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 2e7a385..d1826a3 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -200,6 +200,7 @@ export class Stage { premultiplyAlphaHonored, platform, maxRetryCount, + imageDecodeConcurrency, } = options; assertTruthy( @@ -229,6 +230,7 @@ export class Stage { // undefined -> true (default: assume honored, no probe) premultiplyAlphaHonored: premultiplyAlphaHonored ?? true, maxRetryCount, + imageDecodeConcurrency, }); // Wait for the Texture Manager to initialize diff --git a/src/main-api/Renderer.ts b/src/main-api/Renderer.ts index aa54f7b..7dfb020 100644 --- a/src/main-api/Renderer.ts +++ b/src/main-api/Renderer.ts @@ -481,6 +481,26 @@ export type RendererMainSettings = RendererRuntimeSettings & { */ numImageWorkers: number; + /** + * Maximum number of image fetch+decode operations allowed to run at once + * when image workers are unavailable + * + * @remarks + * Only applies when there is no image worker manager (i.e. + * `numImageWorkers === 0`, or workers/`createImageBitmap` are unsupported). + * In that mode every image decode runs on the main thread, so a burst — such + * as a scroll that makes many image nodes renderable in a single tick — can + * fire dozens of decodes back-to-back and starve the render loop. This caps + * how many run concurrently; on-screen (priority) textures bypass the cap so + * they never wait behind off-screen prefetch. + * + * Has no effect when image workers are active — the worker pool already + * bounds concurrency by its size. Set to `0` to disable the cap (unbounded). + * + * @defaultValue `4` + */ + imageDecodeConcurrency: number; + /** * Renderer Engine * @@ -781,6 +801,10 @@ export class RendererMain extends EventEmitter { textLayoutCacheSize: settings.textLayoutCacheSize ?? 250, numImageWorkers: settings.numImageWorkers !== undefined ? settings.numImageWorkers : 2, + imageDecodeConcurrency: + settings.imageDecodeConcurrency !== undefined + ? settings.imageDecodeConcurrency + : 4, enableContextSpy: settings.enableContextSpy ?? false, forceWebGL2: settings.forceWebGL2 ?? false, disableVertexArrayObject: settings.disableVertexArrayObject ?? false, @@ -854,6 +878,7 @@ export class RendererMain extends EventEmitter { fpsUpdateInterval: settings.fpsUpdateInterval!, enableClear: settings.enableClear!, numImageWorkers: settings.numImageWorkers!, + imageDecodeConcurrency: settings.imageDecodeConcurrency!, renderEngine: settings.renderEngine!, textureMemory: resolvedTxSettings, eventBus: this,