import { expect, test } from "@playwright/test"; import fs from "node:fs"; import path from "node:path"; const root = path.resolve(import.meta.dirname, "../../.."); const blend = fs.readFileSync(path.join(root, "tests/files/web/basic_scene.blend")); const texturePng = fs.readFileSync(path.join(root, "tests/files/web/resources/udim_1001.png")); const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-03/render-resource-budget.json"), "utf8")); for (const offscreen of [false, true]) { test(`M11-03 ${offscreen ? "Offscreen" : "main-thread"} renderer enforces light, shadow and texture budgets`, async ({ page }) => { test.setTimeout(60_000); await page.goto("/"); const result = await page.evaluate(async ({ input, useOffscreen, requestedLights }) => { const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); const { ViewportRenderer } = await import("/src/three-adapter/viewport.ts"); const { OffscreenViewportRenderer } = await import("/src/three-adapter/offscreen-viewport.ts"); const client = new WebEngineClient({ timeoutMs: 30_000 }); const canvas = document.createElement("canvas"); canvas.style.width = "256px"; canvas.style.height = "256px"; document.body.append(canvas); const renderer = useOffscreen ? new OffscreenViewportRenderer(canvas) : new ViewportRenderer(canvas); const waitFor = async (attribute: string): Promise => { for (let attempt = 0; attempt < 300; attempt++) { const value = canvas.getAttribute(attribute); if (value) return value; await new Promise((resolve) => setTimeout(resolve, 10)); } throw new Error(`${attribute} was not published`); }; try { const opened = await client.openBlend(Uint8Array.from(input).buffer); const snapshot = structuredClone(opened.snapshot); const sourceNode = snapshot.nodes.find((node) => node.type === "LIGHT")!; const sourceLight = snapshot.lights.find((light) => light.id === sourceNode.dataId)!; snapshot.nodes = [ ...snapshot.nodes.filter((node) => node.type !== "LIGHT"), ...Array.from({ length: requestedLights }, (_, index) => ({ ...sourceNode, id: `object:M11BudgetLight${index.toString().padStart(2, "0")}`, name: `M11BudgetLight${index.toString().padStart(2, "0")}`, dataId: `light:M11BudgetLight${index.toString().padStart(2, "0")}`, visible: true, })), ]; snapshot.lights = Array.from({ length: requestedLights }, (_, index) => ({ ...sourceLight, id: `light:M11BudgetLight${index.toString().padStart(2, "0")}`, name: `M11BudgetLight${index.toString().padStart(2, "0")}`, lightType: 2, castsShadow: true, })); renderer.setSnapshot(snapshot, [], []); await waitFor("data-render-budget-status"); const textures = Array.from({ length: 257 }, (_, index) => ({ schemaVersion: 1 as const, assetId: `asset:m11-budget-${index}`, imageId: `image:m11-budget-${index}`, mimeType: "image/png", width: 1, height: 1, usage: "BASE_COLOR" as const, colorSpace: "SRGB" as const, sha256: "0".repeat(64), byteLength: 1, data: Uint8Array.of(index & 0xff).buffer, })); renderer.setTextureAssets(textures); await waitFor("data-texture-budget-status"); let actualLights: number | null = null; let actualShadows: number | null = null; if (!useOffscreen) { actualLights = 0; actualShadows = 0; (renderer as InstanceType).scene.traverse((object) => { if (!("isLight" in object) || !object.isLight) return; actualLights! += 1; if ("castShadow" in object && object.castShadow) actualShadows! += 1; }); } return { render: { backend: canvas.dataset.renderBudgetBackend, status: canvas.dataset.renderBudgetStatus, code: canvas.dataset.renderBudgetCode, requestedLights: Number(canvas.dataset.renderBudgetLights), renderedLights: Number(canvas.dataset.renderBudgetRenderedLights), droppedLights: Number(canvas.dataset.renderBudgetDroppedLights), requestedShadows: Number(canvas.dataset.renderBudgetShadows), renderedShadows: Number(canvas.dataset.renderBudgetRenderedShadows), blockedShadows: Number(canvas.dataset.renderBudgetBlockedShadows), dimension: Number(canvas.dataset.renderBudgetShadowMapDimension), }, texture: { status: canvas.dataset.textureBudgetStatus, code: canvas.dataset.textureBudgetCode, assets: Number(canvas.dataset.textureBudgetAssets), loaded: Number(canvas.dataset.textureLoaded), bytes: Number(canvas.dataset.textureBytes), }, actualLights, actualShadows, }; } finally { renderer.dispose(); client.terminate(); canvas.remove(); } }, { input: Array.from(blend), useOffscreen: offscreen, requestedLights: golden.overflow.requestedLights }); expect(result.render).toEqual({ backend: "THREE_WEBGL2", status: "blocked", code: "GPU_LIGHT_BUDGET_EXCEEDED", requestedLights: golden.overflow.requestedLights, renderedLights: golden.overflow.renderedLights, droppedLights: golden.overflow.droppedLights, requestedShadows: golden.overflow.requestedShadowMaps, renderedShadows: golden.overflow.renderedShadowMaps, blockedShadows: golden.overflow.blockedShadowMaps, dimension: golden.webgl2.shadowMapDimension, }); expect(result.texture).toEqual({ status: "blocked", code: "GPU_TEXTURE_BUDGET_EXCEEDED", assets: 257, loaded: 0, bytes: 0, }); if (!offscreen) { expect(result.actualLights).toBe(golden.webgl2.maxLights); expect(result.actualShadows).toBe(golden.webgl2.maxShadowMaps); } }); } test("M11-03 keeps the previous texture set when an aggregate batch is over budget", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(async (input) => { const { GPUTextureStore } = await import("/src/three-adapter/texture-assets.ts"); const store = new GPUTextureStore("THREE_WEBGL2"); try { const data = Uint8Array.from(input).buffer; const digest = await crypto.subtle.digest("SHA-256", data); const kept = { schemaVersion: 1 as const, assetId: "asset:kept", imageId: "image:kept", mimeType: "image/png", width: 8, height: 8, usage: "BASE_COLOR", colorSpace: "SRGB", byteLength: data.byteLength, sha256: [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""), data, } as const; const accepted = await store.upload([kept]); const before = store.getAsset("image:kept", "BASE_COLOR"); const overBudget = Array.from({ length: 257 }, (_, index) => ({ ...kept, assetId: `asset:overflow:${index}`, imageId: `image:overflow:${index}`, data: kept.data.slice(0), })); const blocked = await store.upload(overBudget); const after = store.getAsset("image:kept", "BASE_COLOR"); return { accepted: [accepted.loaded, accepted.rejected, accepted.budget.status], blocked: [blocked.loaded, blocked.rejected, blocked.budget.status, blocked.budget.requestedAssets, blocked.errorCodes[0]], retained: before === after && after?.sha256 === kept.sha256, revision: store.getRevision(), }; } finally { store.dispose(); } }, Array.from(texturePng)); expect(result.accepted).toEqual([1, 0, "READY"]); expect(result.blocked).toEqual([0, 257, "BLOCKED", 258, "GPU_TEXTURE_BUDGET_EXCEEDED"]); expect(result.retained).toBe(true); expect(result.revision).toBe(2); });