GODVERSE grass system + test-ride grinds

js/grass.js: reusable single-file grass module for all our games — GPU-instanced
blades (33k+ in one draw call), vertex-shader wind hashed per blade, root->tip
fake-AO gradient x per-instance tint, alpha-test pixel style (nearest-filter
16x24 canvas cards, stepped sway), deterministic seed, player-push uniform.
docs/GRASS.md records the technique ladder (clump maps, shell texturing,
billboard LOD, MB atlas gen) for future tiers. Auto-grows on parkland + islands,
PARK panel style/density, included in exported levels via buildParkScene.

Test ride: grind physics — falling onto any flattened steel (rails, ledge caps,
pool coping) locks a slide along the segment, pop off with Space or ride off the
end; grass parts around the rider.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-08-03 20:39:45 +10:00
parent a962ac41d9
commit a5c8765776
7 changed files with 349 additions and 10 deletions

View File

@ -46,8 +46,14 @@ persistence (`parks/`, `assets/uploads/`).
(hunyuan3d_mlx image→GLB). Everything free and local on the m3ultra queue.
- **PARK CHECK** — live design linter encoding `docs/DESIGN_PRINCIPLES.md`: pumping
geometry, run-out clearance, crossing lines, skill tiering, buried/floating steel.
- **GRASS** — reusable GODVERSE grass system (`js/grass.js` + `docs/GRASS.md`): GPU-instanced
blades, vertex-shader wind, root→tip fake-AO gradient, player-push. Styles: lush, dry,
**pixel** (nearest-filter PS1 cards). Auto-grows on the parkland ring + islands;
style/density in the PARK panel. Lift the single file into any game.
- **▶ TEST RIDE** (`Enter`) — instant Create-A-Park-style build↔skate toggle with
bookquoy's physics constants over the live data. W push, A/D steer, Space ollie.
bookquoy's physics constants over the live data. W push, A/D steer, Space ollie,
**land on any steel to grind** (rails, ledges, pool coping — pop off with Space),
and the grass parts around you.
## Park format

59
docs/GRASS.md Normal file
View File

@ -0,0 +1,59 @@
# GODVERSE grass — the module and the tricks
`js/grass.js` is the canonical grass system for all our games. It's a **system, not a
mesh** — that's why it lives as a code module with style presets, not as GLBs on the
farm. Lift the single file into any three.js project:
```js
import { makeGrass } from './grass.js';
const grass = makeGrass({ heightAt, areas, style: 'lush', density: 8 });
scene.add(grass); // self-ticking wind; add and forget
grass.userData.setPlayer(x, z); // optional: grass parts around the player
```
Styles: `lush` (tapered instanced blades), `dry` (same, straw palette),
`pixel` (crossed alpha-test cards, 16×24 nearest-filter canvas texture, banded
palette, stepped sway — PS1/N64 vibe).
## The tricks it uses (why it's fast)
1. **GPU instancing** — one `InstancedMesh` = one draw call for up to 40k blades.
Per-blade cost after spawn is zero on the CPU.
2. **Vertex-shader wind**`sin(time + hash(blade origin))`, bend scaled by
`uv.y²` so roots stay planted, tips fly. Two frequencies layered so it doesn't
look like a metronome. Pixel style floors the sway to 3 steps.
3. **Root→tip gradient with darkened base** — geometry vertex colors (0.35 at the
root → 1.15 at the tip) multiply with a per-instance tip tint. Reads as ambient
occlusion without any lighting cost. *The single biggest visual win.*
4. **Per-instance jitter** — scale, rotation, lean, hue offset. Uniformity is what
makes cheap grass look cheap.
5. **alphaTest, never alpha-blend** — no depth sorting, no overdraw melt.
6. **No per-blade shadows** — the base darkening does that job.
7. **Deterministic seed** (mulberry32) — same park, same lawn, every load.
8. **Player-push uniform** — blades within 1.2m bend away from the rider. Sells
physicality for one uniform update per frame.
## The next tiers (when a game needs bigger fields)
- **Chunked frustum culling** — split into 16m tiles, cull per-tile. Needed beyond
~100m fields; our parks don't need it yet.
- **Far-field billboard cards** — crossed quads with a grass-clump texture past
~40m, instanced blades near. Classic LOD split.
- **Clump maps** (Ghost of Tsushima GDC 2021, "Procedural Grass in Ghost of
Tsushima") — sample a Voronoi clump texture: blades in a clump share lean/height,
clump centers get taller blades. Breaks the "even lawn" look at scale.
- **Shell texturing** (Acerola's "How Do Games Render So Much Grass?") — N stacked
transparent shells; dot patterns at increasing height thresholds. Ultra cheap
retro turf/fur; a natural fourth style for the pixel games.
- **Bezier blades + tessellation** for hero close-ups (Sucker Punch use 3-4 verts
per blade too — verts are not where the money is; shading is).
- **MODELBEAST atlas textures** — flux_local can generate blade/clump atlases for
the billboard tiers (`grass blade atlas, alpha background, hand-painted style`),
then `bg_remove_local` for clean alpha.
## In SKATEMAKER PRO
Grass auto-grows on the parkland ring outside the slab and on ISLAND pads, from
`park.grass` (`{style, density}` — PARK panel when nothing is selected). Exported
levels include it via `buildParkScene`; the editor rebuilds it on committed ground
changes only (never mid-drag).

View File

@ -7,6 +7,7 @@ import { makeHeightAt, newElement, newId, elementHit, elementOutline, emptyPark,
normalizeRail } from './parkmath.js';
import { buildGround, buildRails, buildProps, buildLetters, bakeGroundTexture,
GRID_PAD } from './parkbuild.js';
import { makeGrass, parkGrassAreas } from './grass.js';
const AUTOSAVE_KEY = 'smp_autosave_v1';
@ -52,7 +53,7 @@ export function initEditor(container) {
let ground = null, railsGrp = null, propsGrp = null, letterMeshes = [],
markersGrp = new THREE.Group(), selGrp = new THREE.Group(),
underlayMesh = null;
underlayMesh = null, grassGrp = null;
scene.add(markersGrp, selGrp);
// ---------------------------------------------------------------- rebuilds
@ -72,6 +73,16 @@ export function initEditor(container) {
B.x0 - GRID_PAD, B.x1 + GRID_PAD, B.z0 - GRID_PAD, B.z1 + GRID_PAD);
ground.material.needsUpdate = true;
}
function rebuildGrass() {
if (grassGrp) { scene.remove(grassGrp); grassGrp.userData.dispose?.(); grassGrp = null; }
const g = ed.park.grass || (ed.park.grass = { style: 'lush', density: 8 });
if (g.style === 'off') return;
grassGrp = makeGrass({ heightAt: ed.heightAt, areas: parkGrassAreas(ed.park, ed.heightAt),
style: g.style, density: g.density ?? 8, seed: 7 });
scene.add(grassGrp);
}
ed.rebuildGrass = rebuildGrass;
ed.grassPlayer = (x, z, on) => grassGrp?.userData.setPlayer(x, z, on);
function rebuildRails() {
if (railsGrp) scene.remove(railsGrp);
railsGrp = buildRails(ed.park); scene.add(railsGrp);
@ -185,7 +196,7 @@ export function initEditor(container) {
function rebuildAll() {
ed.heightAt = makeHeightAt(ed.park);
rebuildGround(); rebuildRails(); rebuildProps(); rebuildMarkers();
rebuildUnderlay(); rebuildSelection();
rebuildUnderlay(); rebuildGrass(); rebuildSelection();
}
// ---------------------------------------------------------------- undo / persistence
@ -201,7 +212,7 @@ export function initEditor(container) {
// parts: {ground, rails, props, markers, tex, underlay} — default everything cheap
ed.commit = (parts = {}) => {
if (parts.snapshot !== false) snapshot();
if (parts.ground) rebuildGround();
if (parts.ground) { rebuildGround(); rebuildGrass(); }
if (parts.tex) rebakeTexture();
if (parts.rails) rebuildRails();
if (parts.props) rebuildProps();

208
js/grass.js Normal file
View File

@ -0,0 +1,208 @@
// SKATEMAKER PRO — reusable grass system. The GODVERSE grass module: lift this file
// into any game. Styles share one engine: GPU-instanced geometry, vertex-shader wind
// (CPU never touches a blade after spawn), root->tip gradient with darkened base
// (fake AO — the single biggest visual win), per-instance hue/height jitter, and a
// player-push uniform so riders part the grass.
//
// makeGrass({ heightAt, areas, style, density, seed }) -> THREE.Group
// .userData.setPlayer(x, z) — grass bends away from here (test ride / gameplay)
// self-ticks its wind clock; add to scene and forget.
//
// styles: 'lush' (realistic tapered blades) | 'pixel' (chunky nearest-filter cards,
// stepped sway — PS1 vibes) | 'dry' (lush geometry, straw palette)
// areas: [{x0,x1,z0,z1, keep(x,z)}] — rectangles + a predicate, so callers decide
// what counts as lawn (outside the slab, grass zones, islands...).
//
// Perf notes (the tricks, so future-us remembers):
// - InstancedMesh = one draw call per style. 30k blades is nothing on M-series.
// - Wind is sin(time + hash(instance origin)) evaluated in the vertex shader; bend
// scales with (height-along-blade)^2 so roots stay planted.
// - alphaTest, never alpha blend — no sorting, no overdraw melt.
// - No per-blade shadows; the root-darkening gradient reads as occlusion.
// - Next tier when we need it: chunked frustum culling, far-field billboard cards,
// clump maps (Ghost of Tsushima GDC talk), shell texturing for retro fur/turf.
import * as THREE from 'three';
const mulberry = seed => () => {
seed |= 0; seed = seed + 0x6D2B79F5 | 0;
let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
const PALETTES = {
lush: { base: 0x2e5b25, tipA: 0x6fae4a, tipB: 0x8fc65e, sat: 0.12 },
dry: { base: 0x6b6234, tipA: 0xb3a55e, tipB: 0xc9bd7a, sat: 0.10 },
pixel: { levels: ['#1e4a1c', '#2f6b28', '#4a9436', '#71b848'] },
};
// tapered blade: 5 verts, 3 tris, bends in-shader. h encoded in uv.y.
function bladeGeometry() {
const g = new THREE.BufferGeometry();
const w = 0.045;
g.setAttribute('position', new THREE.Float32BufferAttribute([
-w, 0, 0, w, 0, 0, -w * 0.7, 0.5, 0, w * 0.7, 0.5, 0, 0, 1, 0,
], 3));
g.setAttribute('uv', new THREE.Float32BufferAttribute([0, 0, 1, 0, 0, 0.5, 1, 0.5, 0.5, 1], 2));
g.setIndex([0, 1, 2, 2, 1, 3, 2, 3, 4]);
g.computeVertexNormals();
return g;
}
// chunky pixel card: crossed quads, nearest-filter canvas texture, banded palette
function pixelTexture(rand) {
const cv = document.createElement('canvas'); cv.width = 16; cv.height = 24;
const g = cv.getContext('2d');
const cols = PALETTES.pixel.levels;
for (let i = 0; i < 10; i++) { // a few fat pixel blades
const bx = 1 + Math.floor(rand() * 14);
const h = 10 + Math.floor(rand() * 13);
for (let y = 0; y < h; y++) {
g.fillStyle = cols[Math.min(cols.length - 1, Math.floor(y / h * cols.length))];
const wob = Math.floor(Math.sin(y * 0.6 + i) * (y / h) * 2);
g.fillRect(bx + wob, 23 - y, y > h * 0.7 ? 1 : 2, 1);
}
}
const tex = new THREE.CanvasTexture(cv);
tex.magFilter = tex.minFilter = THREE.NearestFilter;
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
function windify(material, opts) {
material.onBeforeCompile = sh => {
sh.uniforms.uTime = opts.uTime;
sh.uniforms.uPlayer = opts.uPlayer;
sh.vertexShader = `
uniform float uTime; uniform vec3 uPlayer;
` + sh.vertexShader.replace('#include <begin_vertex>', `
#include <begin_vertex>
{
vec4 org = instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
float phase = org.x * 1.7 + org.z * 2.3;
float t = uv.y * uv.y; // roots planted, tips fly
float sway = sin(uTime * ${opts.pixel ? '1.6' : '2.2'} + phase)
+ 0.5 * sin(uTime * 4.7 + phase * 1.3);
${opts.pixel ? 'sway = floor(sway * 3.0) / 3.0;' : ''}
transformed.x += t * sway * ${opts.pixel ? '0.06' : '0.09'};
transformed.z += t * 0.04 * sin(uTime * 1.4 + phase);
vec2 away = org.xz - uPlayer.xz;
float d = length(away);
if (d < 1.2 && uPlayer.y > 0.5) transformed.xz += normalize(away) * t * (1.2 - d) * 0.5;
}`);
};
return material;
}
export function makeGrass({ heightAt, areas, style = 'lush', density = 8, seed = 7 }) {
const grp = new THREE.Group(); grp.name = 'grass';
if (style === 'off' || !areas.length) { grp.userData.setPlayer = () => {}; return grp; }
const rand = mulberry(seed);
const uTime = { value: 0 }, uPlayer = { value: new THREE.Vector3(0, -10, 0) };
const pixel = style === 'pixel';
// scatter
const spots = [];
for (const a of areas) {
const n = Math.min(20000, Math.round((a.x1 - a.x0) * (a.z1 - a.z0) * density));
for (let i = 0; i < n; i++) {
const x = a.x0 + rand() * (a.x1 - a.x0), z = a.z0 + rand() * (a.z1 - a.z0);
if (a.keep && !a.keep(x, z)) continue;
spots.push([x, heightAt(x, z), z]);
}
}
const count = Math.min(spots.length, 40000);
let mesh;
if (pixel) {
const geo = new THREE.PlaneGeometry(0.5, 0.5);
geo.translate(0, 0.25, 0);
const mat = windify(new THREE.MeshLambertMaterial({
map: pixelTexture(rand), alphaTest: 0.5, side: THREE.DoubleSide,
}), { uTime, uPlayer, pixel: true });
mesh = new THREE.InstancedMesh(geo, mat, count * 2); // crossed pairs
} else {
const mat = windify(new THREE.MeshLambertMaterial({ vertexColors: false }), { uTime, uPlayer });
mesh = new THREE.InstancedMesh(bladeGeometry(), mat, count);
}
const pal = PALETTES[style] || PALETTES.lush;
const dummy = new THREE.Object3D();
const cA = new THREE.Color(pal.tipA ?? 0x6fae4a), cB = new THREE.Color(pal.tipB ?? 0x8fc65e);
const tint = new THREE.Color();
let k = 0;
for (let i = 0; i < count; i++) {
const [x, y, z] = spots[i];
const s = 0.7 + rand() * 0.7;
if (pixel) {
for (const ry of [0, Math.PI / 2]) {
dummy.position.set(x, y, z);
dummy.rotation.set(0, ry + rand() * 0.6, 0);
dummy.scale.setScalar(s);
dummy.updateMatrix();
mesh.setMatrixAt(k++, dummy.matrix);
}
} else {
dummy.position.set(x, y, z);
dummy.rotation.set((rand() - 0.5) * 0.15, rand() * Math.PI * 2, (rand() - 0.5) * 0.2);
dummy.scale.set(s, s * (0.8 + rand() * 0.8), s);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
tint.lerpColors(cA, cB, rand()).offsetHSL((rand() - 0.5) * pal.sat, 0, 0);
mesh.setColorAt(i, tint);
}
}
if (!pixel) {
// root->tip gradient: geometry vertex colors (darkened base) multiply with the
// per-instance tip tint natively (USE_COLOR × USE_INSTANCING_COLOR)
const g = mesh.geometry;
const shades = [0.35, 0.35, 0.7, 0.7, 1.15]; // per-vertex root->tip multiplier
g.setAttribute('color', new THREE.Float32BufferAttribute(
shades.flatMap(v => [v, v, v]), 3));
mesh.material.vertexColors = true;
}
mesh.instanceMatrix.needsUpdate = true;
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
mesh.frustumCulled = false;
grp.add(mesh);
// self-ticking wind clock — drop into any scene, no contract changes needed
let alive = true;
const t0 = performance.now();
(function tick() {
if (!alive) return;
uTime.value = (performance.now() - t0) / 1000;
requestAnimationFrame(tick);
})();
grp.userData.setPlayer = (x, z, active = true) => {
uPlayer.value.set(x, active ? 1 : -10, z);
};
grp.userData.dispose = () => { alive = false; mesh.geometry.dispose(); mesh.material.dispose(); };
return grp;
}
// standard park areas: the parkland ring outside the slab + island pads + grassy zones
export function parkGrassAreas(park, heightAt) {
const B = park.bounds, PAD = 13;
const areas = [];
const outside = (x, z) => (x < B.x0 || x > B.x1 || z < B.z0 || z > B.z1) &&
heightAt(x, z) < 2.2;
areas.push({ x0: B.x0 - PAD, x1: B.x1 + PAD, z0: B.z0 - PAD, z1: B.z0, keep: outside });
areas.push({ x0: B.x0 - PAD, x1: B.x1 + PAD, z0: B.z1, z1: B.z1 + PAD, keep: outside });
areas.push({ x0: B.x0 - PAD, x1: B.x0, z0: B.z0, z1: B.z1, keep: outside });
areas.push({ x0: B.x1, x1: B.x1 + PAD, z0: B.z0, z1: B.z1, keep: outside });
for (const e of park.elements || []) {
if (e.kind !== 'dome') continue;
const c = Math.cos(e.rot || 0), s = Math.sin(e.rot || 0);
areas.push({
x0: e.x - Math.max(e.rx, e.rz), x1: e.x + Math.max(e.rx, e.rz),
z0: e.z - Math.max(e.rx, e.rz), z1: e.z + Math.max(e.rx, e.rz),
keep: (x, z) => {
const dx = x - e.x, dz = z - e.z;
const u = (dx * c + dz * s) / e.rx, v = (-dx * s + dz * c) / e.rz;
return u * u + v * v < 0.92;
},
});
}
return areas;
}

View File

@ -4,6 +4,7 @@ import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { makeHeightAt, normalizeRail, bowlRimWorld } from './parkmath.js';
import { makeBuiltinProp } from './props3d.js';
import { makeGrass, parkGrassAreas } from './grass.js';
export const GRID_RES = 0.5, GRID_PAD = 14;
@ -218,6 +219,13 @@ export function buildParkScene(scene, park, opts = {}) {
const heightAt = makeHeightAt(park);
const ground = buildGround(park);
scene.add(ground);
let grass = null;
const gcfg = park.grass || { style: 'lush', density: 8 };
if (gcfg.style && gcfg.style !== 'off' && !opts.skipGrass) {
grass = makeGrass({ heightAt, areas: parkGrassAreas(park, heightAt),
style: gcfg.style, density: gcfg.density ?? 8 });
scene.add(grass);
}
const rails = buildRails(park);
scene.add(rails);
const props = buildProps(park, heightAt, opts.onPropLoaded);
@ -227,5 +235,5 @@ export function buildParkScene(scene, park, opts = {}) {
letterMeshes = buildLetters(park, heightAt);
for (const m of letterMeshes) scene.add(m);
}
return { ground, rails, props, letterMeshes };
return { ground, rails, props, letterMeshes, grass };
}

View File

@ -2,14 +2,15 @@
// toggle is what makes an editor sing. Simplified bookquoy physics over the live
// park data — same heightAt, same constants, so speed and pop feel like the game.
import * as THREE from 'three';
import { makeHeightAt, makeNormalAt } from './parkmath.js';
import { makeHeightAt, makeNormalAt, flattenRails } from './parkmath.js';
const GRAV = 18, MAX_SPEED = 10.5, PUSH_ACC = 6.5, FRICTION = 0.28, STEER = 2.6;
export function attachTestRide(ed) {
const keys = new Set();
let rider = null, heightAt = null, normalAt = null;
let rider = null, heightAt = null, normalAt = null, rails = [];
let pos, heading = 0, speed = 0, vy = 0, state = 'roll';
let grind = null; // {seg, t, dir, len}
const N = new THREE.Vector3();
const camTarget = new THREE.Vector3();
let savedCam = null;
@ -36,6 +37,7 @@ export function attachTestRide(ed) {
function start() {
heightAt = makeHeightAt(ed.park);
normalAt = makeNormalAt(heightAt);
rails = flattenRails(ed.park);
const s = ed.park.spawn;
pos = new THREE.Vector3(s.x, heightAt(s.x, s.z), s.z);
heading = s.heading; speed = 0; vy = 0; state = 'roll';
@ -45,9 +47,11 @@ export function attachTestRide(ed) {
ed.controls.enabled = false;
ed.testing = true;
ed.emit('test', true);
ed.emit('status', 'TEST RIDE — W push · A/D steer · S brake · SPACE ollie · R respawn · ENTER back to editing');
ed.emit('status', 'TEST RIDE — W push · A/D steer · S brake · SPACE ollie · land on steel to grind · R respawn · ENTER back to editing');
}
function stop() {
ed.grassPlayer?.(0, 0, false);
grind = null;
if (rider) ed.scene.remove(rider);
rider = null;
ed.testing = false;
@ -78,18 +82,51 @@ export function attachTestRide(ed) {
pos.y = h;
if (keys.has('Space')) { state = 'air'; vy = 4.6 + speed * 0.12; }
}
} else if (state === 'grind') {
grind.t += grind.dir * speed * dt / grind.len;
const g = grind.seg;
if (!keys.has('Space')) grind.latch = false; // must re-press to pop off
if (grind.t <= 0 || grind.t >= 1 || (keys.has('Space') && !grind.latch)) {
state = 'air'; vy = 2.6; grind = null;
} else {
pos.x = g.a[0] + (g.b[0] - g.a[0]) * grind.t;
pos.z = g.a[1] + (g.b[1] - g.a[1]) * grind.t;
pos.y = g.ya + (g.yb - g.ya) * grind.t + 0.06;
heading = Math.atan2((g.b[0] - g.a[0]) * grind.dir, (g.b[1] - g.a[1]) * grind.dir);
}
} else {
vy -= GRAV * dt;
pos.x += fwd.x * speed * dt; pos.z += fwd.z * speed * dt; pos.y += vy * dt;
if (vy < 0) { // falling: try to lock a grind
for (const g of rails) {
const dx = g.b[0] - g.a[0], dz = g.b[1] - g.a[1];
const len = Math.hypot(dx, dz) || 0.01;
const t = ((pos.x - g.a[0]) * dx + (pos.z - g.a[1]) * dz) / (len * len);
if (t < 0.02 || t > 0.98) continue;
const px = g.a[0] + dx * t, pz = g.a[1] + dz * t;
const railY = g.ya + (g.yb - g.ya) * t;
if (Math.hypot(pos.x - px, pos.z - pz) < 0.4 &&
pos.y > railY - 0.05 && pos.y < railY + 0.4) {
const dir = (Math.sin(heading) * dx + Math.cos(heading) * dz) >= 0 ? 1 : -1;
grind = { seg: g, t, dir, len, latch: keys.has('Space') };
state = 'grind'; vy = 0;
speed = Math.max(speed, 3.5);
break;
}
}
}
if (state === 'air') {
const h = heightAt(pos.x, pos.z);
if (pos.y <= h) { pos.y = h; state = 'roll'; vy = 0; }
}
}
if (keys.has('KeyR')) {
const s = ed.park.spawn;
pos.set(s.x, heightAt(s.x, s.z), s.z); heading = s.heading; speed = 0; state = 'roll';
}
rider.position.copy(pos);
rider.rotation.y = heading;
ed.grassPlayer?.(pos.x, pos.z, true);
if (state === 'roll') {
normalAt(pos.x, pos.z, N);
const tilt = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), N);

View File

@ -360,6 +360,16 @@ export function initUI(ed) {
function buildParkInspector() {
inspector.append(el('div', 'ititle', 'PARK'));
const g = ed.park.grass || (ed.park.grass = { style: 'lush', density: 8 });
const gsel = el('select');
gsel.innerHTML = '<option value="lush">lush grass</option><option value="dry">dry grass</option>' +
'<option value="pixel">pixel grass</option><option value="off">no grass</option>';
gsel.value = g.style;
gsel.onchange = () => { g.style = gsel.value; ed.rebuildGrass(); ed.commit({}); };
inspector.append(row('grass', gsel));
inspector.append(numRow(g, 'density', 'blades/m²', 1, 20, 1, (v, done) => {
g.density = v; if (done) { ed.rebuildGrass(); ed.commit({}); }
}));
const cols = [['slabColor', 'slab'], ['grassColor', 'grass']];
for (const [key, label] of cols) {
const c = el('input'); c.type = 'color'; c.value = ed.park[key];