feat: add render graph driven renderer architecture
Amp-Thread-ID: https://ampcode.com/threads/T-019f9d91-77c1-7206-a60f-ed6554ce92ab Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
+311
@@ -0,0 +1,311 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { ViewAtlasError, ViewAtlasManager, type ViewAtlasPlatform } from "@lib/worker/view-atlas.js";
|
||||
|
||||
class FakeBitmap {
|
||||
width = 1;
|
||||
height = 1;
|
||||
closed = false;
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
class FakeCanvas extends EventTarget {
|
||||
context = {} as OffscreenCanvasRenderingContext2D;
|
||||
constructor(
|
||||
public width: number,
|
||||
public height: number,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
getContext() {
|
||||
return this.context;
|
||||
}
|
||||
}
|
||||
function platform(options: { failCrop?: boolean; deferred?: { resolve: (bitmap: FakeBitmap) => void } } = {}) {
|
||||
const canvases: FakeCanvas[] = [],
|
||||
bitmaps: FakeBitmap[] = [];
|
||||
const value: ViewAtlasPlatform = {
|
||||
createCanvas(width, height) {
|
||||
const canvas = new FakeCanvas(width, height);
|
||||
canvases.push(canvas);
|
||||
return canvas as unknown as OffscreenCanvas;
|
||||
},
|
||||
async createBitmap() {
|
||||
if (options.failCrop) throw new Error("crop");
|
||||
if (options.deferred)
|
||||
return new Promise((resolve) => (options.deferred!.resolve = resolve)) as Promise<ImageBitmap>;
|
||||
const bitmap = new FakeBitmap();
|
||||
bitmaps.push(bitmap);
|
||||
return bitmap as unknown as ImageBitmap;
|
||||
},
|
||||
};
|
||||
return { value, canvases, bitmaps };
|
||||
}
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
test("atlas manager is lazy, serialized, and releases its only canvas after last detach", async () => {
|
||||
const fake = platform(),
|
||||
atlas = new ViewAtlasManager(fake.value);
|
||||
assert.equal(atlas.surface(), undefined);
|
||||
const [a, b] = await Promise.all([
|
||||
atlas.attach("a", { width: 100, height: 80 }),
|
||||
atlas.attach("b", { width: 90, height: 70 }),
|
||||
]);
|
||||
assert.equal(fake.canvases.length, 1);
|
||||
assert.equal(a.slot.viewId, "a");
|
||||
assert.equal(b.slot.viewId, "b");
|
||||
assert.deepEqual(await atlas.detach("a"), { invalidatedViewIds: [] });
|
||||
assert(atlas.surface());
|
||||
assert.deepEqual(await atlas.detach("b"), { invalidatedViewIds: [] });
|
||||
assert.equal(atlas.surface(), undefined);
|
||||
await atlas.attach("c", { width: 20, height: 20 });
|
||||
assert.equal(fake.canvases.length, 2);
|
||||
});
|
||||
|
||||
test("detach reports only survivors invalidated by successful compaction", async () => {
|
||||
const fake = platform(),
|
||||
atlas = new ViewAtlasManager(fake.value);
|
||||
await atlas.attach("large", { width: 1000, height: 1000 });
|
||||
await atlas.attach("small", { width: 100, height: 100 });
|
||||
assert.deepEqual(await atlas.detach("large"), { invalidatedViewIds: ["small"] });
|
||||
assert(atlas.slot("small"));
|
||||
});
|
||||
|
||||
test("failed detach compaction with a successful rollback invalidates every survivor", async () => {
|
||||
const fake = platform(),
|
||||
atlas = new ViewAtlasManager(fake.value);
|
||||
await atlas.attach("large", { width: 1000, height: 1000 });
|
||||
await atlas.attach("small-a", { width: 100, height: 100 });
|
||||
await atlas.attach("small-b", { width: 90, height: 90 });
|
||||
let probes = 0;
|
||||
fake.value.createBitmap = async () => {
|
||||
if (probes++ === 0) throw new Error("compact probe failed");
|
||||
return new FakeBitmap() as unknown as ImageBitmap;
|
||||
};
|
||||
assert.deepEqual(await atlas.detach("large"), {
|
||||
invalidatedViewIds: ["small-a", "small-b"],
|
||||
});
|
||||
assert(atlas.slot("small-a"));
|
||||
assert(atlas.slot("small-b"));
|
||||
});
|
||||
|
||||
test("failed surface transition with successful rollback stales all previous slots", async () => {
|
||||
const fake = platform(),
|
||||
atlas = new ViewAtlasManager(fake.value);
|
||||
await atlas.attach("a", { width: 100, height: 100 });
|
||||
const generation = atlas.surface()!.atlasGeneration,
|
||||
slotGeneration = atlas.slot("a")!.slotGeneration;
|
||||
let probes = 0;
|
||||
fake.value.createBitmap = async () => {
|
||||
if (probes++ === 0) throw new Error("new surface probe failed");
|
||||
return new FakeBitmap() as unknown as ImageBitmap;
|
||||
};
|
||||
await assert.rejects(atlas.attach("b", { width: 1000, height: 1000 }), (error: unknown) => {
|
||||
assert(error instanceof ViewAtlasError);
|
||||
assert.equal(error.code, "atlas.crop");
|
||||
return true;
|
||||
});
|
||||
assert.equal(atlas.slot("b"), undefined);
|
||||
assert(atlas.slot("a")!.slotGeneration > slotGeneration);
|
||||
assert(atlas.surface()!.atlasGeneration > generation);
|
||||
});
|
||||
|
||||
test("failed first probe leaves the manager empty and retryable", async () => {
|
||||
const bad = platform({ failCrop: true }),
|
||||
atlas = new ViewAtlasManager(bad.value);
|
||||
await assert.rejects(atlas.attach("a", { width: 10, height: 10 }), (error: unknown) => {
|
||||
assert(error instanceof ViewAtlasError);
|
||||
assert.equal(error.code, "atlas.crop");
|
||||
return true;
|
||||
});
|
||||
assert.equal(atlas.surface(), undefined);
|
||||
bad.value.createBitmap = async () => new FakeBitmap() as unknown as ImageBitmap;
|
||||
await atlas.attach("a", { width: 10, height: 10 });
|
||||
assert(atlas.surface());
|
||||
});
|
||||
|
||||
test("dispose during a pending probe cannot resurrect atlas state", async () => {
|
||||
const deferred = { resolve: (_bitmap: FakeBitmap) => {} },
|
||||
fake = platform({ deferred }),
|
||||
atlas = new ViewAtlasManager(fake.value),
|
||||
attached = atlas.attach("a", { width: 10, height: 10 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
atlas.dispose();
|
||||
const bitmap = new FakeBitmap();
|
||||
deferred.resolve(bitmap);
|
||||
await assert.rejects(attached, { name: "ViewAtlasError" });
|
||||
assert.equal(bitmap.closed, true);
|
||||
assert.equal(atlas.surface(), undefined);
|
||||
});
|
||||
|
||||
test("context loss during initial probe cannot expose the lost candidate", async () => {
|
||||
const deferred = { resolve: (_bitmap: FakeBitmap) => {} },
|
||||
fake = platform({ deferred }),
|
||||
atlas = new ViewAtlasManager(fake.value),
|
||||
attached = atlas.attach("a", { width: 10, height: 10 });
|
||||
await tick();
|
||||
fake.canvases[0]!.dispatchEvent(new Event("contextlost", { cancelable: true }));
|
||||
deferred.resolve(new FakeBitmap());
|
||||
await assert.rejects(attached, (error: unknown) => {
|
||||
assert(error instanceof ViewAtlasError);
|
||||
assert.equal(error.code, "atlas.context-lost");
|
||||
return true;
|
||||
});
|
||||
assert.equal(atlas.surface(), undefined);
|
||||
});
|
||||
|
||||
test("restoration events from a discarded candidate cannot affect its replacement", async () => {
|
||||
const deferred = { resolve: (_bitmap: FakeBitmap) => {} },
|
||||
fake = platform({ deferred }),
|
||||
fatals: ViewAtlasError[] = [],
|
||||
atlas = new ViewAtlasManager(fake.value, (error) => fatals.push(error)),
|
||||
first = atlas.attach("a", { width: 10, height: 10 });
|
||||
await tick();
|
||||
const discarded = fake.canvases[0]!;
|
||||
discarded.dispatchEvent(new Event("contextlost", { cancelable: true }));
|
||||
discarded.dispatchEvent(new Event("contextrestored"));
|
||||
deferred.resolve(new FakeBitmap());
|
||||
await assert.rejects(first, { name: "ViewAtlasError" });
|
||||
fake.value.createBitmap = async () => new FakeBitmap() as unknown as ImageBitmap;
|
||||
await atlas.attach("b", { width: 10, height: 10 });
|
||||
const generation = atlas.surface()!.atlasGeneration;
|
||||
discarded.dispatchEvent(new Event("contextrestored"));
|
||||
await tick();
|
||||
assert.equal(atlas.surface()!.atlasGeneration, generation);
|
||||
assert.deepEqual(fatals, []);
|
||||
});
|
||||
|
||||
test("dispose during an existing resize does not enter rollback or republish state", async () => {
|
||||
const fake = platform(),
|
||||
atlas = new ViewAtlasManager(fake.value);
|
||||
await atlas.attach("a", { width: 10, height: 10 });
|
||||
let resolve = (_bitmap: FakeBitmap) => {};
|
||||
fake.value.createBitmap = () => new Promise((done) => (resolve = done)) as Promise<ImageBitmap>;
|
||||
const resized = atlas.resize("a", { width: 400, height: 400 });
|
||||
await tick();
|
||||
atlas.dispose();
|
||||
resolve(new FakeBitmap());
|
||||
await assert.rejects(resized, { name: "ViewAtlasError" });
|
||||
assert.equal(atlas.surface(), undefined);
|
||||
});
|
||||
|
||||
test("context loss hides the surface and restoration reprobes before exposing it", async () => {
|
||||
const fake = platform(),
|
||||
fatals: ViewAtlasError[] = [],
|
||||
atlas = new ViewAtlasManager(fake.value, (error) => fatals.push(error));
|
||||
await atlas.attach("a", { width: 10, height: 10 });
|
||||
const before = atlas.surface()!;
|
||||
fake.canvases[0]!.dispatchEvent(new Event("contextlost", { cancelable: true }));
|
||||
assert.equal(atlas.surface(), undefined);
|
||||
fake.canvases[0]!.dispatchEvent(new Event("contextrestored"));
|
||||
for (let index = 0; index < 20 && !atlas.surface(); index++) await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const restored = atlas.surface()!;
|
||||
assert(restored);
|
||||
assert(restored.atlasGeneration > before.atlasGeneration);
|
||||
assert.deepEqual(fatals, []);
|
||||
await atlas.detach("a");
|
||||
await atlas.attach("b", { width: 10, height: 10 });
|
||||
assert(atlas.surface());
|
||||
});
|
||||
|
||||
test("a stale restoration cannot expose or poison a newer context loss", async () => {
|
||||
const fake = platform(),
|
||||
fatals: ViewAtlasError[] = [],
|
||||
atlas = new ViewAtlasManager(fake.value, (error) => fatals.push(error));
|
||||
await atlas.attach("a", { width: 10, height: 10 });
|
||||
const canvas = fake.canvases[0]!;
|
||||
let rejectRestore = (_error: Error) => {};
|
||||
fake.value.createBitmap = () => new Promise((_resolve, reject) => (rejectRestore = reject));
|
||||
canvas.dispatchEvent(new Event("contextlost", { cancelable: true }));
|
||||
canvas.dispatchEvent(new Event("contextrestored"));
|
||||
await tick();
|
||||
canvas.dispatchEvent(new Event("contextlost", { cancelable: true }));
|
||||
rejectRestore(new Error("stale restoration"));
|
||||
await tick();
|
||||
assert.equal(atlas.surface(), undefined);
|
||||
assert.deepEqual(fatals, []);
|
||||
fake.value.createBitmap = async () => new FakeBitmap() as unknown as ImageBitmap;
|
||||
canvas.dispatchEvent(new Event("contextrestored"));
|
||||
for (let index = 0; index < 20 && !atlas.surface(); index++) await tick();
|
||||
assert(atlas.surface());
|
||||
assert.deepEqual(fatals, []);
|
||||
});
|
||||
|
||||
test("renderAndCrop clips, clears, paints, and crops a slot without yielding", async () => {
|
||||
const calls: unknown[][] = [],
|
||||
context = new Proxy(
|
||||
{},
|
||||
{
|
||||
get:
|
||||
(_target, property) =>
|
||||
(...args: unknown[]) =>
|
||||
calls.push([property, ...args]),
|
||||
},
|
||||
) as OffscreenCanvasRenderingContext2D,
|
||||
canvases: FakeCanvas[] = [];
|
||||
const crops: number[][] = [];
|
||||
const atlas = new ViewAtlasManager({
|
||||
createCanvas: (width, height) => {
|
||||
const canvas = new FakeCanvas(width, height);
|
||||
canvas.context = context;
|
||||
canvases.push(canvas);
|
||||
return canvas as unknown as OffscreenCanvas;
|
||||
},
|
||||
async createBitmap(_canvas, x, y, width, height) {
|
||||
crops.push([x, y, width, height]);
|
||||
return Object.assign(new FakeBitmap(), { width, height }) as unknown as ImageBitmap;
|
||||
},
|
||||
});
|
||||
await atlas.attach("a", { width: 12, height: 8 });
|
||||
calls.length = crops.length = 0;
|
||||
const bitmap = await atlas.renderAndCrop("a", { width: 12, height: 8 }, (target) => {
|
||||
calls.push(["paint", target.deviceX, target.deviceY, target.deviceWidth, target.deviceHeight]);
|
||||
});
|
||||
assert(bitmap);
|
||||
assert.deepEqual(crops, [[0, 0, 12, 8]]);
|
||||
assert.deepEqual(calls.slice(0, 6), [
|
||||
["save"],
|
||||
["setTransform", 1, 0, 0, 1, 0, 0],
|
||||
["beginPath"],
|
||||
["rect", 0, 0, 12, 8],
|
||||
["clip"],
|
||||
["clearRect", 0, 0, 12, 8],
|
||||
]);
|
||||
assert.deepEqual(calls.at(-2), ["paint", 0, 0, 12, 8]);
|
||||
assert.deepEqual(calls.at(-1), ["restore"]);
|
||||
});
|
||||
|
||||
test("detach waits for a delayed render crop and only then retires its slot", async () => {
|
||||
const fake = platform(),
|
||||
atlas = new ViewAtlasManager(fake.value);
|
||||
await atlas.attach("a", { width: 10, height: 10 });
|
||||
Object.assign(fake.canvases[0]!.context, {
|
||||
save() {},
|
||||
setTransform() {},
|
||||
beginPath() {},
|
||||
rect() {},
|
||||
clip() {},
|
||||
clearRect() {},
|
||||
restore() {},
|
||||
});
|
||||
let resolve = (_bitmap: FakeBitmap) => {},
|
||||
cropStarted = false;
|
||||
fake.value.createBitmap = () =>
|
||||
new Promise((done) => {
|
||||
cropStarted = true;
|
||||
resolve = done;
|
||||
}) as Promise<ImageBitmap>;
|
||||
const rendered = atlas.renderAndCrop("a", { width: 10, height: 10 }, () => {}),
|
||||
detached = atlas.detach("a");
|
||||
for (let index = 0; index < 10 && !cropStarted; index++) await tick();
|
||||
assert.equal(cropStarted, true);
|
||||
assert(atlas.slot("a"), "detach must wait behind the crop");
|
||||
const bitmap = Object.assign(new FakeBitmap(), { width: 10, height: 10 });
|
||||
resolve(bitmap);
|
||||
assert.equal(await rendered, bitmap, "the crop ticket remains valid until it is returned");
|
||||
assert.equal(bitmap.closed, false);
|
||||
assert.deepEqual(await detached, { invalidatedViewIds: [] });
|
||||
assert.equal(atlas.slot("a"), undefined);
|
||||
});
|
||||
Reference in New Issue
Block a user