lawn: the biggest surface in the game gets grain, and dispose() stops leaking maps

The ground plane is 25-50% of frame and was one flat colour, which is
also why the sail's shadow read as a blob rather than a shape. New
build_lawn_texture(): a 512 seamless luminance multiplier (blade grain,
thatch mottle, anti-repeat blotches, 4 cycles of mower stripe, yellow-
shifted dry patches) that modulates COLORS.grass instead of replacing
it. Seam asserted against the interior-max control (14x margin) and
byte-identical across two runs.

world.dispose() freed geometry and material but not material.map — the
exact leak main.js:705 documents for the sail weave, harmless until the
lawn got a map, and a seven-night week rebuilds the world per site.
Closed.

a.test pins the wiring end to end: map present, lawn_grass, sRGB,
RepeatWrapping, and repeat DERIVED from the yard's size (a literal would
pass on any yard). Mutation-checked: hide the PNG and it reds with 'the
grass texture is an orphan again'. 524/0/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-28 21:42:29 +10:00
parent 9ef0941ac4
commit 47d4f3d3af
5 changed files with 251 additions and 1 deletions

View File

@ -9546,3 +9546,60 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
arrive 10× finer on a paling's edge than its face. **UV-unwrapping the factory is its own
gate** — it must land with a texel-density assert, or the next lane ships stretched wood and
calls it detail.
[E+A] 2026-07-28 — 🌱 **THE LAWN HAS GRAIN — the biggest surface in the game stops being paint,
and the disposal hole that would have leaked it is closed in the same commit.**
Selftest **524/0/0**. Both new asserts mutation-checked red-then-green.
**WHY THE LAWN AND NOT SOMETHING PRETTIER.** Measured across five camera poses, the ground
plane is **2550% of frame** — more than twice any other surface — and it is what the sail's
shadow falls on. sail.js says "the shadow IS the product"; a shadow reads as a SHAPE only if
the thing it lands on has something to interrupt. It was also the cheapest wiring available:
`PlaneGeometry` emits clean 0..1 UVs, so this needed **no factory UV work at all** (see the
warning at the end — nothing else in the yard can say that).
**THE TILE**`lawn_grass.png`, 512², 205 KB, `build_lawn_texture()`. A **multiplier, not a
colour**: luminance in a narrow band (0.861.12) so `mat.color = COLORS.grass` survives and
Lane A can retune the lawn without re-exporting anything — exactly what `sail_weave` does to
the cloth. Five layers, all seamless by construction: blade grain (128 cells), thatch mottle
(32/16), big soft blotches (8/4) whose only job is to defeat the eye seeing a 2.5 m tile
repeat across a 24 m yard, and **4 integer cycles of mower stripe**, which is the cheapest
realism in the file — it is what makes a lawn read as MOWN. Dry patches shift hue toward
yellow (+13% R, 16% B) rather than just darkening, because dead grass is not grey grass.
**SEAMLESSNESS IS ASSERTED, NOT EYEBALLED**, and the control is the honest one: the edge-to-
edge step must be no worse than the biggest step INSIDE the tile (a flat tile would sail
through a bare "seam is small" check while proving nothing). Measured **h 0.00243 vs interior
max 0.03379 — a 14× margin.** New helper `_tileable_noise()` wraps by indexing the far edge
back to lattice 0; it deliberately uses python's `random.Random`, matching build_grass_atlas,
NOT numpy's generator, because the factory's promise is byte-identical output and numpy's
default generator does not contract to a stable stream. **Verified: two runs, shasum
identical (4a1fc18d2d03a44c).**
**⚠️ THE DISPOSAL HOLE, FOUND BY ADDING THE FEATURE.** `world.dispose()` disposed geometry
and material but NOT `material.map` — which was harmless while no world material had a map,
and became a leak the instant the lawn got one. **main.js:705 already carries this exact scar
for the sail** ("a material's `.map` is a separate GPU object… the weave was leaking one per
re-rig"). A seven-night week rebuilds the world on every site change, so this would have
leaked a 512² lawn per yard, silently. Closed with the line and the reason beside it.
**THE ASSERT IS THE ANTI-ORPHAN GUARANTEE** (a.test): the lawn must have a map after dress(),
it must be lawn_grass, sRGB (an albedo decoded linear renders washed out — r175 dropped
`encoding`), RepeatWrapping (ClampToEdge would smear one tile over the whole yard), and its
repeat must be **DERIVED from the yard's own size** at 2.5 m/tile — checked by deriving it,
not by pinning a literal, because a literal would pass on a yard of any size and the corner
block is not the backyard. Mutation: hide the PNG → **"the lawn has NO map after dress() —
the grass texture is an orphan again"**, 523 pass / 1 fail. That run also proved the
`try/catch` degrades honestly: every other test stayed green, i.e. a missing texture leaves a
flat green lawn instead of taking the yard down.
**STILL THE OPEN GATE, restated because it is the thing that blocks everything else:**
the other surfaces cannot have maps until the factory UV-unwraps. Every factory mesh carries
Blender's untouched default primitive unwrap — `uvArea` *exactly* 0.3750, U *exactly*
[0.125, 0.875] on fence, shed, table, pergola, carport and all four debris tubs — so texel
density varies **95.8× within a single `fence_panel_v1` mesh**. A `hasAttribute('uv')` check
passes and wood grain still lands 10× finer on a paling's edge than its face. That gate needs
`smart_project` in the factory AND a texel-density assert, or the next lane ships stretched
timber and calls it detail. **Six orphan textures also still await consumers** (`grass_atlas`
at ten sprints, `hail_pips`, `moon`, `plant_shred`, `pond_water`, `pond_normal`,
`sail_tears`) — every one of their systems is live now.

View File

@ -3703,6 +3703,113 @@ def build_grass_atlas():
return out
def _tileable_noise(size, cells, rng):
"""Value noise that wraps EXACTLY, because a lawn tiles ~10x across the yard.
A `cells x cells` lattice of seeded randoms, smoothstepped and bilinearly
interpolated, with the far edge indexed back to lattice 0 (`% cells`). That
modulo IS the seam guarantee it is the same discipline sail_weave states
as "every frequency is an integer number of cycles across the image",
expressed for noise instead of sinusoids. `cells` must divide `size`.
Randomness comes from python's `random.Random`, matching build_grass_atlas,
NOT numpy's generator: the factory's promise is byte-identical output, and
python's Mersenne stream is stable across versions in a way numpy's default
generator does not contract to be.
"""
import numpy as np
assert size % cells == 0, f"{cells} must divide {size} or the tile will not wrap"
g = np.array([[rng.random() for _ in range(cells)] for _ in range(cells)],
dtype=np.float32)
u = (np.arange(size, dtype=np.float32) / size) * cells
i0 = np.floor(u).astype(np.int64) % cells
i1 = (i0 + 1) % cells # the wrap: last cell blends into the first
f = (u - np.floor(u)).astype(np.float32)
f = f * f * (3.0 - 2.0 * f) # smoothstep, so lattice lines don't read as a grid
fy, fx = f[:, None], f[None, :]
a, b = g[np.ix_(i0, i0)], g[np.ix_(i0, i1)]
c, d = g[np.ix_(i1, i0)], g[np.ix_(i1, i1)]
return (a * (1 - fx) + b * fx) * (1 - fy) + (c * (1 - fx) + d * fx) * fy
def build_lawn_texture():
"""The lawn — the biggest surface in the game, and until now one flat colour.
Measured across five camera poses, the ground plane is 25-50% of frame: more
than twice any other surface, and it is what the sail's shadow lands on
("the shadow IS the product", sail.js). A shadow reads as a shape only if
the thing it falls on has a texture to interrupt.
IT IS A MULTIPLIER, NOT A COLOUR. Luminance in a narrow band around 1.0, so
`mat.map = tex` keeps `mat.color = COLORS.grass` and modulates it exactly
what sail_weave does to the cloth, and the reason this is ~40 KB instead of
a full albedo. Lane A can retune the lawn's colour afterwards without
re-exporting anything, which is the whole point of doing it this way.
Four things a real backyard has, layered, all seamless by construction:
· blade-scale grain (128 cells) reads as turf under the player's feet
· thatch/clover mottle (32, 16) reads at walking distance
· big soft blotches (4, 8) the ONLY defence against seeing the
tile repeat across a 24 m yard
· mower stripes 4 integer cycles, very low amplitude;
it is what makes a lawn look MOWN, and
it is the cheapest realism in the file
Dry patches also pull the hue toward yellow (up R, down B) rather than just
darkening, because dead grass is not grey grass.
"""
import numpy as np
import random
SIZE = 512
rng = random.Random(0x4C41574E) # "LAWN" in ascii; the fixed seed IS the determinism
fine = _tileable_noise(SIZE, 128, rng)
mid = _tileable_noise(SIZE, 32, rng)
coarse = _tileable_noise(SIZE, 16, rng)
blotch = _tileable_noise(SIZE, 8, rng)
huge = _tileable_noise(SIZE, 4, rng)
# Mower stripes: integer cycles across the tile, so they wrap. Sign flips
# per stripe like a real roller laying the blades one way then the other.
y = np.arange(SIZE, dtype=np.float32)[:, None] / SIZE
stripes = np.sin(2.0 * np.pi * 4.0 * y)
lum = (1.0
+ 0.085 * (fine - 0.5)
+ 0.070 * (mid - 0.5)
+ 0.055 * (coarse - 0.5)
+ 0.045 * (blotch - 0.5)
+ 0.040 * (huge - 0.5)
+ 0.022 * stripes)
lum = np.clip(lum, 0.70, 1.12).astype(np.float32)
# Dryness rides the big shapes only — patchy lawn, not noisy lawn.
dry = np.clip((blotch * 0.6 + huge * 0.4 - 0.42) * 1.9, 0.0, 1.0).astype(np.float32)
img = np.zeros((SIZE, SIZE, 4), dtype=np.float32)
img[..., 0] = np.clip(lum * (1.0 + 0.13 * dry), 0.0, 1.0)
img[..., 1] = np.clip(lum * (1.0 + 0.02 * dry), 0.0, 1.0)
img[..., 2] = np.clip(lum * (1.0 - 0.16 * dry), 0.0, 1.0)
img[..., 3] = 1.0
# The seam check, asserted rather than eyeballed: with exact wrapping the
# first column must be the natural continuation of the last, so the
# edge-to-edge step can be no worse than the biggest step INSIDE the tile.
# (The interior max is the honest control — a flat tile would pass a
# "seam is small" test while being no evidence at all.)
h_seam = float(np.abs(img[:, 0, :3] - img[:, -1, :3]).mean())
v_seam = float(np.abs(img[0, :, :3] - img[-1, :, :3]).mean())
h_in = float(np.abs(np.diff(img[..., :3], axis=1)).max())
v_in = float(np.abs(np.diff(img[..., :3], axis=0)).max())
assert h_seam <= h_in and v_seam <= v_in, (
f"lawn tile does not wrap: seam h={h_seam:.5f} v={v_seam:.5f} vs "
f"interior max h={h_in:.5f} v={v_in:.5f}")
out, kb = save_png(img, "lawn_grass")
print(f" lawn_grass.png {SIZE}x{SIZE}, seamless (h {h_seam:.5f} <= {h_in:.5f}), "
f"multiplier {lum.min():.2f}-{lum.max():.2f}, {kb} KB")
return out
# ============================================================================
# REGISTRY — expected dims are asserted in the verify pass, so a silent scale
# regression can never reach main. (dx, dy, dz) ranges in metres.
@ -4304,6 +4411,7 @@ def main():
build_all(only)
reset_to_empty()
build_grass_atlas()
build_lawn_texture()
build_sail_textures()
build_pond_textures()
build_hail_and_shred_atlases()

View File

@ -1881,6 +1881,44 @@ export default async function run(t) {
}
});
/**
* THE LAWN IS ACTUALLY WEARING ITS TEXTURE the anti-orphan assert.
*
* Written because of the repo's own worst asset habit: 7 of the 8 textures
* the factory generates have never had a runtime consumer, `grass_atlas`
* for ten sprints, with Lane E asking every sprint to wire it or bin it. A
* committed texture that nothing loads is the "looks wired, isn't" defect
* class in its purest form, and the only cure that cannot rot is an assert
* that fails when the wiring goes away.
*
* It checks the FOUR things that are each silently wrong-able:
* map present the wiring exists at all
* SRGBColorSpace an albedo decoded as linear renders washed out (r175
* dropped `encoding`, so this is easy to get wrong)
* RepeatWrapping ClampToEdge would smear one tile over the whole yard
* repeat from YARD a hardcoded repeat makes a 30x20 m yard's grass a
* different scale from a 24x16 m one
*/
t.test('the lawn wears its grain, at the right scale for THIS yard', () => {
if (!rtDressed) return 'SKIPPED — no server — dress() unavailable';
const ground = rtWorld.root?.getObjectByName('ground')
?? (() => { let g = null; rtWorld.root?.traverse((o) => { if (o.name === 'ground') g = o; }); return g; })();
assert(ground, 'no mesh named "ground" — the lawn assert is aimed at something that moved');
const map = ground.material?.map;
assert(map, 'the lawn has NO map after dress() — the grass texture is an orphan again');
assert(/lawn_grass/.test(map.image?.src ?? map.source?.data?.src ?? ''),
'the ground is wearing some other texture than lawn_grass.png');
assertEq(map.colorSpace, THREE.SRGBColorSpace, 'lawn albedo must be sRGB or it renders washed out');
assertEq(map.wrapS, THREE.RepeatWrapping, 'lawn must repeat, not clamp');
assertEq(map.wrapT, THREE.RepeatWrapping, 'lawn must repeat, not clamp');
// The scale is DERIVED, so the check derives it too rather than copying a
// number — a pinned literal here would pass on a yard of any size.
const TILE_M = 2.5;
assertClose(map.repeat.x, reloaded.yard.width / TILE_M, 1e-6, 'lawn tile scale is not the yard width');
assertClose(map.repeat.y, reloaded.yard.depth / TILE_M, 1e-6, 'lawn tile scale is not the yard depth');
return `${reloaded.yard.width}x${reloaded.yard.depth} m yard -> repeat ${map.repeat.x}x${map.repeat.y} @ ${TILE_M} m/tile`;
});
t.test('editor: GLB-backed anchors ADOPT, and the carport is still a trap', () => {
if (!rtDressed) return 'SKIPPED — no server — dress() unavailable';
// `collateral` is set by adoptAnchor and by nothing else, so its presence

View File

@ -662,6 +662,42 @@ export function createWorld(scene, opts = {}) {
const { GLTFLoader } = await import('../vendor/addons/loaders/GLTFLoader.js');
const loader = new GLTFLoader();
/**
* The lawn gets its grain. Biggest surface in the game (25-50% of frame,
* measured over five camera poses) and it was one flat colour, which is
* also what made the sail's shadow read as a soft blob rather than a shape.
*
* Wired exactly like sail.js's weave, for the same reasons: module-relative
* URL, `RepeatWrapping`, SRGBColorSpace (r175 spelling `encoding` is
* gone), anisotropy because the ground is *always* viewed at a raking
* angle, and a try/catch so a missing texture leaves a flat green lawn
* instead of taking the yard down.
*
* `mat.color` is KEPT: E's tile is luminance in a narrow band, so it
* multiplies COLORS.grass rather than replacing it. Retuning the lawn's
* colour stays a one-line change in this file, with no re-export.
*
* repeat is computed from the yard's real size at a fixed metres-per-tile,
* NOT hardcoded: yards differ (24x16 vs 30x20) and a constant repeat would
* make the corner block's grass a different scale from the backyard's
* the same class of bug as a texture atlas addressed by cell index.
*/
const groundMat = ground.material;
try {
const TILE_M = 2.5; // one tile per 2.5 m of lawn
const tex = await new THREE.TextureLoader().loadAsync(
new URL('../models/textures/lawn_grass.png', import.meta.url).href,
);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(YARD.width / TILE_M, YARD.depth / TILE_M);
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 8; // grazing angle over 20+ m of ground
groundMat.map = tex;
groundMat.needsUpdate = true;
} catch (err) {
console.warn('[world] lawn texture missing, keeping flat grass:', err.message);
}
/** Take a graybox stand-in out of the scene AND out of `solids`. */
const retire = (obj) => {
if (!obj) return;
@ -1012,7 +1048,18 @@ export function createWorld(scene, opts = {}) {
* lights and hands them back on its own dispose, so by here they're ours).
*/
dispose() {
root.traverse((o) => { o.geometry?.dispose?.(); o.material?.dispose?.(); });
// `.map` is disposed EXPLICITLY, and it is not pedantry: a material's
// texture is a separate GPU object that `material.dispose()` does not
// free. main.js:705 carries the same line with the scar attached ("the
// weave was leaking one per re-rig"), and the moment the lawn got a map
// this traversal inherited that bug — seven nights of a week rebuild the
// world on every site change, so it would have leaked a 512² lawn per
// yard, silently, exactly like the sail did.
root.traverse((o) => {
o.geometry?.dispose?.();
o.material?.map?.dispose?.();
o.material?.dispose?.();
});
root.parent?.remove(root);
sun.parent?.remove(sun);
sun.target.parent?.remove(sun.target);

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB