feat: the land is a surface, not paint — standard material and a 220 m detail relief
The ground was `MeshLambertMaterial` with a vertex colour and no maps of any
kind, next to a sea that has been a `MeshStandardMaterial` with a swell map on
it since `createWater` was written. Two things came out of that gap.
The louder one is invisible in the code and obvious in a picture: three forwards
`scene.environment` only to `isMeshStandardMaterial`, so the whole PMREM sky
`environmentRig.ts` builds — sun lobe, `SKY_RADIANCE_SHARE`, the ground bounce —
was reaching the water, the ports and the vessels and not one photon of it was
reaching the state. The land has been paying the counterweight for it the whole
time: the day stops gave up about a tenth of `hemiIntensity` and a quarter of
`ambientIntensity` to make room for an environment term the ground could not
receive.
The quieter one is the facets. `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 nothing on them.
So: one `MeshStandardMaterial` at roughness 0.92, metalness 0, carrying a tiling
detail normal map built from `textures.ts`' own `tileableNoise` / `sampleField` /
`normalMapData` at a **220 m** repeat rather than the office's 2 m — which is
sub-pixel at every stand-off this board can reach, orbit floor included. 220 m is
legible from about 45 km down and mip-flat above about 110 km, and it is not
300 m because a tile the size of a terrain cell paints the same pattern on every
cell and lands its repeat on the facet edges it exists to hide.
Zero new draw calls, and that was the constraint: same 18 objects, same one
shared material, same one attribute triple, same one program switch.
`cost-at.mjs` reads 355,763 / 437 draws at the whole-board pose, unchanged.
Three shader edits, none of which the material has a dial for:
1. The detail UV is `position.xz`, in the vertex shader. A real `uv` attribute
would be 369 KB against the ground's current 1.66 MB and would have to be
added as one object shared by all 18 geometries or the arrangement quietly
duplicates it seventeen times. The plan projection is also the natural
parameterisation of a heightfield.
2. The tangent-space slope is scaled by the cosine of the *drawn* slope.
`verticalExaggeration` is 15 here, so a real 10° hillside draws at 69° and a
real 30° Sierra face at 83°, and a plan-projected tile on a face at θ is
stretched 1/cos θ along the fall line — 2.8x and 8.7x. Scaling by cos θ
restores the same bumps instead of a smear of vertical stripes, for a dot
product against world up rather than the three fetches triplanar would cost.
3. It fades out between 25 km and 70 km of view distance, as the sea's does at
`SEA_CALM_NEAR`. That matters more here than for the water: the board's own
budget pose is 400 km out, where a 220 m tile is 0.6 device pixels.
The shore plate goes standard with it, mapless. It is the one place on the board
where two materials meet edge to edge in the same colour, and a Lambert plate
against a standard ground would have put a step of about a fifth of the
hemisphere's diffuse contribution around every coastline in the state.
A handheld gets the material and the environment and not the map — "off" is *no*
map, the way `MaterialQuality`'s `low` rung is, because the cost is one fetch and
`getTangentFrame`'s three derivative pairs per fragment and a smaller map costs
exactly the same fetch.
`tileableNoise`, `sampleField` and `normalMapData` are exported from
`textures.ts` rather than reimplemented, and `normalMapData` takes the tile size
it converts against instead of reading the office's constant. Same reason that
file takes `fbm` from `engine/world.ts`: a second noise is a second thing to keep
tiling, and a second Sobel is a second place to get the flipY reasoning wrong.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,13 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
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 { 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;
|
||||
assert.equal(terrain.castShadow, true, "the hills shadow nothing again");
|
||||
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
|
||||
// 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.
|
||||
@@ -263,3 +275,250 @@ test("the shore plate receives and does not cast", async () => {
|
||||
// whole coastline out across the water as a hard slab.
|
||||
assert.equal(plate.castShadow, false);
|
||||
});
|
||||
|
||||
// ---- 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 \* \( 1\.0 - groundFar \);/);
|
||||
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.
|
||||
assert.ok(
|
||||
Math.abs(tile - world.metresPerUnit / 220) < 1e-9,
|
||||
`one repeat covers ${world.metresPerUnit / tile} m, not 220`,
|
||||
);
|
||||
const fade = shader.uniforms.uGroundFade?.value as THREE.Vector2;
|
||||
assert.ok(fade.x > 0 && fade.y > fade.x, "the detail never fades, so it aliases at the horizon");
|
||||
// Stated in metres of view distance and converted here, so the fade means the
|
||||
// same thing on every board. The board's own budget pose is 400 km out, where
|
||||
// a 220 m tile is 0.6 device pixels — the fetch has to be worth nothing there.
|
||||
assert.ok(
|
||||
Math.abs(fade.y * world.metresPerUnit - 70_000) < 1,
|
||||
`the detail survives to ${Math.round(fade.y * world.metresPerUnit)} m`,
|
||||
);
|
||||
});
|
||||
|
||||
// ---- 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 220 m, so the map is laid
|
||||
* down about five 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 8.5° 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));
|
||||
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");
|
||||
/*
|
||||
* 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 () => {
|
||||
|
||||
Reference in New Issue
Block a user