fix: the ground's detail was switched off everywhere, and the map was a grid of stars
Three defects in the round before this one, all of them found by rendering the board and looking at it, none of them by a test that existed. **1. The fade was wrong by a factor of nine, so the map did nothing.** It copied `SEA_CALM_NEAR`'s form: `length( vViewPosition )` against a window converted from real metres by `metresPerUnit`. That conversion assumes the camera's distance is a plan measurement. 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 — so a window written as "25 km to 70 km" closed at about 2.8 km of stand-off and the detail was off everywhere a viewer can actually go. Proved rather than guessed: a fivefold amplitude produced a byte-identical PNG of the Central Valley. It now measures the quantity instead of inferring 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 board scale, exaggeration, field of view, viewport and device pixel ratio, all five of which the old form was guessing at. Off under 2 px, full over 8: the same thresholds the stand-off study arrived at, without any of its assumptions. The geometric mean of the two axes floored at an eighth of the larger, because that is what `anisotropy = 8` resolves. **2. The repeat went 220 m -> 440 m, and the recipe with it.** With the map finally visible, the flat valley came out as a regular grid of four-pointed stars, one per repeat, marching to the horizon. The cause is `tileableNoise` at a `scale` of 2: below about 4, the blend's own wrapped cross-fade is the largest feature in the tile. The layers now run 8 / 22 / 60 on a 128 lattice at 512², so a repeat holds eight or so features instead of one and nothing in it is identifiable. Mean slope 9.9°, peak 39.4°. **3. The shore plate needed the map, not just the material.** 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 at San Francisco it came back as a zigzag. Both now come out of one `groundMaterial`, which is two material objects (the plate is a flat colour, the terrain a vertex ramp, and three compiles those to different programs regardless) and the same one material per mesh as before. The bytes of the detail map are built once and shared; the `DataTexture`s are not, because `scene.ts` disposes a board by traversing it. Zero new draw calls, unchanged: 437 at the whole-board pose, 348 at 7.7 km over SF, 389 at 7.7 km over LA. Triangles unchanged to within the drift of the moving vessels and aircraft. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -276,6 +276,35 @@ test("the shore plate receives and does not cast", async () => {
|
||||
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 ----------------------------------------------------
|
||||
|
||||
/*
|
||||
@@ -380,26 +409,39 @@ test("the detail UV is built from the plan, because the ground has no uv attribu
|
||||
// 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, /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.
|
||||
// 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 / 220) < 1e-9,
|
||||
`one repeat covers ${world.metresPerUnit / tile} m, not 220`,
|
||||
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;
|
||||
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`,
|
||||
);
|
||||
/*
|
||||
* 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 ------------------------------------------------
|
||||
@@ -455,8 +497,8 @@ test("the ground detail map tiles: the wrap is no sharper than the interior", ()
|
||||
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
|
||||
* 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.
|
||||
@@ -492,7 +534,7 @@ test("the ground detail map lights the same at 128 as at 256", () => {
|
||||
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
|
||||
// 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}°`);
|
||||
|
||||
Reference in New Issue
Block a user