1
0

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:
Claude
2026-08-24 19:49:15 -07:00
parent 1049f942b8
commit 0d7eb28bb5
4 changed files with 707 additions and 12 deletions
+13 -1
View File
@@ -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 () => {