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