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>
209 lines
8.7 KiB
JavaScript
209 lines
8.7 KiB
JavaScript
// 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;
|
||
}
|