// 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 ', ` #include { 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; }