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:
parent
9ef0941ac4
commit
47d4f3d3af
57
THREADS.md
57
THREADS.md
@ -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 **25–50% 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.86–1.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.
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
BIN
web/world/models/textures/lawn_grass.png
Normal file
BIN
web/world/models/textures/lawn_grass.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 205 KiB |
Loading…
Reference in New Issue
Block a user