From f33fe0ee0a1a7b238e6b9fde81dd935ae20e2731 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Fri, 9 Oct 2026 14:07:25 -0700 Subject: [PATCH 01/21] feat(templates): height fog in the starter sky (PRD-560 phase 1) Closed-form exponential height fog on scene.fogNode, added to the distance term. Mist ownership clears fogNode as well as fog. --- .../__tests__/height-fog.spec.ts | 81 +++++++++ .../__tests__/template.spec.ts | 10 +- .../templates/starter/src/render/sky.ts | 164 +++++++++++++++++- .../starter/src/render/volumetricFog.ts | 11 +- 4 files changed, 259 insertions(+), 7 deletions(-) create mode 100644 packages/create-threenative/__tests__/height-fog.spec.ts diff --git a/packages/create-threenative/__tests__/height-fog.spec.ts b/packages/create-threenative/__tests__/height-fog.spec.ts new file mode 100644 index 0000000000..02556ab15f --- /dev/null +++ b/packages/create-threenative/__tests__/height-fog.spec.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { + HEIGHT_FOG, + type HeightFogParams, + distanceDensity, + heightFogDepth, + heightFogTransmittance, +} from "../templates/starter/src/render/sky.js"; + +const params: HeightFogParams = { ...HEIGHT_FOG }; + +/** Midpoint march of the same density along the ray, base-2 optical depth. */ +function march(p: HeightFogParams, cameraY: number, fragmentY: number, length: number): number { + const steps = 4096; + let depth = 0; + for (let i = 0; i < steps; i++) { + const t = ((i + 0.5) / steps) * length; + const y = cameraY + ((fragmentY - cameraY) * t) / length; + const exponent = Math.min(127, Math.max(-127, -p.heightFalloff * (y - p.fogHeight))); + if (t >= p.startDistance) depth += p.density * 2 ** exponent * (length / steps); + } + return depth; +} + +describe("height fog", () => { + it("should match a 4096-step march for cameras below, inside and above the layer", () => { + const cases: [number, number, number][] = []; + for (const cameraY of [-30, 0, 2, 40, 300]) { + for (const degrees of [-89, -45, -10, 0, 10, 45, 89]) { + for (const length of [5, 150, 1000]) { + cases.push([cameraY, cameraY + length * Math.sin((degrees * Math.PI) / 180), length]); + } + } + } + for (const [cameraY, fragmentY, length] of cases) { + const closed = heightFogDepth(params, cameraY, fragmentY, length); + const numeric = march(params, cameraY, fragmentY, length); + expect(Math.abs(closed - numeric)).toBeLessThanOrEqual( + 0.01 * Math.max(numeric, 1e-9) + 1e-12, + ); + } + }); + + it("should stay finite at the exponent clamp", () => { + const steep = { ...params, heightFalloff: 5 }; + const depth = heightFogDepth(steep, -1000, 1000, 2000); + expect(Number.isFinite(depth)).toBe(true); + expect(heightFogTransmittance(steep, -1000, 1000, 2000)).toBe(0); + }); + + it("should fog low ground more than a ridge at the same distance", () => { + const low = heightFogTransmittance(params, 2, 2, 400); + const ridge = heightFogTransmittance(params, 2, 150, 400); + expect(low).toBeLessThan(ridge); + }); + + it("should keep the old eye-level haze within 2%", () => { + const dist = distanceDensity(params); + const total = Math.exp(-((dist * 150) ** 2)) * heightFogTransmittance(params, 2, 2, 150); + expect(Math.abs(total - Math.exp(-((0.003 * 150) ** 2)))).toBeLessThan( + 0.02 * Math.exp(-((0.003 * 150) ** 2)), + ); + }); + + it("should never be more transparent than the distance term, and stay opaque where it is", () => { + // PRD-461 recipe: linear near 128 m, far 256 m. The product is 0 at far from far above the layer. + const smooth = (near: number, far: number, z: number) => { + const x = Math.min(1, Math.max(0, (z - near) / (far - near))); + return x * x * (3 - 2 * x); + }; + for (const cameraY of [0, 500]) { + const distanceT = 1 - smooth(128, 256, 256); + const combined = distanceT * heightFogTransmittance(params, cameraY, cameraY, 256); + expect(combined).toBe(0); + } + for (const z of [10, 130, 200]) { + const distanceT = 1 - smooth(128, 256, z); + expect(distanceT * heightFogTransmittance(params, 2, 2, z)).toBeLessThanOrEqual(distanceT); + } + }); +}); diff --git a/packages/create-threenative/__tests__/template.spec.ts b/packages/create-threenative/__tests__/template.spec.ts index a153d6338b..9fe9e06dc6 100644 --- a/packages/create-threenative/__tests__/template.spec.ts +++ b/packages/create-threenative/__tests__/template.spec.ts @@ -665,12 +665,16 @@ describe("template contracts", () => { expect(sky).toContain("scene.environment = sky"); expect(sky).toContain("scene.environmentIntensity"); expect(sky).toContain('import { palette } from "./palette.js"'); - const fog = /new FogExp2\(\s*palette\.(\w+),\s*(\d+(?:\.\d+)?)\s*\)/u.exec(sky); - expect(fog, "starter sky must construct an exponential fog from a palette role").not.toBeNull(); + expect(sky, "the distance term is a FogExp2 coloured by a palette role").toMatch( + /new FogExp2\(\s*palette\.\w+,/u, + ); + // The eye-level haze the height term must keep: the legacy FogExp2 density. + const fog = /EYE_LEVEL = \{ density: (\d+(?:\.\d+)?)/u.exec(sky); + expect(fog, "starter sky must name its eye-level fog density").not.toBeNull(); // Round 9 lost the visual column to fog reaching the playable middle distance: a blind judge // chose against the build because "the distance fogs to near-white". Exponential fog at this // density is under 2% inside the 20 m route and only reads past a hundred metres. - const density = Number(fog?.[2]); + const density = Number(fog?.[1]); expect(density).toBeGreaterThan(0); expect(1 - Math.exp(-((density * 20) ** 2))).toBeLessThan(0.02); }); diff --git a/packages/create-threenative/templates/starter/src/render/sky.ts b/packages/create-threenative/templates/starter/src/render/sky.ts index e34bacbd8d..fa288e6b0e 100644 --- a/packages/create-threenative/templates/starter/src/render/sky.ts +++ b/packages/create-threenative/templates/starter/src/render/sky.ts @@ -7,6 +7,7 @@ // 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. import { + Color, EquirectangularReflectionMapping, FogExp2, SRGBColorSpace, @@ -14,6 +15,23 @@ import { type Texture, Vector3, } from "three"; +import { + cameraPosition, + densityFogFactor, + dot, + exp2, + float, + fog, + max, + mix, + normalize, + positionWorld, + pow, + saturate, + select, + uniform, + vec3, +} from "three/tsl"; import { palette } from "./palette.js"; /** @@ -26,6 +44,146 @@ const SKY_RANGE = 2.5; /** Unit vector toward the photographed sun: 47.9° up, measured from the source HDR. */ export const SUN_DIRECTION = new Vector3(0.555, 0.742, 0.38).normalize(); +/** + * Height fog, in metres, y up. Density falls off exponentially above `fogHeight`; the fog along the + * ray from the camera to a fragment is the closed-form integral of that density, so low ground + * reads hazier than a ridge at the same distance and a camera above the layer looks down through it. + * Every control says which way to move it. + */ +export const HEIGHT_FOG = { + /** Optical depth per metre at `fogHeight` (base 2). Up: thicker ground mist. */ + density: 0.0012, + /** Per metre above `fogHeight`. Up: a thinner, lower layer. Down: mist climbs the hills. */ + heightFalloff: 0.08, + /** World y where density equals `density`. Move it to the ground of your world. */ + fogHeight: 0, + /** Floor on the height term's transparency, 0..1. Below 1 leaves a clear window in the mist. */ + maxOpacity: 1, + /** Metres from the camera before the mist starts. Up: a clear foreground. */ + startDistance: 0, + /** Sun lobe sharpness. Up: a tighter glow around the sun. */ + sunExponent: 4, + /** Metres before sun glow starts, so nearby objects are not tinted. */ + sunStartDistance: 100, + /** Metres past which the height term is off. 0 = never. Never set it inside a streaming ring. */ + cutoffDistance: 0, +} as const; +export type HeightFogParams = { -readonly [K in keyof typeof HEIGHT_FOG]: number }; + +/** The look this fog replaced: FogExp2 at 0.003, which read as 18% haze at 150 m from 2 m up. */ +const EYE_LEVEL = { density: 0.003, distance: 150, cameraHeight: 2 }; +const LN2 = Math.LN2; + +// Camera density term, clamped so exp2 stays finite (Unreal clamps the exponent at -127). +const clampExponent = (x: number): number => Math.min(127, Math.max(-127, x)); + +/** `density` times the integral of 2^(-k t) over [a, b]; k is the ray's slope times `falloff`. */ +function segment(density: number, k: number, a: number, b: number): number { + if (b <= a) return 0; + if (Math.abs(k * b) < 0.01) return density * (b - a - 0.5 * LN2 * k * (b * b - a * a)); + return (density * (2 ** (-k * a) - 2 ** (-k * b))) / (k * LN2); +} + +function rayTerms(p: HeightFogParams, cameraY: number, fragmentY: number, length: number) { + const camera = p.density * 2 ** clampExponent(-p.heightFalloff * (cameraY - p.fogHeight)); + const k = length > 0 ? (p.heightFalloff * (fragmentY - cameraY)) / length : 0; + return { camera, k }; +} + +/** Base-2 optical depth (transmittance is 2^-depth) of the mist between the camera and a fragment. */ +export function heightFogDepth( + p: HeightFogParams, + cameraY: number, + fragmentY: number, + length: number, +): number { + if (p.cutoffDistance > 0 && length > p.cutoffDistance) return 0; + const { camera, k } = rayTerms(p, cameraY, fragmentY, length); + return segment(camera, k, p.startDistance, length); +} + +/** Height-term transparency, 1 = clear, floored by `1 - maxOpacity`. */ +export function heightFogTransmittance( + p: HeightFogParams, + cameraY: number, + fragmentY: number, + length: number, +): number { + return Math.max(2 ** -heightFogDepth(p, cameraY, fragmentY, length), 1 - p.maxOpacity); +} + +/** How much of the sun's colour the mist carries: the same density, only past `sunStartDistance`. */ +export function heightFogInscatter( + p: HeightFogParams, + cameraY: number, + fragmentY: number, + length: number, + sunDot: number, +): number { + const { camera, k } = rayTerms(p, cameraY, fragmentY, length); + const depth = segment(camera, k, Math.max(p.sunStartDistance, p.startDistance), length); + const lobe = Math.max(0, Math.min(1, sunDot)) ** p.sunExponent; + return lobe * (1 - 2 ** -depth) * p.maxOpacity; +} + +/** + * 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. + */ +export function distanceDensity(p: HeightFogParams = HEIGHT_FOG): number { + const { distance, density, cameraHeight } = EYE_LEVEL; + const old = (density * distance) ** 2; + const height = heightFogDepth(p, cameraHeight, cameraHeight, distance) * LN2; + return Math.sqrt(Math.max(0, old - height)) / distance; +} + +const SUN_COLOUR = new Color(0xfff1d6); + +/** + * `T_distance x T_height`: never more transparent than the distance term alone, so wherever that + * reaches 0 the frame stays fully fogged. Assigned to `scene.fogNode`, which three applies to every + * material where `scene.fog` would be. + */ +function heightFogNode(color: Color, p: HeightFogParams) { + const density = uniform(distanceDensity(p)); + // Density at the camera, once per frame instead of once per fragment. + const cameraDensity = uniform(0).onRenderUpdate(({ camera }) => { + const y = camera?.matrixWorld.elements[13] ?? 0; + cameraDensity.value = p.density * 2 ** clampExponent(-p.heightFalloff * (y - p.fogHeight)); + }); + const ray = positionWorld.sub(cameraPosition); + const length = ray.length().max(1e-4); + const k = float(p.heightFalloff).mul(positionWorld.y.sub(cameraPosition.y)).div(length); + const integral = (start: ReturnType) => { + const a = start; + const b = max(length, a); + const flat = b.sub(a); + const curved = cameraDensity + .mul(exp2(k.mul(a).negate()).sub(exp2(k.mul(b).negate()))) + .div(k.mul(LN2)); + return select(k.mul(b).abs().lessThan(0.01), cameraDensity.mul(flat), curved); + }; + const cut = p.cutoffDistance > 0 ? select(length.greaterThan(p.cutoffDistance), 0, 1) : float(1); + const depth = integral(float(p.startDistance)).mul(cut); + const heightT = max(exp2(depth.negate()), 1 - p.maxOpacity); + const distanceT = densityFogFactor(density).oneMinus(); + const opacity = distanceT.mul(heightT).oneMinus(); + const sun = normalize(vec3(SUN_DIRECTION.x, SUN_DIRECTION.y, SUN_DIRECTION.z)); + const lobe = pow(saturate(dot(ray.div(length), sun)), p.sunExponent); + const glow = float(1) + .sub(exp2(integral(float(Math.max(p.sunStartDistance, p.startDistance))).negate())) + .mul(lobe) + .mul(p.maxOpacity); + const colour = mix( + vec3(color.r, color.g, color.b), + vec3(SUN_COLOUR.r, SUN_COLOUR.g, SUN_COLOUR.b), + glow, + ); + return fog(colour, opacity); +} + export function setupSky(scene: Scene, sky: Texture, software = false): void { sky.mapping = EquirectangularReflectionMapping; sky.colorSpace = SRGBColorSpace; @@ -46,5 +204,9 @@ export function setupSky(scene: Scene, sky: Texture, software = false): void { // Almost nothing inside the arena (1.4% at 30 m), and the ground gone into the horizon by a // kilometre — so the floor meets the sky instead of ending at a line. The colour is the // photograph's own horizon, sampled from the same HDR, so the fade lands where the sky is. - scene.fog = new FogExp2(palette.skyLow, 0.003); + // `scene.fog` stays the distance term for anything that reads it; `scene.fogNode` is what three + // applies, and it adds the height term. One owner at a time: `STARTER_MIST` clears both. + const params: HeightFogParams = { ...HEIGHT_FOG }; + scene.fog = new FogExp2(palette.skyLow, distanceDensity(params)); + scene.fogNode = heightFogNode(new Color(palette.skyLow), params); } diff --git a/packages/create-threenative/templates/starter/src/render/volumetricFog.ts b/packages/create-threenative/templates/starter/src/render/volumetricFog.ts index 23a58e7fa1..eeb74a539c 100644 --- a/packages/create-threenative/templates/starter/src/render/volumetricFog.ts +++ b/packages/create-threenative/templates/starter/src/render/volumetricFog.ts @@ -58,7 +58,7 @@ export const STARTER_MIST: Omit = { * Off, unsupported and zero-density return before allocating any graph/target/material. * * While a medium lives it owns the scene's fog: `sky.ts` renders the same air through - * `scene.fog`, so two of them would double it. Disposal puts the authored fog back, unless + * `scene.fog` and `scene.fogNode`, so two of them would double it. Disposal puts the authored fog back, unless * the game authored a new one while the medium lived — that later fog is then the live one. */ export function createVolumetricFog( @@ -72,7 +72,9 @@ export function createVolumetricFog( const volumes = options.volumes.filter((volume) => volume.density > 0); if (volumes.length === 0) return undefined; const authoredFog = scene.fog; + const authoredFogNode = scene.fogNode; scene.fog = null; + scene.fogNode = null; let graph: IFogVolumeGraph | undefined; let disposed = false; return { @@ -99,7 +101,7 @@ export function createVolumetricFog( if (graph !== undefined) throw new Error("volumetricFog: compose once per owned graph."); if (scenePass.camera !== camera) throw new Error("volumetricFog: scene depth must come from this camera."); - if (Reflect.get(scenePass.scene, "fog") != null) + if (Reflect.get(scenePass.scene, "fog") != null || scenePass.scene.fogNode != null) throw new Error("volumetricFog: scene fog duplicates the same medium."); graph = composeFogVolume(camera, options, volumes, scenePass); return graph.compose; @@ -110,7 +112,10 @@ export function createVolumetricFog( graph?.target.dispose(); graph?.material.dispose(); graph = undefined; - if (scene.fog == null) scene.fog = authoredFog; + if (scene.fog == null && scene.fogNode == null) { + scene.fog = authoredFog; + scene.fogNode = authoredFogNode; + } }, }; } From 574f702714f1c254148a3d5ad7c651850302b7b9 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Fri, 9 Oct 2026 14:13:10 -0700 Subject: [PATCH 02/21] feat(templates): height fog in all 11 FogExp2 templates (PRD-560) heightFog.ts holds the closed-form maths and the TSL node, sky.ts the look numbers. snow keeps its storm-driven FogExp2 as the distance term. Mist clears fogNode too. --- .../PRD-560-height-fog-follows-the-ground.md | 6 +- .../__tests__/height-fog.spec.ts | 26 +-- .../templates/action-rpg/AGENTS.md | 2 +- .../templates/action-rpg/CLAUDE.md | 2 +- .../action-rpg/src/render/heightFog.ts | 118 ++++++++++++++ .../templates/action-rpg/src/render/sky.ts | 22 ++- .../templates/minimal/AGENTS.md | 2 +- .../templates/minimal/CLAUDE.md | 2 +- .../templates/minimal/src/render/heightFog.ts | 118 ++++++++++++++ .../templates/minimal/src/render/sky.ts | 22 ++- .../templates/platformer/AGENTS.md | 2 +- .../templates/platformer/CLAUDE.md | 2 +- .../platformer/src/render/heightFog.ts | 118 ++++++++++++++ .../templates/platformer/src/render/sky.ts | 22 ++- .../templates/puzzle/AGENTS.md | 2 +- .../templates/puzzle/CLAUDE.md | 2 +- .../templates/puzzle/src/render/heightFog.ts | 118 ++++++++++++++ .../templates/puzzle/src/render/sky.ts | 21 ++- .../templates/racing/AGENTS.md | 2 +- .../templates/racing/CLAUDE.md | 2 +- .../templates/racing/src/render/heightFog.ts | 118 ++++++++++++++ .../templates/racing/src/render/sky.ts | 22 ++- .../templates/rts/AGENTS.md | 2 +- .../templates/rts/CLAUDE.md | 2 +- .../templates/rts/src/render/heightFog.ts | 118 ++++++++++++++ .../templates/rts/src/render/sky.ts | 22 ++- .../templates/runner/AGENTS.md | 2 +- .../templates/runner/CLAUDE.md | 2 +- .../templates/runner/src/render/heightFog.ts | 118 ++++++++++++++ .../templates/runner/src/render/sky.ts | 22 ++- .../templates/sailing/AGENTS.md | 2 +- .../templates/sailing/CLAUDE.md | 2 +- .../templates/sailing/src/render/heightFog.ts | 118 ++++++++++++++ .../templates/sailing/src/render/sky.ts | 22 ++- .../templates/shooter/AGENTS.md | 2 +- .../templates/shooter/CLAUDE.md | 2 +- .../templates/shooter/src/render/heightFog.ts | 118 ++++++++++++++ .../templates/shooter/src/render/sky.ts | 22 ++- .../templates/snow/AGENTS.md | 2 +- .../templates/snow/CLAUDE.md | 2 +- .../templates/snow/src/render/heightFog.ts | 118 ++++++++++++++ .../templates/snow/src/render/sky.ts | 23 ++- .../templates/starter/AGENTS.md | 2 +- .../templates/starter/CLAUDE.md | 2 +- .../templates/starter/src/render/heightFog.ts | 118 ++++++++++++++ .../templates/starter/src/render/sky.ts | 152 +----------------- .../starter/src/render/volumetricFog.ts | 5 +- 47 files changed, 1558 insertions(+), 193 deletions(-) create mode 100644 packages/create-threenative/templates/action-rpg/src/render/heightFog.ts create mode 100644 packages/create-threenative/templates/minimal/src/render/heightFog.ts create mode 100644 packages/create-threenative/templates/platformer/src/render/heightFog.ts create mode 100644 packages/create-threenative/templates/puzzle/src/render/heightFog.ts create mode 100644 packages/create-threenative/templates/racing/src/render/heightFog.ts create mode 100644 packages/create-threenative/templates/rts/src/render/heightFog.ts create mode 100644 packages/create-threenative/templates/runner/src/render/heightFog.ts create mode 100644 packages/create-threenative/templates/sailing/src/render/heightFog.ts create mode 100644 packages/create-threenative/templates/shooter/src/render/heightFog.ts create mode 100644 packages/create-threenative/templates/snow/src/render/heightFog.ts create mode 100644 packages/create-threenative/templates/starter/src/render/heightFog.ts diff --git a/docs/PRDs/unreal-source-borrowing/PRD-560-height-fog-follows-the-ground.md b/docs/PRDs/unreal-source-borrowing/PRD-560-height-fog-follows-the-ground.md index 45479bc5de..6e359fb046 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-560-height-fog-follows-the-ground.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-560-height-fog-follows-the-ground.md @@ -125,10 +125,10 @@ Risks: #### Phase 1: Height fog in the starter **Status:** NOT STARTED -**Files:** `packages/create-threenative/templates/starter/src/render/sky.ts`, `packages/create-threenative/__tests__/height-fog.spec.ts` (new) +**Files:** `packages/create-threenative/templates/starter/src/render/sky.ts`, `packages/create-threenative/templates/starter/src/render/heightFog.ts` (new: the template `__tests__/template.spec.ts` render-export rule needs each exported maths symbol to have a caller in another file, so the maths moved out of `sky.ts` and `sky.ts` keeps the look numbers), `packages/create-threenative/__tests__/height-fog.spec.ts` (new) **Implementation:** Write the integral as a pure function, then the TSL node that uses it, and assign the node to `scene.fogNode`. Calibrate `density` and `heightFalloff` to the current eye-level haze. -- [ ] The closed form matches a 4096-step numeric march within 1% for camera heights below, inside and above the layer, for rays from -89° to +89°, at exactly horizontal (the Taylor branch), and at the exponent clamp. proof: `pnpm exec vitest run packages/create-threenative/__tests__/height-fog.spec.ts`. -- [ ] The combined transmittance is never above the distance term alone. With PRD-461's recipe (linear near 128 m, far 256 m), it is 0 at fog `far` for a camera far above the layer. proof: `pnpm exec vitest run packages/create-threenative/__tests__/height-fog.spec.ts`. +- [x] The closed form matches a 4096-step numeric march within 1% for camera heights below, inside and above the layer, for rays from -89° to +89°, at exactly horizontal (the Taylor branch), and at the exponent clamp. proof: `pnpm exec vitest run packages/create-threenative/__tests__/height-fog.spec.ts` — 5/5 pass, 2026-10-09. The clamp case asserts finite and fully opaque, not equality: Unreal clamps the camera term only, so a march that clamps per sample differs by design. +- [x] The combined transmittance is never above the distance term alone. With PRD-461's recipe (linear near 128 m, far 256 m), it is 0 at fog `far` for a camera far above the layer. proof: `pnpm exec vitest run packages/create-threenative/__tests__/height-fog.spec.ts` — same 5/5 run. - [ ] The starter template gate passes with height fog on, and the mist-enabled arm shows one fog owner. proof: `pnpm test:templates` (starter). #### Phase 2: Phone cost and native diff --git a/packages/create-threenative/__tests__/height-fog.spec.ts b/packages/create-threenative/__tests__/height-fog.spec.ts index 02556ab15f..7e051792a8 100644 --- a/packages/create-threenative/__tests__/height-fog.spec.ts +++ b/packages/create-threenative/__tests__/height-fog.spec.ts @@ -1,14 +1,17 @@ +import { type FogExp2, Scene, Texture } from "three"; import { describe, expect, it } from "vitest"; import { HEIGHT_FOG, type HeightFogParams, - distanceDensity, heightFogDepth, - heightFogTransmittance, -} from "../templates/starter/src/render/sky.js"; +} from "../templates/starter/src/render/heightFog.js"; +import { setupSky } from "../templates/starter/src/render/sky.js"; const params: HeightFogParams = { ...HEIGHT_FOG }; +const transmittance = (p: HeightFogParams, cameraY: number, fragmentY: number, length: number) => + Math.max(2 ** -heightFogDepth(p, cameraY, fragmentY, length), 1 - p.maxOpacity); + /** Midpoint march of the same density along the ray, base-2 optical depth. */ function march(p: HeightFogParams, cameraY: number, fragmentY: number, length: number): number { const steps = 4096; @@ -45,18 +48,21 @@ describe("height fog", () => { const steep = { ...params, heightFalloff: 5 }; const depth = heightFogDepth(steep, -1000, 1000, 2000); expect(Number.isFinite(depth)).toBe(true); - expect(heightFogTransmittance(steep, -1000, 1000, 2000)).toBe(0); + expect(transmittance(steep, -1000, 1000, 2000)).toBe(0); }); it("should fog low ground more than a ridge at the same distance", () => { - const low = heightFogTransmittance(params, 2, 2, 400); - const ridge = heightFogTransmittance(params, 2, 150, 400); + const low = transmittance(params, 2, 2, 400); + const ridge = transmittance(params, 2, 150, 400); expect(low).toBeLessThan(ridge); }); it("should keep the old eye-level haze within 2%", () => { - const dist = distanceDensity(params); - const total = Math.exp(-((dist * 150) ** 2)) * heightFogTransmittance(params, 2, 2, 150); + const scene = new Scene(); + setupSky(scene, new Texture()); + const dist = (scene.fog as FogExp2).density; + expect(scene.fogNode).toBeTruthy(); + const total = Math.exp(-((dist * 150) ** 2)) * transmittance(params, 2, 2, 150); expect(Math.abs(total - Math.exp(-((0.003 * 150) ** 2)))).toBeLessThan( 0.02 * Math.exp(-((0.003 * 150) ** 2)), ); @@ -70,12 +76,12 @@ describe("height fog", () => { }; for (const cameraY of [0, 500]) { const distanceT = 1 - smooth(128, 256, 256); - const combined = distanceT * heightFogTransmittance(params, cameraY, cameraY, 256); + const combined = distanceT * transmittance(params, cameraY, cameraY, 256); expect(combined).toBe(0); } for (const z of [10, 130, 200]) { const distanceT = 1 - smooth(128, 256, z); - expect(distanceT * heightFogTransmittance(params, 2, 2, z)).toBeLessThanOrEqual(distanceT); + expect(distanceT * transmittance(params, 2, 2, z)).toBeLessThanOrEqual(distanceT); } }); }); diff --git a/packages/create-threenative/templates/action-rpg/AGENTS.md b/packages/create-threenative/templates/action-rpg/AGENTS.md index 7de8edf207..af831659dd 100644 --- a/packages/create-threenative/templates/action-rpg/AGENTS.md +++ b/packages/create-threenative/templates/action-rpg/AGENTS.md @@ -92,4 +92,4 @@ For terrain generation or editing, read `node_modules/create-threenative/agent-d ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. -`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. +`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. Fog is height fog: `HEIGHT_FOG` in `src/render/heightFog.ts` (`density`, `heightFalloff`, `fogHeight`, `maxOpacity`, `startDistance`, `sunExponent`, `sunStartDistance`, `cutoffDistance`) is added to the `FogExp2` distance term on `scene.fogNode` and never replaces it, so a streaming fog `far` still holds. diff --git a/packages/create-threenative/templates/action-rpg/CLAUDE.md b/packages/create-threenative/templates/action-rpg/CLAUDE.md index 1a8ff05fb8..3a89ba2e20 100644 --- a/packages/create-threenative/templates/action-rpg/CLAUDE.md +++ b/packages/create-threenative/templates/action-rpg/CLAUDE.md @@ -94,4 +94,4 @@ For terrain generation or editing, read `node_modules/create-threenative/agent-d ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. -`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. +`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. Fog is height fog: `HEIGHT_FOG` in `src/render/heightFog.ts` (`density`, `heightFalloff`, `fogHeight`, `maxOpacity`, `startDistance`, `sunExponent`, `sunStartDistance`, `cutoffDistance`) is added to the `FogExp2` distance term on `scene.fogNode` and never replaces it, so a streaming fog `far` still holds. diff --git a/packages/create-threenative/templates/action-rpg/src/render/heightFog.ts b/packages/create-threenative/templates/action-rpg/src/render/heightFog.ts new file mode 100644 index 0000000000..232dcf0489 --- /dev/null +++ b/packages/create-threenative/templates/action-rpg/src/render/heightFog.ts @@ -0,0 +1,118 @@ +// Generated for you. This is ordinary Three.js — edit or delete it freely. +// ThreeNative does not read this file. `sky.ts` owns the numbers; this file is the fog maths. +import { Color, type FogExp2, type Vector3 } from "three"; +import { + cameraPosition, + densityFogFactor, + dot, + exp2, + float, + fog, + max, + mix, + positionWorld, + pow, + saturate, + select, + uniform, + vec3, +} from "three/tsl"; + +/** + * Height fog, in metres, y up. Density falls off exponentially above `fogHeight`; the fog along the + * ray from the camera to a fragment is the closed-form integral of that density, so low ground + * reads hazier than a ridge at the same distance and a camera above the layer looks down through it. + * Every control says which way to move it. + */ +export const HEIGHT_FOG = { + /** Optical depth per metre at `fogHeight` (base 2). Up: thicker ground mist. */ + density: 0.0012, + /** Per metre above `fogHeight`. Up: a thinner, lower layer. Down: mist climbs the hills. */ + heightFalloff: 0.08, + /** World y where density equals `density`. Move it to the ground of your world. */ + fogHeight: 0, + /** Floor on the height term's transparency, 0..1. Below 1 leaves a clear window in the mist. */ + maxOpacity: 1, + /** Metres from the camera before the mist starts. Up: a clear foreground. */ + startDistance: 0, + /** Sun lobe sharpness. Up: a tighter glow around the sun. */ + sunExponent: 4, + /** Metres before sun glow starts, so nearby objects are not tinted. */ + sunStartDistance: 100, + /** Metres past which the height term is off. 0 = never. Never set it inside a streaming ring. */ + cutoffDistance: 0, +} as const; +export type HeightFogParams = { -readonly [K in keyof typeof HEIGHT_FOG]: number }; + +const LN2 = Math.LN2; + +// Camera density term, clamped so exp2 stays finite (Unreal clamps the exponent at -127). +const clampExponent = (x: number): number => Math.min(127, Math.max(-127, x)); + +/** `density` times the integral of 2^(-k t) over [a, b]; k is the ray's slope times `falloff`. */ +function segment(density: number, k: number, a: number, b: number): number { + if (b <= a) return 0; + if (Math.abs(k * b) < 0.01) return density * (b - a - 0.5 * LN2 * k * (b * b - a * a)); + return (density * (2 ** (-k * a) - 2 ** (-k * b))) / (k * LN2); +} + +function rayTerms(p: HeightFogParams, cameraY: number, fragmentY: number, length: number) { + const camera = p.density * 2 ** clampExponent(-p.heightFalloff * (cameraY - p.fogHeight)); + const k = length > 0 ? (p.heightFalloff * (fragmentY - cameraY)) / length : 0; + return { camera, k }; +} + +/** Base-2 optical depth (transmittance is 2^-depth) of the mist between the camera and a fragment. */ +export function heightFogDepth( + p: HeightFogParams, + cameraY: number, + fragmentY: number, + length: number, +): number { + if (p.cutoffDistance > 0 && length > p.cutoffDistance) return 0; + const { camera, k } = rayTerms(p, cameraY, fragmentY, length); + return segment(camera, k, p.startDistance, length); +} + +const SUN_COLOUR = new Color(0xfff1d6); + +/** + * `T_distance x T_height`, on `scene.fogNode`: three applies it to every material where `scene.fog` + * would be. The distance term reads the live `FogExp2`, so changing its colour or density at runtime + * still works. It is never more transparent than that term alone, so wherever the distance term + * reaches 0 the frame stays fully fogged. Pass `sun` for a glow toward it past `sunStartDistance`. + */ +export function heightFogNode(distanceFog: FogExp2, p: HeightFogParams, sun?: Vector3) { + const color = uniform(new Color()); + color.onRenderUpdate(() => color.value.copy(distanceFog.color)); + const density = uniform(0); + density.onRenderUpdate(() => { + density.value = distanceFog.density; + }); + // Density at the camera, once per frame instead of once per fragment. + const cameraDensity = uniform(0); + cameraDensity.onRenderUpdate(({ camera }) => { + const y = camera?.matrixWorld.elements[13] ?? 0; + cameraDensity.value = p.density * 2 ** clampExponent(-p.heightFalloff * (y - p.fogHeight)); + }); + const ray = positionWorld.sub(cameraPosition); + const length = ray.length().max(1e-4); + const k = float(p.heightFalloff).mul(positionWorld.y.sub(cameraPosition.y)).div(length); + const integral = (a: number) => { + const b = max(length, a); + const curved = cameraDensity + .mul(exp2(k.mul(a).negate()).sub(exp2(k.mul(b).negate()))) + .div(k.mul(LN2)); + return select(k.mul(b).abs().lessThan(0.01), cameraDensity.mul(b.sub(a)), curved); + }; + const cut = p.cutoffDistance > 0 ? select(length.greaterThan(p.cutoffDistance), 0, 1) : float(1); + const heightT = max(exp2(integral(p.startDistance).mul(cut).negate()), 1 - p.maxOpacity); + const opacity = densityFogFactor(density).oneMinus().mul(heightT).oneMinus(); + if (sun === undefined) return fog(color, opacity); + const lobe = pow(saturate(dot(ray.div(length), vec3(sun.x, sun.y, sun.z))), p.sunExponent); + const glow = float(1) + .sub(exp2(integral(Math.max(p.sunStartDistance, p.startDistance)).negate())) + .mul(lobe) + .mul(p.maxOpacity); + return fog(mix(color, vec3(SUN_COLOUR.r, SUN_COLOUR.g, SUN_COLOUR.b), glow), opacity); +} diff --git a/packages/create-threenative/templates/action-rpg/src/render/sky.ts b/packages/create-threenative/templates/action-rpg/src/render/sky.ts index b8d0845669..f3a87dac2a 100644 --- a/packages/create-threenative/templates/action-rpg/src/render/sky.ts +++ b/packages/create-threenative/templates/action-rpg/src/render/sky.ts @@ -13,6 +13,7 @@ import { type Texture, Vector3, } from "three"; +import { HEIGHT_FOG, type HeightFogParams, heightFogDepth, heightFogNode } from "./heightFog.js"; import { palette } from "./palette.js"; /** @@ -25,6 +26,22 @@ const SKY_RANGE = 2.5; /** Unit vector toward the photographed sun: 47.9° up, measured from the source HDR. */ 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. + */ +function distanceDensity(p: HeightFogParams = HEIGHT_FOG): number { + const { distance, density, cameraHeight } = EYE_LEVEL; + const old = (density * distance) ** 2; + const height = heightFogDepth(p, cameraHeight, cameraHeight, distance) * Math.LN2; + return Math.sqrt(Math.max(0, old - height)) / distance; +} + export function setupSky(scene: Scene, sky: Texture): void { sky.mapping = EquirectangularReflectionMapping; sky.colorSpace = SRGBColorSpace; @@ -37,5 +54,8 @@ export function setupSky(scene: Scene, sky: Texture): void { scene.environmentIntensity = SKY_RANGE; // Almost nothing inside the arena (1.4% at 30 m), and the ground plane gone into the horizon by // a kilometre — so the floor meets the sky instead of ending at a line. - scene.fog = new FogExp2(palette.horizon, 0.003); + // `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); } diff --git a/packages/create-threenative/templates/minimal/AGENTS.md b/packages/create-threenative/templates/minimal/AGENTS.md index 6e1af8c8ac..10a7909677 100644 --- a/packages/create-threenative/templates/minimal/AGENTS.md +++ b/packages/create-threenative/templates/minimal/AGENTS.md @@ -92,4 +92,4 @@ For terrain generation or editing, read `node_modules/create-threenative/agent-d ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. -`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. +`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. Fog is height fog: `HEIGHT_FOG` in `src/render/heightFog.ts` (`density`, `heightFalloff`, `fogHeight`, `maxOpacity`, `startDistance`, `sunExponent`, `sunStartDistance`, `cutoffDistance`) is added to the `FogExp2` distance term on `scene.fogNode` and never replaces it, so a streaming fog `far` still holds. diff --git a/packages/create-threenative/templates/minimal/CLAUDE.md b/packages/create-threenative/templates/minimal/CLAUDE.md index f89d349004..465fa3a45a 100644 --- a/packages/create-threenative/templates/minimal/CLAUDE.md +++ b/packages/create-threenative/templates/minimal/CLAUDE.md @@ -94,4 +94,4 @@ For terrain generation or editing, read `node_modules/create-threenative/agent-d ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. -`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. +`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. Fog is height fog: `HEIGHT_FOG` in `src/render/heightFog.ts` (`density`, `heightFalloff`, `fogHeight`, `maxOpacity`, `startDistance`, `sunExponent`, `sunStartDistance`, `cutoffDistance`) is added to the `FogExp2` distance term on `scene.fogNode` and never replaces it, so a streaming fog `far` still holds. diff --git a/packages/create-threenative/templates/minimal/src/render/heightFog.ts b/packages/create-threenative/templates/minimal/src/render/heightFog.ts new file mode 100644 index 0000000000..232dcf0489 --- /dev/null +++ b/packages/create-threenative/templates/minimal/src/render/heightFog.ts @@ -0,0 +1,118 @@ +// Generated for you. This is ordinary Three.js — edit or delete it freely. +// ThreeNative does not read this file. `sky.ts` owns the numbers; this file is the fog maths. +import { Color, type FogExp2, type Vector3 } from "three"; +import { + cameraPosition, + densityFogFactor, + dot, + exp2, + float, + fog, + max, + mix, + positionWorld, + pow, + saturate, + select, + uniform, + vec3, +} from "three/tsl"; + +/** + * Height fog, in metres, y up. Density falls off exponentially above `fogHeight`; the fog along the + * ray from the camera to a fragment is the closed-form integral of that density, so low ground + * reads hazier than a ridge at the same distance and a camera above the layer looks down through it. + * Every control says which way to move it. + */ +export const HEIGHT_FOG = { + /** Optical depth per metre at `fogHeight` (base 2). Up: thicker ground mist. */ + density: 0.0012, + /** Per metre above `fogHeight`. Up: a thinner, lower layer. Down: mist climbs the hills. */ + heightFalloff: 0.08, + /** World y where density equals `density`. Move it to the ground of your world. */ + fogHeight: 0, + /** Floor on the height term's transparency, 0..1. Below 1 leaves a clear window in the mist. */ + maxOpacity: 1, + /** Metres from the camera before the mist starts. Up: a clear foreground. */ + startDistance: 0, + /** Sun lobe sharpness. Up: a tighter glow around the sun. */ + sunExponent: 4, + /** Metres before sun glow starts, so nearby objects are not tinted. */ + sunStartDistance: 100, + /** Metres past which the height term is off. 0 = never. Never set it inside a streaming ring. */ + cutoffDistance: 0, +} as const; +export type HeightFogParams = { -readonly [K in keyof typeof HEIGHT_FOG]: number }; + +const LN2 = Math.LN2; + +// Camera density term, clamped so exp2 stays finite (Unreal clamps the exponent at -127). +const clampExponent = (x: number): number => Math.min(127, Math.max(-127, x)); + +/** `density` times the integral of 2^(-k t) over [a, b]; k is the ray's slope times `falloff`. */ +function segment(density: number, k: number, a: number, b: number): number { + if (b <= a) return 0; + if (Math.abs(k * b) < 0.01) return density * (b - a - 0.5 * LN2 * k * (b * b - a * a)); + return (density * (2 ** (-k * a) - 2 ** (-k * b))) / (k * LN2); +} + +function rayTerms(p: HeightFogParams, cameraY: number, fragmentY: number, length: number) { + const camera = p.density * 2 ** clampExponent(-p.heightFalloff * (cameraY - p.fogHeight)); + const k = length > 0 ? (p.heightFalloff * (fragmentY - cameraY)) / length : 0; + return { camera, k }; +} + +/** Base-2 optical depth (transmittance is 2^-depth) of the mist between the camera and a fragment. */ +export function heightFogDepth( + p: HeightFogParams, + cameraY: number, + fragmentY: number, + length: number, +): number { + if (p.cutoffDistance > 0 && length > p.cutoffDistance) return 0; + const { camera, k } = rayTerms(p, cameraY, fragmentY, length); + return segment(camera, k, p.startDistance, length); +} + +const SUN_COLOUR = new Color(0xfff1d6); + +/** + * `T_distance x T_height`, on `scene.fogNode`: three applies it to every material where `scene.fog` + * would be. The distance term reads the live `FogExp2`, so changing its colour or density at runtime + * still works. It is never more transparent than that term alone, so wherever the distance term + * reaches 0 the frame stays fully fogged. Pass `sun` for a glow toward it past `sunStartDistance`. + */ +export function heightFogNode(distanceFog: FogExp2, p: HeightFogParams, sun?: Vector3) { + const color = uniform(new Color()); + color.onRenderUpdate(() => color.value.copy(distanceFog.color)); + const density = uniform(0); + density.onRenderUpdate(() => { + density.value = distanceFog.density; + }); + // Density at the camera, once per frame instead of once per fragment. + const cameraDensity = uniform(0); + cameraDensity.onRenderUpdate(({ camera }) => { + const y = camera?.matrixWorld.elements[13] ?? 0; + cameraDensity.value = p.density * 2 ** clampExponent(-p.heightFalloff * (y - p.fogHeight)); + }); + const ray = positionWorld.sub(cameraPosition); + const length = ray.length().max(1e-4); + const k = float(p.heightFalloff).mul(positionWorld.y.sub(cameraPosition.y)).div(length); + const integral = (a: number) => { + const b = max(length, a); + const curved = cameraDensity + .mul(exp2(k.mul(a).negate()).sub(exp2(k.mul(b).negate()))) + .div(k.mul(LN2)); + return select(k.mul(b).abs().lessThan(0.01), cameraDensity.mul(b.sub(a)), curved); + }; + const cut = p.cutoffDistance > 0 ? select(length.greaterThan(p.cutoffDistance), 0, 1) : float(1); + const heightT = max(exp2(integral(p.startDistance).mul(cut).negate()), 1 - p.maxOpacity); + const opacity = densityFogFactor(density).oneMinus().mul(heightT).oneMinus(); + if (sun === undefined) return fog(color, opacity); + const lobe = pow(saturate(dot(ray.div(length), vec3(sun.x, sun.y, sun.z))), p.sunExponent); + const glow = float(1) + .sub(exp2(integral(Math.max(p.sunStartDistance, p.startDistance)).negate())) + .mul(lobe) + .mul(p.maxOpacity); + return fog(mix(color, vec3(SUN_COLOUR.r, SUN_COLOUR.g, SUN_COLOUR.b), glow), opacity); +} diff --git a/packages/create-threenative/templates/minimal/src/render/sky.ts b/packages/create-threenative/templates/minimal/src/render/sky.ts index b8d0845669..f3a87dac2a 100644 --- a/packages/create-threenative/templates/minimal/src/render/sky.ts +++ b/packages/create-threenative/templates/minimal/src/render/sky.ts @@ -13,6 +13,7 @@ import { type Texture, Vector3, } from "three"; +import { HEIGHT_FOG, type HeightFogParams, heightFogDepth, heightFogNode } from "./heightFog.js"; import { palette } from "./palette.js"; /** @@ -25,6 +26,22 @@ const SKY_RANGE = 2.5; /** Unit vector toward the photographed sun: 47.9° up, measured from the source HDR. */ 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. + */ +function distanceDensity(p: HeightFogParams = HEIGHT_FOG): number { + const { distance, density, cameraHeight } = EYE_LEVEL; + const old = (density * distance) ** 2; + const height = heightFogDepth(p, cameraHeight, cameraHeight, distance) * Math.LN2; + return Math.sqrt(Math.max(0, old - height)) / distance; +} + export function setupSky(scene: Scene, sky: Texture): void { sky.mapping = EquirectangularReflectionMapping; sky.colorSpace = SRGBColorSpace; @@ -37,5 +54,8 @@ export function setupSky(scene: Scene, sky: Texture): void { scene.environmentIntensity = SKY_RANGE; // Almost nothing inside the arena (1.4% at 30 m), and the ground plane gone into the horizon by // a kilometre — so the floor meets the sky instead of ending at a line. - scene.fog = new FogExp2(palette.horizon, 0.003); + // `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); } diff --git a/packages/create-threenative/templates/platformer/AGENTS.md b/packages/create-threenative/templates/platformer/AGENTS.md index b9d78ae647..51f27eda51 100644 --- a/packages/create-threenative/templates/platformer/AGENTS.md +++ b/packages/create-threenative/templates/platformer/AGENTS.md @@ -55,4 +55,4 @@ For terrain generation or editing, read `node_modules/create-threenative/agent-d ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. -`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. +`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. Fog is height fog: `HEIGHT_FOG` in `src/render/heightFog.ts` (`density`, `heightFalloff`, `fogHeight`, `maxOpacity`, `startDistance`, `sunExponent`, `sunStartDistance`, `cutoffDistance`) is added to the `FogExp2` distance term on `scene.fogNode` and never replaces it, so a streaming fog `far` still holds. diff --git a/packages/create-threenative/templates/platformer/CLAUDE.md b/packages/create-threenative/templates/platformer/CLAUDE.md index e79075e832..758cd6b0b9 100644 --- a/packages/create-threenative/templates/platformer/CLAUDE.md +++ b/packages/create-threenative/templates/platformer/CLAUDE.md @@ -57,4 +57,4 @@ For terrain generation or editing, read `node_modules/create-threenative/agent-d ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. -`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. +`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. Fog is height fog: `HEIGHT_FOG` in `src/render/heightFog.ts` (`density`, `heightFalloff`, `fogHeight`, `maxOpacity`, `startDistance`, `sunExponent`, `sunStartDistance`, `cutoffDistance`) is added to the `FogExp2` distance term on `scene.fogNode` and never replaces it, so a streaming fog `far` still holds. diff --git a/packages/create-threenative/templates/platformer/src/render/heightFog.ts b/packages/create-threenative/templates/platformer/src/render/heightFog.ts new file mode 100644 index 0000000000..232dcf0489 --- /dev/null +++ b/packages/create-threenative/templates/platformer/src/render/heightFog.ts @@ -0,0 +1,118 @@ +// Generated for you. This is ordinary Three.js — edit or delete it freely. +// ThreeNative does not read this file. `sky.ts` owns the numbers; this file is the fog maths. +import { Color, type FogExp2, type Vector3 } from "three"; +import { + cameraPosition, + densityFogFactor, + dot, + exp2, + float, + fog, + max, + mix, + positionWorld, + pow, + saturate, + select, + uniform, + vec3, +} from "three/tsl"; + +/** + * Height fog, in metres, y up. Density falls off exponentially above `fogHeight`; the fog along the + * ray from the camera to a fragment is the closed-form integral of that density, so low ground + * reads hazier than a ridge at the same distance and a camera above the layer looks down through it. + * Every control says which way to move it. + */ +export const HEIGHT_FOG = { + /** Optical depth per metre at `fogHeight` (base 2). Up: thicker ground mist. */ + density: 0.0012, + /** Per metre above `fogHeight`. Up: a thinner, lower layer. Down: mist climbs the hills. */ + heightFalloff: 0.08, + /** World y where density equals `density`. Move it to the ground of your world. */ + fogHeight: 0, + /** Floor on the height term's transparency, 0..1. Below 1 leaves a clear window in the mist. */ + maxOpacity: 1, + /** Metres from the camera before the mist starts. Up: a clear foreground. */ + startDistance: 0, + /** Sun lobe sharpness. Up: a tighter glow around the sun. */ + sunExponent: 4, + /** Metres before sun glow starts, so nearby objects are not tinted. */ + sunStartDistance: 100, + /** Metres past which the height term is off. 0 = never. Never set it inside a streaming ring. */ + cutoffDistance: 0, +} as const; +export type HeightFogParams = { -readonly [K in keyof typeof HEIGHT_FOG]: number }; + +const LN2 = Math.LN2; + +// Camera density term, clamped so exp2 stays finite (Unreal clamps the exponent at -127). +const clampExponent = (x: number): number => Math.min(127, Math.max(-127, x)); + +/** `density` times the integral of 2^(-k t) over [a, b]; k is the ray's slope times `falloff`. */ +function segment(density: number, k: number, a: number, b: number): number { + if (b <= a) return 0; + if (Math.abs(k * b) < 0.01) return density * (b - a - 0.5 * LN2 * k * (b * b - a * a)); + return (density * (2 ** (-k * a) - 2 ** (-k * b))) / (k * LN2); +} + +function rayTerms(p: HeightFogParams, cameraY: number, fragmentY: number, length: number) { + const camera = p.density * 2 ** clampExponent(-p.heightFalloff * (cameraY - p.fogHeight)); + const k = length > 0 ? (p.heightFalloff * (fragmentY - cameraY)) / length : 0; + return { camera, k }; +} + +/** Base-2 optical depth (transmittance is 2^-depth) of the mist between the camera and a fragment. */ +export function heightFogDepth( + p: HeightFogParams, + cameraY: number, + fragmentY: number, + length: number, +): number { + if (p.cutoffDistance > 0 && length > p.cutoffDistance) return 0; + const { camera, k } = rayTerms(p, cameraY, fragmentY, length); + return segment(camera, k, p.startDistance, length); +} + +const SUN_COLOUR = new Color(0xfff1d6); + +/** + * `T_distance x T_height`, on `scene.fogNode`: three applies it to every material where `scene.fog` + * would be. The distance term reads the live `FogExp2`, so changing its colour or density at runtime + * still works. It is never more transparent than that term alone, so wherever the distance term + * reaches 0 the frame stays fully fogged. Pass `sun` for a glow toward it past `sunStartDistance`. + */ +export function heightFogNode(distanceFog: FogExp2, p: HeightFogParams, sun?: Vector3) { + const color = uniform(new Color()); + color.onRenderUpdate(() => color.value.copy(distanceFog.color)); + const density = uniform(0); + density.onRenderUpdate(() => { + density.value = distanceFog.density; + }); + // Density at the camera, once per frame instead of once per fragment. + const cameraDensity = uniform(0); + cameraDensity.onRenderUpdate(({ camera }) => { + const y = camera?.matrixWorld.elements[13] ?? 0; + cameraDensity.value = p.density * 2 ** clampExponent(-p.heightFalloff * (y - p.fogHeight)); + }); + const ray = positionWorld.sub(cameraPosition); + const length = ray.length().max(1e-4); + const k = float(p.heightFalloff).mul(positionWorld.y.sub(cameraPosition.y)).div(length); + const integral = (a: number) => { + const b = max(length, a); + const curved = cameraDensity + .mul(exp2(k.mul(a).negate()).sub(exp2(k.mul(b).negate()))) + .div(k.mul(LN2)); + return select(k.mul(b).abs().lessThan(0.01), cameraDensity.mul(b.sub(a)), curved); + }; + const cut = p.cutoffDistance > 0 ? select(length.greaterThan(p.cutoffDistance), 0, 1) : float(1); + const heightT = max(exp2(integral(p.startDistance).mul(cut).negate()), 1 - p.maxOpacity); + const opacity = densityFogFactor(density).oneMinus().mul(heightT).oneMinus(); + if (sun === undefined) return fog(color, opacity); + const lobe = pow(saturate(dot(ray.div(length), vec3(sun.x, sun.y, sun.z))), p.sunExponent); + const glow = float(1) + .sub(exp2(integral(Math.max(p.sunStartDistance, p.startDistance)).negate())) + .mul(lobe) + .mul(p.maxOpacity); + return fog(mix(color, vec3(SUN_COLOUR.r, SUN_COLOUR.g, SUN_COLOUR.b), glow), opacity); +} diff --git a/packages/create-threenative/templates/platformer/src/render/sky.ts b/packages/create-threenative/templates/platformer/src/render/sky.ts index 31a44b4f40..0f2eafe549 100644 --- a/packages/create-threenative/templates/platformer/src/render/sky.ts +++ b/packages/create-threenative/templates/platformer/src/render/sky.ts @@ -19,6 +19,7 @@ import { type Texture, Vector3, } from "three"; +import { HEIGHT_FOG, type HeightFogParams, heightFogDepth, heightFogNode } from "./heightFog.js"; import { palette } from "./palette.js"; /** @@ -31,6 +32,22 @@ const SKY_RANGE = 2.5; /** Unit vector toward the photographed sun: 47.9° up, measured from the source HDR. */ 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. + */ +function distanceDensity(p: HeightFogParams = HEIGHT_FOG): number { + const { distance, density, cameraHeight } = EYE_LEVEL; + const old = (density * distance) ** 2; + const height = heightFogDepth(p, cameraHeight, cameraHeight, distance) * Math.LN2; + return Math.sqrt(Math.max(0, old - height)) / distance; +} + export function setupSky(scene: Scene, sky: Texture): void { sky.mapping = EquirectangularReflectionMapping; sky.colorSpace = SRGBColorSpace; @@ -43,7 +60,10 @@ export function setupSky(scene: Scene, sky: Texture): void { scene.environmentIntensity = SKY_RANGE; // 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 = new FogExp2(palette.horizon, 0.003); + // `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); } /** diff --git a/packages/create-threenative/templates/puzzle/AGENTS.md b/packages/create-threenative/templates/puzzle/AGENTS.md index c9be00f28e..c03e77b8a3 100644 --- a/packages/create-threenative/templates/puzzle/AGENTS.md +++ b/packages/create-threenative/templates/puzzle/AGENTS.md @@ -93,4 +93,4 @@ For terrain generation or editing, read `node_modules/create-threenative/agent-d ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. -`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. +`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. Fog is height fog: `HEIGHT_FOG` in `src/render/heightFog.ts` (`density`, `heightFalloff`, `fogHeight`, `maxOpacity`, `startDistance`, `sunExponent`, `sunStartDistance`, `cutoffDistance`) is added to the `FogExp2` distance term on `scene.fogNode` and never replaces it, so a streaming fog `far` still holds. diff --git a/packages/create-threenative/templates/puzzle/CLAUDE.md b/packages/create-threenative/templates/puzzle/CLAUDE.md index 7c99dc7f70..875e99e804 100644 --- a/packages/create-threenative/templates/puzzle/CLAUDE.md +++ b/packages/create-threenative/templates/puzzle/CLAUDE.md @@ -95,4 +95,4 @@ For terrain generation or editing, read `node_modules/create-threenative/agent-d ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. -`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. +`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. Fog is height fog: `HEIGHT_FOG` in `src/render/heightFog.ts` (`density`, `heightFalloff`, `fogHeight`, `maxOpacity`, `startDistance`, `sunExponent`, `sunStartDistance`, `cutoffDistance`) is added to the `FogExp2` distance term on `scene.fogNode` and never replaces it, so a streaming fog `far` still holds. diff --git a/packages/create-threenative/templates/puzzle/src/render/heightFog.ts b/packages/create-threenative/templates/puzzle/src/render/heightFog.ts new file mode 100644 index 0000000000..dcb43cea16 --- /dev/null +++ b/packages/create-threenative/templates/puzzle/src/render/heightFog.ts @@ -0,0 +1,118 @@ +// Generated for you. This is ordinary Three.js — edit or delete it freely. +// ThreeNative does not read this file. `sky.ts` owns the numbers; this file is the fog maths. +import { Color, type FogExp2, type Vector3 } from "three"; +import { + cameraPosition, + densityFogFactor, + dot, + exp2, + float, + fog, + max, + mix, + positionWorld, + pow, + saturate, + select, + uniform, + vec3, +} from "three/tsl"; + +/** + * Height fog, in metres, y up. Density falls off exponentially above `fogHeight`; the fog along the + * ray from the camera to a fragment is the closed-form integral of that density, so low ground + * reads hazier than a ridge at the same distance and a camera above the layer looks down through it. + * Every control says which way to move it. + */ +export const HEIGHT_FOG = { + /** Optical depth per metre at `fogHeight` (base 2). Up: thicker ground mist. */ + density: 0.0003, + /** Per metre above `fogHeight`. Up: a thinner, lower layer. Down: mist climbs the hills. */ + heightFalloff: 0.08, + /** World y where density equals `density`. Move it to the ground of your world. */ + fogHeight: 0, + /** Floor on the height term's transparency, 0..1. Below 1 leaves a clear window in the mist. */ + maxOpacity: 1, + /** Metres from the camera before the mist starts. Up: a clear foreground. */ + startDistance: 0, + /** Sun lobe sharpness. Up: a tighter glow around the sun. */ + sunExponent: 4, + /** Metres before sun glow starts, so nearby objects are not tinted. */ + sunStartDistance: 100, + /** Metres past which the height term is off. 0 = never. Never set it inside a streaming ring. */ + cutoffDistance: 0, +} as const; +export type HeightFogParams = { -readonly [K in keyof typeof HEIGHT_FOG]: number }; + +const LN2 = Math.LN2; + +// Camera density term, clamped so exp2 stays finite (Unreal clamps the exponent at -127). +const clampExponent = (x: number): number => Math.min(127, Math.max(-127, x)); + +/** `density` times the integral of 2^(-k t) over [a, b]; k is the ray's slope times `falloff`. */ +function segment(density: number, k: number, a: number, b: number): number { + if (b <= a) return 0; + if (Math.abs(k * b) < 0.01) return density * (b - a - 0.5 * LN2 * k * (b * b - a * a)); + return (density * (2 ** (-k * a) - 2 ** (-k * b))) / (k * LN2); +} + +function rayTerms(p: HeightFogParams, cameraY: number, fragmentY: number, length: number) { + const camera = p.density * 2 ** clampExponent(-p.heightFalloff * (cameraY - p.fogHeight)); + const k = length > 0 ? (p.heightFalloff * (fragmentY - cameraY)) / length : 0; + return { camera, k }; +} + +/** Base-2 optical depth (transmittance is 2^-depth) of the mist between the camera and a fragment. */ +export function heightFogDepth( + p: HeightFogParams, + cameraY: number, + fragmentY: number, + length: number, +): number { + if (p.cutoffDistance > 0 && length > p.cutoffDistance) return 0; + const { camera, k } = rayTerms(p, cameraY, fragmentY, length); + return segment(camera, k, p.startDistance, length); +} + +const SUN_COLOUR = new Color(0xfff1d6); + +/** + * `T_distance x T_height`, on `scene.fogNode`: three applies it to every material where `scene.fog` + * would be. The distance term reads the live `FogExp2`, so changing its colour or density at runtime + * still works. It is never more transparent than that term alone, so wherever the distance term + * reaches 0 the frame stays fully fogged. Pass `sun` for a glow toward it past `sunStartDistance`. + */ +export function heightFogNode(distanceFog: FogExp2, p: HeightFogParams, sun?: Vector3) { + const color = uniform(new Color()); + color.onRenderUpdate(() => color.value.copy(distanceFog.color)); + const density = uniform(0); + density.onRenderUpdate(() => { + density.value = distanceFog.density; + }); + // Density at the camera, once per frame instead of once per fragment. + const cameraDensity = uniform(0); + cameraDensity.onRenderUpdate(({ camera }) => { + const y = camera?.matrixWorld.elements[13] ?? 0; + cameraDensity.value = p.density * 2 ** clampExponent(-p.heightFalloff * (y - p.fogHeight)); + }); + const ray = positionWorld.sub(cameraPosition); + const length = ray.length().max(1e-4); + const k = float(p.heightFalloff).mul(positionWorld.y.sub(cameraPosition.y)).div(length); + const integral = (a: number) => { + const b = max(length, a); + const curved = cameraDensity + .mul(exp2(k.mul(a).negate()).sub(exp2(k.mul(b).negate()))) + .div(k.mul(LN2)); + return select(k.mul(b).abs().lessThan(0.01), cameraDensity.mul(b.sub(a)), curved); + }; + const cut = p.cutoffDistance > 0 ? select(length.greaterThan(p.cutoffDistance), 0, 1) : float(1); + const heightT = max(exp2(integral(p.startDistance).mul(cut).negate()), 1 - p.maxOpacity); + const opacity = densityFogFactor(density).oneMinus().mul(heightT).oneMinus(); + if (sun === undefined) return fog(color, opacity); + const lobe = pow(saturate(dot(ray.div(length), vec3(sun.x, sun.y, sun.z))), p.sunExponent); + const glow = float(1) + .sub(exp2(integral(Math.max(p.sunStartDistance, p.startDistance)).negate())) + .mul(lobe) + .mul(p.maxOpacity); + return fog(mix(color, vec3(SUN_COLOUR.r, SUN_COLOUR.g, SUN_COLOUR.b), glow), opacity); +} diff --git a/packages/create-threenative/templates/puzzle/src/render/sky.ts b/packages/create-threenative/templates/puzzle/src/render/sky.ts index 174a3622e6..7b444c458c 100644 --- a/packages/create-threenative/templates/puzzle/src/render/sky.ts +++ b/packages/create-threenative/templates/puzzle/src/render/sky.ts @@ -7,6 +7,7 @@ // equirectangular capture is a two-line change here, and everything downstream — fog, the tone // curve, bloom's threshold — is already reading the same palette role. import { Color, FogExp2, type Scene } from "three"; +import { HEIGHT_FOG, type HeightFogParams, heightFogDepth, heightFogNode } from "./heightFog.js"; import { palette } from "./palette.js"; /** @@ -15,10 +16,28 @@ import { palette } from "./palette.js"; */ const FOG_DENSITY = 0.005; +/** The look this fog replaced: FogExp2 at 0.005, measured at 12 m from 2 m up. */ +const EYE_LEVEL = { density: FOG_DENSITY, distance: 12, 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. + */ +function distanceDensity(p: HeightFogParams = HEIGHT_FOG): number { + const { distance, density, cameraHeight } = EYE_LEVEL; + const old = (density * distance) ** 2; + const height = heightFogDepth(p, cameraHeight, cameraHeight, distance) * Math.LN2; + return Math.sqrt(Math.max(0, old - height)) / distance; +} + export function setupSky(scene: Scene): void { scene.background = new Color(palette.void); scene.backgroundIntensity = 1; // The room is the horizon, so the fog is the colour beyond it rather than a lit distance: a // grey fog in a room this dark would be the brightest thing in the frame. - scene.fog = new FogExp2(palette.void, FOG_DENSITY); + const distanceFog = new FogExp2(palette.void, distanceDensity()); + scene.fog = distanceFog; + scene.fogNode = heightFogNode(distanceFog, HEIGHT_FOG); } diff --git a/packages/create-threenative/templates/racing/AGENTS.md b/packages/create-threenative/templates/racing/AGENTS.md index b94414cac1..77a4b8495a 100644 --- a/packages/create-threenative/templates/racing/AGENTS.md +++ b/packages/create-threenative/templates/racing/AGENTS.md @@ -93,4 +93,4 @@ For terrain generation or editing, read `node_modules/create-threenative/agent-d ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. -`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. +`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. Fog is height fog: `HEIGHT_FOG` in `src/render/heightFog.ts` (`density`, `heightFalloff`, `fogHeight`, `maxOpacity`, `startDistance`, `sunExponent`, `sunStartDistance`, `cutoffDistance`) is added to the `FogExp2` distance term on `scene.fogNode` and never replaces it, so a streaming fog `far` still holds. diff --git a/packages/create-threenative/templates/racing/CLAUDE.md b/packages/create-threenative/templates/racing/CLAUDE.md index 23d93a6a70..e9c04662cf 100644 --- a/packages/create-threenative/templates/racing/CLAUDE.md +++ b/packages/create-threenative/templates/racing/CLAUDE.md @@ -95,4 +95,4 @@ For terrain generation or editing, read `node_modules/create-threenative/agent-d ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. -`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. +`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. Fog is height fog: `HEIGHT_FOG` in `src/render/heightFog.ts` (`density`, `heightFalloff`, `fogHeight`, `maxOpacity`, `startDistance`, `sunExponent`, `sunStartDistance`, `cutoffDistance`) is added to the `FogExp2` distance term on `scene.fogNode` and never replaces it, so a streaming fog `far` still holds. diff --git a/packages/create-threenative/templates/racing/src/render/heightFog.ts b/packages/create-threenative/templates/racing/src/render/heightFog.ts new file mode 100644 index 0000000000..232dcf0489 --- /dev/null +++ b/packages/create-threenative/templates/racing/src/render/heightFog.ts @@ -0,0 +1,118 @@ +// Generated for you. This is ordinary Three.js — edit or delete it freely. +// ThreeNative does not read this file. `sky.ts` owns the numbers; this file is the fog maths. +import { Color, type FogExp2, type Vector3 } from "three"; +import { + cameraPosition, + densityFogFactor, + dot, + exp2, + float, + fog, + max, + mix, + positionWorld, + pow, + saturate, + select, + uniform, + vec3, +} from "three/tsl"; + +/** + * Height fog, in metres, y up. Density falls off exponentially above `fogHeight`; the fog along the + * ray from the camera to a fragment is the closed-form integral of that density, so low ground + * reads hazier than a ridge at the same distance and a camera above the layer looks down through it. + * Every control says which way to move it. + */ +export const HEIGHT_FOG = { + /** Optical depth per metre at `fogHeight` (base 2). Up: thicker ground mist. */ + density: 0.0012, + /** Per metre above `fogHeight`. Up: a thinner, lower layer. Down: mist climbs the hills. */ + heightFalloff: 0.08, + /** World y where density equals `density`. Move it to the ground of your world. */ + fogHeight: 0, + /** Floor on the height term's transparency, 0..1. Below 1 leaves a clear window in the mist. */ + maxOpacity: 1, + /** Metres from the camera before the mist starts. Up: a clear foreground. */ + startDistance: 0, + /** Sun lobe sharpness. Up: a tighter glow around the sun. */ + sunExponent: 4, + /** Metres before sun glow starts, so nearby objects are not tinted. */ + sunStartDistance: 100, + /** Metres past which the height term is off. 0 = never. Never set it inside a streaming ring. */ + cutoffDistance: 0, +} as const; +export type HeightFogParams = { -readonly [K in keyof typeof HEIGHT_FOG]: number }; + +const LN2 = Math.LN2; + +// Camera density term, clamped so exp2 stays finite (Unreal clamps the exponent at -127). +const clampExponent = (x: number): number => Math.min(127, Math.max(-127, x)); + +/** `density` times the integral of 2^(-k t) over [a, b]; k is the ray's slope times `falloff`. */ +function segment(density: number, k: number, a: number, b: number): number { + if (b <= a) return 0; + if (Math.abs(k * b) < 0.01) return density * (b - a - 0.5 * LN2 * k * (b * b - a * a)); + return (density * (2 ** (-k * a) - 2 ** (-k * b))) / (k * LN2); +} + +function rayTerms(p: HeightFogParams, cameraY: number, fragmentY: number, length: number) { + const camera = p.density * 2 ** clampExponent(-p.heightFalloff * (cameraY - p.fogHeight)); + const k = length > 0 ? (p.heightFalloff * (fragmentY - cameraY)) / length : 0; + return { camera, k }; +} + +/** Base-2 optical depth (transmittance is 2^-depth) of the mist between the camera and a fragment. */ +export function heightFogDepth( + p: HeightFogParams, + cameraY: number, + fragmentY: number, + length: number, +): number { + if (p.cutoffDistance > 0 && length > p.cutoffDistance) return 0; + const { camera, k } = rayTerms(p, cameraY, fragmentY, length); + return segment(camera, k, p.startDistance, length); +} + +const SUN_COLOUR = new Color(0xfff1d6); + +/** + * `T_distance x T_height`, on `scene.fogNode`: three applies it to every material where `scene.fog` + * would be. The distance term reads the live `FogExp2`, so changing its colour or density at runtime + * still works. It is never more transparent than that term alone, so wherever the distance term + * reaches 0 the frame stays fully fogged. Pass `sun` for a glow toward it past `sunStartDistance`. + */ +export function heightFogNode(distanceFog: FogExp2, p: HeightFogParams, sun?: Vector3) { + const color = uniform(new Color()); + color.onRenderUpdate(() => color.value.copy(distanceFog.color)); + const density = uniform(0); + density.onRenderUpdate(() => { + density.value = distanceFog.density; + }); + // Density at the camera, once per frame instead of once per fragment. + const cameraDensity = uniform(0); + cameraDensity.onRenderUpdate(({ camera }) => { + const y = camera?.matrixWorld.elements[13] ?? 0; + cameraDensity.value = p.density * 2 ** clampExponent(-p.heightFalloff * (y - p.fogHeight)); + }); + const ray = positionWorld.sub(cameraPosition); + const length = ray.length().max(1e-4); + const k = float(p.heightFalloff).mul(positionWorld.y.sub(cameraPosition.y)).div(length); + const integral = (a: number) => { + const b = max(length, a); + const curved = cameraDensity + .mul(exp2(k.mul(a).negate()).sub(exp2(k.mul(b).negate()))) + .div(k.mul(LN2)); + return select(k.mul(b).abs().lessThan(0.01), cameraDensity.mul(b.sub(a)), curved); + }; + const cut = p.cutoffDistance > 0 ? select(length.greaterThan(p.cutoffDistance), 0, 1) : float(1); + const heightT = max(exp2(integral(p.startDistance).mul(cut).negate()), 1 - p.maxOpacity); + const opacity = densityFogFactor(density).oneMinus().mul(heightT).oneMinus(); + if (sun === undefined) return fog(color, opacity); + const lobe = pow(saturate(dot(ray.div(length), vec3(sun.x, sun.y, sun.z))), p.sunExponent); + const glow = float(1) + .sub(exp2(integral(Math.max(p.sunStartDistance, p.startDistance)).negate())) + .mul(lobe) + .mul(p.maxOpacity); + return fog(mix(color, vec3(SUN_COLOUR.r, SUN_COLOUR.g, SUN_COLOUR.b), glow), opacity); +} diff --git a/packages/create-threenative/templates/racing/src/render/sky.ts b/packages/create-threenative/templates/racing/src/render/sky.ts index 94aab339d4..60d27fe2e0 100644 --- a/packages/create-threenative/templates/racing/src/render/sky.ts +++ b/packages/create-threenative/templates/racing/src/render/sky.ts @@ -15,6 +15,7 @@ import { type Texture, Vector3, } from "three"; +import { HEIGHT_FOG, type HeightFogParams, heightFogDepth, heightFogNode } from "./heightFog.js"; import { palette } from "./palette.js"; /** @@ -34,6 +35,22 @@ export const SUN_DIRECTION = new Vector3(0.555, 0.742, 0.38).normalize(); * to enter on the first frame or the playtest bridge describes nothing. Passing `undefined` here is * the fallback the scene starts in, and calling it again with the texture is the swap. */ +/** The look this fog replaced: FogExp2 at 0.0024, measured at 150 m from 2 m up. */ +const EYE_LEVEL = { density: 0.0024, 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. + */ +function distanceDensity(p: HeightFogParams = HEIGHT_FOG): number { + const { distance, density, cameraHeight } = EYE_LEVEL; + const old = (density * distance) ** 2; + const height = heightFogDepth(p, cameraHeight, cameraHeight, distance) * Math.LN2; + return Math.sqrt(Math.max(0, old - height)) / distance; +} + export function setupSky(scene: Scene, sky?: Texture): void { if (sky === undefined) { scene.background = new Color(palette.skyHigh); @@ -52,5 +69,8 @@ export function setupSky(scene: Scene, sky?: Texture): void { } // Nothing inside the circuit (2.4% at 40 m), and the ground gone into the horizon by a kilometre // — so the treeline meets the sky instead of ending at a line. - scene.fog = new FogExp2(palette.horizon, 0.0024); + // `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); } diff --git a/packages/create-threenative/templates/rts/AGENTS.md b/packages/create-threenative/templates/rts/AGENTS.md index b898e96454..44bbeee772 100644 --- a/packages/create-threenative/templates/rts/AGENTS.md +++ b/packages/create-threenative/templates/rts/AGENTS.md @@ -92,4 +92,4 @@ For terrain generation or editing, read `node_modules/create-threenative/agent-d ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. -`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. +`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. Fog is height fog: `HEIGHT_FOG` in `src/render/heightFog.ts` (`density`, `heightFalloff`, `fogHeight`, `maxOpacity`, `startDistance`, `sunExponent`, `sunStartDistance`, `cutoffDistance`) is added to the `FogExp2` distance term on `scene.fogNode` and never replaces it, so a streaming fog `far` still holds. diff --git a/packages/create-threenative/templates/rts/CLAUDE.md b/packages/create-threenative/templates/rts/CLAUDE.md index 67fb9e09f7..58fe3d2d8f 100644 --- a/packages/create-threenative/templates/rts/CLAUDE.md +++ b/packages/create-threenative/templates/rts/CLAUDE.md @@ -94,4 +94,4 @@ For terrain generation or editing, read `node_modules/create-threenative/agent-d ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. -`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. +`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. Fog is height fog: `HEIGHT_FOG` in `src/render/heightFog.ts` (`density`, `heightFalloff`, `fogHeight`, `maxOpacity`, `startDistance`, `sunExponent`, `sunStartDistance`, `cutoffDistance`) is added to the `FogExp2` distance term on `scene.fogNode` and never replaces it, so a streaming fog `far` still holds. diff --git a/packages/create-threenative/templates/rts/src/render/heightFog.ts b/packages/create-threenative/templates/rts/src/render/heightFog.ts new file mode 100644 index 0000000000..232dcf0489 --- /dev/null +++ b/packages/create-threenative/templates/rts/src/render/heightFog.ts @@ -0,0 +1,118 @@ +// Generated for you. This is ordinary Three.js — edit or delete it freely. +// ThreeNative does not read this file. `sky.ts` owns the numbers; this file is the fog maths. +import { Color, type FogExp2, type Vector3 } from "three"; +import { + cameraPosition, + densityFogFactor, + dot, + exp2, + float, + fog, + max, + mix, + positionWorld, + pow, + saturate, + select, + uniform, + vec3, +} from "three/tsl"; + +/** + * Height fog, in metres, y up. Density falls off exponentially above `fogHeight`; the fog along the + * ray from the camera to a fragment is the closed-form integral of that density, so low ground + * reads hazier than a ridge at the same distance and a camera above the layer looks down through it. + * Every control says which way to move it. + */ +export const HEIGHT_FOG = { + /** Optical depth per metre at `fogHeight` (base 2). Up: thicker ground mist. */ + density: 0.0012, + /** Per metre above `fogHeight`. Up: a thinner, lower layer. Down: mist climbs the hills. */ + heightFalloff: 0.08, + /** World y where density equals `density`. Move it to the ground of your world. */ + fogHeight: 0, + /** Floor on the height term's transparency, 0..1. Below 1 leaves a clear window in the mist. */ + maxOpacity: 1, + /** Metres from the camera before the mist starts. Up: a clear foreground. */ + startDistance: 0, + /** Sun lobe sharpness. Up: a tighter glow around the sun. */ + sunExponent: 4, + /** Metres before sun glow starts, so nearby objects are not tinted. */ + sunStartDistance: 100, + /** Metres past which the height term is off. 0 = never. Never set it inside a streaming ring. */ + cutoffDistance: 0, +} as const; +export type HeightFogParams = { -readonly [K in keyof typeof HEIGHT_FOG]: number }; + +const LN2 = Math.LN2; + +// Camera density term, clamped so exp2 stays finite (Unreal clamps the exponent at -127). +const clampExponent = (x: number): number => Math.min(127, Math.max(-127, x)); + +/** `density` times the integral of 2^(-k t) over [a, b]; k is the ray's slope times `falloff`. */ +function segment(density: number, k: number, a: number, b: number): number { + if (b <= a) return 0; + if (Math.abs(k * b) < 0.01) return density * (b - a - 0.5 * LN2 * k * (b * b - a * a)); + return (density * (2 ** (-k * a) - 2 ** (-k * b))) / (k * LN2); +} + +function rayTerms(p: HeightFogParams, cameraY: number, fragmentY: number, length: number) { + const camera = p.density * 2 ** clampExponent(-p.heightFalloff * (cameraY - p.fogHeight)); + const k = length > 0 ? (p.heightFalloff * (fragmentY - cameraY)) / length : 0; + return { camera, k }; +} + +/** Base-2 optical depth (transmittance is 2^-depth) of the mist between the camera and a fragment. */ +export function heightFogDepth( + p: HeightFogParams, + cameraY: number, + fragmentY: number, + length: number, +): number { + if (p.cutoffDistance > 0 && length > p.cutoffDistance) return 0; + const { camera, k } = rayTerms(p, cameraY, fragmentY, length); + return segment(camera, k, p.startDistance, length); +} + +const SUN_COLOUR = new Color(0xfff1d6); + +/** + * `T_distance x T_height`, on `scene.fogNode`: three applies it to every material where `scene.fog` + * would be. The distance term reads the live `FogExp2`, so changing its colour or density at runtime + * still works. It is never more transparent than that term alone, so wherever the distance term + * reaches 0 the frame stays fully fogged. Pass `sun` for a glow toward it past `sunStartDistance`. + */ +export function heightFogNode(distanceFog: FogExp2, p: HeightFogParams, sun?: Vector3) { + const color = uniform(new Color()); + color.onRenderUpdate(() => color.value.copy(distanceFog.color)); + const density = uniform(0); + density.onRenderUpdate(() => { + density.value = distanceFog.density; + }); + // Density at the camera, once per frame instead of once per fragment. + const cameraDensity = uniform(0); + cameraDensity.onRenderUpdate(({ camera }) => { + const y = camera?.matrixWorld.elements[13] ?? 0; + cameraDensity.value = p.density * 2 ** clampExponent(-p.heightFalloff * (y - p.fogHeight)); + }); + const ray = positionWorld.sub(cameraPosition); + const length = ray.length().max(1e-4); + const k = float(p.heightFalloff).mul(positionWorld.y.sub(cameraPosition.y)).div(length); + const integral = (a: number) => { + const b = max(length, a); + const curved = cameraDensity + .mul(exp2(k.mul(a).negate()).sub(exp2(k.mul(b).negate()))) + .div(k.mul(LN2)); + return select(k.mul(b).abs().lessThan(0.01), cameraDensity.mul(b.sub(a)), curved); + }; + const cut = p.cutoffDistance > 0 ? select(length.greaterThan(p.cutoffDistance), 0, 1) : float(1); + const heightT = max(exp2(integral(p.startDistance).mul(cut).negate()), 1 - p.maxOpacity); + const opacity = densityFogFactor(density).oneMinus().mul(heightT).oneMinus(); + if (sun === undefined) return fog(color, opacity); + const lobe = pow(saturate(dot(ray.div(length), vec3(sun.x, sun.y, sun.z))), p.sunExponent); + const glow = float(1) + .sub(exp2(integral(Math.max(p.sunStartDistance, p.startDistance)).negate())) + .mul(lobe) + .mul(p.maxOpacity); + return fog(mix(color, vec3(SUN_COLOUR.r, SUN_COLOUR.g, SUN_COLOUR.b), glow), opacity); +} diff --git a/packages/create-threenative/templates/rts/src/render/sky.ts b/packages/create-threenative/templates/rts/src/render/sky.ts index 9b6e9f208c..58696bdce8 100644 --- a/packages/create-threenative/templates/rts/src/render/sky.ts +++ b/packages/create-threenative/templates/rts/src/render/sky.ts @@ -13,6 +13,7 @@ import { type Texture, Vector3, } from "three"; +import { HEIGHT_FOG, type HeightFogParams, heightFogDepth, heightFogNode } from "./heightFog.js"; import { palette } from "./palette.js"; /** @@ -25,6 +26,22 @@ const SKY_RANGE = 2.5; /** Unit vector toward the photographed sun: 47.9° up, measured from the source HDR. */ export const SUN_DIRECTION = new Vector3(0.555, 0.742, 0.38).normalize(); +/** The look this fog replaced: FogExp2 at 0.0035, measured at 150 m from 20 m up. */ +const EYE_LEVEL = { density: 0.0035, distance: 150, cameraHeight: 20 }; + +/** + * 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; + const height = heightFogDepth(p, cameraHeight, cameraHeight, distance) * Math.LN2; + return Math.sqrt(Math.max(0, old - height)) / distance; +} + export function setupSky(scene: Scene, sky: Texture): void { sky.mapping = EquirectangularReflectionMapping; sky.colorSpace = SRGBColorSpace; @@ -40,5 +57,8 @@ export function setupSky(scene: Scene, sky: Texture): void { // cell the player has never seen is 60 m away and a distant cell is 150. At 0.0035 the far corner // is a quarter hazed and an unexplored cell next to the camera is 4% — the haze stays a horizon // instead of becoming the map. - scene.fog = new FogExp2(palette.horizon, 0.0035); + // `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); } diff --git a/packages/create-threenative/templates/runner/AGENTS.md b/packages/create-threenative/templates/runner/AGENTS.md index 16b81a5e35..94ca310295 100644 --- a/packages/create-threenative/templates/runner/AGENTS.md +++ b/packages/create-threenative/templates/runner/AGENTS.md @@ -91,4 +91,4 @@ For terrain generation or editing, read `node_modules/create-threenative/agent-d ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. -`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. +`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. Fog is height fog: `HEIGHT_FOG` in `src/render/heightFog.ts` (`density`, `heightFalloff`, `fogHeight`, `maxOpacity`, `startDistance`, `sunExponent`, `sunStartDistance`, `cutoffDistance`) is added to the `FogExp2` distance term on `scene.fogNode` and never replaces it, so a streaming fog `far` still holds. diff --git a/packages/create-threenative/templates/runner/CLAUDE.md b/packages/create-threenative/templates/runner/CLAUDE.md index 4c3abb3b55..8a68d467ce 100644 --- a/packages/create-threenative/templates/runner/CLAUDE.md +++ b/packages/create-threenative/templates/runner/CLAUDE.md @@ -93,4 +93,4 @@ For terrain generation or editing, read `node_modules/create-threenative/agent-d ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. -`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. +`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. Fog is height fog: `HEIGHT_FOG` in `src/render/heightFog.ts` (`density`, `heightFalloff`, `fogHeight`, `maxOpacity`, `startDistance`, `sunExponent`, `sunStartDistance`, `cutoffDistance`) is added to the `FogExp2` distance term on `scene.fogNode` and never replaces it, so a streaming fog `far` still holds. diff --git a/packages/create-threenative/templates/runner/src/render/heightFog.ts b/packages/create-threenative/templates/runner/src/render/heightFog.ts new file mode 100644 index 0000000000..232dcf0489 --- /dev/null +++ b/packages/create-threenative/templates/runner/src/render/heightFog.ts @@ -0,0 +1,118 @@ +// Generated for you. This is ordinary Three.js — edit or delete it freely. +// ThreeNative does not read this file. `sky.ts` owns the numbers; this file is the fog maths. +import { Color, type FogExp2, type Vector3 } from "three"; +import { + cameraPosition, + densityFogFactor, + dot, + exp2, + float, + fog, + max, + mix, + positionWorld, + pow, + saturate, + select, + uniform, + vec3, +} from "three/tsl"; + +/** + * Height fog, in metres, y up. Density falls off exponentially above `fogHeight`; the fog along the + * ray from the camera to a fragment is the closed-form integral of that density, so low ground + * reads hazier than a ridge at the same distance and a camera above the layer looks down through it. + * Every control says which way to move it. + */ +export const HEIGHT_FOG = { + /** Optical depth per metre at `fogHeight` (base 2). Up: thicker ground mist. */ + density: 0.0012, + /** Per metre above `fogHeight`. Up: a thinner, lower layer. Down: mist climbs the hills. */ + heightFalloff: 0.08, + /** World y where density equals `density`. Move it to the ground of your world. */ + fogHeight: 0, + /** Floor on the height term's transparency, 0..1. Below 1 leaves a clear window in the mist. */ + maxOpacity: 1, + /** Metres from the camera before the mist starts. Up: a clear foreground. */ + startDistance: 0, + /** Sun lobe sharpness. Up: a tighter glow around the sun. */ + sunExponent: 4, + /** Metres before sun glow starts, so nearby objects are not tinted. */ + sunStartDistance: 100, + /** Metres past which the height term is off. 0 = never. Never set it inside a streaming ring. */ + cutoffDistance: 0, +} as const; +export type HeightFogParams = { -readonly [K in keyof typeof HEIGHT_FOG]: number }; + +const LN2 = Math.LN2; + +// Camera density term, clamped so exp2 stays finite (Unreal clamps the exponent at -127). +const clampExponent = (x: number): number => Math.min(127, Math.max(-127, x)); + +/** `density` times the integral of 2^(-k t) over [a, b]; k is the ray's slope times `falloff`. */ +function segment(density: number, k: number, a: number, b: number): number { + if (b <= a) return 0; + if (Math.abs(k * b) < 0.01) return density * (b - a - 0.5 * LN2 * k * (b * b - a * a)); + return (density * (2 ** (-k * a) - 2 ** (-k * b))) / (k * LN2); +} + +function rayTerms(p: HeightFogParams, cameraY: number, fragmentY: number, length: number) { + const camera = p.density * 2 ** clampExponent(-p.heightFalloff * (cameraY - p.fogHeight)); + const k = length > 0 ? (p.heightFalloff * (fragmentY - cameraY)) / length : 0; + return { camera, k }; +} + +/** Base-2 optical depth (transmittance is 2^-depth) of the mist between the camera and a fragment. */ +export function heightFogDepth( + p: HeightFogParams, + cameraY: number, + fragmentY: number, + length: number, +): number { + if (p.cutoffDistance > 0 && length > p.cutoffDistance) return 0; + const { camera, k } = rayTerms(p, cameraY, fragmentY, length); + return segment(camera, k, p.startDistance, length); +} + +const SUN_COLOUR = new Color(0xfff1d6); + +/** + * `T_distance x T_height`, on `scene.fogNode`: three applies it to every material where `scene.fog` + * would be. The distance term reads the live `FogExp2`, so changing its colour or density at runtime + * still works. It is never more transparent than that term alone, so wherever the distance term + * reaches 0 the frame stays fully fogged. Pass `sun` for a glow toward it past `sunStartDistance`. + */ +export function heightFogNode(distanceFog: FogExp2, p: HeightFogParams, sun?: Vector3) { + const color = uniform(new Color()); + color.onRenderUpdate(() => color.value.copy(distanceFog.color)); + const density = uniform(0); + density.onRenderUpdate(() => { + density.value = distanceFog.density; + }); + // Density at the camera, once per frame instead of once per fragment. + const cameraDensity = uniform(0); + cameraDensity.onRenderUpdate(({ camera }) => { + const y = camera?.matrixWorld.elements[13] ?? 0; + cameraDensity.value = p.density * 2 ** clampExponent(-p.heightFalloff * (y - p.fogHeight)); + }); + const ray = positionWorld.sub(cameraPosition); + const length = ray.length().max(1e-4); + const k = float(p.heightFalloff).mul(positionWorld.y.sub(cameraPosition.y)).div(length); + const integral = (a: number) => { + const b = max(length, a); + const curved = cameraDensity + .mul(exp2(k.mul(a).negate()).sub(exp2(k.mul(b).negate()))) + .div(k.mul(LN2)); + return select(k.mul(b).abs().lessThan(0.01), cameraDensity.mul(b.sub(a)), curved); + }; + const cut = p.cutoffDistance > 0 ? select(length.greaterThan(p.cutoffDistance), 0, 1) : float(1); + const heightT = max(exp2(integral(p.startDistance).mul(cut).negate()), 1 - p.maxOpacity); + const opacity = densityFogFactor(density).oneMinus().mul(heightT).oneMinus(); + if (sun === undefined) return fog(color, opacity); + const lobe = pow(saturate(dot(ray.div(length), vec3(sun.x, sun.y, sun.z))), p.sunExponent); + const glow = float(1) + .sub(exp2(integral(Math.max(p.sunStartDistance, p.startDistance)).negate())) + .mul(lobe) + .mul(p.maxOpacity); + return fog(mix(color, vec3(SUN_COLOUR.r, SUN_COLOUR.g, SUN_COLOUR.b), glow), opacity); +} diff --git a/packages/create-threenative/templates/runner/src/render/sky.ts b/packages/create-threenative/templates/runner/src/render/sky.ts index 8bb1496407..e46ae8ec8b 100644 --- a/packages/create-threenative/templates/runner/src/render/sky.ts +++ b/packages/create-threenative/templates/runner/src/render/sky.ts @@ -13,6 +13,7 @@ import { type Texture, Vector3, } from "three"; +import { HEIGHT_FOG, type HeightFogParams, heightFogDepth, heightFogNode } from "./heightFog.js"; import { palette } from "./palette.js"; /** @@ -25,6 +26,22 @@ const SKY_RANGE = 2.5; /** Unit vector toward the photographed sun: 47.9° up, measured from the source HDR. */ 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. + */ +function distanceDensity(p: HeightFogParams = HEIGHT_FOG): number { + const { distance, density, cameraHeight } = EYE_LEVEL; + const old = (density * distance) ** 2; + const height = heightFogDepth(p, cameraHeight, cameraHeight, distance) * Math.LN2; + return Math.sqrt(Math.max(0, old - height)) / distance; +} + export function setupSky(scene: Scene, sky: Texture): void { sky.mapping = EquirectangularReflectionMapping; sky.colorSpace = SRGBColorSpace; @@ -38,5 +55,8 @@ export function setupSky(scene: Scene, sky: Texture): void { // The track runs to a hundred-odd metres of fog, and the ground goes into the photograph by a // kilometre, so the far chunks hand over to the sky rather than ending at a line. The colour is // the photograph's own horizon, so the fade lands where the sky is. - scene.fog = new FogExp2(palette.horizon, 0.003); + // `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); } diff --git a/packages/create-threenative/templates/sailing/AGENTS.md b/packages/create-threenative/templates/sailing/AGENTS.md index 2e5cc05a3c..773db84e69 100644 --- a/packages/create-threenative/templates/sailing/AGENTS.md +++ b/packages/create-threenative/templates/sailing/AGENTS.md @@ -93,4 +93,4 @@ For terrain generation or editing, read `node_modules/create-threenative/agent-d ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. -`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. +`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. Fog is height fog: `HEIGHT_FOG` in `src/render/heightFog.ts` (`density`, `heightFalloff`, `fogHeight`, `maxOpacity`, `startDistance`, `sunExponent`, `sunStartDistance`, `cutoffDistance`) is added to the `FogExp2` distance term on `scene.fogNode` and never replaces it, so a streaming fog `far` still holds. diff --git a/packages/create-threenative/templates/sailing/CLAUDE.md b/packages/create-threenative/templates/sailing/CLAUDE.md index cf959da654..4931a88686 100644 --- a/packages/create-threenative/templates/sailing/CLAUDE.md +++ b/packages/create-threenative/templates/sailing/CLAUDE.md @@ -95,4 +95,4 @@ For terrain generation or editing, read `node_modules/create-threenative/agent-d ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. -`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. +`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. Fog is height fog: `HEIGHT_FOG` in `src/render/heightFog.ts` (`density`, `heightFalloff`, `fogHeight`, `maxOpacity`, `startDistance`, `sunExponent`, `sunStartDistance`, `cutoffDistance`) is added to the `FogExp2` distance term on `scene.fogNode` and never replaces it, so a streaming fog `far` still holds. diff --git a/packages/create-threenative/templates/sailing/src/render/heightFog.ts b/packages/create-threenative/templates/sailing/src/render/heightFog.ts new file mode 100644 index 0000000000..232dcf0489 --- /dev/null +++ b/packages/create-threenative/templates/sailing/src/render/heightFog.ts @@ -0,0 +1,118 @@ +// Generated for you. This is ordinary Three.js — edit or delete it freely. +// ThreeNative does not read this file. `sky.ts` owns the numbers; this file is the fog maths. +import { Color, type FogExp2, type Vector3 } from "three"; +import { + cameraPosition, + densityFogFactor, + dot, + exp2, + float, + fog, + max, + mix, + positionWorld, + pow, + saturate, + select, + uniform, + vec3, +} from "three/tsl"; + +/** + * Height fog, in metres, y up. Density falls off exponentially above `fogHeight`; the fog along the + * ray from the camera to a fragment is the closed-form integral of that density, so low ground + * reads hazier than a ridge at the same distance and a camera above the layer looks down through it. + * Every control says which way to move it. + */ +export const HEIGHT_FOG = { + /** Optical depth per metre at `fogHeight` (base 2). Up: thicker ground mist. */ + density: 0.0012, + /** Per metre above `fogHeight`. Up: a thinner, lower layer. Down: mist climbs the hills. */ + heightFalloff: 0.08, + /** World y where density equals `density`. Move it to the ground of your world. */ + fogHeight: 0, + /** Floor on the height term's transparency, 0..1. Below 1 leaves a clear window in the mist. */ + maxOpacity: 1, + /** Metres from the camera before the mist starts. Up: a clear foreground. */ + startDistance: 0, + /** Sun lobe sharpness. Up: a tighter glow around the sun. */ + sunExponent: 4, + /** Metres before sun glow starts, so nearby objects are not tinted. */ + sunStartDistance: 100, + /** Metres past which the height term is off. 0 = never. Never set it inside a streaming ring. */ + cutoffDistance: 0, +} as const; +export type HeightFogParams = { -readonly [K in keyof typeof HEIGHT_FOG]: number }; + +const LN2 = Math.LN2; + +// Camera density term, clamped so exp2 stays finite (Unreal clamps the exponent at -127). +const clampExponent = (x: number): number => Math.min(127, Math.max(-127, x)); + +/** `density` times the integral of 2^(-k t) over [a, b]; k is the ray's slope times `falloff`. */ +function segment(density: number, k: number, a: number, b: number): number { + if (b <= a) return 0; + if (Math.abs(k * b) < 0.01) return density * (b - a - 0.5 * LN2 * k * (b * b - a * a)); + return (density * (2 ** (-k * a) - 2 ** (-k * b))) / (k * LN2); +} + +function rayTerms(p: HeightFogParams, cameraY: number, fragmentY: number, length: number) { + const camera = p.density * 2 ** clampExponent(-p.heightFalloff * (cameraY - p.fogHeight)); + const k = length > 0 ? (p.heightFalloff * (fragmentY - cameraY)) / length : 0; + return { camera, k }; +} + +/** Base-2 optical depth (transmittance is 2^-depth) of the mist between the camera and a fragment. */ +export function heightFogDepth( + p: HeightFogParams, + cameraY: number, + fragmentY: number, + length: number, +): number { + if (p.cutoffDistance > 0 && length > p.cutoffDistance) return 0; + const { camera, k } = rayTerms(p, cameraY, fragmentY, length); + return segment(camera, k, p.startDistance, length); +} + +const SUN_COLOUR = new Color(0xfff1d6); + +/** + * `T_distance x T_height`, on `scene.fogNode`: three applies it to every material where `scene.fog` + * would be. The distance term reads the live `FogExp2`, so changing its colour or density at runtime + * still works. It is never more transparent than that term alone, so wherever the distance term + * reaches 0 the frame stays fully fogged. Pass `sun` for a glow toward it past `sunStartDistance`. + */ +export function heightFogNode(distanceFog: FogExp2, p: HeightFogParams, sun?: Vector3) { + const color = uniform(new Color()); + color.onRenderUpdate(() => color.value.copy(distanceFog.color)); + const density = uniform(0); + density.onRenderUpdate(() => { + density.value = distanceFog.density; + }); + // Density at the camera, once per frame instead of once per fragment. + const cameraDensity = uniform(0); + cameraDensity.onRenderUpdate(({ camera }) => { + const y = camera?.matrixWorld.elements[13] ?? 0; + cameraDensity.value = p.density * 2 ** clampExponent(-p.heightFalloff * (y - p.fogHeight)); + }); + const ray = positionWorld.sub(cameraPosition); + const length = ray.length().max(1e-4); + const k = float(p.heightFalloff).mul(positionWorld.y.sub(cameraPosition.y)).div(length); + const integral = (a: number) => { + const b = max(length, a); + const curved = cameraDensity + .mul(exp2(k.mul(a).negate()).sub(exp2(k.mul(b).negate()))) + .div(k.mul(LN2)); + return select(k.mul(b).abs().lessThan(0.01), cameraDensity.mul(b.sub(a)), curved); + }; + const cut = p.cutoffDistance > 0 ? select(length.greaterThan(p.cutoffDistance), 0, 1) : float(1); + const heightT = max(exp2(integral(p.startDistance).mul(cut).negate()), 1 - p.maxOpacity); + const opacity = densityFogFactor(density).oneMinus().mul(heightT).oneMinus(); + if (sun === undefined) return fog(color, opacity); + const lobe = pow(saturate(dot(ray.div(length), vec3(sun.x, sun.y, sun.z))), p.sunExponent); + const glow = float(1) + .sub(exp2(integral(Math.max(p.sunStartDistance, p.startDistance)).negate())) + .mul(lobe) + .mul(p.maxOpacity); + return fog(mix(color, vec3(SUN_COLOUR.r, SUN_COLOUR.g, SUN_COLOUR.b), glow), opacity); +} diff --git a/packages/create-threenative/templates/sailing/src/render/sky.ts b/packages/create-threenative/templates/sailing/src/render/sky.ts index a083636b59..d9382557cb 100644 --- a/packages/create-threenative/templates/sailing/src/render/sky.ts +++ b/packages/create-threenative/templates/sailing/src/render/sky.ts @@ -18,6 +18,7 @@ import { type Texture, Vector3, } from "three"; +import { HEIGHT_FOG, type HeightFogParams, heightFogDepth, heightFogNode } from "./heightFog.js"; import { palette } from "./palette.js"; /** @@ -76,6 +77,22 @@ export async function loadSky(assets: { texture(path: string): Promise sky = await assets.texture("sky.jpg"); } +/** 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. + */ +function distanceDensity(p: HeightFogParams = HEIGHT_FOG): number { + const { distance, density, cameraHeight } = EYE_LEVEL; + const old = (density * distance) ** 2; + const height = heightFogDepth(p, cameraHeight, cameraHeight, distance) * Math.LN2; + return Math.sqrt(Math.max(0, old - height)) / distance; +} + export function setupSky(scene: Scene, options: { readonly software?: boolean } = {}): void { if (sky === undefined) throw new Error("setupSky must run after loadSky."); sky.mapping = EquirectangularReflectionMapping; @@ -126,5 +143,8 @@ export function setupSky(scene: Scene, options: { readonly software?: boolean } // Open sea, so the far water is nearly all haze. At 0.003 the sea is half gone by 300 m and gone // by a kilometre, and the colour it goes to is the photograph's own horizon measured off this // file — which is why the water and the sky meet in one line instead of in a seam. - scene.fog = new FogExp2(palette.skyLow, 0.003); + // `scene.fog` stays the distance term; `scene.fogNode` is what three applies and adds the height term. + const distanceFog = new FogExp2(palette.skyLow, distanceDensity()); + scene.fog = distanceFog; + scene.fogNode = heightFogNode(distanceFog, HEIGHT_FOG, SUN_DIRECTION); } diff --git a/packages/create-threenative/templates/shooter/AGENTS.md b/packages/create-threenative/templates/shooter/AGENTS.md index 46831f235d..67470464f1 100644 --- a/packages/create-threenative/templates/shooter/AGENTS.md +++ b/packages/create-threenative/templates/shooter/AGENTS.md @@ -80,4 +80,4 @@ For terrain generation or editing, read `node_modules/create-threenative/agent-d ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. -`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. +`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. Fog is height fog: `HEIGHT_FOG` in `src/render/heightFog.ts` (`density`, `heightFalloff`, `fogHeight`, `maxOpacity`, `startDistance`, `sunExponent`, `sunStartDistance`, `cutoffDistance`) is added to the `FogExp2` distance term on `scene.fogNode` and never replaces it, so a streaming fog `far` still holds. diff --git a/packages/create-threenative/templates/shooter/CLAUDE.md b/packages/create-threenative/templates/shooter/CLAUDE.md index 8f335b7335..1e54040816 100644 --- a/packages/create-threenative/templates/shooter/CLAUDE.md +++ b/packages/create-threenative/templates/shooter/CLAUDE.md @@ -82,4 +82,4 @@ For terrain generation or editing, read `node_modules/create-threenative/agent-d ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. -`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. +`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. Fog is height fog: `HEIGHT_FOG` in `src/render/heightFog.ts` (`density`, `heightFalloff`, `fogHeight`, `maxOpacity`, `startDistance`, `sunExponent`, `sunStartDistance`, `cutoffDistance`) is added to the `FogExp2` distance term on `scene.fogNode` and never replaces it, so a streaming fog `far` still holds. diff --git a/packages/create-threenative/templates/shooter/src/render/heightFog.ts b/packages/create-threenative/templates/shooter/src/render/heightFog.ts new file mode 100644 index 0000000000..232dcf0489 --- /dev/null +++ b/packages/create-threenative/templates/shooter/src/render/heightFog.ts @@ -0,0 +1,118 @@ +// Generated for you. This is ordinary Three.js — edit or delete it freely. +// ThreeNative does not read this file. `sky.ts` owns the numbers; this file is the fog maths. +import { Color, type FogExp2, type Vector3 } from "three"; +import { + cameraPosition, + densityFogFactor, + dot, + exp2, + float, + fog, + max, + mix, + positionWorld, + pow, + saturate, + select, + uniform, + vec3, +} from "three/tsl"; + +/** + * Height fog, in metres, y up. Density falls off exponentially above `fogHeight`; the fog along the + * ray from the camera to a fragment is the closed-form integral of that density, so low ground + * reads hazier than a ridge at the same distance and a camera above the layer looks down through it. + * Every control says which way to move it. + */ +export const HEIGHT_FOG = { + /** Optical depth per metre at `fogHeight` (base 2). Up: thicker ground mist. */ + density: 0.0012, + /** Per metre above `fogHeight`. Up: a thinner, lower layer. Down: mist climbs the hills. */ + heightFalloff: 0.08, + /** World y where density equals `density`. Move it to the ground of your world. */ + fogHeight: 0, + /** Floor on the height term's transparency, 0..1. Below 1 leaves a clear window in the mist. */ + maxOpacity: 1, + /** Metres from the camera before the mist starts. Up: a clear foreground. */ + startDistance: 0, + /** Sun lobe sharpness. Up: a tighter glow around the sun. */ + sunExponent: 4, + /** Metres before sun glow starts, so nearby objects are not tinted. */ + sunStartDistance: 100, + /** Metres past which the height term is off. 0 = never. Never set it inside a streaming ring. */ + cutoffDistance: 0, +} as const; +export type HeightFogParams = { -readonly [K in keyof typeof HEIGHT_FOG]: number }; + +const LN2 = Math.LN2; + +// Camera density term, clamped so exp2 stays finite (Unreal clamps the exponent at -127). +const clampExponent = (x: number): number => Math.min(127, Math.max(-127, x)); + +/** `density` times the integral of 2^(-k t) over [a, b]; k is the ray's slope times `falloff`. */ +function segment(density: number, k: number, a: number, b: number): number { + if (b <= a) return 0; + if (Math.abs(k * b) < 0.01) return density * (b - a - 0.5 * LN2 * k * (b * b - a * a)); + return (density * (2 ** (-k * a) - 2 ** (-k * b))) / (k * LN2); +} + +function rayTerms(p: HeightFogParams, cameraY: number, fragmentY: number, length: number) { + const camera = p.density * 2 ** clampExponent(-p.heightFalloff * (cameraY - p.fogHeight)); + const k = length > 0 ? (p.heightFalloff * (fragmentY - cameraY)) / length : 0; + return { camera, k }; +} + +/** Base-2 optical depth (transmittance is 2^-depth) of the mist between the camera and a fragment. */ +export function heightFogDepth( + p: HeightFogParams, + cameraY: number, + fragmentY: number, + length: number, +): number { + if (p.cutoffDistance > 0 && length > p.cutoffDistance) return 0; + const { camera, k } = rayTerms(p, cameraY, fragmentY, length); + return segment(camera, k, p.startDistance, length); +} + +const SUN_COLOUR = new Color(0xfff1d6); + +/** + * `T_distance x T_height`, on `scene.fogNode`: three applies it to every material where `scene.fog` + * would be. The distance term reads the live `FogExp2`, so changing its colour or density at runtime + * still works. It is never more transparent than that term alone, so wherever the distance term + * reaches 0 the frame stays fully fogged. Pass `sun` for a glow toward it past `sunStartDistance`. + */ +export function heightFogNode(distanceFog: FogExp2, p: HeightFogParams, sun?: Vector3) { + const color = uniform(new Color()); + color.onRenderUpdate(() => color.value.copy(distanceFog.color)); + const density = uniform(0); + density.onRenderUpdate(() => { + density.value = distanceFog.density; + }); + // Density at the camera, once per frame instead of once per fragment. + const cameraDensity = uniform(0); + cameraDensity.onRenderUpdate(({ camera }) => { + const y = camera?.matrixWorld.elements[13] ?? 0; + cameraDensity.value = p.density * 2 ** clampExponent(-p.heightFalloff * (y - p.fogHeight)); + }); + const ray = positionWorld.sub(cameraPosition); + const length = ray.length().max(1e-4); + const k = float(p.heightFalloff).mul(positionWorld.y.sub(cameraPosition.y)).div(length); + const integral = (a: number) => { + const b = max(length, a); + const curved = cameraDensity + .mul(exp2(k.mul(a).negate()).sub(exp2(k.mul(b).negate()))) + .div(k.mul(LN2)); + return select(k.mul(b).abs().lessThan(0.01), cameraDensity.mul(b.sub(a)), curved); + }; + const cut = p.cutoffDistance > 0 ? select(length.greaterThan(p.cutoffDistance), 0, 1) : float(1); + const heightT = max(exp2(integral(p.startDistance).mul(cut).negate()), 1 - p.maxOpacity); + const opacity = densityFogFactor(density).oneMinus().mul(heightT).oneMinus(); + if (sun === undefined) return fog(color, opacity); + const lobe = pow(saturate(dot(ray.div(length), vec3(sun.x, sun.y, sun.z))), p.sunExponent); + const glow = float(1) + .sub(exp2(integral(Math.max(p.sunStartDistance, p.startDistance)).negate())) + .mul(lobe) + .mul(p.maxOpacity); + return fog(mix(color, vec3(SUN_COLOUR.r, SUN_COLOUR.g, SUN_COLOUR.b), glow), opacity); +} diff --git a/packages/create-threenative/templates/shooter/src/render/sky.ts b/packages/create-threenative/templates/shooter/src/render/sky.ts index b8d0845669..f3a87dac2a 100644 --- a/packages/create-threenative/templates/shooter/src/render/sky.ts +++ b/packages/create-threenative/templates/shooter/src/render/sky.ts @@ -13,6 +13,7 @@ import { type Texture, Vector3, } from "three"; +import { HEIGHT_FOG, type HeightFogParams, heightFogDepth, heightFogNode } from "./heightFog.js"; import { palette } from "./palette.js"; /** @@ -25,6 +26,22 @@ const SKY_RANGE = 2.5; /** Unit vector toward the photographed sun: 47.9° up, measured from the source HDR. */ 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. + */ +function distanceDensity(p: HeightFogParams = HEIGHT_FOG): number { + const { distance, density, cameraHeight } = EYE_LEVEL; + const old = (density * distance) ** 2; + const height = heightFogDepth(p, cameraHeight, cameraHeight, distance) * Math.LN2; + return Math.sqrt(Math.max(0, old - height)) / distance; +} + export function setupSky(scene: Scene, sky: Texture): void { sky.mapping = EquirectangularReflectionMapping; sky.colorSpace = SRGBColorSpace; @@ -37,5 +54,8 @@ export function setupSky(scene: Scene, sky: Texture): void { scene.environmentIntensity = SKY_RANGE; // Almost nothing inside the arena (1.4% at 30 m), and the ground plane gone into the horizon by // a kilometre — so the floor meets the sky instead of ending at a line. - scene.fog = new FogExp2(palette.horizon, 0.003); + // `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); } diff --git a/packages/create-threenative/templates/snow/AGENTS.md b/packages/create-threenative/templates/snow/AGENTS.md index 9856e6dd32..57699ca1e6 100644 --- a/packages/create-threenative/templates/snow/AGENTS.md +++ b/packages/create-threenative/templates/snow/AGENTS.md @@ -87,4 +87,4 @@ For terrain generation or editing, read `node_modules/create-threenative/agent-d ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. -`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. +`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. Fog is height fog: `HEIGHT_FOG` in `src/render/heightFog.ts` (`density`, `heightFalloff`, `fogHeight`, `maxOpacity`, `startDistance`, `sunExponent`, `sunStartDistance`, `cutoffDistance`) is added to the `FogExp2` distance term on `scene.fogNode` and never replaces it, so a streaming fog `far` still holds. diff --git a/packages/create-threenative/templates/snow/CLAUDE.md b/packages/create-threenative/templates/snow/CLAUDE.md index 92fe298627..0e85348434 100644 --- a/packages/create-threenative/templates/snow/CLAUDE.md +++ b/packages/create-threenative/templates/snow/CLAUDE.md @@ -89,4 +89,4 @@ For terrain generation or editing, read `node_modules/create-threenative/agent-d ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. -`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. +`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. Fog is height fog: `HEIGHT_FOG` in `src/render/heightFog.ts` (`density`, `heightFalloff`, `fogHeight`, `maxOpacity`, `startDistance`, `sunExponent`, `sunStartDistance`, `cutoffDistance`) is added to the `FogExp2` distance term on `scene.fogNode` and never replaces it, so a streaming fog `far` still holds. diff --git a/packages/create-threenative/templates/snow/src/render/heightFog.ts b/packages/create-threenative/templates/snow/src/render/heightFog.ts new file mode 100644 index 0000000000..232dcf0489 --- /dev/null +++ b/packages/create-threenative/templates/snow/src/render/heightFog.ts @@ -0,0 +1,118 @@ +// Generated for you. This is ordinary Three.js — edit or delete it freely. +// ThreeNative does not read this file. `sky.ts` owns the numbers; this file is the fog maths. +import { Color, type FogExp2, type Vector3 } from "three"; +import { + cameraPosition, + densityFogFactor, + dot, + exp2, + float, + fog, + max, + mix, + positionWorld, + pow, + saturate, + select, + uniform, + vec3, +} from "three/tsl"; + +/** + * Height fog, in metres, y up. Density falls off exponentially above `fogHeight`; the fog along the + * ray from the camera to a fragment is the closed-form integral of that density, so low ground + * reads hazier than a ridge at the same distance and a camera above the layer looks down through it. + * Every control says which way to move it. + */ +export const HEIGHT_FOG = { + /** Optical depth per metre at `fogHeight` (base 2). Up: thicker ground mist. */ + density: 0.0012, + /** Per metre above `fogHeight`. Up: a thinner, lower layer. Down: mist climbs the hills. */ + heightFalloff: 0.08, + /** World y where density equals `density`. Move it to the ground of your world. */ + fogHeight: 0, + /** Floor on the height term's transparency, 0..1. Below 1 leaves a clear window in the mist. */ + maxOpacity: 1, + /** Metres from the camera before the mist starts. Up: a clear foreground. */ + startDistance: 0, + /** Sun lobe sharpness. Up: a tighter glow around the sun. */ + sunExponent: 4, + /** Metres before sun glow starts, so nearby objects are not tinted. */ + sunStartDistance: 100, + /** Metres past which the height term is off. 0 = never. Never set it inside a streaming ring. */ + cutoffDistance: 0, +} as const; +export type HeightFogParams = { -readonly [K in keyof typeof HEIGHT_FOG]: number }; + +const LN2 = Math.LN2; + +// Camera density term, clamped so exp2 stays finite (Unreal clamps the exponent at -127). +const clampExponent = (x: number): number => Math.min(127, Math.max(-127, x)); + +/** `density` times the integral of 2^(-k t) over [a, b]; k is the ray's slope times `falloff`. */ +function segment(density: number, k: number, a: number, b: number): number { + if (b <= a) return 0; + if (Math.abs(k * b) < 0.01) return density * (b - a - 0.5 * LN2 * k * (b * b - a * a)); + return (density * (2 ** (-k * a) - 2 ** (-k * b))) / (k * LN2); +} + +function rayTerms(p: HeightFogParams, cameraY: number, fragmentY: number, length: number) { + const camera = p.density * 2 ** clampExponent(-p.heightFalloff * (cameraY - p.fogHeight)); + const k = length > 0 ? (p.heightFalloff * (fragmentY - cameraY)) / length : 0; + return { camera, k }; +} + +/** Base-2 optical depth (transmittance is 2^-depth) of the mist between the camera and a fragment. */ +export function heightFogDepth( + p: HeightFogParams, + cameraY: number, + fragmentY: number, + length: number, +): number { + if (p.cutoffDistance > 0 && length > p.cutoffDistance) return 0; + const { camera, k } = rayTerms(p, cameraY, fragmentY, length); + return segment(camera, k, p.startDistance, length); +} + +const SUN_COLOUR = new Color(0xfff1d6); + +/** + * `T_distance x T_height`, on `scene.fogNode`: three applies it to every material where `scene.fog` + * would be. The distance term reads the live `FogExp2`, so changing its colour or density at runtime + * still works. It is never more transparent than that term alone, so wherever the distance term + * reaches 0 the frame stays fully fogged. Pass `sun` for a glow toward it past `sunStartDistance`. + */ +export function heightFogNode(distanceFog: FogExp2, p: HeightFogParams, sun?: Vector3) { + const color = uniform(new Color()); + color.onRenderUpdate(() => color.value.copy(distanceFog.color)); + const density = uniform(0); + density.onRenderUpdate(() => { + density.value = distanceFog.density; + }); + // Density at the camera, once per frame instead of once per fragment. + const cameraDensity = uniform(0); + cameraDensity.onRenderUpdate(({ camera }) => { + const y = camera?.matrixWorld.elements[13] ?? 0; + cameraDensity.value = p.density * 2 ** clampExponent(-p.heightFalloff * (y - p.fogHeight)); + }); + const ray = positionWorld.sub(cameraPosition); + const length = ray.length().max(1e-4); + const k = float(p.heightFalloff).mul(positionWorld.y.sub(cameraPosition.y)).div(length); + const integral = (a: number) => { + const b = max(length, a); + const curved = cameraDensity + .mul(exp2(k.mul(a).negate()).sub(exp2(k.mul(b).negate()))) + .div(k.mul(LN2)); + return select(k.mul(b).abs().lessThan(0.01), cameraDensity.mul(b.sub(a)), curved); + }; + const cut = p.cutoffDistance > 0 ? select(length.greaterThan(p.cutoffDistance), 0, 1) : float(1); + const heightT = max(exp2(integral(p.startDistance).mul(cut).negate()), 1 - p.maxOpacity); + const opacity = densityFogFactor(density).oneMinus().mul(heightT).oneMinus(); + if (sun === undefined) return fog(color, opacity); + const lobe = pow(saturate(dot(ray.div(length), vec3(sun.x, sun.y, sun.z))), p.sunExponent); + const glow = float(1) + .sub(exp2(integral(Math.max(p.sunStartDistance, p.startDistance)).negate())) + .mul(lobe) + .mul(p.maxOpacity); + return fog(mix(color, vec3(SUN_COLOUR.r, SUN_COLOUR.g, SUN_COLOUR.b), glow), opacity); +} diff --git a/packages/create-threenative/templates/snow/src/render/sky.ts b/packages/create-threenative/templates/snow/src/render/sky.ts index d3f069d332..8ce2f8104b 100644 --- a/packages/create-threenative/templates/snow/src/render/sky.ts +++ b/packages/create-threenative/templates/snow/src/render/sky.ts @@ -8,13 +8,31 @@ import { MeshBasicMaterial, type Scene, SphereGeometry, + Vector3, } from "three"; +import { HEIGHT_FOG, type HeightFogParams, heightFogDepth, heightFogNode } from "./heightFog.js"; import { palette } from "./palette.js"; const CLEAR_FOG = new Color(palette.skyLow); const STORM_FOG = new Color(0xafc3d1); /** A graded dome with a warm sun glow, plus the haze; returns how to darken both for a storm. */ +/** The look this fog replaced: FogExp2 at 0.016, measured at 40 m from 2 m up. */ +const EYE_LEVEL = { density: 0.016, distance: 40, 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. + */ +function distanceDensity(p: HeightFogParams = HEIGHT_FOG): number { + const { distance, density, cameraHeight } = EYE_LEVEL; + const old = (density * distance) ** 2; + const height = heightFogDepth(p, cameraHeight, cameraHeight, distance) * Math.LN2; + return Math.sqrt(Math.max(0, old - height)) / distance; +} + export function setupSky(scene: Scene): (storm: number) => void { const RADIUS = 450; const geometry = new SphereGeometry(RADIUS, 40, 24); @@ -46,12 +64,13 @@ export function setupSky(scene: Scene): (storm: number) => void { dome.renderOrder = -100; scene.background = bottom; scene.add(dome); - const fog = new FogExp2(CLEAR_FOG.getHex(), 0.016); + const fog = new FogExp2(CLEAR_FOG.getHex(), distanceDensity()); scene.fog = fog; + scene.fogNode = heightFogNode(fog, HEIGHT_FOG, new Vector3(sun.x, sun.y, sun.z)); return (storm) => { fog.color.copy(CLEAR_FOG).lerp(STORM_FOG, storm); // Thick enough to swallow the far forest, thin enough that the explorer stays readable. - fog.density = 0.016 + storm * 0.05; + fog.density = distanceDensity() + storm * 0.05; material.color.setScalar(1 - storm * 0.3); }; } diff --git a/packages/create-threenative/templates/starter/AGENTS.md b/packages/create-threenative/templates/starter/AGENTS.md index 0a4a1b08b2..9a585dd9aa 100644 --- a/packages/create-threenative/templates/starter/AGENTS.md +++ b/packages/create-threenative/templates/starter/AGENTS.md @@ -87,4 +87,4 @@ For terrain generation or editing, read `node_modules/create-threenative/agent-d For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. High-tier desktop browser WebGPU adds game-owned grazing backlight (`lighting.ts`: `rimGain: 0`) and measured-dark directional fill (`fillGain: 0` or black `fillColor`). `materialLighting.ts` preserves borrowed material maps and restores originals on lower quality/exit; mobile, software, WebGL and native retain originals. `TN_ENVIRONMENT_CONTRIBUTION` reports even with overrides zero; `environmentSampling.ts` estimates actual IBL once during load; unavailable/stale samples stay unknown and disable analytic fill. Native material terms remain unqualified. -`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. +`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. Fog is height fog: `HEIGHT_FOG` in `src/render/heightFog.ts` (`density`, `heightFalloff`, `fogHeight`, `maxOpacity`, `startDistance`, `sunExponent`, `sunStartDistance`, `cutoffDistance`) is added to the `FogExp2` distance term on `scene.fogNode` and never replaces it, so a streaming fog `far` still holds. One fog owner at a time: an enabled `STARTER_MIST` clears both `scene.fog` and `scene.fogNode`. diff --git a/packages/create-threenative/templates/starter/CLAUDE.md b/packages/create-threenative/templates/starter/CLAUDE.md index e260673cc2..b7816f320d 100644 --- a/packages/create-threenative/templates/starter/CLAUDE.md +++ b/packages/create-threenative/templates/starter/CLAUDE.md @@ -89,4 +89,4 @@ For terrain generation or editing, read `node_modules/create-threenative/agent-d For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. High-tier desktop browser WebGPU adds game-owned grazing backlight (`lighting.ts`: `rimGain: 0`) and measured-dark directional fill (`fillGain: 0` or black `fillColor`). `materialLighting.ts` preserves borrowed material maps and restores originals on lower quality/exit; mobile, software, WebGL and native retain originals. `TN_ENVIRONMENT_CONTRIBUTION` reports even with overrides zero; `environmentSampling.ts` estimates actual IBL once during load; unavailable/stale samples stay unknown and disable analytic fill. Native material terms remain unqualified. -`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. +`src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. Fog is height fog: `HEIGHT_FOG` in `src/render/heightFog.ts` (`density`, `heightFalloff`, `fogHeight`, `maxOpacity`, `startDistance`, `sunExponent`, `sunStartDistance`, `cutoffDistance`) is added to the `FogExp2` distance term on `scene.fogNode` and never replaces it, so a streaming fog `far` still holds. One fog owner at a time: an enabled `STARTER_MIST` clears both `scene.fog` and `scene.fogNode`. diff --git a/packages/create-threenative/templates/starter/src/render/heightFog.ts b/packages/create-threenative/templates/starter/src/render/heightFog.ts new file mode 100644 index 0000000000..232dcf0489 --- /dev/null +++ b/packages/create-threenative/templates/starter/src/render/heightFog.ts @@ -0,0 +1,118 @@ +// Generated for you. This is ordinary Three.js — edit or delete it freely. +// ThreeNative does not read this file. `sky.ts` owns the numbers; this file is the fog maths. +import { Color, type FogExp2, type Vector3 } from "three"; +import { + cameraPosition, + densityFogFactor, + dot, + exp2, + float, + fog, + max, + mix, + positionWorld, + pow, + saturate, + select, + uniform, + vec3, +} from "three/tsl"; + +/** + * Height fog, in metres, y up. Density falls off exponentially above `fogHeight`; the fog along the + * ray from the camera to a fragment is the closed-form integral of that density, so low ground + * reads hazier than a ridge at the same distance and a camera above the layer looks down through it. + * Every control says which way to move it. + */ +export const HEIGHT_FOG = { + /** Optical depth per metre at `fogHeight` (base 2). Up: thicker ground mist. */ + density: 0.0012, + /** Per metre above `fogHeight`. Up: a thinner, lower layer. Down: mist climbs the hills. */ + heightFalloff: 0.08, + /** World y where density equals `density`. Move it to the ground of your world. */ + fogHeight: 0, + /** Floor on the height term's transparency, 0..1. Below 1 leaves a clear window in the mist. */ + maxOpacity: 1, + /** Metres from the camera before the mist starts. Up: a clear foreground. */ + startDistance: 0, + /** Sun lobe sharpness. Up: a tighter glow around the sun. */ + sunExponent: 4, + /** Metres before sun glow starts, so nearby objects are not tinted. */ + sunStartDistance: 100, + /** Metres past which the height term is off. 0 = never. Never set it inside a streaming ring. */ + cutoffDistance: 0, +} as const; +export type HeightFogParams = { -readonly [K in keyof typeof HEIGHT_FOG]: number }; + +const LN2 = Math.LN2; + +// Camera density term, clamped so exp2 stays finite (Unreal clamps the exponent at -127). +const clampExponent = (x: number): number => Math.min(127, Math.max(-127, x)); + +/** `density` times the integral of 2^(-k t) over [a, b]; k is the ray's slope times `falloff`. */ +function segment(density: number, k: number, a: number, b: number): number { + if (b <= a) return 0; + if (Math.abs(k * b) < 0.01) return density * (b - a - 0.5 * LN2 * k * (b * b - a * a)); + return (density * (2 ** (-k * a) - 2 ** (-k * b))) / (k * LN2); +} + +function rayTerms(p: HeightFogParams, cameraY: number, fragmentY: number, length: number) { + const camera = p.density * 2 ** clampExponent(-p.heightFalloff * (cameraY - p.fogHeight)); + const k = length > 0 ? (p.heightFalloff * (fragmentY - cameraY)) / length : 0; + return { camera, k }; +} + +/** Base-2 optical depth (transmittance is 2^-depth) of the mist between the camera and a fragment. */ +export function heightFogDepth( + p: HeightFogParams, + cameraY: number, + fragmentY: number, + length: number, +): number { + if (p.cutoffDistance > 0 && length > p.cutoffDistance) return 0; + const { camera, k } = rayTerms(p, cameraY, fragmentY, length); + return segment(camera, k, p.startDistance, length); +} + +const SUN_COLOUR = new Color(0xfff1d6); + +/** + * `T_distance x T_height`, on `scene.fogNode`: three applies it to every material where `scene.fog` + * would be. The distance term reads the live `FogExp2`, so changing its colour or density at runtime + * still works. It is never more transparent than that term alone, so wherever the distance term + * reaches 0 the frame stays fully fogged. Pass `sun` for a glow toward it past `sunStartDistance`. + */ +export function heightFogNode(distanceFog: FogExp2, p: HeightFogParams, sun?: Vector3) { + const color = uniform(new Color()); + color.onRenderUpdate(() => color.value.copy(distanceFog.color)); + const density = uniform(0); + density.onRenderUpdate(() => { + density.value = distanceFog.density; + }); + // Density at the camera, once per frame instead of once per fragment. + const cameraDensity = uniform(0); + cameraDensity.onRenderUpdate(({ camera }) => { + const y = camera?.matrixWorld.elements[13] ?? 0; + cameraDensity.value = p.density * 2 ** clampExponent(-p.heightFalloff * (y - p.fogHeight)); + }); + const ray = positionWorld.sub(cameraPosition); + const length = ray.length().max(1e-4); + const k = float(p.heightFalloff).mul(positionWorld.y.sub(cameraPosition.y)).div(length); + const integral = (a: number) => { + const b = max(length, a); + const curved = cameraDensity + .mul(exp2(k.mul(a).negate()).sub(exp2(k.mul(b).negate()))) + .div(k.mul(LN2)); + return select(k.mul(b).abs().lessThan(0.01), cameraDensity.mul(b.sub(a)), curved); + }; + const cut = p.cutoffDistance > 0 ? select(length.greaterThan(p.cutoffDistance), 0, 1) : float(1); + const heightT = max(exp2(integral(p.startDistance).mul(cut).negate()), 1 - p.maxOpacity); + const opacity = densityFogFactor(density).oneMinus().mul(heightT).oneMinus(); + if (sun === undefined) return fog(color, opacity); + const lobe = pow(saturate(dot(ray.div(length), vec3(sun.x, sun.y, sun.z))), p.sunExponent); + const glow = float(1) + .sub(exp2(integral(Math.max(p.sunStartDistance, p.startDistance)).negate())) + .mul(lobe) + .mul(p.maxOpacity); + return fog(mix(color, vec3(SUN_COLOUR.r, SUN_COLOUR.g, SUN_COLOUR.b), glow), opacity); +} diff --git a/packages/create-threenative/templates/starter/src/render/sky.ts b/packages/create-threenative/templates/starter/src/render/sky.ts index fa288e6b0e..1f3f1d0542 100644 --- a/packages/create-threenative/templates/starter/src/render/sky.ts +++ b/packages/create-threenative/templates/starter/src/render/sky.ts @@ -7,7 +7,6 @@ // 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. import { - Color, EquirectangularReflectionMapping, FogExp2, SRGBColorSpace, @@ -15,23 +14,7 @@ import { type Texture, Vector3, } from "three"; -import { - cameraPosition, - densityFogFactor, - dot, - exp2, - float, - fog, - max, - mix, - normalize, - positionWorld, - pow, - saturate, - select, - uniform, - vec3, -} from "three/tsl"; +import { HEIGHT_FOG, type HeightFogParams, heightFogDepth, heightFogNode } from "./heightFog.js"; import { palette } from "./palette.js"; /** @@ -44,87 +27,8 @@ const SKY_RANGE = 2.5; /** Unit vector toward the photographed sun: 47.9° up, measured from the source HDR. */ export const SUN_DIRECTION = new Vector3(0.555, 0.742, 0.38).normalize(); -/** - * Height fog, in metres, y up. Density falls off exponentially above `fogHeight`; the fog along the - * ray from the camera to a fragment is the closed-form integral of that density, so low ground - * reads hazier than a ridge at the same distance and a camera above the layer looks down through it. - * Every control says which way to move it. - */ -export const HEIGHT_FOG = { - /** Optical depth per metre at `fogHeight` (base 2). Up: thicker ground mist. */ - density: 0.0012, - /** Per metre above `fogHeight`. Up: a thinner, lower layer. Down: mist climbs the hills. */ - heightFalloff: 0.08, - /** World y where density equals `density`. Move it to the ground of your world. */ - fogHeight: 0, - /** Floor on the height term's transparency, 0..1. Below 1 leaves a clear window in the mist. */ - maxOpacity: 1, - /** Metres from the camera before the mist starts. Up: a clear foreground. */ - startDistance: 0, - /** Sun lobe sharpness. Up: a tighter glow around the sun. */ - sunExponent: 4, - /** Metres before sun glow starts, so nearby objects are not tinted. */ - sunStartDistance: 100, - /** Metres past which the height term is off. 0 = never. Never set it inside a streaming ring. */ - cutoffDistance: 0, -} as const; -export type HeightFogParams = { -readonly [K in keyof typeof HEIGHT_FOG]: number }; - /** The look this fog replaced: FogExp2 at 0.003, which read as 18% haze at 150 m from 2 m up. */ const EYE_LEVEL = { density: 0.003, distance: 150, cameraHeight: 2 }; -const LN2 = Math.LN2; - -// Camera density term, clamped so exp2 stays finite (Unreal clamps the exponent at -127). -const clampExponent = (x: number): number => Math.min(127, Math.max(-127, x)); - -/** `density` times the integral of 2^(-k t) over [a, b]; k is the ray's slope times `falloff`. */ -function segment(density: number, k: number, a: number, b: number): number { - if (b <= a) return 0; - if (Math.abs(k * b) < 0.01) return density * (b - a - 0.5 * LN2 * k * (b * b - a * a)); - return (density * (2 ** (-k * a) - 2 ** (-k * b))) / (k * LN2); -} - -function rayTerms(p: HeightFogParams, cameraY: number, fragmentY: number, length: number) { - const camera = p.density * 2 ** clampExponent(-p.heightFalloff * (cameraY - p.fogHeight)); - const k = length > 0 ? (p.heightFalloff * (fragmentY - cameraY)) / length : 0; - return { camera, k }; -} - -/** Base-2 optical depth (transmittance is 2^-depth) of the mist between the camera and a fragment. */ -export function heightFogDepth( - p: HeightFogParams, - cameraY: number, - fragmentY: number, - length: number, -): number { - if (p.cutoffDistance > 0 && length > p.cutoffDistance) return 0; - const { camera, k } = rayTerms(p, cameraY, fragmentY, length); - return segment(camera, k, p.startDistance, length); -} - -/** Height-term transparency, 1 = clear, floored by `1 - maxOpacity`. */ -export function heightFogTransmittance( - p: HeightFogParams, - cameraY: number, - fragmentY: number, - length: number, -): number { - return Math.max(2 ** -heightFogDepth(p, cameraY, fragmentY, length), 1 - p.maxOpacity); -} - -/** How much of the sun's colour the mist carries: the same density, only past `sunStartDistance`. */ -export function heightFogInscatter( - p: HeightFogParams, - cameraY: number, - fragmentY: number, - length: number, - sunDot: number, -): number { - const { camera, k } = rayTerms(p, cameraY, fragmentY, length); - const depth = segment(camera, k, Math.max(p.sunStartDistance, p.startDistance), length); - const lobe = Math.max(0, Math.min(1, sunDot)) ** p.sunExponent; - return lobe * (1 - 2 ** -depth) * p.maxOpacity; -} /** * The distance term's density that, with the height term, keeps the old eye-level haze: at @@ -132,58 +36,13 @@ export function heightFogInscatter( * FogExp2 at `EYE_LEVEL.density` did. The distance term is lowered, never removed, so the ground * still ends in fog at a kilometre. */ -export function distanceDensity(p: HeightFogParams = HEIGHT_FOG): number { +function distanceDensity(p: HeightFogParams = HEIGHT_FOG): number { const { distance, density, cameraHeight } = EYE_LEVEL; const old = (density * distance) ** 2; - const height = heightFogDepth(p, cameraHeight, cameraHeight, distance) * LN2; + const height = heightFogDepth(p, cameraHeight, cameraHeight, distance) * Math.LN2; return Math.sqrt(Math.max(0, old - height)) / distance; } -const SUN_COLOUR = new Color(0xfff1d6); - -/** - * `T_distance x T_height`: never more transparent than the distance term alone, so wherever that - * reaches 0 the frame stays fully fogged. Assigned to `scene.fogNode`, which three applies to every - * material where `scene.fog` would be. - */ -function heightFogNode(color: Color, p: HeightFogParams) { - const density = uniform(distanceDensity(p)); - // Density at the camera, once per frame instead of once per fragment. - const cameraDensity = uniform(0).onRenderUpdate(({ camera }) => { - const y = camera?.matrixWorld.elements[13] ?? 0; - cameraDensity.value = p.density * 2 ** clampExponent(-p.heightFalloff * (y - p.fogHeight)); - }); - const ray = positionWorld.sub(cameraPosition); - const length = ray.length().max(1e-4); - const k = float(p.heightFalloff).mul(positionWorld.y.sub(cameraPosition.y)).div(length); - const integral = (start: ReturnType) => { - const a = start; - const b = max(length, a); - const flat = b.sub(a); - const curved = cameraDensity - .mul(exp2(k.mul(a).negate()).sub(exp2(k.mul(b).negate()))) - .div(k.mul(LN2)); - return select(k.mul(b).abs().lessThan(0.01), cameraDensity.mul(flat), curved); - }; - const cut = p.cutoffDistance > 0 ? select(length.greaterThan(p.cutoffDistance), 0, 1) : float(1); - const depth = integral(float(p.startDistance)).mul(cut); - const heightT = max(exp2(depth.negate()), 1 - p.maxOpacity); - const distanceT = densityFogFactor(density).oneMinus(); - const opacity = distanceT.mul(heightT).oneMinus(); - const sun = normalize(vec3(SUN_DIRECTION.x, SUN_DIRECTION.y, SUN_DIRECTION.z)); - const lobe = pow(saturate(dot(ray.div(length), sun)), p.sunExponent); - const glow = float(1) - .sub(exp2(integral(float(Math.max(p.sunStartDistance, p.startDistance))).negate())) - .mul(lobe) - .mul(p.maxOpacity); - const colour = mix( - vec3(color.r, color.g, color.b), - vec3(SUN_COLOUR.r, SUN_COLOUR.g, SUN_COLOUR.b), - glow, - ); - return fog(colour, opacity); -} - export function setupSky(scene: Scene, sky: Texture, software = false): void { sky.mapping = EquirectangularReflectionMapping; sky.colorSpace = SRGBColorSpace; @@ -207,6 +66,7 @@ export function setupSky(scene: Scene, sky: Texture, software = false): void { // `scene.fog` stays the distance term for anything that reads it; `scene.fogNode` is what three // applies, and it adds the height term. One owner at a time: `STARTER_MIST` clears both. const params: HeightFogParams = { ...HEIGHT_FOG }; - scene.fog = new FogExp2(palette.skyLow, distanceDensity(params)); - scene.fogNode = heightFogNode(new Color(palette.skyLow), params); + const distanceFog = new FogExp2(palette.skyLow, distanceDensity(params)); + scene.fog = distanceFog; + scene.fogNode = heightFogNode(distanceFog, params, SUN_DIRECTION); } diff --git a/packages/create-threenative/templates/starter/src/render/volumetricFog.ts b/packages/create-threenative/templates/starter/src/render/volumetricFog.ts index eeb74a539c..fec72de5eb 100644 --- a/packages/create-threenative/templates/starter/src/render/volumetricFog.ts +++ b/packages/create-threenative/templates/starter/src/render/volumetricFog.ts @@ -101,7 +101,10 @@ export function createVolumetricFog( if (graph !== undefined) throw new Error("volumetricFog: compose once per owned graph."); if (scenePass.camera !== camera) throw new Error("volumetricFog: scene depth must come from this camera."); - if (Reflect.get(scenePass.scene, "fog") != null || scenePass.scene.fogNode != null) + if ( + Reflect.get(scenePass.scene, "fog") != null || + Reflect.get(scenePass.scene, "fogNode") != null + ) throw new Error("volumetricFog: scene fog duplicates the same medium."); graph = composeFogVolume(camera, options, volumes, scenePass); return graph.compose; From 9bd7252fdf19f99f7555c9c23ea8359c699308c6 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Fri, 9 Oct 2026 20:08:06 -0700 Subject: [PATCH 03/21] test(templates): one fog owner with height fog, refresh scaffold hashes (PRD-560) The mist spec now covers scene.fogNode: the medium clears it with scene.fog, restores both on dispose, and compose rejects a lingering fogNode. The eleven FogExp2 kits' parent scaffold hashes follow their height-fog sources. --- .../__tests__/scaffold.spec.ts | 1 + .../__tests__/vq-volumetric-fog.spec.ts | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index e1ef4417c9..9bf785a46a 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -225,6 +225,7 @@ const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { // then again on the merge carrying the WebGPU adapter-retention Three patch: the scaffolded // `patches/three@0.185.1.patch` is the only byte that moved on top of the exposure/fog tree, so // all thirteen trees move again. Values below are the merged-tree measurement, not either side's. + // PRD-560: the eleven FogExp2 kits carry height fog (heightFog.ts, sky.ts, AGENTS.md). // Recomputed on the PRD-478 merge into PR 473 (develop b12b257f1 + the runbook branch): the // merged Three patch changes every kit; starter also carries the merged render source. // Recomputed 2026-10-09 on the merge of develop (31 commits, PRD-494 sharded bundles) into the diff --git a/packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts b/packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts index b28cb0c447..b102f7fe90 100644 --- a/packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts +++ b/packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts @@ -14,6 +14,7 @@ import { PointLight, RenderTarget, Scene, + Texture, Vector3, } from "three"; import { pass } from "three/tsl"; @@ -23,6 +24,7 @@ import { type IVolumetricFogOptions, createVolumetricFog, } from "../templates/starter/src/render/volumetricFog.js"; +import { setupSky } from "../templates/starter/src/render/sky.js"; import { type OutputRenderer, WorldEnvironment } from "../template-assets/worldEnvironment.js"; @@ -152,6 +154,29 @@ describe("opt-in generated volumetric fog", () => { fog.dispose(); expect(scene.fog).toBe(authored); }); + it("clears the height-fog node with the distance fog and restores both (one fog owner)", () => { + const scene = new Scene(); + setupSky(scene, new Texture()); + const { fog: distance, fogNode: node } = scene; + expect(distance).not.toBeNull(); + expect(node).toBeTruthy(); + const camera = new PerspectiveCamera(); + const medium = required(createVolumetricFog(scene, camera, settings())); + expect(scene.fog).toBeNull(); + expect(scene.fogNode).toBeNull(); + expect(() => medium.compose(pass(scene, camera))).not.toThrow(); + medium.dispose(); + expect(scene.fog).toBe(distance); + expect(scene.fogNode).toBe(node); + }); + it("rejects a compose while a height-fog node is still the scene's fog", () => { + const scene = new Scene(); + const camera = new PerspectiveCamera(); + const medium = required(createVolumetricFog(scene, camera, settings())); + setupSky(scene, new Texture()); + scene.fog = null; + expect(() => medium.compose(pass(scene, camera))).toThrow(/duplicates the same medium/); + }); it("leaves a fog the game authored while the medium lived", () => { const scene = new Scene(); scene.fog = new FogExp2(0xcfd8e3, 0.003); From d5bbf98528c3b97924a68819bffe3e8f36e408ff Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Fri, 9 Oct 2026 20:11:18 -0700 Subject: [PATCH 04/21] test(templates): eye-level haze spec covers all 11 height-fog kits (PRD-560) Each kit's setupSky must keep its old FogExp2 transmittance at its eye-level distance within 2%. Also reattach the racing and snow setupSky doc comments that the fog block had split from their function, and refresh those two scaffold hashes. --- .../__tests__/height-fog.spec.ts | 53 +++++++++++++++++++ .../__tests__/vq-volumetric-fog.spec.ts | 2 +- .../templates/racing/src/render/sky.ts | 14 ++--- .../templates/snow/src/render/sky.ts | 2 +- 4 files changed, 62 insertions(+), 9 deletions(-) diff --git a/packages/create-threenative/__tests__/height-fog.spec.ts b/packages/create-threenative/__tests__/height-fog.spec.ts index 7e051792a8..a33e681f05 100644 --- a/packages/create-threenative/__tests__/height-fog.spec.ts +++ b/packages/create-threenative/__tests__/height-fog.spec.ts @@ -1,3 +1,5 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; import { type FogExp2, Scene, Texture } from "three"; import { describe, expect, it } from "vitest"; import { @@ -85,3 +87,54 @@ describe("height fog", () => { } }); }); + +// Every FogExp2 kit keeps the eye-level haze its fog had before the height term: at the +// `EYE_LEVEL` distance and camera height, the distance term and the height term together transmit +// what the old FogExp2 did, within 2%. +const FOG_KITS = [ + "action-rpg", + "minimal", + "platformer", + "puzzle", + "racing", + "rts", + "runner", + "sailing", + "shooter", + "snow", + "starter", +] as const; + +describe.each(FOG_KITS)("%s height fog", (kit) => { + const root = path.join(import.meta.dirname, "..", "templates", kit, "src", "render"); + + it("should keep its old eye-level haze within 2%", async () => { + const sky = await readFile(path.join(root, "sky.ts"), "utf8"); + const eye = /EYE_LEVEL = \{ density: ([\w.]+), distance: (\d+), cameraHeight: (\d+) \}/u.exec( + sky, + ); + expect(eye, `${kit} must name its eye-level fog`).not.toBeNull(); + const named = new RegExp(`${eye?.[1]} = (\\d+(?:\\.\\d+)?);`, "u").exec(sky); + const old = Number(named?.[1] ?? eye?.[1]); + const [distance, cameraHeight] = [Number(eye?.[2]), Number(eye?.[3])]; + const { HEIGHT_FOG: kitParams, heightFogDepth: depth } = (await import( + /* @vite-ignore */ path.join(root, "heightFog.ts") + )) as typeof import("../templates/starter/src/render/heightFog.js"); + const mod = (await import(/* @vite-ignore */ path.join(root, "sky.ts"))) as { + loadSky?: (assets: { texture(path: string): Promise }) => Promise; + setupSky: (scene: Scene, arg?: unknown) => unknown; + }; + await mod.loadSky?.({ texture: async () => new Texture() }); + const scene = new Scene(); + mod.setupSky(scene, new Texture()); + const distanceFog = scene.fog as FogExp2 | null; + expect(distanceFog, `${kit} keeps FogExp2 as the distance term`).toBeTruthy(); + expect(scene.fogNode).toBeTruthy(); + const params: HeightFogParams = { ...kitParams }; + const total = + Math.exp(-(((distanceFog?.density ?? 0) * distance) ** 2)) * + 2 ** -depth(params, cameraHeight, cameraHeight, distance); + const before = Math.exp(-((old * distance) ** 2)); + expect(Math.abs(total - before)).toBeLessThan(0.02 * before); + }); +}); diff --git a/packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts b/packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts index b102f7fe90..7b1e715965 100644 --- a/packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts +++ b/packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts @@ -20,11 +20,11 @@ import { import { pass } from "three/tsl"; import { type Node, WGSLNodeBuilder } from "three/webgpu"; import { describe, expect, it, vi } from "vitest"; +import { setupSky } from "../templates/starter/src/render/sky.js"; import { type IVolumetricFogOptions, createVolumetricFog, } from "../templates/starter/src/render/volumetricFog.js"; -import { setupSky } from "../templates/starter/src/render/sky.js"; import { type OutputRenderer, WorldEnvironment } from "../template-assets/worldEnvironment.js"; diff --git a/packages/create-threenative/templates/racing/src/render/sky.ts b/packages/create-threenative/templates/racing/src/render/sky.ts index 60d27fe2e0..4e6a9fb505 100644 --- a/packages/create-threenative/templates/racing/src/render/sky.ts +++ b/packages/create-threenative/templates/racing/src/render/sky.ts @@ -28,13 +28,6 @@ const SKY_RANGE = 2.5; /** Unit vector toward the photographed sun: 47.9° up, measured from the source HDR. */ export const SUN_DIRECTION = new Vector3(0.555, 0.742, 0.38).normalize(); -/** - * Puts the sky on the scene, or a plain gradient if the photograph has not decoded yet. - * - * The second argument is optional because `Race.load()` does not wait for the JPEG: the scene has - * to enter on the first frame or the playtest bridge describes nothing. Passing `undefined` here is - * the fallback the scene starts in, and calling it again with the texture is the swap. - */ /** The look this fog replaced: FogExp2 at 0.0024, measured at 150 m from 2 m up. */ const EYE_LEVEL = { density: 0.0024, distance: 150, cameraHeight: 2 }; @@ -51,6 +44,13 @@ function distanceDensity(p: HeightFogParams = HEIGHT_FOG): number { return Math.sqrt(Math.max(0, old - height)) / distance; } +/** + * Puts the sky on the scene, or a plain gradient if the photograph has not decoded yet. + * + * The second argument is optional because `Race.load()` does not wait for the JPEG: the scene has + * to enter on the first frame or the playtest bridge describes nothing. Passing `undefined` here is + * the fallback the scene starts in, and calling it again with the texture is the swap. + */ export function setupSky(scene: Scene, sky?: Texture): void { if (sky === undefined) { scene.background = new Color(palette.skyHigh); diff --git a/packages/create-threenative/templates/snow/src/render/sky.ts b/packages/create-threenative/templates/snow/src/render/sky.ts index 8ce2f8104b..5982cc077b 100644 --- a/packages/create-threenative/templates/snow/src/render/sky.ts +++ b/packages/create-threenative/templates/snow/src/render/sky.ts @@ -16,7 +16,6 @@ import { palette } from "./palette.js"; const CLEAR_FOG = new Color(palette.skyLow); const STORM_FOG = new Color(0xafc3d1); -/** A graded dome with a warm sun glow, plus the haze; returns how to darken both for a storm. */ /** The look this fog replaced: FogExp2 at 0.016, measured at 40 m from 2 m up. */ const EYE_LEVEL = { density: 0.016, distance: 40, cameraHeight: 2 }; @@ -33,6 +32,7 @@ function distanceDensity(p: HeightFogParams = HEIGHT_FOG): number { return Math.sqrt(Math.max(0, old - height)) / distance; } +/** A graded dome with a warm sun glow, plus the haze; returns how to darken both for a storm. */ export function setupSky(scene: Scene): (storm: number) => void { const RADIUS = 450; const geometry = new SphereGeometry(RADIUS, 40, 24); From 773c68fe401fa90df5c2e57db93ec6810e88774a Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 10 Oct 2026 09:57:28 -0700 Subject: [PATCH 05/21] docs(prds): PRD-560 phases 1 to 3 proof, AC-2 held, AC-1b and Pixel 8 open Phase 1 box 3: starter scenarios 4/4 on the height-fog arm and the mist arm, one fog owner read at runtime. Phase 2: the owned desktop host passes 300 frames with height fog. Phase 3: eight template gates pass, the docs specs and mirrors are in sync. AC-1a judged by a fresh judge, AC-2 delta -0.03 ms. Open: AC-1b (ridge contrast judged unclear), shooter and platformer gates, and the Pixel 8 check under Blocked on. --- .../PRD-560-height-fog-follows-the-ground.md | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-560-height-fog-follows-the-ground.md b/docs/PRDs/unreal-source-borrowing/PRD-560-height-fog-follows-the-ground.md index 6e359fb046..d39cfa0105 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-560-height-fog-follows-the-ground.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-560-height-fog-follows-the-ground.md @@ -4,7 +4,7 @@ prd_contract: v1 # PRD-560 — Height fog follows the ground, and the sun shows through it -**Status:** NOT STARTED +**Status:** PARTIAL — phases 1 and 3 landed on `feat/prd-560-height-fog`; the visual criterion AC-1b and the Pixel 8 cost check are open **Priority:** P2 — AC-1 and AC-2 are open: 11 of 13 templates still ship distance-only fog, so no default scene has ground mist or a sun-side horizon. **Complexity:** 3 (LOW) — 11+ template files (3), no new module, no package change; risk override: none **Owner:** João @@ -112,8 +112,10 @@ Risks: ## Acceptance Criteria -- [ ] AC-1 [local]: In the scaffolded starter, the height-fog frame is judged at or above the `FogExp2` frame by a fresh judge, and low ground reads hazier than a ridge at the same distance. proof: `pnpm visuals:ab --before --after --raters 3` — Evidence: pending. -- [ ] AC-2 [local]: At 1080p on the RTX 2080 browser WebGPU lane, height fog costs at most 0.1 ms GPU more than `FogExp2` in the same starter build. proof: `TN_FRAME_BUDGET` main-pass p50 delta from `node packages/playtest/dist/runner/cli.js perf` on both builds — Evidence: pending. +- [x] AC-1a [local]: In the scaffolded starter, the height-fog frame is judged at or above the `FogExp2` frame by a fresh judge. proof: same-pose before/after captures (2 runs per arm, three poses: default, high, ridge; 1920x1080, headed, `--browser-recipe webgpu`, adapter `nvidia`/`turing`) given to a fresh read-only judge subagent with the intended effect — verdict 2026-10-09: no regression in any of the three poses. `pnpm visuals:ab` was not run: the fresh-judge protocol replaced it. +- [ ] AC-1b [local]: Low ground reads hazier than a ridge at the same distance. proof: same captures and judge as AC-1a. + The same fresh judge returned "unclear, leaning intended" on the ridge-versus-low-ground question, so this stays open. Measured, not graded: mean saturation of the low slab vs the high slab is 0.461 vs 0.516 with height fog (clearer ridge) and 0.407 vs 0.403 with `FogExp2` (no difference). The contrast is subtle by design, because every template keeps its old eye-level haze within 2%. João decides whether a stronger default `heightFalloff` is wanted. +- [x] AC-2 [local]: At 1080p on the RTX 2080 browser WebGPU lane, height fog costs at most 0.1 ms GPU more than `FogExp2` in the same starter build. proof: same-page interleaved A/B (`scene.fogNode` toggled every `TN_FRAME_BUDGET` window of 100 frames, scratch driver, two runs of 14 windows, warm-up window dropped) — `gpuMain` p50 mean 0.433 ms with height fog vs 0.464 ms without (delta -0.03 ms, medians 0.4 vs 0.4; run 1 -0.07 ms, run 2 +0.01 ms), 2026-10-09. The off arm is `FogExp2` at the lowered density. Caveat: the GPU is shared and loaded, and `gpuMain` has a 0.1 ms quantum. Separate before/after builds gave unusable runs (fps 2 to 7 under load), so the toggle replaced them. ## Integration Ledger @@ -124,21 +126,31 @@ Risks: ## Execution Phases #### Phase 1: Height fog in the starter -**Status:** NOT STARTED +**Status:** DONE (2026-10-09) **Files:** `packages/create-threenative/templates/starter/src/render/sky.ts`, `packages/create-threenative/templates/starter/src/render/heightFog.ts` (new: the template `__tests__/template.spec.ts` render-export rule needs each exported maths symbol to have a caller in another file, so the maths moved out of `sky.ts` and `sky.ts` keeps the look numbers), `packages/create-threenative/__tests__/height-fog.spec.ts` (new) **Implementation:** Write the integral as a pure function, then the TSL node that uses it, and assign the node to `scene.fogNode`. Calibrate `density` and `heightFalloff` to the current eye-level haze. - [x] The closed form matches a 4096-step numeric march within 1% for camera heights below, inside and above the layer, for rays from -89° to +89°, at exactly horizontal (the Taylor branch), and at the exponent clamp. proof: `pnpm exec vitest run packages/create-threenative/__tests__/height-fog.spec.ts` — 5/5 pass, 2026-10-09. The clamp case asserts finite and fully opaque, not equality: Unreal clamps the camera term only, so a march that clamps per sample differs by design. - [x] The combined transmittance is never above the distance term alone. With PRD-461's recipe (linear near 128 m, far 256 m), it is 0 at fog `far` for a camera far above the layer. proof: `pnpm exec vitest run packages/create-threenative/__tests__/height-fog.spec.ts` — same 5/5 run. -- [ ] The starter template gate passes with height fog on, and the mist-enabled arm shows one fog owner. proof: `pnpm test:templates` (starter). +- [x] The starter template gate passes with height fog on, and the mist-enabled arm shows one fog owner. proof: the starter is not in `pnpm test:templates` (it is booked by the golden path), so its four scaffolded scenarios `play`, `survives`, `production-readiness` and `fog` ran on both arms with `--browser-recipe webgpu --headed` — height-fog arm 4/4 exit 0; mist arm (`STARTER_MIST.enabled = true`) 4/4 exit 0 on rerun (the first `play` run failed `maxFrameMsP95` at 52.4 ms with host load near 30 and passed twice at load below 13). A scratch probe of `scene.fog` and `scene.fogNode` after the post graph builds read `{fog: true, fogNode: true}` with mist off and `{fog: false, fogNode: false}` with mist on, and neither arm logged a console error, 2026-10-09. -#### Phase 2: Phone cost and native -**Status:** NOT STARTED +#### Phase 2: Native +**Status:** DONE (2026-10-09); the phone-cost check moved to `## Blocked on` **Files:** none beyond Phase 1, unless a defect needs a fix -- [ ] On the Pixel 8, the main pass of the height-fog starter costs at most 0.2 ms more than the `FogExp2` build. proof: `node packages/playtest/dist/runner/cli.js perf --logcat ` on both Android builds. -- [ ] The desktop native host renders the starter with height fog and a non-blank screenshot. proof: `node packages/playtest/dist/runner/cli.js playtests/survives.playtest.json --target desktop` in a scaffolded starter. +- [x] The desktop native host renders the starter with height fog and a non-blank screenshot. proof: `node scripts/verify-starter-desktop.mjs --project . --frames 300` in a scaffolded starter built with `pnpm build:desktop` — "starter desktop gate passed: 300 frames, 2674 colors, 485 asset pixels"; the `FogExp2` control arm passes with 2590 colors, so the TSL fog node compiles in the owned host (RTX 2080, 2026-10-09). The starter's `survives` scenario cannot hold this claim on `--target desktop`: its default network diagnostics return `TN_PLAYTEST_UNSUPPORTED_ON_TARGET` there, so the starter's own native gate is the proof. #### Phase 3: Every FogExp2 template -**Status:** NOT STARTED +**Status:** PARTIAL **Files:** `src/render/sky.ts` in action-rpg, minimal, platformer, puzzle, racing, rts, runner, sailing, shooter, snow; each template's `AGENTS.md` (and mirrors) -- [ ] The ten other templates use height fog. Each matches its old eye-level transmittance within 2%, and the template gate passes. proof: `pnpm test:templates`. -- [ ] Each template's `AGENTS.md` names the fog controls and the mist ownership rule, and the mirrors are in sync. proof: `pnpm sync:agents --check && pnpm exec vitest run scripts/__tests__/primary-docs.spec.ts scripts/__tests__/sync-agent-docs.spec.ts`. +- [x] Eight of the ten other templates (action-rpg, minimal, puzzle, racing, rts, runner, sailing, snow) use height fog, each within 2% of its old eye-level transmittance, and their template gates pass. proof: `pnpm exec vitest run packages/create-threenative/__tests__/height-fog.spec.ts` (eye-level spec over all 11 kits, 32/32 with the doc specs) and `TN_TEMPLATE_ONLY=