Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/assets/__tests__/bake-receipt.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,9 +365,9 @@ describe("bake receipt", () => {
await compileAssets({ cwd: root, config: { budget: "none" } });

await expect(readFile(path.join(root, "public", output ?? ""))).rejects.toThrow(/ENOENT/u);
await expect(readFile(path.join(root, "public", "assets.manifest.json"))).rejects.toThrow(
/ENOENT/u,
);
expect(
JSON.parse(await readFile(path.join(root, "public", "assets.manifest.json"), "utf8")),
).toEqual({ version: 1, entries: {} });
expect(await readFile(path.join(root, "public", "icon.txt"), "utf8")).toBe(
"belongs to the game",
);
Expand Down
31 changes: 21 additions & 10 deletions packages/assets/__tests__/compile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -722,9 +722,7 @@ describe("compileAssets", () => {
expect(secondManifest.entries["rock.png"].output).not.toBe(first.entries["rock.png"].output);
});

it("should skip compilation without touching the output when no source directory exists", async () => {
// The pre-pipeline state: projects built before this step have no assets/ and their
// builds must keep working unchanged.
it("should publish an empty manifest when no source directory exists", async () => {
const root = await makeTempDir("threenative-compile-absent-");

const result = await compileAssets({ cwd: root });
Expand All @@ -736,7 +734,18 @@ describe("compileAssets", () => {
skippedCompression: [],
written: 0,
});
await expect(stat(path.join(root, "public"))).rejects.toThrow();
expect(
JSON.parse(await readFile(path.join(root, "public/assets.manifest.json"), "utf8")),
).toEqual({ version: 1, entries: {} });
});

it("should publish an empty manifest when the source directory is empty", async () => {
const root = await makeTempDir("threenative-compile-empty-");
await mkdir(path.join(root, "assets"));
await compileAssets({ cwd: root });
expect(
JSON.parse(await readFile(path.join(root, "public/assets.manifest.json"), "utf8")),
).toEqual({ version: 1, entries: {} });
});

it("should throw when the source path exists but is not a directory", async () => {
Expand Down Expand Up @@ -787,9 +796,7 @@ describe("compileAssets", () => {
expect(compiled).toEqual(source);
});

it("should not publish an empty manifest when the source holds only dotfiles", async () => {
// A served manifest is authoritative at runtime: an empty one would reject every load
// in a game whose assets still live in public/ by hand. Empty source means no manifest.
it("should publish an empty manifest when the source holds only dotfiles", async () => {
const root = await makeTempDir("threenative-compile-empty-source-");
await mkdir(path.join(root, "assets"));
await writeFile(path.join(root, "assets", ".gitkeep"), "");
Expand All @@ -803,10 +810,12 @@ describe("compileAssets", () => {
skippedCompression: [],
written: 0,
});
await expect(stat(path.join(root, "public", "assets.manifest.json"))).rejects.toThrow();
expect(
JSON.parse(await readFile(path.join(root, "public/assets.manifest.json"), "utf8")),
).toEqual({ version: 1, entries: {} });
});

it("should drop the stale manifest once the last input is removed", async () => {
it("should empty the stale manifest once the last input is removed", async () => {
const root = await makeTempDir("threenative-compile-stale-manifest-");
await mkdir(path.join(root, "assets"));
await writeFile(path.join(root, "assets", "rock.png"), rgbaPng({ height: 32, width: 32 }));
Expand All @@ -815,7 +824,9 @@ describe("compileAssets", () => {

await compileAssets({ cwd: root });

await expect(stat(path.join(root, "public", "assets.manifest.json"))).rejects.toThrow();
expect(
JSON.parse(await readFile(path.join(root, "public/assets.manifest.json"), "utf8")),
).toEqual({ version: 1, entries: {} });
});

it("should throw on unknown config keys and duplicate logical paths", async () => {
Expand Down
8 changes: 3 additions & 5 deletions packages/assets/src/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2393,7 +2393,7 @@ export async function compileAssets(
await measureBudget({}, layout.outputRoot, layout.runtimeDecoderCapabilities, layout.budget),
))
console.log(line);
await rm(manifestPath, { force: true });
await writeManifest(manifestPath, layout.outputRoot, undefined, {});
await rm(receiptPath, { force: true });
await rm(pendingReceiptPath, { force: true });
return { concurrencyUsed: 1, passCosts: [], skipped: 0, skippedCompression: [], written: 0 };
Expand Down Expand Up @@ -2585,16 +2585,14 @@ export async function compileAssets(
let textureCount = 0;
let compressedModelCount = 0;

// An empty (or dotfile-only) source must never publish an empty manifest: the runtime treats
// a served manifest as authoritative and would reject every load against it. A source that
// held inputs last build drops its stale manifest here, restoring the no-manifest fallback.
// Asset-free projects still serve the manifest the runtime loader requests.
if (logicals.length === 0) {
for (const line of formatBudget(
await measureBudget({}, layout.outputRoot, layout.runtimeDecoderCapabilities, layout.budget),
))
console.log(line);
await removeStaleReceiptOutputs(layout.outputRoot, writtenBefore, new Set());
if (previous.raw !== undefined) await rm(manifestPath, { force: true });
await writeManifest(manifestPath, layout.outputRoot, previous.raw, {});
await rm(receiptPath, { force: true });
await rm(pendingReceiptPath, { force: true });
const report = await runHealthReport([], layout.targets);
Expand Down
28 changes: 27 additions & 1 deletion packages/core/__tests__/assets.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,24 @@ describe("IAssetLoader through the asset manifest", () => {
expect(requests).toEqual(["/assets/rock.png"]);
});

it("should load the raw path when the served manifest lists nothing", async () => {
// An asset-free build serves `{ version: 1, entries: {} }` so the probe never 404s; files in
// `public/` must still load verbatim instead of being rejected as unlisted.
const fetchAsset = vi.fn(async () => manifestResponse({ version: 1, entries: {} }));
vi.stubGlobal("fetch", fetchAsset);
const requests: string[] = [];
const assets = createAssetLoader({
basePath: "/assets",
model: async (url) => {
requests.push(url);
return { url };
},
});

await expect(assets.model("rock.png")).resolves.toEqual({ url: "/assets/rock.png" });
expect(requests).toEqual(["/assets/rock.png"]);
});

it("should fall back to the source directory when the compiled output is gone", async () => {
// The delete-test: the bake produced `rock.a1b2c3.png` and the manifest that named it, both
// were deleted, and only `assets/rock.png` is left. Before this the loader asked for
Expand Down Expand Up @@ -693,9 +711,17 @@ describe("IAssetLoader through the asset manifest", () => {
});

it("should throw when the manifest is present but the path is absent", async () => {
// The manifest lists another file: an empty one is the asset-free fallback, not authority.
vi.stubGlobal(
"fetch",
vi.fn(async () => manifestResponse({ version: 1, entries: {} })),
vi.fn(async () =>
manifestResponse({
version: 1,
entries: {
"tree.png": { output: "tree.a1b2c3.png", kind: "texture", bytes: 1024, passes: [] },
},
}),
),
);
const assets = createAssetLoader({ basePath: "/assets", model: async () => ({}) });

Expand Down
14 changes: 14 additions & 0 deletions packages/core/__tests__/constraints.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ describe("core constraints", () => {
// envMap's ABSENCE to count it, and constructs no material, sets no property of one and
// chooses no environment: that sky stays the game's. The assertions below keep it so.
file !== "profiling/unlit-foliage-warning.ts" &&
// The render-trap audit reads game-authored opacity and environment dimensions to
// report ignored inputs. It constructs and changes no appearance; checked below.
file !== "profiling/render-trap-audit.ts" &&
file !== "index.ts",
)
.map((file) => withoutComments(readFileSync(path.join(sourceDirectory, file), "utf8")))
Expand All @@ -177,6 +180,17 @@ describe("core constraints", () => {
/new\s+\w*Material|new\s+\w*Light|new\s+Color|tonemapping|postprocessing|\.wgsl/iu,
);

const renderTrapAudit = withoutComments(
readFileSync(path.join(sourceDirectory, "profiling/render-trap-audit.ts"), "utf8"),
);
expect(renderTrapAudit).not.toMatch(
/new\s+\w*(Material|Light|Texture|Geometry)|new\s+(Color|Scene)|tonemapping|postprocessing|\.wgsl/iu,
);
expect(renderTrapAudit).not.toMatch(
/\b(material|scene|drawable|environment)\.\w+\s*(=(?!=)|\+=|-=|\*=|\/=|\+\+|--)/u,
);
expect(renderTrapAudit).not.toMatch(/\.(set|copy|setValues)\s*\(/u);

// The same terms as `warmup.ts` for the same reason: `mergeByMaterial` reads a material's
// identity to decide which meshes share a draw call, and the material itself is the game's —
// so no property of one is ever written here, only its `name` in an error label.
Expand Down
34 changes: 34 additions & 0 deletions packages/core/__tests__/game.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {
DataTexture,
EquirectangularReflectionMapping,
Group,
InstancedMesh,
Mesh,
Expand Down Expand Up @@ -58,6 +60,38 @@ class EmptyScene extends Scene {
}

describe("IGame", () => {
it("reports render traps once per scene entry, including re-entry", async () => {
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
class Traps extends Scene {
override enter(ctx: ICtx): void {
const texture = new DataTexture(new Uint8Array(8 * 65 * 4), 8, 65);
texture.mapping = EquirectangularReflectionMapping;
ctx.scene.environment = texture;
ctx.scene.add(new Mesh(undefined, new MeshBasicMaterial({ opacity: 0.5 })));
}
}
const game = defineGame({
initialState: {},
renderer: renderer(testCanvas()),
scenes: { traps: Traps },
start: "traps",
});
const markers = (): string[] =>
error.mock.calls
.map(([line]) => String(line))
.filter((line) => /TN_ENVIRONMENT_TOO_SMALL|TN_OPACITY_IGNORED/u.test(line));
try {
await game.start();
await Promise.resolve();
expect(markers()).toHaveLength(2);
await game.goto("traps");
expect(markers()).toHaveLength(4);
} finally {
game.stop();
error.mockRestore();
}
});

it.each([false, true])(
"releases the UI bridge when renderer startup fails (host: %s)",
async (host) => {
Expand Down
115 changes: 115 additions & 0 deletions packages/core/__tests__/render-trap-audit.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {
CubeTexture,
DataTexture,
EquirectangularReflectionMapping,
Group,
Line,
LineBasicMaterial,
Mesh,
MeshBasicMaterial,
Points,
PointsMaterial,
Scene,
Sprite,
SpriteMaterial,
} from "three";
import { describe, expect, it } from "vitest";
import {
ENVIRONMENT_TOO_SMALL_MARKER,
OPACITY_IGNORED_MARKER,
renderTrapAudit,
} from "../src/profiling/render-trap-audit.js";

function environment(width: number): DataTexture {
const texture = new DataTexture(new Uint8Array(width * 65 * 4), width, 65);
texture.mapping = EquirectangularReflectionMapping;
return texture;
}

describe("renderTrapAudit", () => {
it("reports an 8 px environment with its fix", () => {
const scene = new Scene();
scene.environment = environment(8);
expect(renderTrapAudit(scene)).toEqual([
expect.stringContaining(`${ENVIRONMENT_TOO_SMALL_MARKER}:`),
]);
expect(renderTrapAudit(scene)[0]).toContain("64 pixels");
});

it("accepts the 64 px PMREM boundary", () => {
const scene = new Scene();
scene.environment = environment(64);
expect(renderTrapAudit(scene)).toEqual([]);
});

it.each([null, new CubeTexture()])("ignores null/cube environments (%s)", (texture) => {
const scene = new Scene();
scene.environment = texture;
expect(renderTrapAudit(scene)).toEqual([]);
});

it.each([
new Mesh(undefined, new MeshBasicMaterial({ opacity: 0.5 })),
new Points(undefined, new PointsMaterial({ opacity: 0.5 })),
new Line(undefined, new LineBasicMaterial({ opacity: 0.5 })),
new Sprite(new SpriteMaterial({ opacity: 0.5, transparent: false })),
])("reports ignored opacity on each drawable kind (%s)", (object) => {
const scene = new Scene();
scene.add(object);
const lines = renderTrapAudit(scene);
expect(lines).toHaveLength(1);
expect(lines[0]).toContain(OPACITY_IGNORED_MARKER);
expect(lines[0]).toContain("transparent: true");
});

it.each([
{ opacity: 0.5, transparent: true },
{ opacity: 0.5, alphaHash: true },
{ opacity: 1 },
{ opacity: 0.5, visible: false },
])("accepts materials that do not draw ignored opacity (%j)", (parameters) => {
const scene = new Scene();
scene.add(new Mesh(undefined, new MeshBasicMaterial(parameters)));
expect(renderTrapAudit(scene)).toEqual([]);
});

it("prunes invisible meshes and subtrees", () => {
const scene = new Scene();
const mesh = new Mesh(undefined, new MeshBasicMaterial({ opacity: 0.5 }));
mesh.visible = false;
scene.add(mesh);
const group = new Group();
group.visible = false;
group.add(new Mesh(undefined, new MeshBasicMaterial({ opacity: 0.5 })));
scene.add(group);
expect(renderTrapAudit(scene)).toEqual([]);
});

it("dedupes materials, handles arrays, and caps examples at five", () => {
const scene = new Scene();
const materials = Array.from(
{ length: 9 },
(_, index) => new MeshBasicMaterial({ name: `water-${index}`, opacity: 0.5 }),
);
scene.add(new Mesh(undefined, materials), new Mesh(undefined, materials));
const line = renderTrapAudit(scene)[0];
expect(line).toContain('"materials":9');
for (let index = 0; index < 5; index++) expect(line).toContain(`water-${index}`);
expect(line).not.toContain("water-5");
});

it("reads structural Node materials and uses their type when unnamed", () => {
const scene = new Scene();
const mesh = new Mesh();
mesh.material = { opacity: 0.5, type: "MeshBasicNodeMaterial" } as MeshBasicMaterial;
scene.add(mesh);
expect(renderTrapAudit(scene)[0]).toContain("MeshBasicNodeMaterial");
});

it("reports both traps in one audit", () => {
const scene = new Scene();
scene.environment = environment(8);
scene.add(new Mesh(undefined, new MeshBasicMaterial({ opacity: 0.5 })));
expect(renderTrapAudit(scene)).toHaveLength(2);
});
});
20 changes: 20 additions & 0 deletions packages/core/capabilities.json
Original file line number Diff line number Diff line change
Expand Up @@ -2611,6 +2611,26 @@
"supersedes": [],
"symbol": "RenderListValidator"
},
{
"aliases": [],
"constraints": [
"structural scene reads only, one visible-tree walk per entry, at most five material examples",
"it changes no scene state; environment and transparency remain the game's decisions"
],
"example": "renderTrapAudit(ctx.scene); // marker lines with the fixes, or an empty array",
"importPath": "@threenative/core",
"kind": "function",
"overrides": [],
"package": "@threenative/core",
"signature": "export function renderTrapAudit(scene: Object3D & { … }",
"situations": [
"audit an equirectangular sky too narrow for three's WebGPU PMREM",
"find visible opaque materials whose authored opacity is ignored"
],
"summary": "Catch scene state that silently loses environment fill or ignores authored opacity. Printed once after `enter()` as `TN_ENVIRONMENT_TOO_SMALL` or `TN_OPACITY_IGNORED`, with the fix in the same console error line so playtest `noConsoleErrors` rejects it.",
"supersedes": [],
"symbol": "renderTrapAudit"
},
{
"aliases": ["fixed seed fixed-step simulation"],
"constraints": [
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,9 @@ async function readManifest(url: string): Promise<IAssetManifest | undefined> {
`Asset manifest '${url}' must be version ${MANIFEST_VERSION} with an 'entries' object.`,
);
}
// An asset-free build still serves an empty manifest (so nothing 404s); it lists nothing, so it
// must not become authoritative over files a project serves verbatim from `public/`.
if (Object.keys(parsed.entries).length === 0) return undefined;
return parsed as unknown as IAssetManifest;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
formatValidationReport,
renderListValidationRequested,
} from "./profiling/render-list-validate.js";
import { renderTrapAudit } from "./profiling/render-trap-audit.js";
import {
describeSceneShape,
describeSceneWarning,
Expand Down Expand Up @@ -893,6 +894,7 @@ class GameImpl<TState extends Record<string, unknown>, TPhysics>
if (this.#scene !== scene) return false;
this.#sceneFrame = typeof frame === "function" ? frame : undefined;
this.#sceneEntered = true;
for (const line of renderTrapAudit(ctx.scene)) console.error(line);
// Cutout PBR with no image-based light renders as cardboard, and the compensating tint the
// author reaches for is invisible in a screenshot review. Once per scene entry, right after
// `enter()`: `load()` and `enter()` are where the game builds its meshes and sets its own
Expand Down
Loading
Loading