skatemakerpro/js/grass.js
type-two a5c8765776 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>
2026-08-03 20:39:45 +10:00

209 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
}