Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f33fe0e
feat(templates): height fog in the starter sky (PRD-560 phase 1)
jonit-dev Oct 9, 2026
574f702
feat(templates): height fog in all 11 FogExp2 templates (PRD-560)
jonit-dev Oct 9, 2026
9bd7252
test(templates): one fog owner with height fog, refresh scaffold hash…
jonit-dev Oct 10, 2026
d5bbf98
test(templates): eye-level haze spec covers all 11 height-fog kits (P…
jonit-dev Oct 10, 2026
773c68f
docs(prds): PRD-560 phases 1 to 3 proof, AC-2 held, AC-1b and Pixel 8…
jonit-dev Oct 10, 2026
3d7b42b
test(templates): refresh scaffold hashes after the rebase onto develo…
jonit-dev Oct 10, 2026
b887480
fix(templates): strengthen judged height fog ridge contrast (PRD-560)
jonit-dev Oct 10, 2026
3f8e591
docs(prd-560): scope retune evidence and keep current proof open
jonit-dev Oct 10, 2026
b72bfa8
docs(prd-560): leave retuned starter gameplay proof open
jonit-dev Oct 10, 2026
c15db14
fix(native): enable supported float texture filtering
jonit-dev Oct 11, 2026
b74a1e6
Merge remote-tracking branch 'origin/develop' into feat/prd-560-heigh…
jonit-dev Oct 11, 2026
2d53787
Refresh native coverage and record combined rendering proof
jonit-dev Oct 11, 2026
d43ee59
fix(starter): retain ground haze with histogram exposure
jonit-dev Oct 11, 2026
6fd23bd
docs(prd-560): record current native and GPU budget proof
jonit-dev Oct 11, 2026
dc88a26
docs(prd-560): record runtime display qualification limits
jonit-dev Oct 11, 2026
e96b864
docs: record matched shooter control and tone gate
jonit-dev Oct 11, 2026
20b7afe
docs: preserve failed shooter cadence follow-ups
jonit-dev Oct 11, 2026
6cd6c0e
docs: record finite shooter progress and functional pass
jonit-dev Oct 11, 2026
ed2660a
docs: record complete shooter scenario coverage
jonit-dev Oct 11, 2026
14a9e02
docs: plan authored puzzle visibility gate repair
jonit-dev Oct 11, 2026
17e2c8b
docs: record typecheck pass and measured visibility replan
jonit-dev Oct 11, 2026
6e16557
fix(templates): restore puzzle visibility and finish height-fog quali…
jonit-dev Oct 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 220 additions & 0 deletions docs/PRDs/done/PRD-560-height-fog-follows-the-ground.md

Large diffs are not rendered by default.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ prd_contract: v1
**Priority:** P2 — AC-1 to AC-3 are open: `Atmosphere.aerialPerspective` mixes every pixel toward one zenith colour, and no template uses the atmosphere at all.
**Complexity:** 4 (MEDIUM) — 1–5 implementation files (1), a new GPU resource with per-frame view-space state (+2), template adoption (+1); risk override: none
**Owner:** João
**Depends on:** None. Builds on the LUT stack that [PRD-248](../done/PRD-248-the-atmosphere-is-luts-the-sky-is-the-games.md) shipped. Composes with [PRD-560](./PRD-560-height-fog-follows-the-ground.md) height fog: this PRD is kilometre-scale air, and PRD-560 is ground mist.
**Depends on:** None. Builds on the LUT stack that [PRD-248](../done/PRD-248-the-atmosphere-is-luts-the-sky-is-the-games.md) shipped. Composes with [PRD-560](../done/PRD-560-height-fog-follows-the-ground.md) height fog: this PRD is kilometre-scale air, and PRD-560 is ground mist.

## Context

Expand Down
2 changes: 1 addition & 1 deletion docs/PRDs/unreal-source-borrowing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ earlier wave unless the "Waits on" column says so.

| Wave | PRD | Outcome | Complexity | Waits on |
| --- | --- | --- | --- | --- |
| 1 | [PRD-560](./PRD-560-height-fog-follows-the-ground.md) | Fog thickens toward the ground and glows toward the sun, at near-zero cost | 3 LOW | — |
| 1 | [PRD-560](../done/PRD-560-height-fog-follows-the-ground.md) | Fog thickens toward the ground and glows toward the sun, at near-zero cost | 3 LOW | — |
| 1 | [PRD-571](../BLOCKED/requires-physical-device/PRD-571-exposure-meters-a-histogram-and-ignores-the-sun.md) | Exposure ignores the brightest and darkest tenth of the frame | 2 LOW | — |
| 1 | [PRD-572](../done/PRD-572-shadow-casters-sort-themselves-into-static-and-moving.md) | A moving shadow caster updates its shadow without a `trackCaster` call | 3 LOW | — |
| 1 | [PRD-567](../done/PRD-567-terrain-layers-blend-by-their-own-height.md) | Terrain layers blend by their own height maps | 3 LOW | owner confirms the core/template split |
Expand Down
6 changes: 3 additions & 3 deletions docs/verification/native-coverage-2026-08-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ targets could not be built and are named below.
| `src/storage/` | 327 | 286 | 87.46% |
| `src/utils/` | 0 | 0 | 0.00% |
| `src/vfs/` | 239 | 195 | 81.59% |
| `src/webgpu/` | 9801 | 7493 | 76.45% |
| `src/webgpu/` | 9802 | 7501 | 76.53% |
| `src/webtransport/` | 1391 | 1078 | 77.50% |
| `src/workers/` | 615 | 527 | 85.69% |
| **TOTAL** | **24729** | **19382** | **78.38%** |
| **TOTAL** | **24730** | **19390** | **78.41%** |

Source digest: `sha256:5d324eaaa156c4fd352ef8e5837a0790060cfeda53a0d89226ce2880b179faeb`
Source digest: `sha256:53fe120fe5582b1199d07506367f09423f1cbcbcda8438ce09515959c65e9ad0`

The default `pnpm budgets` gate reads this committed measurement without configuring or compiling
the native host. Any native source, native C++ test, CTest registration, or coverage aggregation
Expand Down
172 changes: 172 additions & 0 deletions packages/create-threenative/__tests__/height-fog.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
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 {
HEIGHT_FOG,
type HeightFogParams,
heightFogDepth,
} 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;
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(transmittance(steep, -1000, 1000, 2000)).toBe(0);
});

it("should fog low ground more than a ridge at the same distance", () => {
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 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)),
);
});

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 * 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 * transmittance(params, 2, 2, z)).toBeLessThanOrEqual(distanceT);
}
});
});

// 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;

/** The kits whose old fog was 0.003 at 150 m from 2 m up: height fog carries most of that haze. */
const CONTRAST_KITS = [
"action-rpg",
"minimal",
"platformer",
"runner",
"sailing",
"shooter",
"starter",
] as const;

async function loadKit(kit: string) {
const root = path.join(import.meta.dirname, "..", "templates", kit, "src", "render");
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 { 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<Texture> }) => Promise<void>;
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();
return {
cameraHeight: Number(eye?.[3]),
depth,
distance: Number(eye?.[2]),
distanceDensity: distanceFog?.density ?? 0,
old: Number(named?.[1] ?? eye?.[1]),
params: { ...kitParams } as HeightFogParams,
};
}

describe.each(FOG_KITS)("%s height fog", (kit) => {
it("should keep its old eye-level haze within 2%", async () => {
const k = await loadKit(kit);
const total =
Math.exp(-((k.distanceDensity * k.distance) ** 2)) *
2 ** -k.depth(k.params, k.cameraHeight, k.cameraHeight, k.distance);
const before = Math.exp(-((k.old * k.distance) ** 2));
expect(Math.abs(total - before)).toBeLessThan(0.02 * before);
});
});

describe.each(CONTRAST_KITS)("%s ridge contrast", (kit) => {
it("should fog low ground at least 10 points more than a ridge 330 m away", async () => {
const k = await loadKit(kit);
const fog = (fragmentY: number) => {
const length = Math.hypot(330, fragmentY - 3);
const distanceFog = (k.distanceDensity * length) ** 2;
return 1 - Math.exp(-distanceFog) * 2 ** -k.depth(k.params, 3, fragmentY, length);
};
// A 30 m block on the ground (centre 15 m) against the same block 70 m higher (centre 85 m).
expect(fog(15) - fog(85)).toBeGreaterThanOrEqual(0.1);
});
});
Loading
Loading