From 0fa9fb8eff1694675d022fa903007a4a154a6cc6 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 11 Oct 2026 13:04:17 -0700 Subject: [PATCH 01/10] fix(platformer): translucent waterfall, gradient sky instead of the photo sky --- .../__tests__/looks.spec.ts | 45 ++++++++++++ .../__tests__/scaffold.spec.ts | 5 +- .../templates/platformer/AGENTS.md | 2 +- .../templates/platformer/CLAUDE.md | 2 +- .../platformer/src/render/materials.ts | 9 ++- .../platformer/src/render/palette.ts | 6 +- .../templates/platformer/src/render/sky.ts | 68 +++++++++++++------ .../templates/platformer/src/scenes/Play.ts | 9 +-- 8 files changed, 112 insertions(+), 34 deletions(-) diff --git a/packages/create-threenative/__tests__/looks.spec.ts b/packages/create-threenative/__tests__/looks.spec.ts index bf9d1b1e02..788e015244 100644 --- a/packages/create-threenative/__tests__/looks.spec.ts +++ b/packages/create-threenative/__tests__/looks.spec.ts @@ -1,6 +1,16 @@ import { readFile, readdir } from "node:fs/promises"; import path from "node:path"; +import { + Color, + EquirectangularReflectionMapping, + FloatType, + LinearSRGBColorSpace, + Scene, +} 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 { qualityPreset } from "../templates/starter/src/render/quality.js"; const starter = path.resolve("packages/create-threenative/templates/starter"); @@ -459,3 +469,38 @@ 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 linear sky radiance for background and environment with a coloured lower hemisphere", () => { + const sky = skyTexture(); + const { data, width, height } = sky.image; + 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.toArray().entries()) { + expect(actual[index]).toBeCloseTo(value, 6); + } + }; + expect(sky.mapping).toBe(EquirectangularReflectionMapping); + expect(sky.colorSpace).toBe(LinearSRGBColorSpace); + expect(sky.type).toBe(FloatType); + closeTo(colorAt(height - 1), new Color(palette.skyHigh)); + closeTo(colorAt((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 scene = new Scene(); + setupSky(scene, sky); + expect(scene.background).toBe(sky); + expect(scene.environment).toBe(sky); + expect(scene.backgroundIntensity).toBe(1); + expect(scene.environmentIntensity).toBe(1); + expect(sky.colorSpace).toBe(LinearSRGBColorSpace); + sky.dispose(); + }); +}); diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index 4c1a8e83cf..936694a856 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: "d2bab21c86ccc461e6186bd1c9fe96c97c6184d3f8d421ba2c4d2b3891da8185", puzzle: "edd2e8eb7e1602e23e42634afe2fc768ae8a2c4b8e9e1006c208aa9bab343ec3", racing: "33900c8a4b715f9bc8eb5c0c22456b48b9b8f1987fcdbcf32965f0880c442367", rain: "93f8227373687566e46cc8d7f03586cb346b05bb5a5589c97a3e0ee83be76d91", @@ -535,6 +535,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/templates/platformer/AGENTS.md b/packages/create-threenative/templates/platformer/AGENTS.md index 51f27eda51..de1cb30c1a 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 a procedural blue gradient for background and environment light from `palette.ts`; its 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..2424b4b5fd 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 a procedural blue gradient for background and environment light from `palette.ts`; its 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..b4313f7aa2 100644 --- a/packages/create-threenative/templates/platformer/src/render/sky.ts +++ b/packages/create-threenative/templates/platformer/src/render/sky.ts @@ -1,19 +1,20 @@ // 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 both the background and the environment light. +// Its lower hemisphere matches skyFloor, so reflections see the same haze as the floating route. import { BackSide, Color, + DataTexture, EquirectangularReflectionMapping, Float32BufferAttribute, + FloatType, FogExp2, + LinearFilter, + LinearSRGBColorSpace, Mesh, MeshBasicMaterial, - SRGBColorSpace, + RGBAFormat, type Scene, SphereGeometry, type Texture, @@ -22,14 +23,7 @@ import { 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,47 @@ function distanceDensity(p: HeightFogParams = HEIGHT_FOG): number { return Math.sqrt(Math.max(0, old - height)) / distance; } +/** Linear equirectangular radiance, from the lower haze through the horizon to the zenith. */ +export function skyTexture(): DataTexture { + const width = 8; + const height = 65; + const data = new Float32Array(width * height * 4); + 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); + for (let row = 0; row < height; row += 1) { + // 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); + const color = + 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); + for (let column = 0; column < width; column += 1) { + data.set([color.r, color.g, color.b, 1], (row * width + column) * 4); + } + } + const sky = new DataTexture(data, width, height, RGBAFormat, FloatType); + sky.mapping = EquirectangularReflectionMapping; + sky.colorSpace = LinearSRGBColorSpace; + sky.magFilter = LinearFilter; + sky.minFilter = LinearFilter; + sky.needsUpdate = true; + return sky; +} + export function setupSky(scene: Scene, sky: Texture): void { sky.mapping = EquirectangularReflectionMapping; - sky.colorSpace = SRGBColorSpace; + sky.colorSpace = LinearSRGBColorSpace; 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; + 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 +101,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 +146,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/scenes/Play.ts b/packages/create-threenative/templates/platformer/src/scenes/Play.ts index e852b28371..c0b3cfd943 100644 --- a/packages/create-threenative/templates/platformer/src/scenes/Play.ts +++ b/packages/create-threenative/templates/platformer/src/scenes/Play.ts @@ -39,7 +39,7 @@ 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 { TouchControls } from "../render/touch-controls.js"; import type { GameState } from "../state.js"; @@ -106,7 +106,7 @@ export class Play extends Scene { #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,7 +125,7 @@ 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"); + this.#sky = skyTexture(); setupSky(ctx.scene, this.#sky); this.#environmentSample = await sampleEnvironment(ctx.renderer.raw, ctx.scene, { web: getPlatform().runtime === "web", @@ -146,7 +146,8 @@ 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."); + if (this.#sky === undefined) + throw new Error("Play.enter ran before load() created the sky texture."); setupSky(ctx.scene, this.#sky); ctx.scene.add(skyFloor()); const lighting = setupLighting( From 3b57b7c847996e704196fbab518846b9fdec2c19 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 11 Oct 2026 13:46:54 -0700 Subject: [PATCH 02/10] fix(assets): write an empty manifest when a project has no assets --- packages/assets/__tests__/compile.spec.ts | 31 +++++++++++++++-------- packages/assets/src/compile.ts | 8 +++--- 2 files changed, 24 insertions(+), 15 deletions(-) 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); From 4d04fc0b4c1e766d60d588c826b0155cb61db67c Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 11 Oct 2026 13:47:50 -0700 Subject: [PATCH 03/10] fix(platformer): gradient sky wide enough for PMREM, 8-bit sRGB --- .../__tests__/looks.spec.ts | 21 ++++++++------ .../__tests__/scaffold.spec.ts | 2 +- .../templates/platformer/src/render/sky.ts | 29 ++++++++++++------- 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/packages/create-threenative/__tests__/looks.spec.ts b/packages/create-threenative/__tests__/looks.spec.ts index 788e015244..1366f6993d 100644 --- a/packages/create-threenative/__tests__/looks.spec.ts +++ b/packages/create-threenative/__tests__/looks.spec.ts @@ -3,9 +3,9 @@ import path from "node:path"; import { Color, EquirectangularReflectionMapping, - FloatType, - LinearSRGBColorSpace, + SRGBColorSpace, Scene, + UnsignedByteType, } from "three"; import { describe, expect, it } from "vitest"; import { flat } from "../templates/platformer/src/render/materials.js"; @@ -478,20 +478,23 @@ describe("platformer translucent surfaces and gradient sky", () => { expect(flat(0xffffff, { opacity: 0.55 })).toBe(flat(0xffffff, { opacity: 0.55 })); }); - it("uses linear sky radiance for background and environment with a coloured lower hemisphere", () => { + it("uses filterable sRGB sky radiance for background and environment with a coloured lower hemisphere", () => { 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.toArray().entries()) { - expect(actual[index]).toBeCloseTo(value, 6); + 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(LinearSRGBColorSpace); - expect(sky.type).toBe(FloatType); + 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((height - 1) / 2), new Color(palette.horizon)); + 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 scene = new Scene(); @@ -500,7 +503,7 @@ describe("platformer translucent surfaces and gradient sky", () => { expect(scene.environment).toBe(sky); expect(scene.backgroundIntensity).toBe(1); expect(scene.environmentIntensity).toBe(1); - expect(sky.colorSpace).toBe(LinearSRGBColorSpace); + expect(sky.colorSpace).toBe(SRGBColorSpace); sky.dispose(); }); }); diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index 936694a856..268b7bf52d 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: "d2bab21c86ccc461e6186bd1c9fe96c97c6184d3f8d421ba2c4d2b3891da8185", + platformer: "dc97627712532a26913cacd2117daf56b0f83a2059ab11047afa3138059ac08b", puzzle: "edd2e8eb7e1602e23e42634afe2fc768ae8a2c4b8e9e1006c208aa9bab343ec3", racing: "33900c8a4b715f9bc8eb5c0c22456b48b9b8f1987fcdbcf32965f0880c442367", rain: "93f8227373687566e46cc8d7f03586cb346b05bb5a5589c97a3e0ee83be76d91", diff --git a/packages/create-threenative/templates/platformer/src/render/sky.ts b/packages/create-threenative/templates/platformer/src/render/sky.ts index b4313f7aa2..d66ca0e4d8 100644 --- a/packages/create-threenative/templates/platformer/src/render/sky.ts +++ b/packages/create-threenative/templates/platformer/src/render/sky.ts @@ -8,16 +8,16 @@ import { DataTexture, EquirectangularReflectionMapping, Float32BufferAttribute, - FloatType, FogExp2, LinearFilter, - LinearSRGBColorSpace, Mesh, MeshBasicMaterial, RGBAFormat, + SRGBColorSpace, type Scene, SphereGeometry, type Texture, + UnsignedByteType, Vector3, } from "three"; import { HEIGHT_FOG, type HeightFogParams, heightFogDepth, heightFogNode } from "./heightFog.js"; @@ -42,11 +42,12 @@ function distanceDensity(p: HeightFogParams = HEIGHT_FOG): number { return Math.sqrt(Math.max(0, old - height)) / distance; } -/** Linear equirectangular radiance, from the lower haze through the horizon to the zenith. */ +/** sRGB equirectangular radiance, from the lower haze through the horizon to the zenith. */ export function skyTexture(): DataTexture { - const width = 8; - const height = 65; - const data = new Float32Array(width * height * 4); + // 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); const high = new Color(palette.skyHigh); const mid = new Color(palette.skyLow); const horizon = new Color(palette.horizon); @@ -60,13 +61,22 @@ export function skyTexture(): DataTexture { : elevation < 0.5 ? horizon.clone().lerp(mid, elevation * 2) : mid.clone().lerp(high, (elevation - 0.5) * 2); + const encoded = color.clone().convertLinearToSRGB(); for (let column = 0; column < width; column += 1) { - data.set([color.r, color.g, color.b, 1], (row * width + column) * 4); + data.set( + [ + Math.round(encoded.r * 255), + Math.round(encoded.g * 255), + Math.round(encoded.b * 255), + 255, + ], + (row * width + column) * 4, + ); } } - const sky = new DataTexture(data, width, height, RGBAFormat, FloatType); + const sky = new DataTexture(data, width, height, RGBAFormat, UnsignedByteType); sky.mapping = EquirectangularReflectionMapping; - sky.colorSpace = LinearSRGBColorSpace; + sky.colorSpace = SRGBColorSpace; sky.magFilter = LinearFilter; sky.minFilter = LinearFilter; sky.needsUpdate = true; @@ -75,7 +85,6 @@ export function skyTexture(): DataTexture { export function setupSky(scene: Scene, sky: Texture): void { sky.mapping = EquirectangularReflectionMapping; - sky.colorSpace = LinearSRGBColorSpace; scene.background = sky; scene.backgroundIntensity = 1; // three prefilters an equirectangular `scene.environment` itself (PMREM), on WebGPU and WebGL. From e00a4b1d5da7299ab61ae5289012bfb24100ce46 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 11 Oct 2026 13:49:14 -0700 Subject: [PATCH 04/10] fix(core): an empty asset manifest falls back like a missing one Asset-free builds now serve { version: 1, entries: {} } so the loader probe never 404s. A manifest that lists nothing must not reject files a project serves verbatim from public/. --- packages/core/__tests__/assets.spec.ts | 18 ++++++++++++++++++ packages/core/src/assets.ts | 3 +++ 2 files changed, 21 insertions(+) diff --git a/packages/core/__tests__/assets.spec.ts b/packages/core/__tests__/assets.spec.ts index 168eb9fa8f..a78bc8e6a0 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 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; } From ff367c15ad874194ecf862f6d750265fc10544a2 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 11 Oct 2026 13:51:11 -0700 Subject: [PATCH 05/10] test(core): unlisted-path rejection uses a manifest that lists another file --- packages/core/__tests__/assets.spec.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/core/__tests__/assets.spec.ts b/packages/core/__tests__/assets.spec.ts index a78bc8e6a0..02752f7cec 100644 --- a/packages/core/__tests__/assets.spec.ts +++ b/packages/core/__tests__/assets.spec.ts @@ -711,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 () => ({}) }); From e0d9a3e7b75e7cf29155af27b13a4ddfbb194e4c Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 11 Oct 2026 13:58:01 -0700 Subject: [PATCH 06/10] feat(core): report environment maps too small for PMREM and ignored opacity --- packages/core/__tests__/game.spec.ts | 34 ++++++ .../core/__tests__/render-trap-audit.spec.ts | 115 ++++++++++++++++++ packages/core/capabilities.json | 20 +++ packages/core/src/game.ts | 2 + packages/core/src/index.ts | 15 +++ .../core/src/profiling/render-trap-audit.ts | 74 +++++++++++ .../references/capability-reference.md | 15 +++ .../agent-docs/references/debug-surface.md | 5 + packages/create-threenative/capabilities.json | 20 +++ scripts/api-surface.json | 1 + 10 files changed, 301 insertions(+) create mode 100644 packages/core/__tests__/render-trap-audit.spec.ts create mode 100644 packages/core/src/profiling/render-trap-audit.ts 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/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/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/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 { … }", From 580e76ce1bd4b9ec654df5431540328d51613c3f Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 11 Oct 2026 14:22:44 -0700 Subject: [PATCH 07/10] fix(platformer): light with the measured sky fill, keep the gradient as background --- .../__tests__/looks.spec.ts | 46 ++++++- .../__tests__/scaffold.spec.ts | 2 +- .../templates/platformer/AGENTS.md | 2 +- .../templates/platformer/CLAUDE.md | 2 +- .../templates/platformer/src/render/sky.ts | 125 ++++++++++-------- .../templates/platformer/src/scenes/Play.ts | 10 +- 6 files changed, 118 insertions(+), 69 deletions(-) diff --git a/packages/create-threenative/__tests__/looks.spec.ts b/packages/create-threenative/__tests__/looks.spec.ts index 1366f6993d..01956931a9 100644 --- a/packages/create-threenative/__tests__/looks.spec.ts +++ b/packages/create-threenative/__tests__/looks.spec.ts @@ -2,7 +2,11 @@ import { readFile, readdir } from "node:fs/promises"; import path from "node:path"; import { Color, + DataUtils, EquirectangularReflectionMapping, + HalfFloatType, + LinearFilter, + LinearSRGBColorSpace, SRGBColorSpace, Scene, UnsignedByteType, @@ -10,7 +14,7 @@ import { 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 { setupSky, skyEnvironment, skyTexture } from "../templates/platformer/src/render/sky.js"; import { qualityPreset } from "../templates/starter/src/render/quality.js"; const starter = path.resolve("packages/create-threenative/templates/starter"); @@ -478,7 +482,7 @@ describe("platformer translucent surfaces and gradient sky", () => { expect(flat(0xffffff, { opacity: 0.55 })).toBe(flat(0xffffff, { opacity: 0.55 })); }); - it("uses filterable sRGB sky radiance for background and environment with a coloured lower hemisphere", () => { + 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."); @@ -497,13 +501,47 @@ describe("platformer translucent surfaces and gradient sky", () => { 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); + setupSky(scene, sky, environment); expect(scene.background).toBe(sky); - expect(scene.environment).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 268b7bf52d..e97187298e 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: "dc97627712532a26913cacd2117daf56b0f83a2059ab11047afa3138059ac08b", + platformer: "536884a4154586e286e821db9d870946dea1d6eaa4a6064dc053e1ea0a192f65", puzzle: "edd2e8eb7e1602e23e42634afe2fc768ae8a2c4b8e9e1006c208aa9bab343ec3", racing: "33900c8a4b715f9bc8eb5c0c22456b48b9b8f1987fcdbcf32965f0880c442367", rain: "93f8227373687566e46cc8d7f03586cb346b05bb5a5589c97a3e0ee83be76d91", diff --git a/packages/create-threenative/templates/platformer/AGENTS.md b/packages/create-threenative/templates/platformer/AGENTS.md index de1cb30c1a..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` builds a procedural blue gradient for background and environment light from `palette.ts`; its 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. +`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 2424b4b5fd..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` builds a procedural blue gradient for background and environment light from `palette.ts`; its 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. +`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/sky.ts b/packages/create-threenative/templates/platformer/src/render/sky.ts index d66ca0e4d8..31e77daecb 100644 --- a/packages/create-threenative/templates/platformer/src/render/sky.ts +++ b/packages/create-threenative/templates/platformer/src/render/sky.ts @@ -1,15 +1,18 @@ // Generated for you. This is ordinary Three.js — edit or delete it freely. // -// A vertical blue gradient supplies both the background and the environment light. -// Its lower hemisphere matches skyFloor, so reflections see the same haze as the floating route. +// 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, + DataUtils, EquirectangularReflectionMapping, Float32BufferAttribute, FogExp2, + HalfFloatType, LinearFilter, + LinearSRGBColorSpace, Mesh, MeshBasicMaterial, RGBAFormat, @@ -29,12 +32,7 @@ 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. */ const EYE_LEVEL = { density: 0.003, distance: 150, cameraHeight: 2 }; -/** - * The distance term's density that, with the height term, keeps the old eye-level haze: at - * `EYE_LEVEL.distance` along the horizon from `EYE_LEVEL.cameraHeight` the two together transmit what - * FogExp2 at `EYE_LEVEL.density` did. The distance term is lowered, never removed, so the ground - * still ends in fog at a kilometre. - */ +/** Lower distance density so distance + height fog preserve the kit's old eye-level haze. */ function distanceDensity(p: HeightFogParams = HEIGHT_FOG): number { const { distance, density, cameraHeight } = EYE_LEVEL; const old = (density * distance) ** 2; @@ -42,34 +40,31 @@ function distanceDensity(p: HeightFogParams = HEIGHT_FOG): number { return Math.sqrt(Math.max(0, old - height)) / distance; } +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); - 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); for (let row = 0; row < height; row += 1) { - // 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); - const color = - 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); - const encoded = color.clone().convertLinearToSRGB(); + const encoded = gradientColor(row, height).convertLinearToSRGB(); for (let column = 0; column < width; column += 1) { data.set( - [ - Math.round(encoded.r * 255), - Math.round(encoded.g * 255), - Math.round(encoded.b * 255), - 255, - ], + [...encoded.toArray().map((value) => Math.round(value * 255)), 255], (row * width + column) * 4, ); } @@ -83,39 +78,63 @@ export function skyTexture(): DataTexture { return sky; } -export function setupSky(scene: Scene, sky: Texture): void { +// 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; +} + +export function setupSky(scene: Scene, sky: Texture, environment: Texture = sky): void { sky.mapping = EquirectangularReflectionMapping; scene.background = sky; 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; + // 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. + // Thin distance fog retains the far cutoff; fogNode adds the height term. const distanceFog = new FogExp2(palette.horizon, distanceDensity()); scene.fog = distanceFog; scene.fogNode = heightFogNode(distanceFog, HEIGHT_FOG, SUN_DIRECTION); } -/** - * Inside the camera's far plane by a bounding box's diagonal: the playtest's `cameraClearsScene` - * measures a mesh by its box, and a box of half-width R reaches 1.73 R. - */ +/** Inside the camera's far plane by the diagonal of the mesh's bounding box (1.73 R). */ const FLOOR_RADIUS = 480; - /** Radians the floor climbs above the horizon before it has faded out. */ const RISE = 0.35; - -/** - * 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 gradient melts into the haze instead of meeting it at a hard line. - * It is not fogged and never writes depth. - */ +/** Lower sky fades into horizon haze 20° above the route; unfogged, with no depth writes. */ export function skyFloor(): Mesh { const geometry = new SphereGeometry( FLOOR_RADIUS, @@ -152,17 +171,7 @@ export function skyFloor(): Mesh { return floor; } -/** - * The cloud bank, as a list of lobes to instance. - * - * 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. - * - * This is data, not objects, so the batching stays the scene's decision: `src/render/` is ordinary - * Three.js and never reaches back into the framework. - */ +/** Static cloud lobes for the scene to instance as one draw; only the airship and fox move. */ export function cloudLobes( rng: () => number, ): { position: [number, number, number]; scale: [number, number, number] }[] { diff --git a/packages/create-threenative/templates/platformer/src/scenes/Play.ts b/packages/create-threenative/templates/platformer/src/scenes/Play.ts index c0b3cfd943..2945914a53 100644 --- a/packages/create-threenative/templates/platformer/src/scenes/Play.ts +++ b/packages/create-threenative/templates/platformer/src/scenes/Play.ts @@ -39,7 +39,7 @@ import { isWebGLFallbackRenderer, materialLightingEnabled, } from "../render/quality.js"; -import { cloudLobes, setupSky, skyFloor, skyTexture } from "../render/sky.js"; +import { cloudLobes, setupSky, skyEnvironment, skyFloor, skyTexture } from "../render/sky.js"; import { TouchControls } from "../render/touch-controls.js"; import type { GameState } from "../state.js"; @@ -103,6 +103,7 @@ export class Play extends Scene { }; #sky: Texture | undefined; + #skyEnvironment: Texture | undefined; #walkers: Walker[] = []; /** @@ -126,7 +127,8 @@ export class Play extends Scene { this.#walkers.push(walker); } this.#sky = skyTexture(); - setupSky(ctx.scene, this.#sky); + 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,9 +148,9 @@ export class Play extends Scene { predicate: (object) => object.userData.solid === true, }); collapseStage(stage.group); - if (this.#sky === undefined) + if (this.#sky === undefined || this.#skyEnvironment === undefined) throw new Error("Play.enter ran before load() created the sky texture."); - setupSky(ctx.scene, this.#sky); + setupSky(ctx.scene, this.#sky, this.#skyEnvironment); ctx.scene.add(skyFloor()); const lighting = setupLighting( ctx.scene, From 86f55a77457b5520bf6691fcf0654071ba0b1e34 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 11 Oct 2026 14:25:53 -0700 Subject: [PATCH 08/10] docs(platformer): restore sky.ts comments unrelated to the environment split --- .../__tests__/scaffold.spec.ts | 2 +- .../templates/platformer/src/render/sky.ts | 38 ++++++++++++++++--- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index e97187298e..3054784ec7 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: "536884a4154586e286e821db9d870946dea1d6eaa4a6064dc053e1ea0a192f65", + platformer: "1fa7f0c86d8ae96bc9a5aa68a9cfc1b43f3999f729219adc928fa05826c9520c", puzzle: "edd2e8eb7e1602e23e42634afe2fc768ae8a2c4b8e9e1006c208aa9bab343ec3", racing: "33900c8a4b715f9bc8eb5c0c22456b48b9b8f1987fcdbcf32965f0880c442367", rain: "93f8227373687566e46cc8d7f03586cb346b05bb5a5589c97a3e0ee83be76d91", diff --git a/packages/create-threenative/templates/platformer/src/render/sky.ts b/packages/create-threenative/templates/platformer/src/render/sky.ts index 31e77daecb..e9f9e97332 100644 --- a/packages/create-threenative/templates/platformer/src/render/sky.ts +++ b/packages/create-threenative/templates/platformer/src/render/sky.ts @@ -32,7 +32,12 @@ 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. */ const EYE_LEVEL = { density: 0.003, distance: 150, cameraHeight: 2 }; -/** Lower distance density so distance + height fog preserve the kit's old eye-level haze. */ +/** + * The distance term's density that, with the height term, keeps the old eye-level haze: at + * `EYE_LEVEL.distance` along the horizon from `EYE_LEVEL.cameraHeight` the two together transmit what + * FogExp2 at `EYE_LEVEL.density` did. The distance term is lowered, never removed, so the ground + * still ends in fog at a kilometre. + */ function distanceDensity(p: HeightFogParams = HEIGHT_FOG): number { const { distance, density, cameraHeight } = EYE_LEVEL; const old = (density * distance) ** 2; @@ -124,17 +129,30 @@ export function setupSky(scene: Scene, sky: Texture, environment: Texture = sky) // The measured fill lights faces the sun misses and supplies reflections as roughness drops. scene.environment = environment; scene.environmentIntensity = 1; - // Thin distance fog retains the far cutoff; fogNode adds the height term. + // 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. const distanceFog = new FogExp2(palette.horizon, distanceDensity()); scene.fog = distanceFog; scene.fogNode = heightFogNode(distanceFog, HEIGHT_FOG, SUN_DIRECTION); } -/** Inside the camera's far plane by the diagonal of the mesh's bounding box (1.73 R). */ +/** + * Inside the camera's far plane by a bounding box's diagonal: the playtest's `cameraClearsScene` + * measures a mesh by its box, and a box of half-width R reaches 1.73 R. + */ const FLOOR_RADIUS = 480; + /** Radians the floor climbs above the horizon before it has faded out. */ const RISE = 0.35; -/** Lower sky fades into horizon haze 20° above the route; unfogged, with no depth writes. */ + +/** + * 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 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 { const geometry = new SphereGeometry( FLOOR_RADIUS, @@ -171,7 +189,17 @@ export function skyFloor(): Mesh { return floor; } -/** Static cloud lobes for the scene to instance as one draw; only the airship and fox move. */ +/** + * The cloud bank, as a list of lobes to instance. + * + * 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. + * + * This is data, not objects, so the batching stays the scene's decision: `src/render/` is ordinary + * Three.js and never reaches back into the framework. + */ export function cloudLobes( rng: () => number, ): { position: [number, number, number]; scale: [number, number, number] }[] { From 2afd0360124f056443a47e1b7d8e7c492ea06511 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 11 Oct 2026 14:28:22 -0700 Subject: [PATCH 09/10] refactor(platformer): sky environment in its own render file --- .../__tests__/looks.spec.ts | 3 +- .../__tests__/scaffold.spec.ts | 3 +- .../templates/platformer/src/render/sky.ts | 43 +--------------- .../platformer/src/render/skyEnvironment.ts | 50 +++++++++++++++++++ .../templates/platformer/src/scenes/Play.ts | 3 +- 5 files changed, 57 insertions(+), 45 deletions(-) create mode 100644 packages/create-threenative/templates/platformer/src/render/skyEnvironment.ts diff --git a/packages/create-threenative/__tests__/looks.spec.ts b/packages/create-threenative/__tests__/looks.spec.ts index 01956931a9..e150f20b3c 100644 --- a/packages/create-threenative/__tests__/looks.spec.ts +++ b/packages/create-threenative/__tests__/looks.spec.ts @@ -14,7 +14,8 @@ import { 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, skyEnvironment, skyTexture } from "../templates/platformer/src/render/sky.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"); diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index 3054784ec7..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: "1fa7f0c86d8ae96bc9a5aa68a9cfc1b43f3999f729219adc928fa05826c9520c", + 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", diff --git a/packages/create-threenative/templates/platformer/src/render/sky.ts b/packages/create-threenative/templates/platformer/src/render/sky.ts index e9f9e97332..056f6e9162 100644 --- a/packages/create-threenative/templates/platformer/src/render/sky.ts +++ b/packages/create-threenative/templates/platformer/src/render/sky.ts @@ -6,13 +6,10 @@ import { BackSide, Color, DataTexture, - DataUtils, EquirectangularReflectionMapping, Float32BufferAttribute, FogExp2, - HalfFloatType, LinearFilter, - LinearSRGBColorSpace, Mesh, MeshBasicMaterial, RGBAFormat, @@ -45,7 +42,7 @@ function distanceDensity(p: HeightFogParams = HEIGHT_FOG): number { return Math.sqrt(Math.max(0, old - height)) / distance; } -function gradientColor(row: number, height: number): Color { +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); @@ -83,44 +80,6 @@ export function skyTexture(): DataTexture { return sky; } -// 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; -} - export function setupSky(scene: Scene, sky: Texture, environment: Texture = sky): void { sky.mapping = EquirectangularReflectionMapping; scene.background = sky; 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 2945914a53..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, skyEnvironment, skyFloor, skyTexture } 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"; From 2524b59973517b285aae15d3d44ccf193041d63f Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 11 Oct 2026 15:30:25 -0700 Subject: [PATCH 10/10] test: align render audit and empty asset manifest contracts Guard the render-trap audit against creating or mutating appearance while allowing its read-only diagnostics. Expect empty manifests in receipt cleanup and the shared template compiler check, matching the asset-free compiler contract. Verification: 38 core/asset contract tests and the platformer compile case pass. --- packages/assets/__tests__/bake-receipt.spec.ts | 6 +++--- packages/core/__tests__/constraints.spec.ts | 14 ++++++++++++++ .../__tests__/template-assets-compile-cases.ts | 8 +++----- 3 files changed, 20 insertions(+), 8 deletions(-) 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/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/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; }