Spaces:
Running
Running
Codex CLI
feat(world): switch tree materials to simpler Lambert shading for performance optimization
09a3a37
| import * as THREE from 'three'; | |
| import { CFG } from './config.js'; | |
| import { G } from './globals.js'; | |
| // --- Shared materials, geometries, and wind uniforms for trees --- | |
| // We keep these module-scoped so all trees can share them efficiently. | |
| const FOLIAGE_WIND = { uTime: { value: 0 }, uStrength: { value: 0.35 } }; | |
| // Use simpler Lambert shading for mass content to reduce uniforms | |
| const TRUNK_MAT = new THREE.MeshLambertMaterial({ | |
| color: 0x6b4f32, | |
| emissive: 0x000000 | |
| }); | |
| // Foliage material with simple vertex sway, inspired by reference project | |
| const FOLIAGE_MAT = new THREE.MeshLambertMaterial({ | |
| color: 0x2f6b3d, | |
| emissive: 0x000000 | |
| }); | |
| FOLIAGE_MAT.onBeforeCompile = (shader) => { | |
| shader.uniforms.uTime = FOLIAGE_WIND.uTime; | |
| shader.uniforms.uStrength = FOLIAGE_WIND.uStrength; | |
| shader.vertexShader = ( | |
| 'uniform float uTime;\n' + | |
| 'uniform float uStrength;\n' + | |
| shader.vertexShader | |
| ).replace( | |
| '#include <begin_vertex>', | |
| `#include <begin_vertex> | |
| float sway = sin(uTime * 1.7 + position.y * 0.35) * 0.5 + | |
| sin(uTime * 0.9 + position.y * 0.7) * 0.5; | |
| transformed.x += sway * uStrength * (0.4 + position.y * 0.06); | |
| transformed.z += cos(uTime * 1.1 + position.y * 0.42) * uStrength * (0.3 + position.y * 0.05);` | |
| ); | |
| }; | |
| FOLIAGE_MAT.needsUpdate = true; | |
| // Reusable base geometries (scaled per-tree) | |
| // Trunk: slight taper, height 10, translated so base at y=0 | |
| const GEO_TRUNK = new THREE.CylinderGeometry(0.7, 1.2, 10, 8, 1, false); | |
| GEO_TRUNK.translate(0, 5, 0); | |
| // Foliage stack (positions expect trunk height ~10) | |
| const GEO_CONE1 = new THREE.ConeGeometry(6, 10, 8); GEO_CONE1.translate(0, 14, 0); | |
| const GEO_CONE2 = new THREE.ConeGeometry(5, 9, 8); GEO_CONE2.translate(0, 20, 0); | |
| const GEO_CONE3 = new THREE.ConeGeometry(4, 8, 8); GEO_CONE3.translate(0, 25, 0); | |
| const GEO_SPH = new THREE.SphereGeometry(3.5, 8, 6); GEO_SPH.translate(0, 28.5, 0); | |
| // Allow main loop to advance wind time | |
| export function tickForest(timeSec) { | |
| FOLIAGE_WIND.uTime.value = timeSec; | |
| // Distance-based chunk culling for grass/flowers (cheap per-frame visibility) | |
| const cam = G.camera; | |
| if (!cam || !G.foliage) return; | |
| const px = cam.position.x; | |
| const pz = cam.position.z; | |
| const gv = CFG.foliage; | |
| const g2 = gv.grassViewDist * gv.grassViewDist; | |
| const f2 = gv.flowerViewDist * gv.flowerViewDist; | |
| for (let i = 0; i < G.foliage.grass.length; i++) { | |
| const m = G.foliage.grass[i]; | |
| const dx = (m.position.x) - px; | |
| const dz = (m.position.z) - pz; | |
| m.visible = (dx * dx + dz * dz) <= g2; | |
| } | |
| for (let i = 0; i < G.foliage.flowers.length; i++) { | |
| const m = G.foliage.flowers[i]; | |
| const dx = (m.position.x) - px; | |
| const dz = (m.position.z) - pz; | |
| m.visible = (dx * dx + dz * dz) <= f2; | |
| } | |
| } | |
| // --- Ground cover (grass, flowers, bushes, rocks) --- | |
| // Shared materials | |
| const GRASS_MAT = new THREE.MeshLambertMaterial({ | |
| color: 0xffffff, | |
| vertexColors: true, | |
| side: THREE.DoubleSide | |
| }); | |
| GRASS_MAT.onBeforeCompile = (shader) => { | |
| shader.uniforms.uTime = FOLIAGE_WIND.uTime; | |
| shader.uniforms.uStrength = FOLIAGE_WIND.uStrength; | |
| shader.vertexShader = ( | |
| 'uniform float uTime;\n' + | |
| 'uniform float uStrength;\n' + | |
| shader.vertexShader | |
| ).replace( | |
| '#include <begin_vertex>', | |
| `#include <begin_vertex> | |
| #ifdef USE_INSTANCING | |
| // derive a per-instance pseudo-random from translation | |
| float iRand = fract(sin(instanceMatrix[3].x*12.9898 + instanceMatrix[3].z*78.233) * 43758.5453); | |
| #else | |
| float iRand = 0.5; | |
| #endif | |
| float h = clamp(position.y, 0.0, 1.0); | |
| float sway = (sin(uTime*2.2 + iRand*6.2831) * 0.6 + cos(uTime*1.3 + iRand*11.0) * 0.4); | |
| float bend = uStrength * h * h; // bend increases towards tip | |
| transformed.x += sway * bend * 0.25; | |
| transformed.z += sway * bend * 0.18;` | |
| ); | |
| }; | |
| GRASS_MAT.needsUpdate = true; | |
| const FLOWER_MAT = new THREE.MeshLambertMaterial({ | |
| color: 0xffffff, | |
| vertexColors: true, | |
| side: THREE.DoubleSide | |
| }); | |
| FLOWER_MAT.onBeforeCompile = (shader) => { | |
| shader.uniforms.uTime = FOLIAGE_WIND.uTime; | |
| shader.uniforms.uStrength = FOLIAGE_WIND.uStrength; | |
| shader.vertexShader = ( | |
| 'uniform float uTime;\n' + | |
| 'uniform float uStrength;\n' + | |
| shader.vertexShader | |
| ).replace( | |
| '#include <begin_vertex>', | |
| `#include <begin_vertex> | |
| #ifdef USE_INSTANCING | |
| float iRand = fract(sin(instanceMatrix[3].x*19.123 + instanceMatrix[3].z*47.321) * 15731.123); | |
| #else | |
| float iRand = 0.5; | |
| #endif | |
| float h = clamp(position.y, 0.0, 1.0); | |
| float sway = sin(uTime*2.6 + iRand*8.0); | |
| float bend = uStrength * h; | |
| transformed.x += sway * bend * 0.18; | |
| transformed.z += sway * bend * 0.14;` | |
| ); | |
| }; | |
| FLOWER_MAT.needsUpdate = true; | |
| const BUSH_MAT = new THREE.MeshLambertMaterial({ | |
| color: 0x2b6a37, | |
| flatShading: false | |
| }); | |
| const ROCK_MAT = new THREE.MeshLambertMaterial({ | |
| color: 0x7b7066, | |
| flatShading: true | |
| }); | |
| // Base geometries (kept small, cloned per-chunk to inject bounding spheres) | |
| function makeCrossBladeGeometry(width = 0.5, height = 1.2) { | |
| // Two crossed quads around Y axis | |
| const hw = width * 0.5; | |
| const h = height; | |
| const positions = [ | |
| // quad A (X axis) | |
| -hw, 0, 0, hw, 0, 0, hw, h, 0, | |
| -hw, 0, 0, hw, h, 0, -hw, h, 0, | |
| // quad B (Z axis) | |
| 0, 0, -hw, 0, 0, hw, 0, h, hw, | |
| 0, 0, -hw, 0, h, hw, 0, h, -hw, | |
| ]; | |
| const colors = []; | |
| for (let i = 0; i < 12; i++) { | |
| const y = positions[i*3 + 1]; | |
| const t = y / h; // 0 at base -> 1 at tip | |
| const r = THREE.MathUtils.lerp(0.13, 0.25, t); | |
| const g = THREE.MathUtils.lerp(0.28, 0.55, t); | |
| const b = THREE.MathUtils.lerp(0.12, 0.22, t); | |
| colors.push(r, g, b); | |
| } | |
| const geo = new THREE.BufferGeometry(); | |
| geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); | |
| geo.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3)); | |
| geo.computeVertexNormals(); | |
| return geo; | |
| } | |
| const BASE_GEOM = { | |
| // Smaller blades and petals relative to trees | |
| grass: makeCrossBladeGeometry(0.22, 0.45), | |
| flower: makeCrossBladeGeometry(0.18, 0.32), | |
| bush: new THREE.SphereGeometry(0.8, 8, 6), | |
| rock: new THREE.IcosahedronGeometry(0.7, 0) | |
| }; | |
| // Deterministic per-chunk RNG | |
| function lcg(seed) { | |
| let s = (seed >>> 0) || 1; | |
| return () => (s = (1664525 * s + 1013904223) >>> 0) / 4294967296; | |
| } | |
| export function generateGroundCover() { | |
| const S = CFG.foliage; | |
| FOLIAGE_WIND.uStrength.value = S.windStrength; | |
| const half = CFG.forestSize / 2; | |
| const chunk = Math.max(8, S.chunkSize | 0); | |
| const chunksX = Math.ceil(CFG.forestSize / chunk); | |
| const chunksZ = Math.ceil(CFG.forestSize / chunk); | |
| const halfChunk = chunk * 0.5; | |
| const seed = (CFG.seed | 0) ^ (S.seedOffset | 0); | |
| // Helper to set a safe bounding sphere for a chunk-sized instanced mesh | |
| function setChunkBounds(mesh) { | |
| const g = mesh.geometry; | |
| const r = Math.sqrt(halfChunk*halfChunk*2 + 25); // generous Y span | |
| g.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 0, 0), r); | |
| } | |
| // Skip center clearing smoothly | |
| function densityAt(x, z) { | |
| const r = Math.hypot(x, z); | |
| const edge = CFG.clearRadius; | |
| const k = THREE.MathUtils.clamp((r - edge) / (edge * 1.2), 0, 1); | |
| return THREE.MathUtils.lerp(S.densityNearClear, 1, k); | |
| } | |
| // Reset chunk refs | |
| if (!G.foliage) G.foliage = { grass: [], flowers: [] }; | |
| G.foliage.grass.length = 0; | |
| G.foliage.flowers.length = 0; | |
| // Per-chunk generation | |
| for (let iz = 0; iz < chunksZ; iz++) { | |
| for (let ix = 0; ix < chunksX; ix++) { | |
| const cx = -half + ix * chunk + halfChunk; | |
| const cz = -half + iz * chunk + halfChunk; | |
| // Keep within world bounds | |
| if (Math.abs(cx) > half || Math.abs(cz) > half) continue; | |
| // Density scaler by clearing and macro noise for patchiness | |
| const den = densityAt(cx, cz); | |
| const patch = (fbm(cx, cz, 1 / 60, 3, 2, 0.5, seed) * 0.5 + 0.5); | |
| const grassCount = Math.max(0, Math.round(S.grassPerChunk * den * (0.6 + 0.8 * patch))); | |
| const flowerCount = Math.max(0, Math.round(S.flowersPerChunk * den * (0.6 + 0.8 * (1 - patch)))); | |
| const bushesCount = Math.max(0, Math.round(S.bushesPerChunk * den * (0.7 + 0.6 * patch))); | |
| const rocksCount = Math.max(0, Math.round(S.rocksPerChunk * den * (0.7 + 0.6 * (1 - patch)))); | |
| // No work for empty chunks | |
| if (!grassCount && !flowerCount && !bushesCount && !rocksCount) continue; | |
| const rng = lcg(((ix + 1) * 73856093) ^ ((iz + 1) * 19349663) ^ seed); | |
| // Grass | |
| if (grassCount > 0) { | |
| const geom = BASE_GEOM.grass.clone(); | |
| const mesh = new THREE.InstancedMesh(geom, GRASS_MAT, grassCount); | |
| mesh.instanceMatrix.setUsage(THREE.StaticDrawUsage); | |
| mesh.castShadow = false; | |
| mesh.receiveShadow = false; | |
| mesh.position.set(cx, 0, cz); | |
| setChunkBounds(mesh); | |
| const m = new THREE.Matrix4(); | |
| const pos = new THREE.Vector3(); | |
| const quat = new THREE.Quaternion(); | |
| const scl = new THREE.Vector3(); | |
| const color = new THREE.Color(); | |
| for (let i = 0; i < grassCount; i++) { | |
| const dx = (rng() - 0.5) * chunk; | |
| const dz = (rng() - 0.5) * chunk; | |
| const wx = cx + dx; | |
| const wz = cz + dz; | |
| const wy = getTerrainHeight(wx, wz); | |
| // Simple placement; allow gentle slopes without extra checks | |
| const yaw = rng() * Math.PI * 2; | |
| quat.setFromEuler(new THREE.Euler(0, yaw, 0)); | |
| // 1.5x larger than current small baseline | |
| const scale = (0.6 + rng() * 0.35) * 1.5; | |
| scl.set(scale, scale, scale); | |
| pos.set(dx, wy, dz); | |
| m.compose(pos, quat, scl); | |
| mesh.setMatrixAt(i, m); | |
| // instance color variation | |
| color.setRGB(THREE.MathUtils.lerp(0.18, 0.26, rng()), THREE.MathUtils.lerp(0.45, 0.62, rng()), THREE.MathUtils.lerp(0.16, 0.24, rng())); | |
| mesh.setColorAt(i, color); | |
| } | |
| mesh.instanceMatrix.needsUpdate = true; | |
| if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; | |
| G.scene.add(mesh); | |
| G.foliage.grass.push(mesh); | |
| } | |
| // Flowers | |
| if (flowerCount > 0) { | |
| const geom = BASE_GEOM.flower.clone(); | |
| const mesh = new THREE.InstancedMesh(geom, FLOWER_MAT, flowerCount); | |
| mesh.instanceMatrix.setUsage(THREE.StaticDrawUsage); | |
| mesh.castShadow = false; | |
| mesh.receiveShadow = false; | |
| mesh.position.set(cx, 0, cz); | |
| setChunkBounds(mesh); | |
| const m = new THREE.Matrix4(); | |
| const pos = new THREE.Vector3(); | |
| const quat = new THREE.Quaternion(); | |
| const scl = new THREE.Vector3(); | |
| const color = new THREE.Color(); | |
| for (let i = 0; i < flowerCount; i++) { | |
| const dx = (rng() - 0.5) * chunk; | |
| const dz = (rng() - 0.5) * chunk; | |
| const wx = cx + dx; | |
| const wz = cz + dz; | |
| const wy = getTerrainHeight(wx, wz) + 0.02; | |
| const yaw = rng() * Math.PI * 2; | |
| quat.setFromEuler(new THREE.Euler(0, yaw, 0)); | |
| // Flowers 1.5x larger than current small baseline | |
| const scale = (0.7 + rng() * 0.3) * 1.5; | |
| scl.set(scale, scale, scale); | |
| pos.set(dx, wy, dz); | |
| m.compose(pos, quat, scl); | |
| mesh.setMatrixAt(i, m); | |
| // bright palette variations | |
| const palettes = [ | |
| new THREE.Color(0xff6fb3), // pink | |
| new THREE.Color(0xffda66), // yellow | |
| new THREE.Color(0x8be37c), // mint | |
| new THREE.Color(0x6fc3ff), // sky | |
| new THREE.Color(0xff8a6f) // peach | |
| ]; | |
| const base = palettes[Math.floor(rng() * palettes.length)]; | |
| color.copy(base).multiplyScalar(0.9 + rng()*0.2); | |
| mesh.setColorAt(i, color); | |
| } | |
| mesh.instanceMatrix.needsUpdate = true; | |
| if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; | |
| G.scene.add(mesh); | |
| G.foliage.flowers.push(mesh); | |
| } | |
| // Bushes | |
| if (bushesCount > 0) { | |
| const geom = BASE_GEOM.bush.clone(); | |
| const mesh = new THREE.InstancedMesh(geom, BUSH_MAT, bushesCount); | |
| mesh.instanceMatrix.setUsage(THREE.StaticDrawUsage); | |
| mesh.castShadow = false; | |
| mesh.receiveShadow = true; | |
| mesh.position.set(cx, 0, cz); | |
| setChunkBounds(mesh); | |
| const m = new THREE.Matrix4(); | |
| const pos = new THREE.Vector3(); | |
| const quat = new THREE.Quaternion(); | |
| const scl = new THREE.Vector3(); | |
| for (let i = 0; i < bushesCount; i++) { | |
| const dx = (rng() - 0.5) * chunk; | |
| const dz = (rng() - 0.5) * chunk; | |
| const wx = cx + dx; | |
| const wz = cz + dz; | |
| const wy = getTerrainHeight(wx, wz) + 0.2; | |
| quat.identity(); | |
| const s = 0.7 + rng() * 0.8; | |
| scl.set(s, s, s); | |
| pos.set(dx, wy, dz); | |
| m.compose(pos, quat, scl); | |
| mesh.setMatrixAt(i, m); | |
| } | |
| mesh.instanceMatrix.needsUpdate = true; | |
| G.scene.add(mesh); | |
| } | |
| // Rocks | |
| if (rocksCount > 0) { | |
| const geom = BASE_GEOM.rock.clone(); | |
| const mesh = new THREE.InstancedMesh(geom, ROCK_MAT, rocksCount); | |
| mesh.instanceMatrix.setUsage(THREE.StaticDrawUsage); | |
| mesh.castShadow = true; | |
| mesh.receiveShadow = true; | |
| mesh.position.set(cx, 0, cz); | |
| setChunkBounds(mesh); | |
| const m = new THREE.Matrix4(); | |
| const pos = new THREE.Vector3(); | |
| const quat = new THREE.Quaternion(); | |
| const scl = new THREE.Vector3(); | |
| const color = new THREE.Color(); | |
| for (let i = 0; i < rocksCount; i++) { | |
| const dx = (rng() - 0.5) * chunk; | |
| const dz = (rng() - 0.5) * chunk; | |
| const wx = cx + dx; | |
| const wz = cz + dz; | |
| const wy = getTerrainHeight(wx, wz) + 0.05; | |
| const yaw = rng() * Math.PI * 2; | |
| quat.setFromEuler(new THREE.Euler(0, yaw, 0)); | |
| const s = 0.4 + rng() * 0.9; | |
| scl.set(s * (0.8 + rng()*0.4), s, s * (0.8 + rng()*0.4)); | |
| pos.set(dx, wy, dz); | |
| m.compose(pos, quat, scl); | |
| mesh.setMatrixAt(i, m); | |
| // slight per-rock color tint | |
| color.setHSL(0.07, 0.08, THREE.MathUtils.lerp(0.32, 0.46, rng())); | |
| if (mesh.instanceColor) mesh.setColorAt(i, color); | |
| } | |
| mesh.instanceMatrix.needsUpdate = true; | |
| if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; | |
| G.scene.add(mesh); | |
| } | |
| } | |
| } | |
| } | |
| // --- Procedural terrain helpers --- | |
| // Hash-based 2D value noise for deterministic hills (pure 32-bit integer math) | |
| function hash2i(xi, yi, seed) { | |
| let h = Math.imul(xi, 374761393) ^ Math.imul(yi, 668265263) ^ Math.imul(seed, 2147483647); | |
| h = Math.imul(h ^ (h >>> 13), 1274126177); | |
| h = (h ^ (h >>> 16)) >>> 0; | |
| return h / 4294967296; // [0,1) | |
| } | |
| function smoothstep(a, b, t) { | |
| if (t <= a) return 0; | |
| if (t >= b) return 1; | |
| t = (t - a) / (b - a); | |
| return t * t * (3 - 2 * t); | |
| } | |
| function lerp(a, b, t) { return a + (b - a) * t; } | |
| function valueNoise2(x, z, seed) { | |
| const xi = Math.floor(x); | |
| const zi = Math.floor(z); | |
| const xf = x - xi; | |
| const zf = z - zi; | |
| const s = smoothstep(0, 1, xf); | |
| const t = smoothstep(0, 1, zf); | |
| const v00 = hash2i(xi, zi, seed); | |
| const v10 = hash2i(xi + 1, zi, seed); | |
| const v01 = hash2i(xi, zi + 1, seed); | |
| const v11 = hash2i(xi + 1, zi + 1, seed); | |
| const x1 = lerp(v00, v10, s); | |
| const x2 = lerp(v01, v11, s); | |
| return lerp(x1, x2, t) * 2 - 1; // [-1,1] | |
| } | |
| function fbm(x, z, baseFreq, octaves, lacunarity, gain, seed) { | |
| let sum = 0; | |
| let amp = 1; | |
| let freq = baseFreq; | |
| for (let i = 0; i < octaves; i++) { | |
| sum += amp * valueNoise2(x * freq, z * freq, seed); | |
| freq *= lacunarity; | |
| amp *= gain; | |
| } | |
| return sum; | |
| } | |
| // Exported height sampler so other systems can stick to the ground | |
| export function getTerrainHeight(x, z) { | |
| const seed = (CFG.seed | 0) ^ 0x9e3779b9; | |
| // Gentle rolling hills with subtle detail | |
| const h1 = fbm(x, z, 1 / 90, 4, 2, 0.5, seed); | |
| const h2 = fbm(x, z, 1 / 28, 3, 2, 0.5, seed + 1337); | |
| const h3 = fbm(x, z, 1 / 9, 2, 2, 0.5, seed + 4242); | |
| let h = h1 * 3.6 + h2 * 1.7 + h3 * 0.6; // total amplitude ~ up to ~6-7 | |
| // Soften near the center to keep spawn area playable | |
| const r = Math.hypot(x, z); | |
| const mask = smoothstep(CFG.clearRadius * 0.8, CFG.clearRadius * 1.8, r); | |
| h *= mask; | |
| return h; | |
| } | |
| // --- Procedural ground textures (albedo + normal) --- | |
| function generateGroundTextures(size = 1024) { | |
| const seed = (CFG.seed | 0) ^ 0x51f9ac4d; | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = size; canvas.height = size; | |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); | |
| const nCanvas = document.createElement('canvas'); | |
| nCanvas.width = size; nCanvas.height = size; | |
| const nctx = nCanvas.getContext('2d'); | |
| // Choose a periodic cell count that divides nicely for octaves | |
| // Bigger = less obvious repeats; must keep perf reasonable | |
| const cells = 64; // base lattice cells across the tile | |
| // Periodic hash: wrap lattice coordinates to make the noise tile | |
| function hash2Periodic(xi, yi, period, s) { | |
| const px = ((xi % period) + period) % period; | |
| const py = ((yi % period) + period) % period; | |
| return hash2i(px, py, s); | |
| } | |
| function valueNoise2Periodic(x, z, period, s) { | |
| const xi = Math.floor(x); | |
| const zi = Math.floor(z); | |
| const xf = x - xi; | |
| const zf = z - zi; | |
| const sx = smoothstep(0, 1, xf); | |
| const sz = smoothstep(0, 1, zf); | |
| const v00 = hash2Periodic(xi, zi, period, s); | |
| const v10 = hash2Periodic(xi + 1, zi, period, s); | |
| const v01 = hash2Periodic(xi, zi + 1, period, s); | |
| const v11 = hash2Periodic(xi + 1, zi + 1, period, s); | |
| const x1 = lerp(v00, v10, sx); | |
| const x2 = lerp(v01, v11, sx); | |
| return lerp(x1, x2, sz) * 2 - 1; | |
| } | |
| function fbmPeriodic(x, z, octaves, s) { | |
| let sum = 0; | |
| let amp = 0.5; | |
| let freq = 1; | |
| for (let i = 0; i < octaves; i++) { | |
| const period = cells * freq; | |
| sum += amp * valueNoise2Periodic(x * freq, z * freq, period, s + i * 1013); | |
| freq *= 2; | |
| amp *= 0.5; | |
| } | |
| return sum; // roughly [-1,1] | |
| } | |
| // Precompute a height-ish field for normal derivation (two-pass) | |
| const H = new Float32Array(size * size); | |
| const idx = (x, y) => y * size + x; | |
| for (let y = 0; y < size; y++) { | |
| for (let x = 0; x < size; x++) { | |
| // Map pixel -> tile domain [0, cells] | |
| const u = (x / size) * cells; | |
| const v = (y / size) * cells; | |
| const nLow = fbmPeriodic(u * 0.75, v * 0.75, 3, seed); | |
| const nHi = fbmPeriodic(u * 3.0, v * 3.0, 2, seed + 999); | |
| // Height proxy for normals: mix broad and fine details | |
| const h = 0.6 * (nLow * 0.5 + 0.5) + 0.4 * (nHi * 0.5 + 0.5); | |
| H[idx(x, y)] = h; | |
| } | |
| } | |
| const img = ctx.createImageData(size, size); | |
| const data = img.data; | |
| const nimg = nctx.createImageData(size, size); | |
| const ndata = nimg.data; | |
| // Palettes | |
| const grassDark = [0x20, 0x5a, 0x2b]; // #205a2b deep green | |
| const grassLight = [0x4c, 0x9a, 0x3b]; // #4c9a3b lively green | |
| const dryGrass = [0x88, 0xa0, 0x55]; // #88a055 sun-kissed | |
| const dirtDark = [0x4f, 0x39, 0x2c]; // #4f392c rich soil | |
| const dirtLight = [0x73, 0x5a, 0x48]; // #735a48 lighter soil | |
| function mixColor(a, b, t) { | |
| return [ | |
| Math.round(lerp(a[0], b[0], t)), | |
| Math.round(lerp(a[1], b[1], t)), | |
| Math.round(lerp(a[2], b[2], t)) | |
| ]; | |
| } | |
| // Second pass: color + normal | |
| const strength = 2.2; // normal intensity | |
| for (let y = 0; y < size; y++) { | |
| for (let x = 0; x < size; x++) { | |
| const u = (x / size) * cells; | |
| const v = (y / size) * cells; | |
| // Patchiness control | |
| const broad = fbmPeriodic(u * 0.8, v * 0.8, 3, seed + 17) * 0.5 + 0.5; | |
| const detail = fbmPeriodic(u * 3.2, v * 3.2, 2, seed + 23) * 0.5 + 0.5; | |
| let grassness = smoothstep(0.38, 0.62, broad); | |
| grassness = lerp(grassness, grassness * (0.7 + 0.3 * detail), 0.5); | |
| // Choose palette and mix for variation | |
| const grassMid = mixColor(grassDark, grassLight, 0.6); | |
| const grassCol = mixColor(grassMid, dryGrass, 0.25 + 0.35 * detail); | |
| const dirtCol = mixColor(dirtDark, dirtLight, 0.35 + 0.4 * detail); | |
| const col = mixColor(dirtCol, grassCol, grassness); | |
| const p = idx(x, y) * 4; | |
| data[p + 0] = col[0]; | |
| data[p + 1] = col[1]; | |
| data[p + 2] = col[2]; | |
| data[p + 3] = 255; | |
| // Normal from height field with wrapping | |
| const xL = (x - 1 + size) % size, xR = (x + 1) % size; | |
| const yT = (y - 1 + size) % size, yB = (y + 1) % size; | |
| const hL = H[idx(xL, y)], hR = H[idx(xR, y)]; | |
| const hT = H[idx(x, yT)], hB = H[idx(x, yB)]; | |
| const dx = (hR - hL) * strength; | |
| const dy = (hB - hT) * strength; | |
| let nx = -dx, ny = -dy, nz = 1.0; | |
| const invLen = 1 / Math.hypot(nx, ny, nz); | |
| nx *= invLen; ny *= invLen; nz *= invLen; | |
| ndata[p + 0] = Math.round((nx * 0.5 + 0.5) * 255); | |
| ndata[p + 1] = Math.round((ny * 0.5 + 0.5) * 255); | |
| ndata[p + 2] = Math.round((nz * 0.5 + 0.5) * 255); | |
| ndata[p + 3] = 255; | |
| } | |
| } | |
| ctx.putImageData(img, 0, 0); | |
| nctx.putImageData(nimg, 0, 0); | |
| const map = new THREE.CanvasTexture(canvas); | |
| map.colorSpace = THREE.SRGBColorSpace; | |
| map.generateMipmaps = true; | |
| map.minFilter = THREE.LinearMipmapLinearFilter; | |
| map.magFilter = THREE.LinearFilter; | |
| map.wrapS = map.wrapT = THREE.RepeatWrapping; | |
| const normalMap = new THREE.CanvasTexture(nCanvas); | |
| normalMap.generateMipmaps = true; | |
| normalMap.minFilter = THREE.LinearMipmapLinearFilter; | |
| normalMap.magFilter = THREE.LinearFilter; | |
| normalMap.wrapS = normalMap.wrapT = THREE.RepeatWrapping; | |
| // Anisotropy if available | |
| try { | |
| const maxAniso = G.renderer && G.renderer.capabilities ? G.renderer.capabilities.getMaxAnisotropy() : 0; | |
| if (maxAniso && maxAniso > 0) { | |
| map.anisotropy = Math.min(8, maxAniso); | |
| normalMap.anisotropy = Math.min(8, maxAniso); | |
| } | |
| } catch (_) {} | |
| return { map, normalMap }; | |
| } | |
| export function setupGround() { | |
| const segs = 160; // enough resolution for smooth hills | |
| const geometry = new THREE.PlaneGeometry(CFG.forestSize, CFG.forestSize, segs, segs); | |
| geometry.rotateX(-Math.PI / 2); | |
| // Displace vertices along Y using our height function | |
| const pos = geometry.attributes.position; | |
| const colors = new Float32Array(pos.count * 3); | |
| let minY = Infinity, maxY = -Infinity; | |
| for (let i = 0; i < pos.count; i++) { | |
| const x = pos.getX(i); | |
| const z = pos.getZ(i); | |
| const y = getTerrainHeight(x, z); | |
| pos.setY(i, y); | |
| if (y < minY) minY = y; | |
| if (y > maxY) maxY = y; | |
| } | |
| pos.needsUpdate = true; | |
| geometry.computeVertexNormals(); | |
| // Macro vertex colors based on slope (normal.y) and height | |
| const nrm = geometry.attributes.normal; | |
| for (let i = 0; i < pos.count; i++) { | |
| const x = pos.getX(i); | |
| const z = pos.getZ(i); | |
| const y = pos.getY(i); | |
| const ny = nrm.getY(i); | |
| const r = Math.hypot(x, z); | |
| const clear = 1.0 - smoothstep(CFG.clearRadius * 0.7, CFG.clearRadius * 1.4, r); | |
| const flat = smoothstep(0.6, 0.96, ny); // flat areas -> grass | |
| const hNorm = (y - minY) / Math.max(1e-5, (maxY - minY)); | |
| // Grass tint varies with height; dirt tint more constant | |
| const grassDark = new THREE.Color(0x1f4f28); | |
| const grassLight = new THREE.Color(0x3f8f3a); | |
| const dirtDark = new THREE.Color(0x4a3a2e); | |
| const dirtLight = new THREE.Color(0x6a5040); | |
| const grassTint = grassDark.clone().lerp(grassLight, 0.35 + 0.45 * hNorm); | |
| const dirtTint = dirtDark.clone().lerp(dirtLight, 0.35); | |
| // Reduce grass in the central clearing for readability | |
| const grassness = THREE.MathUtils.clamp(flat * (1.0 - 0.65 * clear), 0, 1); | |
| const tint = dirtTint.clone().lerp(grassTint, grassness); | |
| colors[i * 3 + 0] = tint.r; | |
| colors[i * 3 + 1] = tint.g; | |
| colors[i * 3 + 2] = tint.b; | |
| } | |
| geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); | |
| // High-frequency detail textures (tileable) + macro vertex tint | |
| const { map, normalMap } = generateGroundTextures(1024); | |
| // Repeat detail across the forest (fewer repeats = larger features) | |
| // Previously: forestSize / 4 (very fine, looked too uniform) | |
| const repeats = Math.max(12, Math.round(CFG.forestSize / 12)); | |
| map.repeat.set(repeats, repeats); | |
| normalMap.repeat.set(repeats, repeats); | |
| const material = new THREE.MeshStandardMaterial({ | |
| color: 0xffffff, | |
| map, | |
| normalMap, | |
| roughness: 0.95, | |
| metalness: 0.0, | |
| vertexColors: true | |
| }); | |
| // Add a subtle world-space macro variation to break tiling repetition | |
| material.onBeforeCompile = (shader) => { | |
| shader.uniforms.uMacroScale = { value: 0.035 }; // frequency in world units | |
| shader.uniforms.uMacroStrength = { value: 0.28 }; // mix into base color | |
| shader.vertexShader = ( | |
| 'varying vec3 vWorldPos;\n' + | |
| shader.vertexShader | |
| ).replace( | |
| '#include <worldpos_vertex>', | |
| `#include <worldpos_vertex> | |
| vWorldPos = worldPosition.xyz;` | |
| ); | |
| // Cheap 2D value-noise FBM in fragment to modulate albedo in world space | |
| const NOISE_CHUNK = ` | |
| varying vec3 vWorldPos; | |
| uniform float uMacroScale; | |
| uniform float uMacroStrength; | |
| float hash12(vec2 p){ | |
| vec3 p3 = fract(vec3(p.xyx) * 0.1031); | |
| p3 += dot(p3, p3.yzx + 33.33); | |
| return fract((p3.x + p3.y) * p3.z); | |
| } | |
| float vnoise(vec2 p){ | |
| vec2 i = floor(p); | |
| vec2 f = fract(p); | |
| float a = hash12(i); | |
| float b = hash12(i + vec2(1.0, 0.0)); | |
| float c = hash12(i + vec2(0.0, 1.0)); | |
| float d = hash12(i + vec2(1.0, 1.0)); | |
| vec2 u = f*f*(3.0-2.0*f); | |
| return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y; | |
| } | |
| float fbm2(vec2 p){ | |
| float t = 0.0; | |
| float amp = 0.5; | |
| for(int i=0;i<4;i++){ | |
| t += amp * vnoise(p); | |
| p *= 2.0; | |
| amp *= 0.5; | |
| } | |
| return t; | |
| } | |
| `; | |
| shader.fragmentShader = ( | |
| NOISE_CHUNK + shader.fragmentShader | |
| ).replace( | |
| '#include <map_fragment>', | |
| `#include <map_fragment> | |
| // World-space macro color variation to reduce visible tiling | |
| vec2 st = vWorldPos.xz * uMacroScale; | |
| float macro = fbm2(st); | |
| macro = macro * 0.5 + 0.5; // [0,1] | |
| float m = mix(0.82, 1.18, macro); | |
| diffuseColor.rgb *= mix(1.0, m, uMacroStrength);` | |
| ); | |
| }; | |
| const ground = new THREE.Mesh(geometry, material); | |
| ground.receiveShadow = true; | |
| G.scene.add(ground); | |
| G.ground = ground; | |
| // With instanced trees we approximate trunk blocking via spatial grid; keep raycast blockers minimal | |
| G.blockers = [ground]; | |
| } | |
| export function generateForest() { | |
| const clearRadiusSq = CFG.clearRadius * CFG.clearRadius; | |
| const halfSize = CFG.forestSize / 2; | |
| let placed = 0; | |
| const maxAttempts = CFG.treeCount * 3; | |
| let attempts = 0; | |
| // Reset data | |
| G.treeColliders.length = 0; | |
| if (!G.treeTrunks) G.treeTrunks = []; | |
| G.treeTrunks.length = 0; // retained for compatibility; no longer populated with individual meshes | |
| if (!G.treeMeshes) G.treeMeshes = []; | |
| G.treeMeshes.length = 0; | |
| // Prepare instanced batches (upper bound capacity = CFG.treeCount) | |
| const trunkIM = new THREE.InstancedMesh(GEO_TRUNK, TRUNK_MAT, CFG.treeCount); | |
| trunkIM.castShadow = true; trunkIM.receiveShadow = true; | |
| const cone1IM = new THREE.InstancedMesh(GEO_CONE1, FOLIAGE_MAT, CFG.treeCount); | |
| const cone2IM = new THREE.InstancedMesh(GEO_CONE2, FOLIAGE_MAT, CFG.treeCount); | |
| const cone3IM = new THREE.InstancedMesh(GEO_CONE3, FOLIAGE_MAT, CFG.treeCount); | |
| const crownIM = new THREE.InstancedMesh(GEO_SPH, FOLIAGE_MAT, CFG.treeCount); | |
| cone1IM.castShadow = cone2IM.castShadow = cone3IM.castShadow = crownIM.castShadow = false; | |
| cone1IM.receiveShadow = cone2IM.receiveShadow = cone3IM.receiveShadow = crownIM.receiveShadow = true; | |
| const m = new THREE.Matrix4(); | |
| const quat = new THREE.Quaternion(); | |
| const scl = new THREE.Vector3(); | |
| while (placed < CFG.treeCount && attempts < maxAttempts) { | |
| attempts++; | |
| const x = (G.random() - 0.5) * CFG.forestSize; | |
| const z = (G.random() - 0.5) * CFG.forestSize; | |
| // Check clearing | |
| if (x * x + z * z < clearRadiusSq) continue; | |
| // Check distance to other trees | |
| let tooClose = false; | |
| for (const collider of G.treeColliders) { | |
| const dx = x - collider.x; | |
| const dz = z - collider.z; | |
| if (dx * dx + dz * dz < Math.pow(collider.radius * 2, 2)) { tooClose = true; break; } | |
| } | |
| if (tooClose) continue; | |
| // Random uniform scale and rotation per tree | |
| const s = 0.75 + G.random() * 0.8; // ~0.75..1.55 | |
| const fScale = s * (0.9 + G.random() * 0.15); | |
| const yaw = G.random() * Math.PI * 2; | |
| quat.setFromEuler(new THREE.Euler(0, yaw, 0)); | |
| const y = getTerrainHeight(x, z); | |
| m.compose(new THREE.Vector3(x, y, z), quat, new THREE.Vector3(s, s, s)); | |
| trunkIM.setMatrixAt(placed, m); | |
| // Foliage uses same transform but with foliage scale factor | |
| m.compose(new THREE.Vector3(x, y, z), quat, new THREE.Vector3(fScale, fScale, fScale)); | |
| cone1IM.setMatrixAt(placed, m); | |
| cone2IM.setMatrixAt(placed, m); | |
| cone3IM.setMatrixAt(placed, m); | |
| crownIM.setMatrixAt(placed, m); | |
| // Collider roughly matching trunk base radius | |
| const trunkBaseRadius = 1.2 * s; | |
| G.treeColliders.push({ x, z, radius: trunkBaseRadius }); | |
| placed++; | |
| } | |
| trunkIM.count = placed; cone1IM.count = placed; cone2IM.count = placed; cone3IM.count = placed; crownIM.count = placed; | |
| trunkIM.instanceMatrix.needsUpdate = true; | |
| cone1IM.instanceMatrix.needsUpdate = cone2IM.instanceMatrix.needsUpdate = true; | |
| cone3IM.instanceMatrix.needsUpdate = crownIM.instanceMatrix.needsUpdate = true; | |
| if (placed > 0) { | |
| G.scene.add(trunkIM, cone1IM, cone2IM, cone3IM, crownIM); | |
| } | |
| // With instancing, keep blockers to ground; tree collisions handled via grid tests | |
| if (G.ground) { | |
| G.blockers = [G.ground]; | |
| } else { | |
| G.blockers = []; | |
| } | |
| buildTreeGrid(); | |
| } | |
| // ---- Spatial index for tree colliders ---- | |
| // Simple uniform grid over the world to reduce O(N) scans | |
| export function buildTreeGrid(cellSize = 12) { | |
| const half = CFG.forestSize / 2; | |
| const minX = -half, minZ = -half; | |
| const cols = Math.max(1, Math.ceil(CFG.forestSize / cellSize)); | |
| const rows = Math.max(1, Math.ceil(CFG.forestSize / cellSize)); | |
| const cells = new Array(cols * rows); | |
| for (let i = 0; i < cells.length; i++) cells[i] = []; | |
| function cellIndex(ix, iz) { return iz * cols + ix; } | |
| for (const t of G.treeColliders) { | |
| const ix = Math.max(0, Math.min(cols - 1, Math.floor((t.x - minX) / cellSize))); | |
| const iz = Math.max(0, Math.min(rows - 1, Math.floor((t.z - minZ) / cellSize))); | |
| cells[cellIndex(ix, iz)].push(t); | |
| } | |
| G.treeGrid = { cellSize, minX, minZ, cols, rows, cells }; | |
| } | |
| const _nearTrees = []; | |
| function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); } | |
| // Gathers tree colliders around a point within a given radius (XZ plane) | |
| export function getNearbyTrees(x, z, radius = 4) { | |
| _nearTrees.length = 0; | |
| const grid = G.treeGrid; | |
| if (!grid) return _nearTrees; | |
| const { cellSize, minX, minZ, cols, rows, cells } = grid; | |
| const r = Math.max(radius, 0); | |
| const minIx = clamp(Math.floor((x - r - minX) / cellSize), 0, cols - 1); | |
| const maxIx = clamp(Math.floor((x + r - minX) / cellSize), 0, cols - 1); | |
| const minIz = clamp(Math.floor((z - r - minZ) / cellSize), 0, rows - 1); | |
| const maxIz = clamp(Math.floor((z + r - minZ) / cellSize), 0, rows - 1); | |
| for (let iz = minIz; iz <= maxIz; iz++) { | |
| for (let ix = minIx; ix <= maxIx; ix++) { | |
| const cell = cells[iz * cols + ix]; | |
| for (let k = 0; k < cell.length; k++) _nearTrees.push(cell[k]); | |
| } | |
| } | |
| return _nearTrees; | |
| } | |
| const _aabbTrees = []; | |
| export function getTreesInAABB(minX, minZ, maxX, maxZ) { | |
| _aabbTrees.length = 0; | |
| const grid = G.treeGrid; | |
| if (!grid) return _aabbTrees; | |
| const { cellSize, minX: gx, minZ: gz, cols, rows, cells } = grid; | |
| const minIx = clamp(Math.floor((minX - gx) / cellSize), 0, cols - 1); | |
| const maxIx = clamp(Math.floor((maxX - gx) / cellSize), 0, cols - 1); | |
| const minIz = clamp(Math.floor((minZ - gz) / cellSize), 0, rows - 1); | |
| const maxIz = clamp(Math.floor((maxZ - gz) / cellSize), 0, rows - 1); | |
| for (let iz = minIz; iz <= maxIz; iz++) { | |
| for (let ix = minIx; ix <= maxIx; ix++) { | |
| const cell = cells[iz * cols + ix]; | |
| for (let k = 0; k < cell.length; k++) _aabbTrees.push(cell[k]); | |
| } | |
| } | |
| return _aabbTrees; | |
| } | |
| // Fast 2D segment-vs-circle test | |
| function segIntersectsCircle(x1, z1, x2, z2, cx, cz, r) { | |
| const vx = x2 - x1, vz = z2 - z1; | |
| const wx = cx - x1, wz = cz - z1; | |
| const vv = vx * vx + vz * vz; | |
| if (vv <= 1e-6) return false; | |
| let t = (wx * vx + wz * vz) / vv; | |
| if (t < 0) t = 0; else if (t > 1) t = 1; | |
| const px = x1 + t * vx, pz = z1 + t * vz; | |
| const dx = cx - px, dz = cz - pz; | |
| return (dx * dx + dz * dz) <= r * r; | |
| } | |
| // Approximate line-of-sight using tree cylinders and terrain samples (no raycaster) | |
| export function hasLineOfSight(from, to) { | |
| // Terrain occlusion: sample a few points along the ray | |
| const steps = 6; | |
| for (let i = 1; i < steps; i++) { | |
| const t = i / steps; | |
| const x = from.x + (to.x - from.x) * t; | |
| const z = from.z + (to.z - from.z) * t; | |
| const y = from.y + (to.y - from.y) * t; | |
| const gy = getTerrainHeight(x, z) + 0.1; | |
| if (y <= gy) return false; | |
| } | |
| // Tree occlusion using grid-restricted set | |
| const minX = Math.min(from.x, to.x) - 2.0; | |
| const maxX = Math.max(from.x, to.x) + 2.0; | |
| const minZ = Math.min(from.z, to.z) - 2.0; | |
| const maxZ = Math.max(from.z, to.z) + 2.0; | |
| const candidates = getTreesInAABB(minX, minZ, maxX, maxZ); | |
| for (let i = 0; i < candidates.length; i++) { | |
| const t = candidates[i]; | |
| // Use a slightly inflated radius to account for foliage | |
| const r = t.radius + 0.3; | |
| if (segIntersectsCircle(from.x, from.z, to.x, to.z, t.x, t.z, r)) { | |
| // If the segment is sufficiently high (e.g., arrow arc), allow pass | |
| // Estimate height at closest approach t in [0,1] | |
| const vx = to.x - from.x, vz = to.z - from.z; | |
| const wx = t.x - from.x, wz = t.z - from.z; | |
| const vv = vx * vx + vz * vz; | |
| let u = (wx * vx + wz * vz) / (vv || 1); | |
| if (u < 0) u = 0; else if (u > 1) u = 1; | |
| const yAt = from.y + (to.y - from.y) * u; | |
| if (yAt < 8) return false; // below canopy -> blocked | |
| } | |
| } | |
| return true; | |
| } | |