merge: the ground gets a real material
The land was MeshLambertMaterial with vertex colours and no maps at all, while the sea beside it was a MeshStandardMaterial with an animated procedural normal map. Closes that gap: a standard material with roughness and a tiling detail normal generated from seeded noise, zero draw calls and zero triangles. Two versions of this did nothing and were caught only by looking at the pictures — the first faded out at 2.8 km because it copied the sea's camera-distance form, which assumes a plan measurement and is 9x wrong under 15x exaggeration; the second tiled the Central Valley with a regular lattice of four-pointed stars.
This commit is contained in:
+25
-6
@@ -102,8 +102,17 @@ const NOISE_RES = 64;
|
|||||||
* edge equal the right edge: each point is mixed with its wrapped neighbours
|
* edge equal the right edge: each point is mixed with its wrapped neighbours
|
||||||
* weighted by how close it is to them, which is exactly zero contribution in
|
* weighted by how close it is to them, which is exactly zero contribution in
|
||||||
* the middle of the tile and a perfect match at the seam.
|
* the middle of the tile and a perfect match at the seam.
|
||||||
|
*
|
||||||
|
* Exported because the ground uses it too. `engine/terrain.ts` builds one
|
||||||
|
* tiling detail normal map for the land out of these three functions — the
|
||||||
|
* blend, the bilinear read and the Sobel below — rather than reimplementing
|
||||||
|
* them at a 440 m repeat, for the same reason this file takes `fbm` from
|
||||||
|
* `engine/world.ts` rather than writing its own: a second noise is a second
|
||||||
|
* thing to keep tiling, and a second Sobel is a second place for the
|
||||||
|
* flipY/perUv reasoning to be got wrong. Nothing here touches the DOM, so the
|
||||||
|
* land's map is as testable under `node --test` as the office's are.
|
||||||
*/
|
*/
|
||||||
function tileableNoise(res: number, scale: number, offset: number): Float32Array {
|
export function tileableNoise(res: number, scale: number, offset: number): Float32Array {
|
||||||
const field = new Float32Array(res * res);
|
const field = new Float32Array(res * res);
|
||||||
for (let y = 0; y < res; y++) {
|
for (let y = 0; y < res; y++) {
|
||||||
const v = y / res;
|
const v = y / res;
|
||||||
@@ -122,7 +131,7 @@ function tileableNoise(res: number, scale: number, offset: number): Float32Array
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Bilinear read of a wrapped coarse field, in 0..1 texture space. */
|
/** Bilinear read of a wrapped coarse field, in 0..1 texture space. */
|
||||||
function sampleField(field: Float32Array, res: number, u: number, v: number): number {
|
export function sampleField(field: Float32Array, res: number, u: number, v: number): number {
|
||||||
const fx = u * res;
|
const fx = u * res;
|
||||||
const fy = v * res;
|
const fy = v * res;
|
||||||
const x0 = Math.floor(fx);
|
const x0 = Math.floor(fx);
|
||||||
@@ -1075,17 +1084,27 @@ function heightField(recipe: HeightRecipe, size: number): Float32Array {
|
|||||||
* neighbouring rows is enough to bury it.
|
* neighbouring rows is enough to bury it.
|
||||||
*
|
*
|
||||||
* The gradient is converted to a **slope** before it is encoded — `× size` puts
|
* The gradient is converted to a **slope** before it is encoded — `× size` puts
|
||||||
* it per unit UV, `/ TEXTURE_TILE_METRES` puts it per metre — which is why the
|
* it per unit UV, `/ tileMetres` puts it per metre — which is why the same
|
||||||
* same recipe drawn at 256 and at 512 gives the same steepness rather than the
|
* recipe drawn at 256 and at 512 gives the same steepness rather than the
|
||||||
* higher-resolution floor looking twice as smooth.
|
* higher-resolution floor looking twice as smooth.
|
||||||
|
*
|
||||||
|
* `tileMetres` is a parameter rather than the constant it used to read, because
|
||||||
|
* the ground's detail map repeats every 440 m and the office's finishes every
|
||||||
|
* 2 m, and the whole point of the conversion is that the number is the tile's
|
||||||
|
* own. Every caller in this file passes `TEXTURE_TILE_METRES`, which is what
|
||||||
|
* the default preserves.
|
||||||
*/
|
*/
|
||||||
function normalMapData(height: Float32Array, size: number): Uint8Array {
|
export function normalMapData(
|
||||||
|
height: Float32Array,
|
||||||
|
size: number,
|
||||||
|
tileMetres: number = TEXTURE_TILE_METRES,
|
||||||
|
): Uint8Array {
|
||||||
const data = new Uint8Array(size * size * 4);
|
const data = new Uint8Array(size * size * 4);
|
||||||
const at = (x: number, y: number): number =>
|
const at = (x: number, y: number): number =>
|
||||||
height[(((y % size) + size) % size) * size + (((x % size) + size) % size)] ?? 0;
|
height[(((y % size) + size) % size) * size + (((x % size) + size) % size)] ?? 0;
|
||||||
|
|
||||||
// Per-texel Sobel sums weight 4 either side across a 2-texel baseline.
|
// Per-texel Sobel sums weight 4 either side across a 2-texel baseline.
|
||||||
const perUv = size / 8 / TEXTURE_TILE_METRES;
|
const perUv = size / 8 / tileMetres;
|
||||||
|
|
||||||
for (let y = 0; y < size; y++) {
|
for (let y = 0; y < size; y++) {
|
||||||
for (let x = 0; x < size; x++) {
|
for (let x = 0; x < size; x++) {
|
||||||
|
|||||||
+507
-20
@@ -17,6 +17,8 @@
|
|||||||
|
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
|
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
|
||||||
|
import { normalMapData, sampleField, tileableNoise } from "../assets/textures.ts";
|
||||||
|
import { deviceProfile } from "./stage.ts";
|
||||||
import type { ScenePalette } from "./types.ts";
|
import type { ScenePalette } from "./types.ts";
|
||||||
import type { World } from "./world.ts";
|
import type { World } from "./world.ts";
|
||||||
|
|
||||||
@@ -117,8 +119,443 @@ function groundColor(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- The ground's surface --------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How rough the land is, for the physically-shaded ground below.
|
||||||
|
*
|
||||||
|
* The land is not the sea and must not glint like it. At `SEA_*`'s 0.2 a GGX
|
||||||
|
* lobe is a few degrees wide and returns the sun as a hard streak; at 0.92 it
|
||||||
|
* is most of a hemisphere, which is what dry ground, scrub, tarmac and roof
|
||||||
|
* tile all are — the specular term contributes a low, wide sheen and nothing
|
||||||
|
* that reads as a highlight. What the number is really buying is not the
|
||||||
|
* specular at all but the *diffuse* environment term, which is what a Lambert
|
||||||
|
* material has no slot for; see the note on the material in `createTerrain`.
|
||||||
|
*/
|
||||||
|
const GROUND_ROUGHNESS = 0.92;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many metres one repeat of the ground's detail normal map covers, and the
|
||||||
|
* whole reason it is not `TEXTURE_TILE_METRES`.
|
||||||
|
*
|
||||||
|
* The office's constant is 2 m, which is correct for a carpet and meaningless
|
||||||
|
* here. Work out what a repeat is worth in pixels before choosing one. The
|
||||||
|
* desktop budget viewport is 1440 x 900 at a 42° *vertical* field of view, so at
|
||||||
|
* a camera-to-ground distance of `D` scene units one device pixel covers
|
||||||
|
* `D · 2·tan(21°) / 900` scene units — 1.64 m of California's 1,919 m per unit.
|
||||||
|
* Against the distances this board can actually be put at, quoted the way
|
||||||
|
* `TODO.md` and `cost-at.mjs` quote them, as `D × metresPerUnit`:
|
||||||
|
*
|
||||||
|
* | camera distance | m/px | a 2 m repeat | a 440 m repeat |
|
||||||
|
* |---|---|---|---|
|
||||||
|
* | 400 km — the whole state, and where the budget measures | 341 | 0.006 px | 1.3 px |
|
||||||
|
* | 110 km — Los Angeles wide | 94 | 0.02 px | 4.7 px |
|
||||||
|
* | 45 km — the Bay rung | 38.4 | 0.05 px | 11 px |
|
||||||
|
* | 7.7 km | 6.6 | 0.3 px | 67 px |
|
||||||
|
* | 2.5 km | 2.13 | 0.9 px | 207 px |
|
||||||
|
* | 1.9 km — `ORBIT_MIN_STANDOFF_M`, the floor | 1.62 | 1.2 px | 272 px |
|
||||||
|
*
|
||||||
|
* **A 2 m repeat is sub-pixel at every distance the board can reach**, right
|
||||||
|
* down to the orbit floor, so reusing the office's number would have bought a
|
||||||
|
* texture fetch per ground fragment in exchange for a map the mip chain has
|
||||||
|
* already flattened.
|
||||||
|
*
|
||||||
|
* **440 m, and it was 220 m first, and the difference was a photograph.** The
|
||||||
|
* arithmetic says a repeat wants to be a few hundred metres and stops there; it
|
||||||
|
* cannot tell you that at 220 m, with `tileableNoise` at a `scale` of 2, the
|
||||||
|
* blend's own four-way cross-fade is the largest thing in the tile and the flat
|
||||||
|
* Central Valley comes out as a **regular grid of little four-pointed stars**,
|
||||||
|
* one per repeat, marching off to the horizon. That was rendered and looked at.
|
||||||
|
* At 440 m with the layers below there are eight or so features across a repeat
|
||||||
|
* instead of one, so nothing in the tile is identifiable and the repetition
|
||||||
|
* stops being legible as repetition.
|
||||||
|
*
|
||||||
|
* **Not 300 m, and not 600.** The obvious choice is one repeat per terrain cell,
|
||||||
|
* and it is the one number to avoid: the lattice is very nearly axis-aligned, so
|
||||||
|
* a repeat the size of a cell paints the *same* pattern on every cell and lands
|
||||||
|
* its seam exactly on the facet edges it exists to hide. 440 beats against them.
|
||||||
|
*/
|
||||||
|
const GROUND_DETAIL_TILE_METRES = 440;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The detail map's resolution, and the lattice its height field is built on.
|
||||||
|
*
|
||||||
|
* Four texels per lattice cell, which is the ratio that matters. `textures.ts`
|
||||||
|
* records why the noise is evaluated on a coarse lattice and bilinearly
|
||||||
|
* upsampled — four `fbm` calls per pixel at 512² is about sixteen million
|
||||||
|
* `Math.sin`es — and why `normalMapData` uses a Sobel rather than a two-tap
|
||||||
|
* difference: the upsample reproduces the lattice as a faint grid of creases in
|
||||||
|
* the derivative, and averaging three rows buries it. 128 over a 440 m repeat
|
||||||
|
* puts the finest thing the height field can hold at 3.4 m, the same floor the
|
||||||
|
* office's 64-over-2 m gives at its own scale.
|
||||||
|
*
|
||||||
|
* 512 rather than 256 because of the *bottom* of the descent, not the top. At
|
||||||
|
* 256 a texel is 1.72 m, which is exactly one device pixel at the 1.9 km orbit
|
||||||
|
* floor — no margin at all, and a grazing look has none to give. At 512 it is
|
||||||
|
* 0.86 m and the map is minified everywhere in the reachable range, so the mip
|
||||||
|
* chain decides everything and the magnifier decides nothing. The price is
|
||||||
|
* 1.05 MB, 1.4 MB with its mips, against a board whose whole resident geometry
|
||||||
|
* `boards.ts` measures at about 40 MB — and one of it, shared by all 18 ground
|
||||||
|
* objects. It costs about 50 ms of the board build, against `createTerrain`'s
|
||||||
|
* own 159 ms on the Bay Area.
|
||||||
|
*
|
||||||
|
* The Sobel converts gradient to slope per metre, so the two resolutions light
|
||||||
|
* identically — `textureMaps.test.ts` pins that property for the office's maps
|
||||||
|
* and it is the same arithmetic here, checked again below for this one.
|
||||||
|
*/
|
||||||
|
const GROUND_DETAIL_LATTICE = 128;
|
||||||
|
const GROUND_DETAIL_SIZE = 512;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The height field the ground's normal map is differentiated from, in metres of
|
||||||
|
* **drawn** relief.
|
||||||
|
*
|
||||||
|
* Drawn, not real, and the distinction is the one trap in this whole file.
|
||||||
|
* `world.metres` multiplies every elevation by `verticalExaggeration` — 15 on
|
||||||
|
* the merged California board — while a horizontal metre is divided by
|
||||||
|
* `metresPerUnit` alone. A normal map encodes a slope in scene units per scene
|
||||||
|
* unit, so the numbers below are already on the drawn side of that
|
||||||
|
* multiplication: the ±5 m of relief these three layers sum to stands for about
|
||||||
|
* ±0.35 m of real ground, which is a sub-1% grade over 440 m and is about right
|
||||||
|
* for field, scrub and alluvium.
|
||||||
|
*
|
||||||
|
* **They are deliberately not exaggerated to match the geography.** Carrying the
|
||||||
|
* real relief through the board's own 15× would put the map's mean slope near
|
||||||
|
* 45° and its peaks past 70°, and that is the wrong answer for a reason that is
|
||||||
|
* not about taste: the grain would out-shout the geography, the sun's N·L would
|
||||||
|
* swing by ±0.6 across two texels, and a state whose whole subject is its
|
||||||
|
* landforms would read as crumpled foil. The mesh carries the exaggeration; the
|
||||||
|
* finish on it does not.
|
||||||
|
*
|
||||||
|
* Measured on the field these three produce: **mean slope 9.9°, peak 39.4°.**
|
||||||
|
* The three are about a factor of three apart each — roughly 55 m of swell, 20 m
|
||||||
|
* of gully and 7 m of grain — because a single octave at any of those reads as a
|
||||||
|
* pattern and three read as a surface. The `scale`s are all comfortably above 4
|
||||||
|
* for the reason `GROUND_DETAIL_TILE_METRES` records: below that,
|
||||||
|
* `tileableNoise`'s wrapped cross-fade is itself the biggest feature in the tile.
|
||||||
|
*/
|
||||||
|
const GROUND_DETAIL_LAYERS: readonly { scale: number; offset: number; metres: number }[] = [
|
||||||
|
{ scale: 8, offset: 5.7, metres: 21 },
|
||||||
|
{ scale: 22, offset: 19.3, metres: 8 },
|
||||||
|
{ scale: 60, offset: 41.1, metres: 2.6 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the detail gives up, in **device pixels of one repeat**.
|
||||||
|
*
|
||||||
|
* The same argument as `SEA_CALM_NEAR` and a different measurement, because the
|
||||||
|
* first attempt at this used the sea's and it was wrong by a factor of nine —
|
||||||
|
* measured, in a photograph of the Central Valley where the map was doing
|
||||||
|
* literally nothing and a five-fold amplitude produced a byte-identical PNG.
|
||||||
|
*
|
||||||
|
* The sea fades on `length( vViewPosition )` converted from real metres by
|
||||||
|
* `metresPerUnit`, which quietly assumes the camera's distance is a *plan*
|
||||||
|
* distance. On this board it is not: `verticalExaggeration` is 15, so a camera
|
||||||
|
* lifted to 0.6 of its stand-off sits `sqrt(1 + (0.6 x 15)²) = 9.06` stand-offs
|
||||||
|
* away in scene units. A window written as "25 km to 70 km" was therefore
|
||||||
|
* closing at about 2.8 km of stand-off and the detail was off everywhere a
|
||||||
|
* viewer could actually go.
|
||||||
|
*
|
||||||
|
* The fix is to stop inferring the quantity and measure it. `fwidth` of the
|
||||||
|
* detail UV is exactly how much of one repeat a pixel covers, so its reciprocal
|
||||||
|
* is the repeat's width in pixels — independent of the board's scale, the
|
||||||
|
* exaggeration, the field of view, the viewport and the device pixel ratio, all
|
||||||
|
* five of which the old form was implicitly guessing at. Two thresholds then say
|
||||||
|
* what the study of stand-offs already concluded: **under 2 px the map is noise
|
||||||
|
* the mip chain has already flattened, over 8 px it is legible.** For a 440 m
|
||||||
|
* repeat that is the same window as "from about 110 km down to about 45 km",
|
||||||
|
* arrived at without any of the assumptions.
|
||||||
|
*
|
||||||
|
* It matters more here than it does for the water, because the board's own
|
||||||
|
* budget poses live at the far end: `california-one` is measured at whole-board
|
||||||
|
* framing, 400 km out, where a 440 m repeat is 1.3 px. Without this the ground
|
||||||
|
* would be paying a fetch and a tangent frame at the one pose the product is
|
||||||
|
* judged on, to perturb a normal by an amount that has already averaged to zero.
|
||||||
|
*
|
||||||
|
* Two derivative pairs, which is the whole cost, and the normal-map path is
|
||||||
|
* already taking three for `getTangentFrame`.
|
||||||
|
*/
|
||||||
|
const GROUND_DETAIL_NOISE_PX = 2;
|
||||||
|
const GROUND_DETAIL_LEGIBLE_PX = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tiling tangent-space normal map for the land, as raw RGBA bytes.
|
||||||
|
*
|
||||||
|
* The land's answer to `swellNormalData`, and built out of `textures.ts`'
|
||||||
|
* machinery rather than its own: `tileableNoise` for the four-way wrapped blend
|
||||||
|
* that makes the edges match, `sampleField` for the bilinear read, and
|
||||||
|
* `normalMapData` for the Sobel that turns metres into an encoded slope. The
|
||||||
|
* only thing this adds is the recipe and the tile size, which is the whole
|
||||||
|
* difference between a carpet and a hillside.
|
||||||
|
*
|
||||||
|
* No canvas, no `document`, no image: the field is arithmetic, so this function
|
||||||
|
* exists identically in a browser and under `node --test`, and
|
||||||
|
* `scripts/check-no-binaries.mjs` has nothing to find.
|
||||||
|
*/
|
||||||
|
export function groundDetailData(size: number = GROUND_DETAIL_SIZE): Uint8Array {
|
||||||
|
const height = new Float32Array(size * size);
|
||||||
|
for (const layer of GROUND_DETAIL_LAYERS) {
|
||||||
|
const field = tileableNoise(GROUND_DETAIL_LATTICE, layer.scale, layer.offset);
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
// `/ 0.94` is `grain()`'s: four octaves of `fbm` land in 0..0.94. The
|
||||||
|
// half is what centres the layer, so the summed field has no drift in
|
||||||
|
// it and `metres` is a peak-to-trough rather than a ceiling.
|
||||||
|
const n = Math.min(
|
||||||
|
1,
|
||||||
|
Math.max(0, sampleField(field, GROUND_DETAIL_LATTICE, x / size, y / size) / 0.94),
|
||||||
|
);
|
||||||
|
height[y * size + x] = (height[y * size + x] as number) + (n - 0.5) * layer.metres;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalMapData(height, size, GROUND_DETAIL_TILE_METRES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bytes, built once for the life of the page.
|
||||||
|
*
|
||||||
|
* Two materials want this map — the terrain and the shore plate under it — and
|
||||||
|
* the field is a pure function of the constants above, so building it twice is
|
||||||
|
* 50 ms of identical arithmetic. Only the *bytes* are shared: each material gets
|
||||||
|
* its own `DataTexture`, because `scene.ts` disposes a board by traversing it
|
||||||
|
* and disposing what it finds, and a `Texture` shared across boards would be
|
||||||
|
* disposed by the first board to leave. A `Uint8Array` has no GPU resource to
|
||||||
|
* free and is never written to after this line.
|
||||||
|
*/
|
||||||
|
let groundDetailBytes: Uint8Array | null = null;
|
||||||
|
|
||||||
|
function groundDetailTexture(): THREE.DataTexture {
|
||||||
|
const size = GROUND_DETAIL_SIZE;
|
||||||
|
groundDetailBytes ??= groundDetailData(size);
|
||||||
|
const texture = new THREE.DataTexture(groundDetailBytes, size, size);
|
||||||
|
texture.wrapS = THREE.RepeatWrapping;
|
||||||
|
texture.wrapT = THREE.RepeatWrapping;
|
||||||
|
texture.magFilter = THREE.LinearFilter;
|
||||||
|
// Mipmapped for the reason `swellNormalTexture` states: the mip chain of a
|
||||||
|
// normal map converges on flat, so the ground a long way off stops perturbing
|
||||||
|
// its normal without anything having to decide when — and the fade at
|
||||||
|
// `GROUND_DETAIL_NOISE_PX` then finishes the job by taking the fetch's
|
||||||
|
// *result* to zero rather than leaving a residue of the coarsest mip on the
|
||||||
|
// horizon.
|
||||||
|
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||||
|
texture.generateMipmaps = true;
|
||||||
|
// Eight, as the sea has: from a map pose the ground is seen at a grazing
|
||||||
|
// angle across most of the frame, and that is exactly where an isotropic
|
||||||
|
// filter picks a mip several levels too coarse and the detail vanishes
|
||||||
|
// halfway up the screen for no reason the viewer can see.
|
||||||
|
texture.anisotropy = 8;
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
texture.name = "groundDetailNormal";
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this device should pay for the ground's detail relief.
|
||||||
|
*
|
||||||
|
* The precedent is `MaterialQuality`'s `low` rung in `assets/materials.ts`,
|
||||||
|
* which is flat Lambert with no maps — a tier's "off" is *no map*, not a
|
||||||
|
* smaller one, because the cost being avoided is per fragment and a smaller map
|
||||||
|
* costs exactly the same fetch. `deviceProfile()` is the only device knob a
|
||||||
|
* city board reads and it is asked here rather than re-derived, so the
|
||||||
|
* renderer's idea of "phone" cannot drift from the stylesheet's.
|
||||||
|
*
|
||||||
|
* What a handheld keeps is the half of this change that reads at every
|
||||||
|
* distance: the standard material, its specular and its environment term. What
|
||||||
|
* it gives up is the half that only reads inside the last decade of the descent
|
||||||
|
* — one texture fetch and `getTangentFrame`'s three derivative pairs per ground
|
||||||
|
* fragment, on a viewport that is 1.3 Mpx of them, at a whole-board pose where
|
||||||
|
* one repeat of the map is 1.3 px wide.
|
||||||
|
*
|
||||||
|
* `typeof window` because `createTerrain` runs under `node --test` as well,
|
||||||
|
* where there is no device to profile and no fill rate to protect.
|
||||||
|
*/
|
||||||
|
function groundDetailWanted(): boolean {
|
||||||
|
if (typeof window === "undefined") return true;
|
||||||
|
return !deviceProfile().handheld;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one material the ground is shaded by, built for the terrain and again for
|
||||||
|
* the shore plate under it.
|
||||||
|
*
|
||||||
|
* Two materials rather than one because the terrain is `vertexColors` and the
|
||||||
|
* plate is a flat `pal.shore`, and three compiles those to different programs
|
||||||
|
* whatever this returns. **One material per mesh either way**, so the draw call
|
||||||
|
* count is exactly what it was: 17 chunks, one zero-triangle caster and one
|
||||||
|
* plate, sharing two programs between them. The plate is here rather than left
|
||||||
|
* on its own flat colour because a textured terrain against a smooth plate makes
|
||||||
|
* the grid's stair-stepped rim *legible* — which is the one thing
|
||||||
|
* `createShorePlates` exists to hide — and because `WebGLRenderer` forwards
|
||||||
|
* `scene.environment` only to `isMeshStandardMaterial`, so a Lambert plate would
|
||||||
|
* also sit a fifth of the hemisphere's diffuse contribution darker than the
|
||||||
|
* ground it abuts, all the way around the coastline.
|
||||||
|
*/
|
||||||
|
function groundMaterial(
|
||||||
|
world: World,
|
||||||
|
detail: boolean,
|
||||||
|
paint: Pick<THREE.MeshStandardMaterialParameters, "color" | "vertexColors">,
|
||||||
|
): THREE.MeshStandardMaterial {
|
||||||
|
const material = new THREE.MeshStandardMaterial({
|
||||||
|
...paint,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
roughness: GROUND_ROUGHNESS,
|
||||||
|
// Stated rather than defaulted: ground is a dielectric, and a metallic
|
||||||
|
// surface has no diffuse term at all, so a stray metalness here would take
|
||||||
|
// `groundColor`'s whole ramp off the board.
|
||||||
|
metalness: 0,
|
||||||
|
...(detail ? { normalMap: groundDetailTexture() } : {}),
|
||||||
|
});
|
||||||
|
/**
|
||||||
|
* `shadowSide = BackSide` is the acne cure, and it is exactly right for a
|
||||||
|
* heightfield.
|
||||||
|
*
|
||||||
|
* The depth pass then culls every face turned *toward* the sun — precisely
|
||||||
|
* the set of faces that were shadowing themselves — and keeps the faces
|
||||||
|
* turned away from it. The boundary between the two is the terminator, so the
|
||||||
|
* depth recorded along a ridge starts at the crest and runs down its far
|
||||||
|
* slope, and the valley floor beyond, which is still front-facing and
|
||||||
|
* therefore writes nothing, tests against it and lands in shadow. A lit slope
|
||||||
|
* has nothing in the map above it and cannot stipple.
|
||||||
|
*
|
||||||
|
* Without it, a constant `shadow.bias` has to cover a depth-per-texel that
|
||||||
|
* grows as 1/tan(elevation) — one texel is 0.21 scene units on California and
|
||||||
|
* 0.73 on the Bay Area — and there is no single value that is free of acne at
|
||||||
|
* 40° and free of peter-panning at 8°. That is why this was left off through
|
||||||
|
* the previous round, and it is checked here by photographing the boards at
|
||||||
|
* a sun of 12° and of 2°.
|
||||||
|
*
|
||||||
|
* It survives the move to a standard material unchanged, and it has to: the
|
||||||
|
* depth pass does not use this material at all — `WebGLShadowMap` swaps in
|
||||||
|
* its own `MeshDepthMaterial` and copies across `shadowSide`, `alphaMap` and
|
||||||
|
* the displacement slots and nothing else — so the patch below is invisible
|
||||||
|
* to it and this flag is the only thing it reads.
|
||||||
|
*/
|
||||||
|
material.shadowSide = THREE.BackSide;
|
||||||
|
|
||||||
|
if (detail) {
|
||||||
|
/**
|
||||||
|
* Three edits to the standard shader. The ground has no `uv` attribute and
|
||||||
|
* no `tangent` attribute, and both of those are decisions rather than
|
||||||
|
* omissions.
|
||||||
|
*
|
||||||
|
* 1. **The detail UV is derived from world XZ in the vertex shader.** The
|
||||||
|
* alternative is a real `uv` attribute, and it was costed: 46,144
|
||||||
|
* vertices x 2 x 4 bytes is 369 KB against the 1.66 MB of position,
|
||||||
|
* colour and normal the ground already uploads — a 22% growth of the
|
||||||
|
* ground's vertex data to store a number that is `position.xz` times a
|
||||||
|
* constant. It would also have to be added as *one* attribute object
|
||||||
|
* assigned to all 18 geometries, or the shared-buffer arrangement in
|
||||||
|
* `createTerrain` quietly duplicates it seventeen times. The plan
|
||||||
|
* projection is in any case the natural parameterisation of a
|
||||||
|
* heightfield, which is the other half of why there is no attribute to
|
||||||
|
* interpolate. `position.xz` and not the world matrix's, deliberately:
|
||||||
|
* `scene.ts` scales this group in **Y** to move `verticalExaggeration`
|
||||||
|
* without rebuilding a board, and a UV taken off the plan is the one
|
||||||
|
* that does not slide when it does.
|
||||||
|
*
|
||||||
|
* 2. **The slope is scaled by the cosine of the drawn slope, which is the
|
||||||
|
* fix for the exaggeration trap.** `verticalExaggeration` is 15 on this
|
||||||
|
* board, so a real 10° hillside is *drawn* at 69° and a real 30° Sierra
|
||||||
|
* face at 83°. A plan-projected tile on a face at angle θ covers 1/cos θ
|
||||||
|
* of surface along the fall line — 2.8x on that hillside, 8.7x on that
|
||||||
|
* face — so the map's encoded slope describes bumps far shorter than the
|
||||||
|
* ones it is actually being stretched over, and the Sierra escarpment
|
||||||
|
* comes out as a smear of vertical stripes. Multiplying the tangent-space
|
||||||
|
* slope by cos θ restores the geometry exactly: the bumps get longer, so
|
||||||
|
* they get shallower by the same factor. It costs no fetch — the cosine
|
||||||
|
* is a dot product against world up, which `viewMatrix`' second column
|
||||||
|
* already is — and it is why there is no triplanar projection here
|
||||||
|
* tripling the sampler traffic to solve the same problem. The
|
||||||
|
* approximation in it, stated because it is real: the stretch is along
|
||||||
|
* the fall line only and this scales both tangent axes, so the detail is
|
||||||
|
* slightly over-flattened across a steep slope. At the angles the
|
||||||
|
* exaggeration produces that is a rounding error next to the stripes.
|
||||||
|
*
|
||||||
|
* 3. **It fades out once one repeat is worth a couple of pixels**, which is
|
||||||
|
* the sea's `SEA_CALM_NEAR` idea measured rather than inferred. See
|
||||||
|
* `GROUND_DETAIL_NOISE_PX` — that note also records the nine-fold error
|
||||||
|
* the distance form made on a board with a vertical exaggeration on it.
|
||||||
|
*
|
||||||
|
* A patch rather than a `ShaderMaterial`, for the reason `createWater`
|
||||||
|
* gives at length: the lighting this surface has to obey is a sun, a
|
||||||
|
* hemisphere, an ambient, a shadow, a PMREM environment and a fog, all
|
||||||
|
* owned elsewhere and all changing with the hour, and that is exactly what
|
||||||
|
* `MeshStandardMaterial` already implements correctly.
|
||||||
|
*/
|
||||||
|
const uniforms = {
|
||||||
|
/** Repeats of the detail map per scene unit, so the grain is the same
|
||||||
|
* size in metres on a 94 m board and on a 1,919 m one. */
|
||||||
|
uGroundTile: { value: world.metresPerUnit / GROUND_DETAIL_TILE_METRES },
|
||||||
|
/** The fade window, in device pixels of one repeat. See above. */
|
||||||
|
uGroundFade: {
|
||||||
|
value: new THREE.Vector2(GROUND_DETAIL_NOISE_PX, GROUND_DETAIL_LEGIBLE_PX),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
material.onBeforeCompile = (shader) => {
|
||||||
|
Object.assign(shader.uniforms, uniforms);
|
||||||
|
shader.vertexShader = shader.vertexShader
|
||||||
|
.replace(
|
||||||
|
"#include <common>",
|
||||||
|
`#include <common>
|
||||||
|
uniform float uGroundTile;`,
|
||||||
|
)
|
||||||
|
// `uv_vertex` has already written `vNormalMapUv` from the `uv`
|
||||||
|
// attribute, which this geometry does not have — WebGL feeds a constant
|
||||||
|
// for a missing attribute, so every fragment would sample texel 0. This
|
||||||
|
// overwrites it with the plan coordinate, which is the only place the
|
||||||
|
// varying is ever set.
|
||||||
|
.replace(
|
||||||
|
"#include <uv_vertex>",
|
||||||
|
`#include <uv_vertex>
|
||||||
|
vNormalMapUv = position.xz * uGroundTile;`,
|
||||||
|
);
|
||||||
|
shader.fragmentShader = shader.fragmentShader
|
||||||
|
.replace(
|
||||||
|
"#include <common>",
|
||||||
|
`#include <common>
|
||||||
|
uniform vec2 uGroundFade;`,
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
"#include <normal_fragment_maps>",
|
||||||
|
`vec3 mapN = texture2D( normalMap, vNormalMapUv ).xyz * 2.0 - 1.0;
|
||||||
|
// \`viewMatrix\`' second column is world up in view space, and \`normal\` here is
|
||||||
|
// still the interpolated geometric normal — \`normal_fragment_begin\` has run and
|
||||||
|
// \`normal_fragment_maps\` is what this replaces. \`abs\` because the ground is
|
||||||
|
// DoubleSide and a back face arrives already flipped by \`faceDirection\`.
|
||||||
|
float groundFlat = saturate( abs( dot( normal, normalize( mat3( viewMatrix )[1] ) ) ) );
|
||||||
|
// One repeat of the map spans 1.0 in UV, so the reciprocal of the pixel's UV
|
||||||
|
// footprint is the repeat's width in pixels. The geometric mean of the two axes
|
||||||
|
// rather than the larger of them, floored at an eighth of the larger: that is
|
||||||
|
// what \`anisotropy = 8\` actually resolves, and the isotropic maximum would
|
||||||
|
// give the detail up across most of an oblique frame — which is most of a map
|
||||||
|
// pose — while the minimum would keep it on ground stretched to nothing.
|
||||||
|
vec2 groundStep = fwidth( vNormalMapUv );
|
||||||
|
float groundWide = max( groundStep.x, groundStep.y );
|
||||||
|
float groundFoot = max( sqrt( groundStep.x * groundStep.y ), groundWide / 8.0 );
|
||||||
|
float groundSeen = smoothstep( uGroundFade.x, uGroundFade.y, 1.0 / max( groundFoot, 1e-6 ) );
|
||||||
|
mapN.xy *= normalScale * groundFlat * groundSeen;
|
||||||
|
normal = normalize( tbn * mapN );`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/*
|
||||||
|
* `onBeforeCompile` is invisible to three's default key, so a patched
|
||||||
|
* material that does not declare one gets handed somebody else's program.
|
||||||
|
*
|
||||||
|
* The terrain and the shore plate both answer `"tera:ground"` and that is
|
||||||
|
* correct rather than a collision: `WebGLPrograms.getProgramCacheKey`
|
||||||
|
* appends this string to the *whole* parameter list, and `vertexColors`
|
||||||
|
* is in that list — so the two still hash apart, and two boards of the same
|
||||||
|
* kind still share. Set only on the patched branch: an unpatched ground is
|
||||||
|
* an ordinary standard material and must go on sharing three's own key.
|
||||||
|
*/
|
||||||
|
material.customProgramCacheKey = () => "tera:ground";
|
||||||
|
}
|
||||||
|
return material;
|
||||||
|
}
|
||||||
|
|
||||||
/** The smooth flat polygon under each landmass — the crisp coastline. */
|
/** The smooth flat polygon under each landmass — the crisp coastline. */
|
||||||
export function createShorePlates(world: World): THREE.Mesh {
|
export function createShorePlates(world: World, options: TerrainOptions = {}): THREE.Mesh {
|
||||||
const pal = paletteFor(world);
|
const pal = paletteFor(world);
|
||||||
const positions: number[] = [];
|
const positions: number[] = [];
|
||||||
|
|
||||||
@@ -143,9 +580,27 @@ export function createShorePlates(world: World): THREE.Mesh {
|
|||||||
geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
||||||
geo.computeVertexNormals();
|
geo.computeVertexNormals();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same ground material the relief above it wears, in a flat colour.
|
||||||
|
*
|
||||||
|
* It used to be `MeshLambertMaterial({ color: pal.shore })`, and both halves
|
||||||
|
* of `groundMaterial` matter to it. Without the standard class it would get no
|
||||||
|
* `scene.environment` — `WebGLRenderer` forwards that only to
|
||||||
|
* `isMeshStandardMaterial` — and would sit about a fifth of the hemisphere's
|
||||||
|
* diffuse contribution darker than the terrain, all the way around the
|
||||||
|
* coastline: the one place on the board where two materials meet edge to edge
|
||||||
|
* in the same colour, where a step reads as a seam rather than as a different
|
||||||
|
* object. Without the detail map it would be *smooth* against a grained
|
||||||
|
* terrain, and that was photographed at San Francisco — the grid's
|
||||||
|
* stair-stepped rim, which this plate exists to hide, came back as a legible
|
||||||
|
* zigzag between a textured surface and a flat one. The plate is flat at y=0,
|
||||||
|
* so the plan projection the map is applied through is exact on it.
|
||||||
|
*
|
||||||
|
* One mesh, one material, one draw call, exactly as before.
|
||||||
|
*/
|
||||||
const mesh = new THREE.Mesh(
|
const mesh = new THREE.Mesh(
|
||||||
geo,
|
geo,
|
||||||
new THREE.MeshLambertMaterial({ color: pal.shore, side: THREE.DoubleSide }),
|
groundMaterial(world, options.detail ?? groundDetailWanted(), { color: pal.shore }),
|
||||||
);
|
);
|
||||||
mesh.receiveShadow = true;
|
mesh.receiveShadow = true;
|
||||||
/**
|
/**
|
||||||
@@ -454,7 +909,18 @@ function lodPatches(
|
|||||||
* the chunking below for why the split exists and why the caster is not part
|
* the chunking below for why the split exists and why the caster is not part
|
||||||
* of it.
|
* of it.
|
||||||
*/
|
*/
|
||||||
export function createTerrain(world: World): THREE.Group {
|
export interface TerrainOptions {
|
||||||
|
/**
|
||||||
|
* Whether the ground carries its detail normal map.
|
||||||
|
*
|
||||||
|
* Defaults to `groundDetailWanted()`, which is "not on a phone". Passed
|
||||||
|
* explicitly by the tests, which want the map on a board that has no
|
||||||
|
* `window` to profile and want to be able to ask for it off.
|
||||||
|
*/
|
||||||
|
readonly detail?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTerrain(world: World, options: TerrainOptions = {}): THREE.Group {
|
||||||
const pal = paletteFor(world);
|
const pal = paletteFor(world);
|
||||||
// Which cells are drawn, and how big, is `lodPatches`' answer; this function
|
// Which cells are drawn, and how big, is `lodPatches`' answer; this function
|
||||||
// only turns a corner into a vertex.
|
// only turns a corner into a vertex.
|
||||||
@@ -594,27 +1060,48 @@ export function createTerrain(world: World): THREE.Group {
|
|||||||
normalAttribute = new THREE.BufferAttribute(grown, 3);
|
normalAttribute = new THREE.BufferAttribute(grown, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
const material = new THREE.MeshLambertMaterial({ vertexColors: true, side: THREE.DoubleSide });
|
|
||||||
/**
|
/**
|
||||||
* `shadowSide = BackSide` is the acne cure, and it is exactly right for a
|
* Standard rather than Lambert, and the land had the better half of the
|
||||||
* heightfield.
|
* argument `createWater` already won.
|
||||||
*
|
*
|
||||||
* The depth pass then culls every face turned *toward* the sun — precisely
|
* The sea below this is a `MeshStandardMaterial` with a swell map on it; the
|
||||||
* the set of faces that were shadowing themselves — and keeps the faces
|
* state beside it was `MeshLambertMaterial` with a vertex colour and **no
|
||||||
* turned away from it. The boundary between the two is the terminator, so the
|
* maps of any kind** — no albedo, no normal, no roughness — which is most of
|
||||||
* depth recorded along a ridge starts at the crest and runs down its far
|
* why the board read as a painted relief model with a real ocean next to it.
|
||||||
* slope, and the valley floor beyond, which is still front-facing and
|
|
||||||
* therefore writes nothing, tests against it and lands in shadow. A lit slope
|
|
||||||
* has nothing in the map above it and cannot stipple.
|
|
||||||
*
|
*
|
||||||
* Without it, a constant `shadow.bias` has to cover a depth-per-texel that
|
* Two things change, and only one of them is the texture.
|
||||||
* grows as 1/tan(elevation) — one texel is 0.21 scene units on California and
|
*
|
||||||
* 0.73 on the Bay Area — and there is no single value that is free of acne at
|
* **The environment.** `environmentRig.ts` builds a PMREM of the sky the
|
||||||
* 40° and free of peter-panning at 8°. That is why this was left off through
|
* atmosphere decided on — a sun lobe at radiance 1.2, `SKY_RADIANCE_SHARE`
|
||||||
* the previous round, and it is checked here by photographing the boards at
|
* 0.12 of the dome, `GROUND_RADIANCE_SHARE` 0.09 bouncing back — and
|
||||||
* a sun of 12° and of 2°.
|
* `scene.ts` applies it to every city scene. `WebGLRenderer` then forwards
|
||||||
|
* `scene.environment` to `material.isMeshStandardMaterial` **and to nothing
|
||||||
|
* else** (`WebGLRenderer.js`, twice, at the `materialProperties.environment`
|
||||||
|
* assignment). So until this line the whole sky probe was being delivered to
|
||||||
|
* the water, the guardrails, the ports and the vessels, and not one photon of
|
||||||
|
* it reached the land. That is not a subtle loss: `environmentRig.ts` records
|
||||||
|
* that the day stops in `atmosphere.ts` gave up about a tenth of
|
||||||
|
* `hemiIntensity` and a quarter of `ambientIntensity` to make room for the
|
||||||
|
* environment, so the ground has been paying the counterweight for a term it
|
||||||
|
* could not receive. It receives it now, for no second pass, no new uniform
|
||||||
|
* and no new draw call.
|
||||||
|
*
|
||||||
|
* **The relief.** `GROUND_DETAIL_TILE_METRES` explains the repeat, and
|
||||||
|
* `groundMaterial` explains the three shader edits it needs. `lodPatches` collapses flat ground
|
||||||
|
* into patches up to eight 300 m cells across, so at a close pose the state is
|
||||||
|
* kilometre-wide triangles with one normal each, and a tiling detail normal is
|
||||||
|
* the cheapest thing in computer graphics that hides that.
|
||||||
|
*
|
||||||
|
* **What does not change: the draw call count, which has 22 spare of 460.**
|
||||||
|
* This is the same one material object handed to all 17 chunk meshes and to
|
||||||
|
* the caster, so it is the same 18 objects, the same one program switch and
|
||||||
|
* the same one vertex buffer — `terrainLod.test.ts` asserts every part of
|
||||||
|
* that. A texture is not a draw call. A second mesh, a decal layer or a
|
||||||
|
* second pass would have been, which is why none of those is here.
|
||||||
*/
|
*/
|
||||||
material.shadowSide = THREE.BackSide;
|
const material = groundMaterial(world, options.detail ?? groundDetailWanted(), {
|
||||||
|
vertexColors: true,
|
||||||
|
});
|
||||||
|
|
||||||
const group = new THREE.Group();
|
const group = new THREE.Group();
|
||||||
group.name = "terrain";
|
group.name = "terrain";
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ import assert from "node:assert/strict";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
|
||||||
import { createShorePlates, createTerrain, createWater, swellNormalData } from "../../engine/terrain.ts";
|
import {
|
||||||
|
createShorePlates,
|
||||||
|
createTerrain,
|
||||||
|
createWater,
|
||||||
|
groundDetailData,
|
||||||
|
swellNormalData,
|
||||||
|
} from "../../engine/terrain.ts";
|
||||||
import type { City } from "../../engine/types.ts";
|
import type { City } from "../../engine/types.ts";
|
||||||
import { World } from "../../engine/world.ts";
|
import { World } from "../../engine/world.ts";
|
||||||
|
|
||||||
@@ -204,7 +210,13 @@ test("the relief casts, and from a decimated copy of itself", async () => {
|
|||||||
const terrain = createTerrain(world).children[0] as THREE.Mesh;
|
const terrain = createTerrain(world).children[0] as THREE.Mesh;
|
||||||
assert.equal(terrain.castShadow, true, "the hills shadow nothing again");
|
assert.equal(terrain.castShadow, true, "the hills shadow nothing again");
|
||||||
assert.equal(terrain.receiveShadow, true);
|
assert.equal(terrain.receiveShadow, true);
|
||||||
const material = terrain.material as THREE.MeshLambertMaterial;
|
// Standard since the land got a surface response; the cast is stated because
|
||||||
|
// the previous one said `MeshLambertMaterial` and a compile-time cast is
|
||||||
|
// exactly the kind of assertion that goes on passing after it stops being
|
||||||
|
// true. `shadowSide` is unchanged by that move and has to be: the depth pass
|
||||||
|
// swaps in its own `MeshDepthMaterial` and copies this flag across, so it is
|
||||||
|
// the only thing about this material the shadow map ever reads.
|
||||||
|
const material = terrain.material as THREE.MeshStandardMaterial;
|
||||||
// The acne cure. Without it a constant bias has to cover a depth-per-texel
|
// The acne cure. Without it a constant bias has to cover a depth-per-texel
|
||||||
// that grows as 1/tan(sun elevation), and no single value is free of acne at
|
// that grows as 1/tan(sun elevation), and no single value is free of acne at
|
||||||
// a high sun and free of peter-panning at a low one.
|
// a high sun and free of peter-panning at a low one.
|
||||||
@@ -263,3 +275,292 @@ test("the shore plate receives and does not cast", async () => {
|
|||||||
// whole coastline out across the water as a hard slab.
|
// whole coastline out across the water as a hard slab.
|
||||||
assert.equal(plate.castShadow, false);
|
assert.equal(plate.castShadow, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the shore plate wears the same surface as the ground it abuts", async () => {
|
||||||
|
const world = await board();
|
||||||
|
const plate = createShorePlates(world, { detail: true }).material as THREE.MeshStandardMaterial;
|
||||||
|
const ground = (createTerrain(world, { detail: true }).children[0] as THREE.Mesh)
|
||||||
|
.material as THREE.MeshStandardMaterial;
|
||||||
|
/*
|
||||||
|
* Two material objects — the plate is a flat `pal.shore` and the terrain is
|
||||||
|
* `vertexColors`, which three compiles to different programs whatever this
|
||||||
|
* file does — but every term that decides how they *light* has to agree,
|
||||||
|
* because the coastline is the one place on the board where two materials
|
||||||
|
* meet edge to edge in the same colour. A step there reads as a seam.
|
||||||
|
*
|
||||||
|
* Both halves were got wrong in turn and both were caught by a photograph.
|
||||||
|
* Lambert plate against a standard ground: `WebGLRenderer` forwards
|
||||||
|
* `scene.environment` only to `isMeshStandardMaterial`, so the rim sat about a
|
||||||
|
* fifth of the hemisphere's diffuse contribution darker than the ground.
|
||||||
|
* Mapless plate against a grained ground: the terrain grid's stair-stepped rim
|
||||||
|
* — which the plate exists to hide — came back as a legible zigzag between a
|
||||||
|
* textured surface and a smooth one at San Francisco.
|
||||||
|
*/
|
||||||
|
assert.ok(plate.isMeshStandardMaterial, "the coastline is lit by different terms again");
|
||||||
|
assert.equal(plate.roughness, ground.roughness);
|
||||||
|
assert.equal(plate.metalness, ground.metalness);
|
||||||
|
assert.ok(plate.normalMap, "the plate is smooth against grained ground, so the rim is legible");
|
||||||
|
assert.equal(plate.shadowSide, ground.shadowSide);
|
||||||
|
// Not the same *object*: it carries a colour where the ground carries a ramp.
|
||||||
|
assert.notEqual(plate, ground);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- The land's surface ----------------------------------------------------
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Everything below is the land's half of the argument the sea won further up
|
||||||
|
* this file. The ground was `MeshLambertMaterial` with a vertex colour and no
|
||||||
|
* maps of any kind, next to a sea with a swell map on it — and, less visibly,
|
||||||
|
* `WebGLRenderer` forwards `scene.environment` only to `isMeshStandardMaterial`,
|
||||||
|
* so the whole PMREM sky `environmentRig.ts` builds was reaching the water and
|
||||||
|
* not the state. None of that was visible to the type checker, and the two
|
||||||
|
* assertions above it that were about the terrain's material both went through
|
||||||
|
* a compile-time cast that a class change passes straight through.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test("the land catches the sky, which a Lambert card cannot", async () => {
|
||||||
|
const world = await board();
|
||||||
|
const terrain = createTerrain(world).children[0] as THREE.Mesh;
|
||||||
|
const material = terrain.material as THREE.MeshStandardMaterial;
|
||||||
|
/*
|
||||||
|
* `WebGLRenderer.js` reads
|
||||||
|
* `materialProperties.environment = material.isMeshStandardMaterial ? scene.environment : null`
|
||||||
|
* in both of the places it decides a program's lights — so this flag is
|
||||||
|
* literally the switch that lets the sky reach the ground. `scene.ts` applies
|
||||||
|
* the rig to every city scene already; the land was the one large surface in
|
||||||
|
* the frame not receiving it.
|
||||||
|
*/
|
||||||
|
assert.ok(material.isMeshStandardMaterial, "the state went back to being unlit paint");
|
||||||
|
assert.equal(material.metalness, 0, "a metallic ground has no diffuse term at all");
|
||||||
|
// Ground, not water: the sea is under 0.5 so the sun has a path across it,
|
||||||
|
// and the land must be well above that or it glints like wet tarmac at every
|
||||||
|
// hour. The upper bound is what stops someone "simplifying" this to 1.0,
|
||||||
|
// which switches the environment's specular lobe off again.
|
||||||
|
assert.ok(
|
||||||
|
material.roughness > 0.7 && material.roughness < 1,
|
||||||
|
`the land is at roughness ${material.roughness}`,
|
||||||
|
);
|
||||||
|
assert.equal(material.vertexColors, true, "`groundColor`'s ramp is the board's palette");
|
||||||
|
assert.equal(material.shadowSide, THREE.BackSide, "the acne cure did not survive the class change");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the ground's detail relief is on by default and can be switched off whole", async () => {
|
||||||
|
const world = await board();
|
||||||
|
const on = createTerrain(world, { detail: true }).children[0] as THREE.Mesh;
|
||||||
|
const off = createTerrain(world, { detail: false }).children[0] as THREE.Mesh;
|
||||||
|
const lit = on.material as THREE.MeshStandardMaterial;
|
||||||
|
const flat = off.material as THREE.MeshStandardMaterial;
|
||||||
|
|
||||||
|
assert.ok(lit.normalMap, "the ground lost the map that breaks up its 300 m facets");
|
||||||
|
assert.equal(lit.normalMap.wrapS, THREE.RepeatWrapping, "one tile of ground, then a seam");
|
||||||
|
assert.equal(lit.normalMap.wrapT, THREE.RepeatWrapping);
|
||||||
|
assert.equal(lit.normalMap.generateMipmaps, true);
|
||||||
|
// The mip chain of a normal map converges on flat, which is what stops the
|
||||||
|
// grain crawling in the distance without anything having to decide when.
|
||||||
|
assert.equal(lit.normalMap.minFilter, THREE.LinearMipmapLinearFilter);
|
||||||
|
assert.ok(lit.normalMap.anisotropy >= 8, "the ground is grazing across most of a map pose");
|
||||||
|
|
||||||
|
/*
|
||||||
|
* "Off" is *no map*, the way `MaterialQuality`'s `low` rung is no map — not a
|
||||||
|
* smaller one. The cost a handheld is being spared is per fragment: one
|
||||||
|
* texture fetch and `getTangentFrame`'s three derivative pairs on 1.3 Mpx of
|
||||||
|
* them. A quarter-size map costs exactly the same fetch.
|
||||||
|
*/
|
||||||
|
assert.equal(flat.normalMap, null, "a handheld is still paying for the map it cannot see");
|
||||||
|
// And it keeps the half that reads at every stand-off.
|
||||||
|
assert.ok(flat.isMeshStandardMaterial, "the phone lost the sky as well as the grain");
|
||||||
|
assert.equal(flat.shadowSide, THREE.BackSide);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the detail UV is built from the plan, because the ground has no uv attribute", async () => {
|
||||||
|
const world = await board();
|
||||||
|
const terrain = createTerrain(world, { detail: true }).children[0] as THREE.Mesh;
|
||||||
|
/*
|
||||||
|
* The trap this exists for. `vNormalMapUv` is written by three's `uv_vertex`
|
||||||
|
* from the `uv` attribute, WebGL feeds a *constant* for an attribute the
|
||||||
|
* geometry does not supply, and the result is a ground where every fragment
|
||||||
|
* samples texel 0 — a map that costs its fetch and returns one value, with no
|
||||||
|
* error anywhere and nothing to see in a picture but a surface that is
|
||||||
|
* somehow still flat.
|
||||||
|
*/
|
||||||
|
assert.equal(terrain.geometry.getAttribute("uv"), undefined, "then the patch below is dead code");
|
||||||
|
|
||||||
|
const material = terrain.material as THREE.MeshStandardMaterial;
|
||||||
|
assert.equal(
|
||||||
|
material.customProgramCacheKey?.(),
|
||||||
|
"tera:ground",
|
||||||
|
"a patched program sharing three's default key gets someone else's shader",
|
||||||
|
);
|
||||||
|
|
||||||
|
// three's own chunk names, so a rename in an upgrade breaks this rather than
|
||||||
|
// silently dropping an edit — `String.replace` on a missing needle is a no-op.
|
||||||
|
const shader = {
|
||||||
|
uniforms: {} as Record<string, { value: unknown }>,
|
||||||
|
vertexShader: "#include <common>\nvoid main() {\n#include <uv_vertex>\n}",
|
||||||
|
fragmentShader: "#include <common>\nvoid main() {\n#include <normal_fragment_maps>\n}",
|
||||||
|
};
|
||||||
|
material.onBeforeCompile(shader as never, null as never);
|
||||||
|
|
||||||
|
assert.match(shader.vertexShader, /vNormalMapUv = position\.xz \* uGroundTile;/);
|
||||||
|
assert.match(shader.vertexShader, /uniform float uGroundTile;/);
|
||||||
|
// The cosine of the *drawn* slope. `verticalExaggeration` draws a real 10°
|
||||||
|
// hillside at 69° and a real 30° Sierra face at 83°, and a plan-projected
|
||||||
|
// tile on a face at angle θ is stretched 1/cos θ along the fall line — 2.8x
|
||||||
|
// and 8.7x. Scaling the tangent-space slope by cos θ is what turns that back
|
||||||
|
// into the same bumps rather than a smear of vertical stripes, and it is the
|
||||||
|
// reason there is no triplanar projection here costing three fetches.
|
||||||
|
assert.match(shader.fragmentShader, /mapN\.xy \*= normalScale \* groundFlat \* groundSeen;/);
|
||||||
|
// `fwidth` of the detail UV, and not a view distance. The distance form was
|
||||||
|
// written first and was wrong by a factor of nine on this board: a camera
|
||||||
|
// lifted to 0.6 of its stand-off sits 9.06 stand-offs away in scene units once
|
||||||
|
// `verticalExaggeration` has multiplied the lift, so a window stated in real
|
||||||
|
// metres closed at a ninth of the stand-off it named and the map was switched
|
||||||
|
// off everywhere a viewer could go. It was caught by a photograph, not by a
|
||||||
|
// test, which is why there is now a test.
|
||||||
|
assert.match(shader.fragmentShader, /fwidth\( vNormalMapUv \)/);
|
||||||
|
assert.match(shader.fragmentShader, /normal = normalize\( tbn \* mapN \);/);
|
||||||
|
|
||||||
|
const tile = shader.uniforms.uGroundTile?.value as number;
|
||||||
|
// Repeats per scene unit. One repeat is `GROUND_DETAIL_TILE_METRES` of real
|
||||||
|
// ground on every board, which is what keeps the grain the same physical size
|
||||||
|
// on a 94 m board and a 1,919 m one. 440 rather than 220, and the difference
|
||||||
|
// was a photograph: at 220 `tileableNoise`'s own wrapped cross-fade was the
|
||||||
|
// biggest feature in the tile, and the Central Valley came out as a regular
|
||||||
|
// grid of four-pointed stars, one per repeat.
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(tile - world.metresPerUnit / 440) < 1e-9,
|
||||||
|
`one repeat covers ${world.metresPerUnit / tile} m, not 440`,
|
||||||
|
);
|
||||||
|
const fade = shader.uniforms.uGroundFade?.value as THREE.Vector2;
|
||||||
|
/*
|
||||||
|
* Device pixels of one repeat, not scene units — so the window means the same
|
||||||
|
* thing on a 94 m board and a 1,919 m one, at any field of view, at any
|
||||||
|
* viewport, at any device pixel ratio and under any vertical exaggeration. It
|
||||||
|
* is off under 2 px, where the mip chain has already flattened the map, and
|
||||||
|
* full over 8 px, where it is legible. The board's own budget pose is 400 km
|
||||||
|
* out, where a 440 m repeat is 1.3 px: the fetch has to be worth nothing
|
||||||
|
* there.
|
||||||
|
*/
|
||||||
|
assert.ok(fade.x >= 1 && fade.x < fade.y && fade.y <= 16, `the fade window is ${fade.x}..${fade.y} px`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- The ground's detail map ------------------------------------------------
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The same six properties `textureMaps.test.ts` holds the office's relief maps
|
||||||
|
* to, applied to the one the land carries: it is arithmetic rather than a
|
||||||
|
* canvas, so it exists identically here and in a browser and every one of them
|
||||||
|
* is checkable under `node --test`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test("the ground detail map is the same land on every reload, and is not flat", () => {
|
||||||
|
const a = groundDetailData(64);
|
||||||
|
const b = groundDetailData(64);
|
||||||
|
assert.deepEqual(a, b, "two boards would show two different Californias");
|
||||||
|
let peak = 0;
|
||||||
|
for (let i = 0; i < a.length; i += 4) {
|
||||||
|
peak = Math.max(peak, Math.abs((a[i] as number) - 128), Math.abs((a[i + 1] as number) - 128));
|
||||||
|
}
|
||||||
|
// `textureMaps.test.ts` puts it best: a flat normal map is a wasted texture
|
||||||
|
// unit. 8/128 is its threshold and this is the same one.
|
||||||
|
assert.ok(peak >= 8, `the ground's relief peaks at ${peak}/128, which is a flat card`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every texel of the ground detail map decodes to a unit normal facing up", () => {
|
||||||
|
const size = 64;
|
||||||
|
const data = groundDetailData(size);
|
||||||
|
let worst = 0;
|
||||||
|
let lowest = 1;
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
const n = normalAt(data, size, x, y);
|
||||||
|
worst = Math.max(worst, Math.abs(n.length() - 1));
|
||||||
|
lowest = Math.min(lowest, n.z);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(worst < 0.02, `a texel decoded to a normal of length ${1 + worst}`);
|
||||||
|
// A finish on a heightfield, not an overhang. `z` is the surface's own axis
|
||||||
|
// and ground grain that leans past about 45° stops being grain and starts
|
||||||
|
// competing with the geography the mesh is carrying.
|
||||||
|
assert.ok(lowest > 0.6, `a texel leaned to z=${lowest}, which is a cliff, not a field`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the ground detail map tiles: the wrap is no sharper than the interior", () => {
|
||||||
|
const size = 64;
|
||||||
|
const data = groundDetailData(size);
|
||||||
|
let interior = 0;
|
||||||
|
let seam = 0;
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
for (let x = 1; x < size - 1; x++) {
|
||||||
|
interior = Math.max(interior, normalAt(data, size, x, y).distanceTo(normalAt(data, size, x + 1, y)));
|
||||||
|
}
|
||||||
|
seam = Math.max(seam, normalAt(data, size, size - 1, y).distanceTo(normalAt(data, size, 0, y)));
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* California is 1,063 km across and this tile is 440 m, so the map is laid
|
||||||
|
* down about two and a half thousand times along one edge of the board. A derivative
|
||||||
|
* that does not wrap paints a grid over the entire state — which is the exact
|
||||||
|
* failure `tileableNoise`'s four-way blend exists to prevent, reused here
|
||||||
|
* rather than reimplemented.
|
||||||
|
*/
|
||||||
|
assert.ok(
|
||||||
|
seam <= interior * 1.5,
|
||||||
|
`the wrap steps by ${seam} against an interior maximum of ${interior}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the ground detail map lights the same at 128 as at 256", () => {
|
||||||
|
/*
|
||||||
|
* `normalMapData` converts gradient to slope — `x size` puts it per unit UV,
|
||||||
|
* `/ tileMetres` puts it per metre — and that is the only reason a resolution
|
||||||
|
* can be chosen for memory rather than for looks. Drop the division and the
|
||||||
|
* map gets twice as steep every time the size doubles, silently.
|
||||||
|
*/
|
||||||
|
const coarse = groundDetailData(128);
|
||||||
|
const fine = groundDetailData(256);
|
||||||
|
const slope = (data: Uint8Array, size: number): number => {
|
||||||
|
let sum = 0;
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
const n = normalAt(data, size, x, y);
|
||||||
|
sum += Math.hypot(n.x, n.y) / n.z;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sum / (size * size);
|
||||||
|
};
|
||||||
|
const a = slope(coarse, 128);
|
||||||
|
const b = slope(fine, 256);
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(a - b) / a < 0.12,
|
||||||
|
`mean slope is ${a} at 128 and ${b} at 256: the per-metre conversion is gone`,
|
||||||
|
);
|
||||||
|
// And it is a real slope rather than a rounding error: about 9.9° mean on the
|
||||||
|
// shipped field, deliberately far under the board's own 15x exaggeration so
|
||||||
|
// the grain does not out-shout the landforms.
|
||||||
|
assert.ok(a > 0.05 && a < 0.4, `the ground's mean slope is ${Math.atan(a) * 57.3}°`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the ground grain runs in every direction rather than one", () => {
|
||||||
|
/*
|
||||||
|
* The swell's failure, restated for the land: a field whose slope is carried
|
||||||
|
* by one heading reads as corduroy, and on ground it reads as ploughing —
|
||||||
|
* which would be a very confident claim to make about the entire state.
|
||||||
|
*/
|
||||||
|
const size = 128;
|
||||||
|
const data = groundDetailData(size);
|
||||||
|
const buckets = new Array(12).fill(0) as number[];
|
||||||
|
let total = 0;
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
const n = normalAt(data, size, x, y);
|
||||||
|
const slope = Math.hypot(n.x, n.y);
|
||||||
|
if (slope < 1e-4) continue;
|
||||||
|
const angle = (Math.atan2(n.y, n.x) + Math.PI * 2) % Math.PI;
|
||||||
|
const at = Math.min(11, Math.floor((angle / Math.PI) * 12));
|
||||||
|
buckets[at] = (buckets[at] as number) + slope;
|
||||||
|
total += slope;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const dominant = Math.max(...buckets) / total;
|
||||||
|
assert.ok(dominant < 0.25, `${Math.round(dominant * 100)}% of the grain runs one way`);
|
||||||
|
});
|
||||||
|
|||||||
@@ -491,8 +491,20 @@ test("the chunks and the caster share one vertex buffer and one material", async
|
|||||||
|
|
||||||
const materials = new Set(group.children.map((c) => (c as THREE.Mesh).material));
|
const materials = new Set(group.children.map((c) => (c as THREE.Mesh).material));
|
||||||
assert.equal(materials.size, 1, "one material, or the split costs a program switch per chunk");
|
assert.equal(materials.size, 1, "one material, or the split costs a program switch per chunk");
|
||||||
const material = caster.material as THREE.MeshLambertMaterial;
|
// `MeshStandardMaterial` since the ground gained a surface response, and the
|
||||||
|
// cast is restated rather than left saying Lambert: the one shared material
|
||||||
|
// is now a patched standard one, and the point of this assertion is that all
|
||||||
|
// eighteen objects still hold the *same* one.
|
||||||
|
const material = caster.material as THREE.MeshStandardMaterial;
|
||||||
assert.equal(material.shadowSide, THREE.BackSide, "the acne cure did not survive the split");
|
assert.equal(material.shadowSide, THREE.BackSide, "the acne cure did not survive the split");
|
||||||
|
/*
|
||||||
|
* The detail map is on the shared material or it is on nothing. A per-chunk
|
||||||
|
* material would be seventeen program switches, and a caster with a cheaper
|
||||||
|
* material would be two — the `materials.size` assertion above is what
|
||||||
|
* forbids both, and this is the thing that would have tempted someone to
|
||||||
|
* break it.
|
||||||
|
*/
|
||||||
|
assert.ok(material.normalMap, "the shared ground material lost its detail relief");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the chunks are culled and the caster deliberately is not", async () => {
|
test("the chunks are culled and the caster deliberately is not", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user