diff --git a/packages/assets/__tests__/bake-receipt.spec.ts b/packages/assets/__tests__/bake-receipt.spec.ts index ad54e10dcb..35cf8cd19c 100644 --- a/packages/assets/__tests__/bake-receipt.spec.ts +++ b/packages/assets/__tests__/bake-receipt.spec.ts @@ -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", ); diff --git a/packages/assets/__tests__/compile.spec.ts b/packages/assets/__tests__/compile.spec.ts index 277b98118a..1b243eb04c 100644 --- a/packages/assets/__tests__/compile.spec.ts +++ b/packages/assets/__tests__/compile.spec.ts @@ -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 }); @@ -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 () => { @@ -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"), ""); @@ -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 })); @@ -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 () => { diff --git a/packages/assets/src/compile.ts b/packages/assets/src/compile.ts index 2b4327026c..93d7acefed 100644 --- a/packages/assets/src/compile.ts +++ b/packages/assets/src/compile.ts @@ -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 }; @@ -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); diff --git a/packages/core/__tests__/assets.spec.ts b/packages/core/__tests__/assets.spec.ts index 168eb9fa8f..02752f7cec 100644 --- a/packages/core/__tests__/assets.spec.ts +++ b/packages/core/__tests__/assets.spec.ts @@ -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 @@ -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 () => ({}) }); diff --git a/packages/core/__tests__/constraints.spec.ts b/packages/core/__tests__/constraints.spec.ts index 77d14f902a..9affeeb0ff 100644 --- a/packages/core/__tests__/constraints.spec.ts +++ b/packages/core/__tests__/constraints.spec.ts @@ -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"))) @@ -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. diff --git a/packages/core/__tests__/game.spec.ts b/packages/core/__tests__/game.spec.ts index 06e5f68922..acb047fe25 100644 --- a/packages/core/__tests__/game.spec.ts +++ b/packages/core/__tests__/game.spec.ts @@ -1,4 +1,6 @@ import { + DataTexture, + EquirectangularReflectionMapping, Group, InstancedMesh, Mesh, @@ -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) => { diff --git a/packages/core/__tests__/render-trap-audit.spec.ts b/packages/core/__tests__/render-trap-audit.spec.ts new file mode 100644 index 0000000000..2fae3ca4a1 --- /dev/null +++ b/packages/core/__tests__/render-trap-audit.spec.ts @@ -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); + }); +}); diff --git a/packages/core/capabilities.json b/packages/core/capabilities.json index 9d8840d262..59be5ffb52 100644 --- a/packages/core/capabilities.json +++ b/packages/core/capabilities.json @@ -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": [ diff --git a/packages/core/src/assets.ts b/packages/core/src/assets.ts index 9dc681c13e..0519c508f6 100644 --- a/packages/core/src/assets.ts +++ b/packages/core/src/assets.ts @@ -272,6 +272,9 @@ async function readManifest(url: string): Promise { `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; } diff --git a/packages/core/src/game.ts b/packages/core/src/game.ts index e384b514c2..ac83e39742 100644 --- a/packages/core/src/game.ts +++ b/packages/core/src/game.ts @@ -51,6 +51,7 @@ import { formatValidationReport, renderListValidationRequested, } from "./profiling/render-list-validate.js"; +import { renderTrapAudit } from "./profiling/render-trap-audit.js"; import { describeSceneShape, describeSceneWarning, @@ -893,6 +894,7 @@ class GameImpl, 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 diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4234a3c4d9..8198903c6f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -666,6 +666,21 @@ export { unlitFoliageWarning, } from "./profiling/unlit-foliage-warning.js"; export type { IUnlitFoliageWarning } from "./profiling/unlit-foliage-warning.js"; +/** + * 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. + * @situation audit an equirectangular sky too narrow for three's WebGPU PMREM + * @situation find visible opaque materials whose authored opacity is ignored + * @constraint structural scene reads only, one visible-tree walk per entry, at most five material examples + * @constraint 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 + */ +export { + ENVIRONMENT_TOO_SMALL_MARKER, + OPACITY_IGNORED_MARKER, + renderTrapAudit, +} from "./profiling/render-trap-audit.js"; /** * Stop recomposing the transforms of a subtree nobody moves. `markStatic(root)` composes the * subtree once and freezes it; the engine re-arms a root whose own transform the game changes, and diff --git a/packages/core/src/profiling/render-trap-audit.ts b/packages/core/src/profiling/render-trap-audit.ts new file mode 100644 index 0000000000..149fee6fe7 --- /dev/null +++ b/packages/core/src/profiling/render-trap-audit.ts @@ -0,0 +1,74 @@ +import { EquirectangularReflectionMapping, type Object3D } from "three"; + +/** Marker for an equirectangular environment below three's minimum PMREM cube size. */ +export const ENVIRONMENT_TOO_SMALL_MARKER = "TN_ENVIRONMENT_TOO_SMALL"; +/** Marker for a drawn material whose opacity three ignores. */ +export const OPACITY_IGNORED_MARKER = "TN_OPACITY_IGNORED"; + +interface IRenderTrapMaterial { + opacity?: number; + transparent?: boolean; + alphaHash?: boolean; + visible?: boolean; + name?: string; + type?: string; +} + +function opacityIgnored( + material: IRenderTrapMaterial | undefined, +): material is IRenderTrapMaterial { + return ( + material != null && + material.visible !== false && + typeof material.opacity === "number" && + material.opacity < 1 && + material.transparent !== true && + material.alphaHash !== true + ); +} + +/** Structural reads only: Node materials, native adapters and test stubs share this audit. */ +export function renderTrapAudit(scene: Object3D & { environment?: unknown }): string[] { + const lines: string[] = []; + const environment = scene.environment as + | { isTexture?: boolean; mapping?: number; image?: { width?: number } } + | null + | undefined; + const width = environment?.image?.width; + // three r185 derives PMREM's cube size from width / 4; LOD_MIN = 4 requires 16 px. + if ( + environment?.isTexture === true && + environment.mapping === EquirectangularReflectionMapping && + typeof width === "number" && + width / 4 < 16 + ) { + lines.push( + `${ENVIRONMENT_TOO_SMALL_MARKER}:${JSON.stringify({ width })} use an equirectangular environment at least 64 pixels wide for PMREM`, + ); + } + + const materials = new Set(); + const examples: string[] = []; + scene.traverseVisible((object) => { + const drawable = object as Object3D & { + isMesh?: boolean; + isPoints?: boolean; + isLine?: boolean; + isSprite?: boolean; + material?: IRenderTrapMaterial | IRenderTrapMaterial[]; + }; + if (!(drawable.isMesh || drawable.isPoints || drawable.isLine || drawable.isSprite)) return; + const slot = drawable.material; + for (const material of Array.isArray(slot) ? slot : [slot]) { + if (!opacityIgnored(material) || materials.has(material)) continue; + materials.add(material); + if (examples.length < 5) examples.push(material.name || material.type || "unnamed material"); + } + }); + if (materials.size > 0) { + lines.push( + `${OPACITY_IGNORED_MARKER}:${JSON.stringify({ materials: materials.size, examples })} set transparent: true or alphaHash: true on these materials so opacity takes effect`, + ); + } + return lines; +} diff --git a/packages/create-threenative/__tests__/looks.spec.ts b/packages/create-threenative/__tests__/looks.spec.ts index bf9d1b1e02..e150f20b3c 100644 --- a/packages/create-threenative/__tests__/looks.spec.ts +++ b/packages/create-threenative/__tests__/looks.spec.ts @@ -1,6 +1,21 @@ import { readFile, readdir } from "node:fs/promises"; import path from "node:path"; +import { + Color, + DataUtils, + EquirectangularReflectionMapping, + HalfFloatType, + LinearFilter, + LinearSRGBColorSpace, + SRGBColorSpace, + Scene, + UnsignedByteType, +} from "three"; import { describe, expect, it } from "vitest"; +import { flat } from "../templates/platformer/src/render/materials.js"; +import { palette } from "../templates/platformer/src/render/palette.js"; +import { setupSky, skyTexture } from "../templates/platformer/src/render/sky.js"; +import { skyEnvironment } from "../templates/platformer/src/render/skyEnvironment.js"; import { qualityPreset } from "../templates/starter/src/render/quality.js"; const starter = path.resolve("packages/create-threenative/templates/starter"); @@ -459,3 +474,75 @@ function cssDeclaration(rule: string | undefined, property: string): string | un .exec(rule)?.[1] ?.trim(); } + +describe("platformer translucent surfaces and gradient sky", () => { + it("enables transparency only below full opacity and preserves material caching", () => { + expect(flat(0xffffff, { opacity: 0.55 }).transparent).toBe(true); + expect(flat(0xffffff, { opacity: 1 }).transparent).toBe(false); + expect(flat(0xffffff).transparent).toBe(false); + expect(flat(0xffffff, { opacity: 0.55 })).toBe(flat(0xffffff, { opacity: 0.55 })); + }); + + it("uses a filterable gradient background and a separately measured linear sky environment", () => { + const sky = skyTexture(); + const { data, width, height } = sky.image; + if (!(data instanceof Uint8Array)) throw new Error("Sky texture must contain byte data."); + const colorAt = (row: number) => Array.from(data.slice(row * width * 4, row * width * 4 + 3)); + const closeTo = (actual: number[], color: Color) => { + for (const [index, value] of color.clone().convertLinearToSRGB().toArray().entries()) { + expect(Math.abs((actual[index] ?? 0) - Math.round(value * 255))).toBeLessThanOrEqual(1); + } + }; + expect(sky.mapping).toBe(EquirectangularReflectionMapping); + expect(sky.colorSpace).toBe(SRGBColorSpace); + expect(sky.type).toBe(UnsignedByteType); + expect(width).toBeGreaterThanOrEqual(64); + expect(width / 4).toBeGreaterThanOrEqual(16); + closeTo(colorAt(height - 1), new Color(palette.skyHigh)); + closeTo(colorAt(Math.floor((height - 1) / 2)), new Color(palette.horizon)); + closeTo(colorAt(0), new Color(palette.skyHigh).lerp(new Color(palette.horizon), 0.45)); + expect(Array.from(data).every((value) => Number.isFinite(value) && value > 0)).toBe(true); + const environment = skyEnvironment(); + expect(environment.type).toBe(HalfFloatType); + expect(environment.colorSpace).toBe(LinearSRGBColorSpace); + expect(environment.mapping).toBe(EquirectangularReflectionMapping); + expect(environment.minFilter).toBe(LinearFilter); + expect(environment.magFilter).toBe(LinearFilter); + const image = environment.image; + expect(image.width).toBeGreaterThanOrEqual(64); + if (!(image.data instanceof Uint16Array)) + throw new Error("Environment must contain half floats."); + for (const [upper, fill] of [ + [true, [0.355, 0.423, 0.627]], + [false, [0.68, 0.67, 0.633]], + ] as const) { + const sums = [0, 0, 0]; + let weights = 0; + for (let row = 0; row < image.height; row += 1) { + const elevation = (row / (image.height - 1) - 0.5) * Math.PI; + if (elevation >= 0 !== upper) continue; + const weight = Math.abs(Math.cos(elevation)); + for (let column = 0; column < image.width; column += 1) { + weights += weight; + for (let channel = 0; channel < 3; channel += 1) { + const half = image.data[(row * image.width + column) * 4 + channel]; + if (half === undefined) throw new Error("Missing environment sample."); + sums[channel] = (sums[channel] ?? 0) + DataUtils.fromHalfFloat(half) * weight; + } + } + } + for (const [channel, expected] of fill.entries()) { + expect(Math.abs((sums[channel] ?? 0) / weights - expected) / expected).toBeLessThan(0.02); + } + } + const scene = new Scene(); + setupSky(scene, sky, environment); + expect(scene.background).toBe(sky); + expect(scene.environment).toBe(environment); + expect(scene.backgroundIntensity).toBe(1); + expect(scene.environmentIntensity).toBe(1); + expect(sky.colorSpace).toBe(SRGBColorSpace); + sky.dispose(); + environment.dispose(); + }); +}); diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index 4c1a8e83cf..ed2625413f 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -239,7 +239,7 @@ const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { // PRD-560: fingerprints include both height fog and PRD-571 histogram exposure. "action-rpg": "c022de28097aa344b891be25c55b29d2d3053a050f8bfb3c263d782690d7c245", minimal: "3b5aa727964b59028d2b9f4850835a91857d9bb979430ace026b19505444efe1", - platformer: "2f53a214e863a18fe9d6c438a6d32609cf0c7e223f7308499b85c55c66604912", + platformer: "522335b150b9c1027a624a134a76267dc703bb78b10f420ebd1a6ff75d9d26c1", puzzle: "edd2e8eb7e1602e23e42634afe2fc768ae8a2c4b8e9e1006c208aa9bab343ec3", racing: "33900c8a4b715f9bc8eb5c0c22456b48b9b8f1987fcdbcf32965f0880c442367", rain: "93f8227373687566e46cc8d7f03586cb346b05bb5a5589c97a3e0ee83be76d91", @@ -437,6 +437,7 @@ const PLATFORMER_PATHS = [ "src/render/postprocessing.ts", "src/render/props.ts", "src/render/sky.ts", + "src/render/skyEnvironment.ts", "src/render/scenery.ts", "src/render/walkers.ts", "src/render/waterfall.ts", @@ -535,6 +536,9 @@ describe("create-threenative", () => { ); expect(PRD_201_PARENT_SCAFFOLD_HASHES[template]).toBeDefined(); actual[template] = await scaffoldTreeHash(target); + if (template === "platformer") { + await expect(stat(path.join(target, "assets/sky.jpg"))).rejects.toThrow(/ENOENT/u); + } } expect(actual).toEqual(PRD_201_PARENT_SCAFFOLD_HASHES); } finally { diff --git a/packages/create-threenative/__tests__/template-assets-compile-cases.ts b/packages/create-threenative/__tests__/template-assets-compile-cases.ts index 50f7102c88..23cd2bb1a0 100644 --- a/packages/create-threenative/__tests__/template-assets-compile-cases.ts +++ b/packages/create-threenative/__tests__/template-assets-compile-cases.ts @@ -87,11 +87,9 @@ export async function compilesUnderTheDefaultConfig(template: string) { transcoder: basisTranscoderPaths(), }); if (result.written === 0) { - await expect( - readFile(path.join(target, "public", "assets.manifest.json")), - ).rejects.toMatchObject({ - code: "ENOENT", - }); + expect( + JSON.parse(await readFile(path.join(target, "public", "assets.manifest.json"), "utf8")), + ).toEqual({ version: 1, entries: {} }); return; } diff --git a/packages/create-threenative/agent-docs/references/capability-reference.md b/packages/create-threenative/agent-docs/references/capability-reference.md index 52be24855a..99c933dd4b 100644 --- a/packages/create-threenative/agent-docs/references/capability-reference.md +++ b/packages/create-threenative/agent-docs/references/capability-reference.md @@ -2011,6 +2011,21 @@ export class RenderListValidator { … } if (renderListValidationRequested()) console.log(formatValidationReport(report)); ``` +### `renderTrapAudit` + +`function` — 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. + +```ts +export function renderTrapAudit(scene: Object3D & { … } +``` + +- **Use when:** audit an equirectangular sky too narrow for three's WebGPU PMREM · find visible opaque materials whose authored opacity is ignored +- **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 + +```ts +renderTrapAudit(ctx.scene); // marker lines with the fixes, or an empty array +``` + ### `replay` `function` — Record or replay deterministic game input and state. diff --git a/packages/create-threenative/agent-docs/references/debug-surface.md b/packages/create-threenative/agent-docs/references/debug-surface.md index 97a13f34d7..3f6aac7a62 100644 --- a/packages/create-threenative/agent-docs/references/debug-surface.md +++ b/packages/create-threenative/agent-docs/references/debug-surface.md @@ -48,6 +48,11 @@ Present only under the native runtime. The host injects its side; core's viewpor `@threenative/playtest/three`'s device reporting read it (adapter identity, host snapshot). On web this global does not exist — do not probe for it in portable code. +## Scene-entry markers + +`TN_ENVIRONMENT_TOO_SMALL`: scene-entry console error; use an equirectangular environment at least 64 pixels wide for PMREM. +`TN_OPACITY_IGNORED`: scene-entry console error; set `transparent: true` or `alphaHash: true` on visible materials with `opacity < 1`. + ## Runner-side markers Two further globals are set by tooling, not the game, and are named here so a stray diff --git a/packages/create-threenative/capabilities.json b/packages/create-threenative/capabilities.json index 9d8840d262..59be5ffb52 100644 --- a/packages/create-threenative/capabilities.json +++ b/packages/create-threenative/capabilities.json @@ -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": [ diff --git a/packages/create-threenative/templates/platformer/AGENTS.md b/packages/create-threenative/templates/platformer/AGENTS.md index 51f27eda51..87b66030aa 100644 --- a/packages/create-threenative/templates/platformer/AGENTS.md +++ b/packages/create-threenative/templates/platformer/AGENTS.md @@ -43,7 +43,7 @@ Leave `assets` absent: the cook selects target-decodable passes, with `models.sh Relative look capture: a binding with `pointerRelative: true` captures the canvas on click by default; set `captureOnClick: false` and call `ctx.input.captureMouse()` from your own gesture to opt out. Desktop mode precedence is CLI (`--windowed`, `--maximized`, `--fullscreen`) over `display.fullscreen` over `window.maximized`; with both false, `window.width`/`height` size the normal window. Scenes use `load`, `enter`, `update`, `exit`, `render`; physics nodes are Godot-named and disposable. No model here is imported, so there is no authored scale to normalise and the collider is the body: the generated scale-and-snap conventions do not apply to this template. `input.vector("move").y` is +up, so forward uses one explicit `-move.y` conversion. Rigged assets: put a `.glb` in `assets/`, await `ctx.assets.model("hero.glb")` in `Scene.load()`, then drive `AnimationPlayer` beside its entity. `ctx.goto(name)` rebuilds without resetting game state; from a frame function `goto` and then `return`; `ctx.state.set({ /* copy this game's initial-state shape */ })` is a partial patch. `game.goto("")` also rebuilds the scene, but it resets the game's state. Seeded randomness is deterministic only when `defineGame({ seed })` is configured. -`src/render/sky.ts` makes `assets/sky.jpg` (Poly Haven, CC0) background, environment light and fog colour, and `SUN_DIRECTION` aims `lighting.ts`'s one sun at the photographed sun — re-aim it when you swap the file. Imported foliage (Fab/Megascans cutouts) draws with its own albedo, normal map and alpha cutoff under the environment light `sky.ts` makes — never tint it, fake its emission or flatten its normals to make up for missing light; `TN_UNLIT_FOLIAGE` names cutout materials drawn with no environment. `src/render/quality.ts` owns `low`, `medium`, `high`; `isMobile()` chooses `low`, otherwise `high`; override with `setupPost(..., { tier: "low" })`. The native production collector's explicit `--hosted-software` profile selects the existing `low` preset only when no tier override is passed; `TN_QUALITY_TIER` reports `source=hosted-software`. That smoke tier is not physical performance evidence and does not change an ordinary desktop run. Unknown tiers throw. Keep state values human-readable; the bridge flushes about 100 ms and per-frame feel stays in scene-owned Three.js. +`src/render/sky.ts` builds the gradient background from `palette.ts`; a separate measured environment lights the scene, the background's lower hemisphere matches `skyFloor`, and `SUN_DIRECTION` aims `lighting.ts`'s authored sun. Imported foliage (Fab/Megascans cutouts) draws with its own albedo, normal map and alpha cutoff under the environment light `sky.ts` makes — never tint it, fake its emission or flatten its normals to make up for missing light; `TN_UNLIT_FOLIAGE` names cutout materials drawn with no environment. `src/render/quality.ts` owns `low`, `medium`, `high`; `isMobile()` chooses `low`, otherwise `high`; override with `setupPost(..., { tier: "low" })`. The native production collector's explicit `--hosted-software` profile selects the existing `low` preset only when no tier override is passed; `TN_QUALITY_TIER` reports `source=hosted-software`. That smoke tier is not physical performance evidence and does not change an ordinary desktop run. Unknown tiers throw. Keep state values human-readable; the bridge flushes about 100 ms and per-frame feel stays in scene-owned Three.js. When an animation looks wrong, measure it before rewriting it. `clipPoseError` scores a retargeted clip against its source per bone in degrees — whole quaternions relative to each rig's own bind pose, so the two rigs' bind conventions cancel and a limb rolled about its own axis is caught where a bone-direction check reads zero. `clipTrackBindings` names tracks that bind nothing (the `.undefined` failure that plays the bind pose instead of the animation), `clipBoneCoverage` names bones the clip does not drive and which therefore keep the previous clip's pose, and `boneContact` reports in metres whether a named bone reaches the prop it is supposed to be touching. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. diff --git a/packages/create-threenative/templates/platformer/CLAUDE.md b/packages/create-threenative/templates/platformer/CLAUDE.md index 758cd6b0b9..bf97a9e181 100644 --- a/packages/create-threenative/templates/platformer/CLAUDE.md +++ b/packages/create-threenative/templates/platformer/CLAUDE.md @@ -45,7 +45,7 @@ Leave `assets` absent: the cook selects target-decodable passes, with `models.sh Relative look capture: a binding with `pointerRelative: true` captures the canvas on click by default; set `captureOnClick: false` and call `ctx.input.captureMouse()` from your own gesture to opt out. Desktop mode precedence is CLI (`--windowed`, `--maximized`, `--fullscreen`) over `display.fullscreen` over `window.maximized`; with both false, `window.width`/`height` size the normal window. Scenes use `load`, `enter`, `update`, `exit`, `render`; physics nodes are Godot-named and disposable. No model here is imported, so there is no authored scale to normalise and the collider is the body: the generated scale-and-snap conventions do not apply to this template. `input.vector("move").y` is +up, so forward uses one explicit `-move.y` conversion. Rigged assets: put a `.glb` in `assets/`, await `ctx.assets.model("hero.glb")` in `Scene.load()`, then drive `AnimationPlayer` beside its entity. `ctx.goto(name)` rebuilds without resetting game state; from a frame function `goto` and then `return`; `ctx.state.set({ /* copy this game's initial-state shape */ })` is a partial patch. `game.goto("")` also rebuilds the scene, but it resets the game's state. Seeded randomness is deterministic only when `defineGame({ seed })` is configured. -`src/render/sky.ts` makes `assets/sky.jpg` (Poly Haven, CC0) background, environment light and fog colour, and `SUN_DIRECTION` aims `lighting.ts`'s one sun at the photographed sun — re-aim it when you swap the file. Imported foliage (Fab/Megascans cutouts) draws with its own albedo, normal map and alpha cutoff under the environment light `sky.ts` makes — never tint it, fake its emission or flatten its normals to make up for missing light; `TN_UNLIT_FOLIAGE` names cutout materials drawn with no environment. `src/render/quality.ts` owns `low`, `medium`, `high`; `isMobile()` chooses `low`, otherwise `high`; override with `setupPost(..., { tier: "low" })`. The native production collector's explicit `--hosted-software` profile selects the existing `low` preset only when no tier override is passed; `TN_QUALITY_TIER` reports `source=hosted-software`. That smoke tier is not physical performance evidence and does not change an ordinary desktop run. Unknown tiers throw. Keep state values human-readable; the bridge flushes about 100 ms and per-frame feel stays in scene-owned Three.js. +`src/render/sky.ts` builds the gradient background from `palette.ts`; a separate measured environment lights the scene, the background's lower hemisphere matches `skyFloor`, and `SUN_DIRECTION` aims `lighting.ts`'s authored sun. Imported foliage (Fab/Megascans cutouts) draws with its own albedo, normal map and alpha cutoff under the environment light `sky.ts` makes — never tint it, fake its emission or flatten its normals to make up for missing light; `TN_UNLIT_FOLIAGE` names cutout materials drawn with no environment. `src/render/quality.ts` owns `low`, `medium`, `high`; `isMobile()` chooses `low`, otherwise `high`; override with `setupPost(..., { tier: "low" })`. The native production collector's explicit `--hosted-software` profile selects the existing `low` preset only when no tier override is passed; `TN_QUALITY_TIER` reports `source=hosted-software`. That smoke tier is not physical performance evidence and does not change an ordinary desktop run. Unknown tiers throw. Keep state values human-readable; the bridge flushes about 100 ms and per-frame feel stays in scene-owned Three.js. When an animation looks wrong, measure it before rewriting it. `clipPoseError` scores a retargeted clip against its source per bone in degrees — whole quaternions relative to each rig's own bind pose, so the two rigs' bind conventions cancel and a limb rolled about its own axis is caught where a bone-direction check reads zero. `clipTrackBindings` names tracks that bind nothing (the `.undefined` failure that plays the bind pose instead of the animation), `clipBoneCoverage` names bones the clip does not drive and which therefore keep the previous clip's pose, and `boneContact` reports in metres whether a named bone reaches the prop it is supposed to be touching. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. diff --git a/packages/create-threenative/templates/platformer/src/render/materials.ts b/packages/create-threenative/templates/platformer/src/render/materials.ts index 75567f57cd..2dc7074acc 100644 --- a/packages/create-threenative/templates/platformer/src/render/materials.ts +++ b/packages/create-threenative/templates/platformer/src/render/materials.ts @@ -53,14 +53,19 @@ export function toon( ); } -/** Unlit, for things that must stay bright whatever the sun is doing. */ +/** Unlit, for things that stay bright whatever the sun does; opacity below 1 enables transparency. */ export function flat( color: ColorRepresentation, options: { depthWrite?: boolean; fog?: boolean; opacity?: number; side?: typeof DoubleSide } = {}, ): MeshBasicMaterial { return cached( `flat|${String(color)}|${JSON.stringify(options)}`, - () => new MeshBasicMaterial({ color, ...options }), + () => + new MeshBasicMaterial({ + color, + ...options, + transparent: options.opacity !== undefined && options.opacity < 1, + }), ); } diff --git a/packages/create-threenative/templates/platformer/src/render/palette.ts b/packages/create-threenative/templates/platformer/src/render/palette.ts index 1d456f8228..3c3247c496 100644 --- a/packages/create-threenative/templates/platformer/src/render/palette.ts +++ b/packages/create-threenative/templates/platformer/src/render/palette.ts @@ -13,11 +13,11 @@ export const palette = { grass: 0x5cbb37, /** Distance fades into this; also the loading screen's background. */ - horizon: 0xa8d6f5, + horizon: 0xa9dcf7, rock: 0xa8927a, /** The loading screen's progress track. */ - skyHigh: 0x2e88e0, - skyLow: 0xbfe4fb, + skyHigh: 0x0a63c8, + skyLow: 0x2b93e8, /** The one saturated role: coins, gems, stars, the goal. */ accent: 0xffd23f, } as const; diff --git a/packages/create-threenative/templates/platformer/src/render/sky.ts b/packages/create-threenative/templates/platformer/src/render/sky.ts index 0f2eafe549..056f6e9162 100644 --- a/packages/create-threenative/templates/platformer/src/render/sky.ts +++ b/packages/create-threenative/templates/platformer/src/render/sky.ts @@ -1,35 +1,29 @@ // Generated for you. This is ordinary Three.js — edit or delete it freely. // -// The sky is a photograph: `assets/sky.jpg`, Poly Haven's "Kloofendal 48d Partly Cloudy (Pure Sky)" -// by Greg Zaal and Jarod Guest, CC0 (https://polyhaven.com/a/kloofendal_48d_partly_cloudy_puresky). -// The same image is the background, the environment light every surface reflects and is filled by, -// and — through `SUN_DIRECTION` — the direction the sun's shadows fall. Swap the file for any -// equirectangular sky and re-aim `SUN_DIRECTION` at its sun. +// A vertical blue gradient supplies the background; a measured environment supplies the light. +// The background's lower hemisphere matches skyFloor around the floating route. import { BackSide, Color, + DataTexture, EquirectangularReflectionMapping, Float32BufferAttribute, FogExp2, + LinearFilter, Mesh, MeshBasicMaterial, + RGBAFormat, SRGBColorSpace, type Scene, SphereGeometry, type Texture, + UnsignedByteType, Vector3, } from "three"; import { HEIGHT_FOG, type HeightFogParams, heightFogDepth, heightFogNode } from "./heightFog.js"; import { palette } from "./palette.js"; -/** - * How the JPEG was made from the 4k HDR: linear radiance × 0.4, clipped, sRGB-encoded — so white - * in the file is 2.5 in the sky. Multiplying back restores the HDR brightness of the clouds; the sun - * disk itself is clipped, which is why the sun is a light (`lighting.ts`) and not a texel. - */ -const SKY_RANGE = 2.5; - -/** Unit vector toward the photographed sun: 47.9° up, measured from the source HDR. */ +/** Unit vector toward the authored sun, 47.9° above the horizon. */ export const SUN_DIRECTION = new Vector3(0.555, 0.742, 0.38).normalize(); /** The look this fog replaced: FogExp2 at 0.003, measured at 150 m from 2 m up. */ @@ -48,16 +42,52 @@ function distanceDensity(p: HeightFogParams = HEIGHT_FOG): number { return Math.sqrt(Math.max(0, old - height)) / distance; } -export function setupSky(scene: Scene, sky: Texture): void { +export function gradientColor(row: number, height: number): Color { + const high = new Color(palette.skyHigh); + const mid = new Color(palette.skyLow); + const horizon = new Color(palette.horizon); + const deep = high.clone().lerp(horizon, 0.45); + // DataTexture rows start at v=0 (nadir); equirectangular v=0.5 is the horizon. + const elevation = Math.sin((row / (height - 1) - 0.5) * Math.PI); + return elevation < 0 + ? horizon.clone().lerp(deep, Math.min(1, -elevation / 0.6)) + : elevation < 0.5 + ? horizon.clone().lerp(mid, elevation * 2) + : mid.clone().lerp(high, (elevation - 0.5) * 2); +} + +/** sRGB equirectangular radiance, from the lower haze through the horizon to the zenith. */ +export function skyTexture(): DataTexture { + // three's PMREM sizes its cube from width / 4, needing at least 16: keep width >= 64. + const width = 256; + const height = 128; + const data = new Uint8Array(width * height * 4); + for (let row = 0; row < height; row += 1) { + const encoded = gradientColor(row, height).convertLinearToSRGB(); + for (let column = 0; column < width; column += 1) { + data.set( + [...encoded.toArray().map((value) => Math.round(value * 255)), 255], + (row * width + column) * 4, + ); + } + } + const sky = new DataTexture(data, width, height, RGBAFormat, UnsignedByteType); sky.mapping = EquirectangularReflectionMapping; sky.colorSpace = SRGBColorSpace; + sky.magFilter = LinearFilter; + sky.minFilter = LinearFilter; + sky.needsUpdate = true; + return sky; +} + +export function setupSky(scene: Scene, sky: Texture, environment: Texture = sky): void { + sky.mapping = EquirectangularReflectionMapping; scene.background = sky; - scene.backgroundIntensity = SKY_RANGE; + scene.backgroundIntensity = 1; // three prefilters an equirectangular `scene.environment` itself (PMREM), on WebGPU and WebGL. - // It is what makes a standard material read as a material: sky-blue fill on faces the sun - // misses, and a sky to reflect, sharper as roughness drops. - scene.environment = sky; - scene.environmentIntensity = SKY_RANGE; + // The measured fill lights faces the sun misses and supplies reflections as roughness drops. + scene.environment = environment; + scene.environmentIntensity = 1; // The route runs to x=97 with the backdrop cliffs another 60 m behind them, so this is thin on // purpose: at this density it is under 2% inside the playfield and only reads past the castle. // `scene.fog` stays the distance term; `scene.fogNode` is what three applies and adds the height term. @@ -76,11 +106,10 @@ const FLOOR_RADIUS = 480; const RISE = 0.35; /** - * What the world floats in. A photographed sky ends in a flat grey ground disc, and the route has no - * ground: everything under the horizon was a white void the fog could not hide. This is the lower + * What the world floats in. The route has no ground; this is the lower * hemisphere, from the fog colour at the horizon to a deeper sky blue straight down, so distant * rock fades into it exactly as it fades into the fog. It climbs 20 degrees past the horizon and - * fades out there, so the photograph melts into the haze instead of meeting it at a hard line. + * fades out there, so the gradient melts into the haze instead of meeting it at a hard line. * It is not fogged and never writes depth. */ export function skyFloor(): Mesh { @@ -122,8 +151,8 @@ export function skyFloor(): Mesh { /** * The cloud bank, as a list of lobes to instance. * - * Eighteen clouds of five to nine squashed lobes is 130-odd separate spheres, and a photograph sky - * is a static one — so the scene builds one `InstancedBatch` of a unit sphere from this and never + * Eighteen clouds of five to nine squashed lobes is 130-odd separate spheres, and the gradient sky + * is static — so the scene builds one `InstancedBatch` of a unit sphere from this and never * touches it again. The motion in the sky is the airship in `scenery.ts`, which crosses the frame * in twenty seconds, and the parallax of a fox covering ninety-seven metres. * diff --git a/packages/create-threenative/templates/platformer/src/render/skyEnvironment.ts b/packages/create-threenative/templates/platformer/src/render/skyEnvironment.ts new file mode 100644 index 0000000000..f0c22f9fca --- /dev/null +++ b/packages/create-threenative/templates/platformer/src/render/skyEnvironment.ts @@ -0,0 +1,50 @@ +// Generated for you. This is ordinary Three.js — edit or delete it freely. +import { + Color, + DataTexture, + DataUtils, + EquirectangularReflectionMapping, + HalfFloatType, + LinearFilter, + LinearSRGBColorSpace, + RGBAFormat, +} from "three"; +import { gradientColor } from "./sky.js"; + +// Measured from the photographed sky this kit used to light with, preserving its tuned lighting +// when the background became a stylized gradient; edit these fills to relight. +const UPPER_FILL = new Color(0.355, 0.423, 0.627); +const LOWER_FILL = new Color(0.68, 0.67, 0.633); +export function skyEnvironment(): DataTexture { + const width = 256; + const height = 128; + const data = new Uint16Array(width * height * 4); + const upperMean = new Color(0, 0, 0); + let weights = 0; + for (let row = height / 2; row < height; row += 1) { + const elevation = (row / (height - 1) - 0.5) * Math.PI; + const weight = Math.abs(Math.cos(elevation)); + upperMean.add(gradientColor(row, height).multiplyScalar(weight)); + weights += weight; + } + upperMean.multiplyScalar(1 / weights); + const scale = new Color( + UPPER_FILL.r / upperMean.r, + UPPER_FILL.g / upperMean.g, + UPPER_FILL.b / upperMean.b, + ); + for (let row = 0; row < height; row += 1) { + const color = row < height / 2 ? LOWER_FILL : gradientColor(row, height).multiply(scale); + const rgba = [color.r, color.g, color.b, 1].map(DataUtils.toHalfFloat); + for (let column = 0; column < width; column += 1) { + data.set(rgba, (row * width + column) * 4); + } + } + const environment = new DataTexture(data, width, height, RGBAFormat, HalfFloatType); + environment.colorSpace = LinearSRGBColorSpace; + environment.mapping = EquirectangularReflectionMapping; + environment.minFilter = LinearFilter; + environment.magFilter = LinearFilter; + environment.needsUpdate = true; + return environment; +} diff --git a/packages/create-threenative/templates/platformer/src/scenes/Play.ts b/packages/create-threenative/templates/platformer/src/scenes/Play.ts index e852b28371..65cefd676b 100644 --- a/packages/create-threenative/templates/platformer/src/scenes/Play.ts +++ b/packages/create-threenative/templates/platformer/src/scenes/Play.ts @@ -39,7 +39,8 @@ import { isWebGLFallbackRenderer, materialLightingEnabled, } from "../render/quality.js"; -import { cloudLobes, setupSky, skyFloor } from "../render/sky.js"; +import { cloudLobes, setupSky, skyFloor, skyTexture } from "../render/sky.js"; +import { skyEnvironment } from "../render/skyEnvironment.js"; import { TouchControls } from "../render/touch-controls.js"; import type { GameState } from "../state.js"; @@ -103,10 +104,11 @@ export class Play extends Scene { }; #sky: Texture | undefined; + #skyEnvironment: Texture | undefined; #walkers: Walker[] = []; /** - * The sky photograph and the walkers are both built here, and the walkers are registered here + * The sky gradient and the walkers are both built here, and the walkers are registered here * too — a scenario's `setup.place` is applied between `load()` and `enter()`, so an entity that * only appears in `enter()` cannot be placed. That is what makes a stomp reproducible: the * walker is where the scenario put it, not where boot time left it. @@ -125,8 +127,9 @@ export class Play extends Scene { ctx.entities.add(`walker.${this.#walkers.length}`, walker); this.#walkers.push(walker); } - this.#sky = await ctx.assets.texture("sky.jpg"); - setupSky(ctx.scene, this.#sky); + this.#sky = skyTexture(); + this.#skyEnvironment = skyEnvironment(); + setupSky(ctx.scene, this.#sky, this.#skyEnvironment); this.#environmentSample = await sampleEnvironment(ctx.renderer.raw, ctx.scene, { web: getPlatform().runtime === "web", rendererKind: ctx.renderer.kind, @@ -146,8 +149,9 @@ export class Play extends Scene { predicate: (object) => object.userData.solid === true, }); collapseStage(stage.group); - if (this.#sky === undefined) throw new Error("Play.enter ran before load() loaded sky.jpg."); - setupSky(ctx.scene, this.#sky); + if (this.#sky === undefined || this.#skyEnvironment === undefined) + throw new Error("Play.enter ran before load() created the sky texture."); + setupSky(ctx.scene, this.#sky, this.#skyEnvironment); ctx.scene.add(skyFloor()); const lighting = setupLighting( ctx.scene, diff --git a/scripts/api-surface.json b/scripts/api-surface.json index 3974495b4b..ff85e40c8c 100644 --- a/scripts/api-surface.json +++ b/scripts/api-surface.json @@ -174,6 +174,7 @@ "@threenative/core#RenderChain": "export class RenderChain { … }", "@threenative/core#renderListValidationRequested": "export function renderListValidationRequested(): boolean { … }", "@threenative/core#RenderListValidator": "export class RenderListValidator { … }", + "@threenative/core#renderTrapAudit": "export function renderTrapAudit(scene: Object3D & { … }", "@threenative/core#replay": "export function replay< TState extends Record = Record, TPhysics = undefined, >(options: IReplayOptions = { … }", "@threenative/core#resetAudioCueLedger": "export function resetAudioCueLedger(): void { … }", "@threenative/core#resetStaticTransforms": "export function resetStaticTransforms(): void { … }",