From 26c865fafef28ebf2c25307dd16ab3160420f838 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 17 Mar 2026 02:37:30 +0800 Subject: [PATCH 01/71] tree shader --- .../src/systems/shared/world/GPUMaterials.ts | 67 +++++++++---------- 1 file changed, 32 insertions(+), 35 deletions(-) diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index be8538b23..d5c99f00e 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -944,7 +944,7 @@ export function applyRimHighlight( } // ============================================================================ -// TREE DISSOLVE MATERIAL (FORTNITE-STYLE FOLIAGE SHADING) +// TREE DISSOLVE MATERIAL (TOON FOLIAGE SHADING) // ============================================================================ /** @@ -956,10 +956,10 @@ export type TreeMaterialOptions = DissolveMaterialOptions & { }; /** - * Tree-specific dissolve material with soft stylized shading. + * Tree-specific dissolve material with toon shading. * Extends DissolveMaterial with: - * - Soft clamped lighting (Lambert compressed to [0.7, 1.0], no harsh shadows) - * - Fresnel edge brightening on leaves (morpho-style rim glow) + * - Quantized 3-band toon lighting (hard-edged shadow / mid / bright) + * - Hard-edged Fresnel rim on leaves * - Back-SSS translucency for leaves (warm glow when backlit by sun) * - Wind vertex animation for leaves * - Vertex-color AO (G channel darkens crevices) @@ -977,13 +977,13 @@ export type TreeDissolveMaterial = DissolveMaterial & { }; /** - * Creates a tree dissolve material with soft clamped lighting, SSS, and wind. + * Creates a tree dissolve material with toon lighting, SSS, and wind. * - * 1. **Soft lighting** — Lambert + smoothstep clamped to [0.7, 1.0] (no harsh shadows). + * 1. **Toon lighting** — Quantized 3-band Lambert (hard shadow / mid / bright). * 2. **AO** — Vertex color G channel as ambient occlusion. * 3. **SSS** — Back-scatter translucency on leaf materials (warm glow when backlit). * 4. **Wind** — Sine-wave vertex displacement on leaf materials. - * 5. **Edge brightening** — Fresnel-based rim glow on leaves (morpho effect). + * 5. **Rim** — Hard-edged Fresnel rim on leaves. * 6. **Saturation** — Subtle boost keeps colors rich. * 7. **Rim highlight** — Per-instance Fresnel glow for hover feedback. * @@ -1021,12 +1021,13 @@ export function createTreeDissolveMaterial( const HL_BRIGHTEN = 0.08; const HL_RIM_POWER = 2.5; const HL_RIM_STRENGTH = 0.4; - const LIGHT_AMBIENT = 0.3; - const LIGHT_DIFFUSE_STR = 2.0; - const LIGHT_CLAMP_LO = 0.7; - const LIGHT_CLAMP_HI = 1.0; - const LIGHT_AMBIENT_BOOST = 0.15; - const EDGE_BRIGHT = 1.25; + const TOON_DARK = 0.5; + const TOON_MID = 0.75; + const TOON_BRIGHT = 1.0; + const TOON_SHADOW_EDGE = 0.0; + const TOON_MID_EDGE = 0.5; + const TOON_RIM_THRESHOLD = 0.3; + const TOON_RIM_BRIGHT = 1.3; const NIGHT_MIN_BRIGHTNESS = 0.3; // --- Wind vertex displacement (leaf materials only) --- @@ -1062,7 +1063,7 @@ export function createTreeDissolveMaterial( ); material.fog = false; - // --- Output: soft clamped lighting (bypass PBR, compute Lambert from scratch) --- + // --- Output: toon lighting (bypass PBR, compute Lambert from scratch) --- const albedoMap = material.map; const matColor = vec3(material.color.r, material.color.g, material.color.b); @@ -1084,33 +1085,28 @@ export function createTreeDissolveMaterial( baseAlbedo = mul(baseAlbedo, aoMul); } - // ---- Custom Lambert lighting (sphere normals baked into vertex attribute) ---- + // ---- 3-band toon lighting (hard-edged shadow / mid / bright) ---- const N = normalize(mul(modelNormalMatrix, normalLocal)); const L = normalize(vec3(uSunDir)); const NdotL = dot(N, L); const sunI = clamp(uSunIntensity, float(0.0), float(2.0)); const dayFactor = div(sunI, float(2.0)); - const diffuse = mul( - mul(max(NdotL, float(0.0)), float(LIGHT_DIFFUSE_STR)), - sunI, - ); - const ambient = float(LIGHT_AMBIENT); - const totalLight = add(ambient, diffuse); - const softLight = clamp( - smoothstep(float(0.9), float(1.1), totalLight), - float(LIGHT_CLAMP_LO), - float(LIGHT_CLAMP_HI), + const band1 = step(float(TOON_SHADOW_EDGE), NdotL); + const band2 = step(float(TOON_MID_EDGE), NdotL); + const toonLight = add( + float(TOON_DARK), + add( + mul(band1, float(TOON_MID - TOON_DARK)), + mul(band2, float(TOON_BRIGHT - TOON_MID)), + ), ); const nightDim = mix(float(NIGHT_MIN_BRIGHTNESS), float(1.0), dayFactor); - let result: any = mul( - baseAlbedo, - mul(add(softLight, float(LIGHT_AMBIENT_BOOST)), nightDim), - ); + let result: any = mul(baseAlbedo, mul(toonLight, nightDim)); // ---- Sun shade (shadow-side sky tint, matches terrain) ---- result = applyTerrainSunShade(result, N, L, vec3(uShadeColor)); - // ---- SSS + Fresnel edge brightening (leaf only, scaled by dayFactor) ---- + // ---- SSS + hard-edged toon rim (leaf only, scaled by dayFactor) ---- if (isLeaf) { const V = normalize(sub(cameraPosition, positionWorld)); @@ -1123,14 +1119,15 @@ export function createTreeDissolveMaterial( ); result = add(result, mul(vec3(0.95, 1.0, 0.7), sssFactor)); - // Edge brightening (fades at night) + // Hard-edged toon rim (fades at night) const EDotN = clamp(dot(V, N), float(0.0), float(1.0)); - const edgeBright = mix( - float(EDGE_BRIGHT), + const rimMask = sub(float(1.0), step(float(TOON_RIM_THRESHOLD), EDotN)); + const rimBright = mix( float(1.0), - sub(float(1.0), dayFactor), + float(TOON_RIM_BRIGHT), + mul(rimMask, dayFactor), ); - result = mix(mul(result, edgeBright), result, EDotN); + result = mul(result, rimBright); } // ---- Saturation boost (scales with dayFactor so night stays muted) ---- From 86c9774a1a64adde137225b54157ad32492c6aa5 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 17 Mar 2026 05:27:39 +0800 Subject: [PATCH 02/71] refactor: unify sun-shade timing between tree and terrain Centralize lighting config into LightingConfig.ts. Replace tree's rawSunDir-based shade with dayIntensity-driven tint so trees and terrain (scene lights) transition to blue at the same rate. Remove unused applySunShade function and rawSunPosition plumbing. Made-with: Cursor --- .../src/systems/shared/world/Environment.ts | 160 +++++++++--------- .../shared/world/GLBTreeBatchedInstancer.ts | 13 +- .../systems/shared/world/GLBTreeInstancer.ts | 15 +- .../src/systems/shared/world/GPUMaterials.ts | 57 ++----- .../systems/shared/world/LightingConfig.ts | 147 ++++++++++++++++ .../src/systems/shared/world/TerrainShader.ts | 23 +-- .../src/systems/shared/world/TerrainSystem.ts | 15 +- 7 files changed, 262 insertions(+), 168 deletions(-) create mode 100644 packages/shared/src/systems/shared/world/LightingConfig.ts diff --git a/packages/shared/src/systems/shared/world/Environment.ts b/packages/shared/src/systems/shared/world/Environment.ts index 173116cb6..c5e3552f3 100644 --- a/packages/shared/src/systems/shared/world/Environment.ts +++ b/packages/shared/src/systems/shared/world/Environment.ts @@ -8,6 +8,14 @@ import { System } from "../infrastructure/System"; import { SkySystem } from "./SkySystem"; import { setLamppostNightMix } from "./LamppostLightMask"; import { FOG_NEAR, FOG_FAR } from "./FogConfig"; +import { + DAY_CYCLE, + SUN_LIGHT, + HEMISPHERE_LIGHT, + AMBIENT_LIGHT, + EXPOSURE, + FOG_COLORS, +} from "./LightingConfig"; import type { BaseEnvironment, EnvironmentModel, @@ -154,11 +162,7 @@ export class Environment extends System { private lastLightAnchor: THREE.Vector3 = new THREE.Vector3(); // Camera anchor position private readonly LIGHT_DISTANCE = 400; // Distance from target to light - // Auto exposure settings - mimics eye adaptation to different light levels - // Higher exposure at night compensates for lower light, keeping things visible - private readonly DAY_EXPOSURE = 0.85; // Standard exposure for bright daylight - private readonly NIGHT_EXPOSURE = 1.7; // Boosted exposure for night visibility - private currentExposure: number = 0.85; // Smoothed current value + private currentExposure: number = EXPOSURE.DAY; // CSMShadowNode for WebGPU cascaded shadows private csmShadowNode: InstanceType | null = null; @@ -531,24 +535,34 @@ export class Environment extends System { // =================== // TRANSITION FADE - fade light out during sun/moon swap // =================== - const DAWN_START = 0.22; - const DAWN_MID = 0.25; - const DAWN_END = 0.28; - const DUSK_START = 0.72; - const DUSK_MID = 0.75; - const DUSK_END = 0.78; - let transitionFade = 1.0; - if (dayPhase >= DAWN_START && dayPhase < DAWN_MID) { + if (dayPhase >= DAY_CYCLE.DAWN_START && dayPhase < DAY_CYCLE.DAWN_MID) { + transitionFade = + 1.0 - + (dayPhase - DAY_CYCLE.DAWN_START) / + (DAY_CYCLE.DAWN_MID - DAY_CYCLE.DAWN_START); + } else if ( + dayPhase >= DAY_CYCLE.DAWN_MID && + dayPhase < DAY_CYCLE.DAWN_END + ) { + transitionFade = + (dayPhase - DAY_CYCLE.DAWN_MID) / + (DAY_CYCLE.DAWN_END - DAY_CYCLE.DAWN_MID); + } else if ( + dayPhase >= DAY_CYCLE.DUSK_START && + dayPhase < DAY_CYCLE.DUSK_MID + ) { transitionFade = - 1.0 - (dayPhase - DAWN_START) / (DAWN_MID - DAWN_START); - } else if (dayPhase >= DAWN_MID && dayPhase < DAWN_END) { - transitionFade = (dayPhase - DAWN_MID) / (DAWN_END - DAWN_MID); - } else if (dayPhase >= DUSK_START && dayPhase < DUSK_MID) { + 1.0 - + (dayPhase - DAY_CYCLE.DUSK_START) / + (DAY_CYCLE.DUSK_MID - DAY_CYCLE.DUSK_START); + } else if ( + dayPhase >= DAY_CYCLE.DUSK_MID && + dayPhase < DAY_CYCLE.DUSK_END + ) { transitionFade = - 1.0 - (dayPhase - DUSK_START) / (DUSK_MID - DUSK_START); - } else if (dayPhase >= DUSK_MID && dayPhase < DUSK_END) { - transitionFade = (dayPhase - DUSK_MID) / (DUSK_END - DUSK_MID); + (dayPhase - DAY_CYCLE.DUSK_MID) / + (DAY_CYCLE.DUSK_END - DAY_CYCLE.DUSK_MID); } transitionFade = transitionFade * transitionFade * (3 - 2 * transitionFade); // smoothstep @@ -558,40 +572,40 @@ export class Environment extends System { // Use target direction + interpolation to prevent sudden jumps // =================== if (isDay) { - // Daytime: light comes FROM the sun (negate sunDirection which points TO sun) this.targetLightDirection.copy(this.skySystem.sunDirection).negate(); } else { - // Nighttime: light comes FROM the moon (at -sunDirection position) this.targetLightDirection.copy(this.skySystem.sunDirection); } - // Smooth interpolation to prevent sudden direction changes causing flicker - // Lerp factor of 0.02 = ~50 frames to reach target (smooth over ~1 second at 60fps) - this.lightDirection.lerp(this.targetLightDirection, 0.02); + this.lightDirection.lerp( + this.targetLightDirection, + SUN_LIGHT.DIRECTION_LERP, + ); // =================== // LIGHT INTENSITY & COLOR - Single light, simple and correct // =================== if (isDay) { - // Sunlight - warm golden light - const sunIntensity = dayIntensity * 1.8 * transitionFade; + const sunIntensity = + dayIntensity * SUN_LIGHT.DAY_INTENSITY_MULTIPLIER * transitionFade; this.sunLight.intensity = sunIntensity; - // Golden hour coloring near horizon - const nearHorizon = - (dayPhase >= 0.22 && dayPhase < 0.32) || - (dayPhase >= 0.68 && dayPhase < 0.78); + const nearHorizon = SUN_LIGHT.GOLDEN_HOUR_RANGES.some( + ([start, end]) => dayPhase >= start && dayPhase < end, + ); if (nearHorizon) { - this.sunLight.color.setRGB(1.0, 0.85, 0.6); + this.sunLight.color.setRGB(...SUN_LIGHT.GOLDEN_HOUR_COLOR); } else { - this.sunLight.color.setRGB(1.0, 0.98, 0.92); + this.sunLight.color.setRGB(...SUN_LIGHT.DAY_COLOR); } } else { - // Moonlight - cool blue light (stronger for better night visibility) const nightIntensity = 1 - dayIntensity; - const moonIntensity = nightIntensity * 0.6 * transitionFade; + const moonIntensity = + nightIntensity * + SUN_LIGHT.MOON_INTENSITY_MULTIPLIER * + transitionFade; this.sunLight.intensity = moonIntensity; - this.sunLight.color.setRGB(0.6, 0.7, 0.9); + this.sunLight.color.setRGB(...SUN_LIGHT.MOON_COLOR); } // =================== @@ -744,35 +758,38 @@ export class Environment extends System { const nightIntensity = 1 - dayIntensity; if (this.hemisphereLight) { - // Hemisphere light: brighter during day, visible at night - // Day: 0.9, Night: 0.4 (auto exposure handles the rest) - this.hemisphereLight.intensity = 0.4 + dayIntensity * 0.5; + this.hemisphereLight.intensity = + HEMISPHERE_LIGHT.INTENSITY_BASE + + dayIntensity * HEMISPHERE_LIGHT.INTENSITY_DAY_ADD; - // Shift sky color from bright blue (day) to blue-silver (night) + const [dR, dG, dB] = HEMISPHERE_LIGHT.DAY_SKY_COLOR; + const [nR, nG, nB] = HEMISPHERE_LIGHT.NIGHT_SKY_COLOR; this.hemisphereLight.color.setRGB( - 0.53 * dayIntensity + 0.25 * nightIntensity, // R: moonlit sky - 0.81 * dayIntensity + 0.35 * nightIntensity, // G: moonlit sky - 0.92 * dayIntensity + 0.5 * nightIntensity, // B: blue tint at night + dR * dayIntensity + nR * nightIntensity, + dG * dayIntensity + nG * nightIntensity, + dB * dayIntensity + nB * nightIntensity, ); - // Ground color: warm brown during day, blue-grey at night + const [dgR, dgG, dgB] = HEMISPHERE_LIGHT.DAY_GROUND_COLOR; + const [ngR, ngG, ngB] = HEMISPHERE_LIGHT.NIGHT_GROUND_COLOR; this.hemisphereLight.groundColor.setRGB( - 0.36 * dayIntensity + 0.15 * nightIntensity, - 0.27 * dayIntensity + 0.15 * nightIntensity, - 0.18 * dayIntensity + 0.2 * nightIntensity, + dgR * dayIntensity + ngR * nightIntensity, + dgG * dayIntensity + ngG * nightIntensity, + dgB * dayIntensity + ngB * nightIntensity, ); } if (this.ambientLight) { - // Ambient fill: provides base visibility - // Day: 0.5, Night: 0.3 (auto exposure handles the rest) - this.ambientLight.intensity = 0.3 + dayIntensity * 0.2; + this.ambientLight.intensity = + AMBIENT_LIGHT.INTENSITY_BASE + + dayIntensity * AMBIENT_LIGHT.INTENSITY_DAY_ADD; - // Day: warm neutral white, Night: brighter blue moonlight tint + const [adR, adG, adB] = AMBIENT_LIGHT.DAY_COLOR; + const [anR, anG, anB] = AMBIENT_LIGHT.NIGHT_COLOR; this.ambientLight.color.setRGB( - 0.5 + dayIntensity * 0.5, // R: 0.5 at night, 1.0 at day - 0.55 + dayIntensity * 0.4, // G: 0.55 at night, 0.95 at day - 0.7 + dayIntensity * 0.25, // B: 0.7 at night, 0.95 at day (bluer at night) + anR + dayIntensity * (adR - anR), + anG + dayIntensity * (adG - anG), + anB + dayIntensity * (adB - anB), ); } } @@ -788,8 +805,7 @@ export class Environment extends System { // Calculate target exposure based on current dayIntensity using same formula as update const dayIntensity = this.skySystem.dayIntensity; const t = dayIntensity * dayIntensity * (3 - 2 * dayIntensity); // smoothstep - this.currentExposure = - this.NIGHT_EXPOSURE + (this.DAY_EXPOSURE - this.NIGHT_EXPOSURE) * t; + this.currentExposure = EXPOSURE.NIGHT + (EXPOSURE.DAY - EXPOSURE.NIGHT) * t; // Apply immediately to renderer const graphics = this.world.graphics as @@ -806,31 +822,23 @@ export class Environment extends System { * @param dayIntensity 0-1 (0 = night, 1 = day) */ private updateAutoExposure(dayIntensity: number): void { - // Get renderer reference const graphics = this.world.graphics as | { renderer?: { toneMappingExposure?: number } } | undefined; if (!graphics?.renderer) return; - - // Calculate target exposure: lerp from night (high) to day (low) // Using smoothstep for natural-feeling transitions const t = dayIntensity * dayIntensity * (3 - 2 * dayIntensity); // smoothstep - const targetExposure = - this.NIGHT_EXPOSURE + (this.DAY_EXPOSURE - this.NIGHT_EXPOSURE) * t; + const targetExposure = EXPOSURE.NIGHT + (EXPOSURE.DAY - EXPOSURE.NIGHT) * t; - // Smooth interpolation to prevent jarring changes - // Lerp factor of 0.03 = gradual adaptation over ~30 frames - this.currentExposure += (targetExposure - this.currentExposure) * 0.03; + this.currentExposure += + (targetExposure - this.currentExposure) * EXPOSURE.LERP_SPEED; // Apply to renderer graphics.renderer.toneMappingExposure = this.currentExposure; } - // Day fog color: warm beige - private readonly dayFogColor = new THREE.Color(0xd4c8b8); - // Night fog color: dark blue to blend with night sky (slightly lighter for visibility) - private readonly nightFogColor = new THREE.Color(0x2b3445); - // Blended fog color (updated each frame) + private readonly dayFogColor = new THREE.Color(FOG_COLORS.DAY); + private readonly nightFogColor = new THREE.Color(FOG_COLORS.NIGHT); private readonly blendedFogColor = new THREE.Color(); /** @@ -928,21 +936,17 @@ export class Environment extends System { const scene = this.world.stage.scene; - // Hemisphere light - sky color from above, ground color from below - // Provides natural ambient lighting that varies with direction this.hemisphereLight = new THREE.HemisphereLight( - 0x87ceeb, // Sky color (light blue) - 0x5d4837, // Ground color (warm brown) - 0.5, // Higher intensity for better ambient + HEMISPHERE_LIGHT.INITIAL_SKY_COLOR, + HEMISPHERE_LIGHT.INITIAL_GROUND_COLOR, + HEMISPHERE_LIGHT.INITIAL_INTENSITY, ); this.hemisphereLight.name = "EnvironmentHemisphereLight"; scene.add(this.hemisphereLight); - // Ambient light - flat fill light for base visibility - // Ensures objects are never completely black (especially important without env map) this.ambientLight = new THREE.AmbientLight( - 0x606070, // Neutral with slight cool tint - 0.5, // Higher intensity since we removed env map + AMBIENT_LIGHT.INITIAL_COLOR, + AMBIENT_LIGHT.INITIAL_INTENSITY, ); this.ambientLight.name = "EnvironmentAmbientLight"; scene.add(this.ambientLight); diff --git a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts index 8b4e3620f..27178df4e 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts @@ -879,6 +879,7 @@ export function updateGLBTreeBatchedInstancer(): void { sunLight?: { intensity: number }; lightDirection?: THREE.Vector3; hemisphereLight?: { color: THREE.Color }; + getDayIntensity?: () => number; } | null; const wind = world.getSystem("wind") as Wind | null; @@ -908,16 +909,8 @@ export function updateGLBTreeBatchedInstancer(): void { 2.0, ); } - if (env?.hemisphereLight) { - const c = env.hemisphereLight.color; - const avg = (c.r + c.g + c.b) / 3; - if (avg > 0.01) { - treeMat.treeUniforms.shadeColor.value.setRGB( - c.r / avg, - c.g / avg, - c.b / avg, - ); - } + if (env?.getDayIntensity) { + treeMat.treeUniforms.dayIntensity.value = env.getDayIntensity(); } if (wind) { treeMat.treeUniforms.windTime.value = wind.uniforms.time.value; diff --git a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts index 83c72bc20..91f4239bd 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts @@ -84,7 +84,7 @@ let world: World | null = null; const pools = new Map(); const entityToModel = new Map(); -// ---- Geometry extraction (portfolio pattern: reference, not clone) ---- +// ---- Geometry extraction (reference, not clone) ---- interface MeshPart { geometry: THREE.BufferGeometry; @@ -757,6 +757,7 @@ export function updateGLBTreeInstancer(): void { sunLight?: { intensity: number }; lightDirection?: THREE.Vector3; hemisphereLight?: { color: THREE.Color }; + getDayIntensity?: () => number; } | null; const wind = world.getSystem("wind") as Wind | null; @@ -793,16 +794,8 @@ export function updateGLBTreeInstancer(): void { 2.0, ); } - if (env?.hemisphereLight) { - const c = env.hemisphereLight.color; - const avg = (c.r + c.g + c.b) / 3; - if (avg > 0.01) { - treeMat.treeUniforms.shadeColor.value.setRGB( - c.r / avg, - c.g / avg, - c.b / avg, - ); - } + if (env?.getDayIntensity) { + treeMat.treeUniforms.dayIntensity.value = env.getDayIntensity(); } if (wind) { treeMat.treeUniforms.windTime.value = wind.uniforms.time.value; diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index d5c99f00e..b1b5350af 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -62,6 +62,7 @@ import { import { varyingProperty } from "three/tsl"; import { FOG_NEAR_SQ, FOG_FAR_SQ, fogRenderTarget } from "./FogConfig"; import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants"; +import { SUN_SHADE, SUN_LIGHT } from "./LightingConfig"; // ============================================================================ // CONFIGURATION @@ -128,37 +129,6 @@ export const GPU_VEG_CONFIG = { NEAR_CAMERA_FADE_END: 0.05, } as const; -// ============================================================================ -// SHARED TERRAIN LIGHTING (used by both TerrainShader and tree terrain blend) -// ============================================================================ - -/** Sun shade strength — identical in terrain shader and tree shader */ -export const SHADE_STRENGTH = 0.3; - -/** - * Compute sun shade (shadow-side sky tint) on a pre-lit color. - * Used by both the terrain shader's outputNode and the tree shader's - * terrain color blend so both produce the exact same result. - * - * @param color - Already-lit color (e.g. PBR output or manually-lit albedo) - * @param normal - Surface normal (world space) - * @param sunDir - Sun direction uniform (normalised inside) - * @param shadeColor - Normalised hemisphere sky color for shadow tint - */ -export function applyTerrainSunShade( - color: any, - normal: any, - sunDir: any, - shadeColor: any, -) { - const L = normalize(sunDir); - const N = normalize(normal); - const NdotL = dot(N, L); - const shade = sub(float(0.5), mul(NdotL, float(0.5))); - const tinted = mul(color, shadeColor); - return mix(color, tinted, mul(shade, float(SHADE_STRENGTH))); -} - // ============================================================================ // TYPES // ============================================================================ @@ -969,6 +939,7 @@ export type TreeDissolveMaterial = DissolveMaterial & { treeUniforms: { sunDirection: { value: THREE.Vector3 }; sunIntensity: { value: number }; + dayIntensity: { value: number }; shadeColor: { value: THREE.Color }; windTime: { value: number }; windStrength: { value: number }; @@ -1006,9 +977,10 @@ export function createTreeDissolveMaterial( material.vertexColors = false; // --- Uniforms --- - const uSunDir = uniform(new THREE.Vector3(0.5, 0.8, 0.3)); + const uSunDir = uniform(new THREE.Vector3(...SUN_LIGHT.DEFAULT_DIRECTION)); const uSunIntensity = uniform(1.0); - const uShadeColor = uniform(new THREE.Color(0.7, 1.08, 1.22)); + const uDayIntensity = uniform(1.0); + const uShadeColor = uniform(new THREE.Color(...SUN_SHADE.TINT_COLOR)); const uHighlightColor = uniform(new THREE.Color(0x00ffff)); const uWindTime = uniform(0.0); const uWindStrength = uniform(0.3); @@ -1085,12 +1057,21 @@ export function createTreeDissolveMaterial( baseAlbedo = mul(baseAlbedo, aoMul); } + // ---- dayFactor (used by shade, toon, SSS, saturation) ---- + const sunI = clamp(uSunIntensity, float(0.0), float(2.0)); + const dayFactor = div(sunI, float(2.0)); + + // ---- Sun shade on albedo (driven by dayIntensity to match scene light timing) ---- + { + const shadeFactor = sub(float(1.0), uDayIntensity); + const tinted = mul(baseAlbedo, vec3(uShadeColor)); + baseAlbedo = mix(baseAlbedo, tinted, shadeFactor); + } + // ---- 3-band toon lighting (hard-edged shadow / mid / bright) ---- - const N = normalize(mul(modelNormalMatrix, normalLocal)); const L = normalize(vec3(uSunDir)); + const N = normalize(mul(modelNormalMatrix, normalLocal)); const NdotL = dot(N, L); - const sunI = clamp(uSunIntensity, float(0.0), float(2.0)); - const dayFactor = div(sunI, float(2.0)); const band1 = step(float(TOON_SHADOW_EDGE), NdotL); const band2 = step(float(TOON_MID_EDGE), NdotL); const toonLight = add( @@ -1103,9 +1084,6 @@ export function createTreeDissolveMaterial( const nightDim = mix(float(NIGHT_MIN_BRIGHTNESS), float(1.0), dayFactor); let result: any = mul(baseAlbedo, mul(toonLight, nightDim)); - // ---- Sun shade (shadow-side sky tint, matches terrain) ---- - result = applyTerrainSunShade(result, N, L, vec3(uShadeColor)); - // ---- SSS + hard-edged toon rim (leaf only, scaled by dayFactor) ---- if (isLeaf) { const V = normalize(sub(cameraPosition, positionWorld)); @@ -1174,6 +1152,7 @@ export function createTreeDissolveMaterial( treeMat.treeUniforms = { sunDirection: uSunDir as unknown as { value: THREE.Vector3 }, sunIntensity: uSunIntensity as unknown as { value: number }, + dayIntensity: uDayIntensity as unknown as { value: number }, shadeColor: uShadeColor as unknown as { value: THREE.Color }, windTime: uWindTime as unknown as { value: number }, windStrength: uWindStrength as unknown as { value: number }, diff --git a/packages/shared/src/systems/shared/world/LightingConfig.ts b/packages/shared/src/systems/shared/world/LightingConfig.ts new file mode 100644 index 000000000..d3169fca9 --- /dev/null +++ b/packages/shared/src/systems/shared/world/LightingConfig.ts @@ -0,0 +1,147 @@ +/** + * LightingConfig - Central lighting & sky settings for the entire app + * + * Single source of truth for all lighting-related constants: + * sun shade, hemisphere/ambient lights, exposure, fog colors, day-night cycle. + * + * Imported by Environment, SkySystem, TerrainShader, GPUMaterials, and any + * other system that needs lighting parameters. + * + * @module LightingConfig + */ + +// ============================================================================ +// DAY / NIGHT CYCLE +// ============================================================================ + +export const DAY_CYCLE = { + /** Full day cycle duration in seconds */ + DURATION_SEC: 240, + + /** dayPhase thresholds — 0 = midnight, 0.25 = sunrise, 0.5 = noon, 0.75 = sunset */ + DAWN_START: 0.22, + DAWN_MID: 0.25, + DAWN_END: 0.28, + DUSK_START: 0.72, + DUSK_MID: 0.75, + DUSK_END: 0.78, + + /** During full day the intensity oscillates between this floor and 1.0 */ + NOON_MIN_INTENSITY: 0.85, +} as const; + +// ============================================================================ +// DIRECTIONAL LIGHT (SUN / MOON) +// ============================================================================ + +export const SUN_LIGHT = { + /** Fallback sun direction before SkySystem takes over */ + DEFAULT_DIRECTION: [0.5, 0.8, 0.3] as readonly [number, number, number], + + /** Sun intensity = dayIntensity × this × transitionFade */ + DAY_INTENSITY_MULTIPLIER: 1.8, + DAY_COLOR: [1.0, 0.98, 0.92] as readonly [number, number, number], + + /** Golden-hour phase ranges [start, end] (pairs) */ + GOLDEN_HOUR_RANGES: [ + [0.22, 0.32], + [0.68, 0.78], + ] as readonly (readonly [number, number])[], + GOLDEN_HOUR_COLOR: [1.0, 0.85, 0.6] as readonly [number, number, number], + + /** Moon intensity = nightIntensity × this × transitionFade */ + MOON_INTENSITY_MULTIPLIER: 0.25, + MOON_COLOR: [0.05, 0.5, 0.7] as readonly [number, number, number], + + /** Z-axis tilt of the sun arc (0 = flat E-W, 1 = full N-S) */ + TILT: 0.3, + + /** Lerp speed for smooth light-direction interpolation (per frame) */ + DIRECTION_LERP: 0.02, +} as const; + +// ============================================================================ +// SUN SHADE (shadow-side sky tint applied in shaders) +// ============================================================================ + +export const SUN_SHADE = { + /** + * Mix strength: 0 = no shade, 1 = full shade. + * The shade factor is `0.5 − dot(N,L) × 0.5` (0 on lit side, 1 on shadow side). + * Applied on the ALBEDO (before lighting) to avoid double-darkening. + */ + STRENGTH: 1.0, + + /** + * Fixed shade tint color: vec3(0.0, 0.5, 0.7). + * Applied as: tinted = color × TINT_COLOR, then mixed by shade factor. + * Dynamic behavior comes from the shade factor (sun position), not this color. + */ + TINT_COLOR: [0.0, 0.5, 0.7] as readonly [number, number, number], +} as const; + +// ============================================================================ +// HEMISPHERE LIGHT (sky / ground ambient) +// ============================================================================ + +export const HEMISPHERE_LIGHT = { + INITIAL_SKY_COLOR: 0x87ceeb, + INITIAL_GROUND_COLOR: 0x5d4837, + INITIAL_INTENSITY: 0.5, + + /** + * Runtime intensity = BASE + dayIntensity × DAY_ADD + * Night floor = 0.18, Day total = 0.9 (unchanged) + */ + INTENSITY_BASE: 0.18, + INTENSITY_DAY_ADD: 0.72, + + /** Sky color lerped between NIGHT → DAY based on dayIntensity */ + DAY_SKY_COLOR: [0.53, 0.81, 0.92] as readonly [number, number, number], + NIGHT_SKY_COLOR: [0.0, 0.15, 0.3] as readonly [number, number, number], + + /** Ground color lerped between NIGHT → DAY */ + DAY_GROUND_COLOR: [0.36, 0.27, 0.18] as readonly [number, number, number], + NIGHT_GROUND_COLOR: [0.02, 0.05, 0.1] as readonly [number, number, number], +} as const; + +// ============================================================================ +// AMBIENT LIGHT (flat fill) +// ============================================================================ + +export const AMBIENT_LIGHT = { + INITIAL_COLOR: 0x606070, + INITIAL_INTENSITY: 0.5, + + /** + * Runtime intensity = BASE + dayIntensity × DAY_ADD + * Night floor = 0.18, Day total = 0.5 (unchanged) + */ + INTENSITY_BASE: 0.18, + INTENSITY_DAY_ADD: 0.32, + + /** Color lerped between NIGHT → DAY */ + DAY_COLOR: [1.0, 0.95, 0.95] as readonly [number, number, number], + NIGHT_COLOR: [0.05, 0.35, 0.5] as readonly [number, number, number], +} as const; + +// ============================================================================ +// AUTO EXPOSURE (eye adaptation) +// ============================================================================ + +export const EXPOSURE = { + DAY: 0.85, + /** Slight boost at night */ + NIGHT: 1.0, + /** Per-frame lerp speed toward target exposure */ + LERP_SPEED: 0.03, +} as const; + +// ============================================================================ +// FOG COLORS (day / night scene fog — separate from sky-fog render target) +// ============================================================================ + +export const FOG_COLORS = { + DAY: 0xd4c8b8, + NIGHT: 0x2b3445, +} as const; diff --git a/packages/shared/src/systems/shared/world/TerrainShader.ts b/packages/shared/src/systems/shared/world/TerrainShader.ts index 14175c1ee..0f5bd877b 100644 --- a/packages/shared/src/systems/shared/world/TerrainShader.ts +++ b/packages/shared/src/systems/shared/world/TerrainShader.ts @@ -44,7 +44,7 @@ import { import { getRoadInfluenceTextureState } from "./RoadInfluenceMask"; import { getLamppostLightTextureState } from "./LamppostLightMask"; import { FOG_NEAR_SQ, FOG_FAR_SQ, fogRenderTarget } from "./FogConfig"; -import { applyTerrainSunShade } from "./GPUMaterials"; +import { SUN_LIGHT } from "./LightingConfig"; export const TERRAIN_SHADER_CONSTANTS = { TRIPLANAR_SCALE: 0.5, @@ -631,7 +631,6 @@ export const MAX_VERTEX_LIGHTS = 8; export type TerrainUniforms = { sunPosition: { value: THREE.Vector3 }; sunDirection: { value: THREE.Vector3 }; - shadeColor: { value: THREE.Color }; time: { value: number }; fogEnabled: { value: number }; // 1.0 = fog enabled, 0.0 = fog disabled (for minimap) // Vertex lighting uniforms (lampposts, etc.) @@ -688,8 +687,7 @@ export function createTerrainMaterial(): THREE.Material & { const noiseTex = generateNoiseTexture(); const sunPositionUniform = uniform(vec3(100, 100, 100)); - const sunDirectionUniform = uniform(vec3(0.5, 0.8, 0.3)); - const shadeColorUniform = uniform(vec3(0.7, 1.08, 1.22)); + const sunDirectionUniform = uniform(vec3(...SUN_LIGHT.DEFAULT_DIRECTION)); const timeUniform = uniform(float(0)); const noiseScale = uniform(float(TERRAIN_SHADER_CONSTANTS.NOISE_SCALE)); @@ -1095,6 +1093,9 @@ export function createTerrainMaterial(): THREE.Material & { const fogColor = fogTexNode.rgb; // === CREATE MATERIAL === + // No custom sun shade here — terrain is a standard PBR material, + // so it gets its night blue tint from the scene lights (moon, hemisphere, + // ambient) which are already blue-shifted at night in LightingConfig. const material = new MeshStandardNodeMaterial(); material.colorNode = litTerrain; material.roughness = 1.0; @@ -1102,25 +1103,15 @@ export function createTerrainMaterial(): THREE.Material & { material.side = THREE.FrontSide; material.fog = false; - // Apply sun shade + fog AFTER PBR lighting via outputNode + // Fog applied AFTER PBR lighting (fog blends with sky, must be post-lit) material.outputNode = Fn(() => { const litColor = output; - - // Sun shade: shared function (identical to tree shader terrain blend) - const shaded = applyTerrainSunShade( - litColor.rgb, - normalWorld, - vec3(sunDirectionUniform), - vec3(shadeColorUniform), - ); - - return vec4(mix(shaded, fogColor, fogFactor), litColor.a); + return vec4(mix(litColor.rgb, fogColor, fogFactor), litColor.a); })(); const terrainUniforms: TerrainUniforms = { sunPosition: sunPositionUniform, sunDirection: sunDirectionUniform as unknown as { value: THREE.Vector3 }, - shadeColor: shadeColorUniform as unknown as { value: THREE.Color }, time: timeUniform, fogEnabled: fogEnabledUniform, // Vertex lighting arrays diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts index 891e0baac..a80a2af47 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -38,7 +38,6 @@ import type { ShorelineConfig, } from "./TerrainHeightParams"; import { BiomeType, DEFAULT_BIOME, BIOME_LIST } from "./TerrainBiomeTypes"; - // Import terrain generator from procgen package import { TerrainGenerator, @@ -5041,27 +5040,15 @@ export class TerrainSystem extends System { if (materialWithUniforms) { materialWithUniforms.terrainUniforms.time.value = this.terrainTime; - // Sync sun direction and shade color from Environment system + // Sync sun direction from Environment system const env = this.world.getSystem("environment") as { lightDirection?: THREE.Vector3; - hemisphereLight?: { color: THREE.Color }; } | null; if (env?.lightDirection) { materialWithUniforms.terrainUniforms.sunDirection.value .copy(env.lightDirection) .negate(); } - if (env?.hemisphereLight) { - const c = env.hemisphereLight.color; - const avg = (c.r + c.g + c.b) / 3; - if (avg > 0.01) { - (materialWithUniforms.terrainUniforms.shadeColor.value as any).set( - c.r / avg, - c.g / avg, - c.b / avg, - ); - } - } // Fog texture is the shared fogRenderTarget from FogConfig — no sync needed From 4fb5e1a5e333297304b04bbf42a6a93ac979af99 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 17 Mar 2026 05:28:29 +0800 Subject: [PATCH 03/71] refactor: use LightingConfig constants in SkySystem Replace hardcoded day cycle thresholds and sun tilt with centralized DAY_CYCLE and SUN_LIGHT config values from LightingConfig.ts. Made-with: Cursor --- .../src/systems/shared/world/SkySystem.ts | 37 +++++++++---------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/packages/shared/src/systems/shared/world/SkySystem.ts b/packages/shared/src/systems/shared/world/SkySystem.ts index c2addbfaa..10903ab3f 100644 --- a/packages/shared/src/systems/shared/world/SkySystem.ts +++ b/packages/shared/src/systems/shared/world/SkySystem.ts @@ -38,6 +38,7 @@ import { } from "../../../extras/three/three"; import type { World, WorldOptions } from "../../../types"; import { fogRenderTarget } from "./FogConfig"; +import { DAY_CYCLE, SUN_LIGHT } from "./LightingConfig"; // ----------------------------- // Utility: Procedural noise textures (avoids external deps) @@ -160,7 +161,7 @@ export class SkySystem extends System { private galaxyTextureUniform: { value: THREE.Texture | null } | null = null; private elapsed = 0; - private dayDurationSec = 240; // full day cycle in seconds + private dayDurationSec = DAY_CYCLE.DURATION_SEC; // Pre-allocated vector for sun direction to avoid per-frame allocation private _sunDir = new THREE.Vector3(); private _dayPhase = 0; @@ -1065,31 +1066,28 @@ export class SkySystem extends System { // Calculate day intensity with SHARP transitions at sunrise/sunset // Night stays truly dark until sunrise, then rapid transition // This creates the feeling of "darkest before dawn" then sudden light - const DAWN_START = 0.22; // Start brightening just before sunrise - const DAWN_END = 0.28; // Full brightness shortly after sunrise - const DUSK_START = 0.72; // Start darkening just before sunset - const DUSK_END = 0.78; // Full darkness shortly after sunset - - // Smoothstep helper for smooth but sharp transitions const smoothstep = (edge0: number, edge1: number, x: number) => { const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0))); return t * t * (3 - 2 * t); }; let dayIntensity: number; - if (dayPhase < DAWN_START || dayPhase >= DUSK_END) { - // Deep night - completely dark + if (dayPhase < DAY_CYCLE.DAWN_START || dayPhase >= DAY_CYCLE.DUSK_END) { dayIntensity = 0; - } else if (dayPhase < DAWN_END) { - // Dawn transition - rapid brightening - dayIntensity = smoothstep(DAWN_START, DAWN_END, dayPhase); - } else if (dayPhase < DUSK_START) { - // Full day - slight variation with noon being brightest - const noonFactor = 1 - Math.abs(dayPhase - 0.5) * 2; // 0 at edges, 1 at noon - dayIntensity = 0.85 + noonFactor * 0.15; // 0.85 to 1.0 + } else if (dayPhase < DAY_CYCLE.DAWN_END) { + dayIntensity = smoothstep( + DAY_CYCLE.DAWN_START, + DAY_CYCLE.DAWN_END, + dayPhase, + ); + } else if (dayPhase < DAY_CYCLE.DUSK_START) { + const noonFactor = 1 - Math.abs(dayPhase - 0.5) * 2; + dayIntensity = + DAY_CYCLE.NOON_MIN_INTENSITY + + noonFactor * (1 - DAY_CYCLE.NOON_MIN_INTENSITY); } else { - // Dusk transition - rapid darkening - dayIntensity = 1 - smoothstep(DUSK_START, DUSK_END, dayPhase); + dayIntensity = + 1 - smoothstep(DAY_CYCLE.DUSK_START, DAY_CYCLE.DUSK_END, dayPhase); } this._dayIntensity = dayIntensity; @@ -1107,8 +1105,7 @@ export class SkySystem extends System { const sunElevation = Math.sin(sunArcAngle); // -1 to 1, peaks at noon const sunAzimuth = Math.cos(sunArcAngle); // 1 at sunrise, -1 at sunset - // Add slight Z offset for sun path tilt (makes shadows more interesting) - const sunTilt = 0.3; // 30% tilt toward/away from camera + const sunTilt = SUN_LIGHT.TILT; this._sunDir .set( From 6257ebba7a9dc5574ae828b6e390346228d916fa Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 17 Mar 2026 05:31:03 +0800 Subject: [PATCH 04/71] refactor: add reusable applySunShade TSL function to LightingConfig Extract dayIntensity-driven shade logic into a shared function so any future custom shader can import it for consistent blue tint timing. Made-with: Cursor --- .../src/systems/shared/world/GPUMaterials.ts | 8 ++---- .../systems/shared/world/LightingConfig.ts | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index b1b5350af..cc44ad932 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -62,7 +62,7 @@ import { import { varyingProperty } from "three/tsl"; import { FOG_NEAR_SQ, FOG_FAR_SQ, fogRenderTarget } from "./FogConfig"; import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants"; -import { SUN_SHADE, SUN_LIGHT } from "./LightingConfig"; +import { SUN_SHADE, SUN_LIGHT, applySunShade } from "./LightingConfig"; // ============================================================================ // CONFIGURATION @@ -1062,11 +1062,7 @@ export function createTreeDissolveMaterial( const dayFactor = div(sunI, float(2.0)); // ---- Sun shade on albedo (driven by dayIntensity to match scene light timing) ---- - { - const shadeFactor = sub(float(1.0), uDayIntensity); - const tinted = mul(baseAlbedo, vec3(uShadeColor)); - baseAlbedo = mix(baseAlbedo, tinted, shadeFactor); - } + baseAlbedo = applySunShade(baseAlbedo, uDayIntensity, vec3(uShadeColor)); // ---- 3-band toon lighting (hard-edged shadow / mid / bright) ---- const L = normalize(vec3(uSunDir)); diff --git a/packages/shared/src/systems/shared/world/LightingConfig.ts b/packages/shared/src/systems/shared/world/LightingConfig.ts index d3169fca9..899de8c1a 100644 --- a/packages/shared/src/systems/shared/world/LightingConfig.ts +++ b/packages/shared/src/systems/shared/world/LightingConfig.ts @@ -137,6 +137,32 @@ export const EXPOSURE = { LERP_SPEED: 0.03, } as const; +// ============================================================================ +// SHARED SUN-SHADE SHADER FUNCTION (TSL) +// ============================================================================ + +import { sub, mul, float, mix, vec3 } from "../../../extras/three/three"; + +/** + * Apply a day/night sky-tint to a colour in TSL. + * + * Driven by `dayIntensity` (0 = full night, 1 = full day) so the tint + * transitions at the exact same rate as the scene lights. Reusable for + * any custom shader that bypasses standard PBR lighting. + * + * Should be called on the raw **albedo** (before lighting) so the hue + * shift survives through subsequent brightness reductions. + * + * @param color - Albedo / base color node + * @param dayIntensity - Uniform or node with range [0, 1] + * @param shadeColor - Tint color node (e.g. `vec3(...SUN_SHADE.TINT_COLOR)`) + */ +export function applySunShade(color: any, dayIntensity: any, shadeColor: any) { + const shadeFactor = sub(float(1.0), dayIntensity); + const tinted = mul(color, shadeColor); + return mix(color, tinted, shadeFactor); +} + // ============================================================================ // FOG COLORS (day / night scene fog — separate from sky-fog render target) // ============================================================================ From 0a55dcb71a5bb0f32ff88cc83bf01e97432f7ae4 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 17 Mar 2026 06:01:50 +0800 Subject: [PATCH 05/71] fix: use normalWorldGeometry for tree toon lighting Use normalWorldGeometry instead of manual modelNormalMatrix * normalLocal to get the correct world-space sphere normal with per-instance rotation applied, without normal map or face-direction distortion. Made-with: Cursor --- packages/shared/src/extras/three/three.ts | 1 + packages/shared/src/systems/shared/world/GPUMaterials.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/extras/three/three.ts b/packages/shared/src/extras/three/three.ts index effea7625..270efb7fc 100644 --- a/packages/shared/src/extras/three/three.ts +++ b/packages/shared/src/extras/three/three.ts @@ -41,6 +41,7 @@ export const { positionView, normalLocal, normalWorld, + normalWorldGeometry, normalView, cameraPosition, cameraProjectionMatrix, diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index cc44ad932..2de16c3b5 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -52,6 +52,7 @@ import { viewportCoordinate, normalView, normalWorld, + normalWorldGeometry, normalize, pow, attribute, @@ -1066,7 +1067,7 @@ export function createTreeDissolveMaterial( // ---- 3-band toon lighting (hard-edged shadow / mid / bright) ---- const L = normalize(vec3(uSunDir)); - const N = normalize(mul(modelNormalMatrix, normalLocal)); + const N = normalize(normalWorldGeometry); const NdotL = dot(N, L); const band1 = step(float(TOON_SHADOW_EDGE), NdotL); const band2 = step(float(TOON_MID_EDGE), NdotL); From 9fff7f0d0f1f2692ed6bc06bc20901832efdb9a6 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 17 Mar 2026 06:11:40 +0800 Subject: [PATCH 06/71] refactor: unify night brightness with single NIGHT.BRIGHTNESS config Add NIGHT.BRIGHTNESS master knob that controls both the tree shader nightDim floor and scene light intensity bases, so adjusting one value changes tree and terrain night brightness together. Made-with: Cursor --- .../src/systems/shared/world/GPUMaterials.ts | 4 +-- .../systems/shared/world/LightingConfig.ts | 36 +++++++++++-------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index 2de16c3b5..9a6d3b1ed 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -63,7 +63,7 @@ import { import { varyingProperty } from "three/tsl"; import { FOG_NEAR_SQ, FOG_FAR_SQ, fogRenderTarget } from "./FogConfig"; import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants"; -import { SUN_SHADE, SUN_LIGHT, applySunShade } from "./LightingConfig"; +import { SUN_SHADE, SUN_LIGHT, NIGHT, applySunShade } from "./LightingConfig"; // ============================================================================ // CONFIGURATION @@ -1001,7 +1001,7 @@ export function createTreeDissolveMaterial( const TOON_MID_EDGE = 0.5; const TOON_RIM_THRESHOLD = 0.3; const TOON_RIM_BRIGHT = 1.3; - const NIGHT_MIN_BRIGHTNESS = 0.3; + const NIGHT_MIN_BRIGHTNESS = NIGHT.BRIGHTNESS; // --- Wind vertex displacement (leaf materials only) --- // Displacement is proportional to local Y so it auto-scales to any model diff --git a/packages/shared/src/systems/shared/world/LightingConfig.ts b/packages/shared/src/systems/shared/world/LightingConfig.ts index 899de8c1a..0dde6126c 100644 --- a/packages/shared/src/systems/shared/world/LightingConfig.ts +++ b/packages/shared/src/systems/shared/world/LightingConfig.ts @@ -50,7 +50,7 @@ export const SUN_LIGHT = { GOLDEN_HOUR_COLOR: [1.0, 0.85, 0.6] as readonly [number, number, number], /** Moon intensity = nightIntensity × this × transitionFade */ - MOON_INTENSITY_MULTIPLIER: 0.25, + MOON_INTENSITY_MULTIPLIER: 0.35, MOON_COLOR: [0.05, 0.5, 0.7] as readonly [number, number, number], /** Z-axis tilt of the sun arc (0 = flat E-W, 1 = full N-S) */ @@ -80,21 +80,30 @@ export const SUN_SHADE = { TINT_COLOR: [0.0, 0.5, 0.7] as readonly [number, number, number], } as const; +// ============================================================================ +// NIGHT BRIGHTNESS (master knob for tree + terrain) +// ============================================================================ + +export const NIGHT = { + /** Master night brightness (0 = pitch black, 1 = full day brightness). + * Controls both the tree shader nightDim floor and scene light intensity bases. */ + BRIGHTNESS: 0.5, +} as const; + // ============================================================================ // HEMISPHERE LIGHT (sky / ground ambient) // ============================================================================ +const HEMISPHERE_DAY_TOTAL = 0.9; + export const HEMISPHERE_LIGHT = { INITIAL_SKY_COLOR: 0x87ceeb, INITIAL_GROUND_COLOR: 0x5d4837, INITIAL_INTENSITY: 0.5, - /** - * Runtime intensity = BASE + dayIntensity × DAY_ADD - * Night floor = 0.18, Day total = 0.9 (unchanged) - */ - INTENSITY_BASE: 0.18, - INTENSITY_DAY_ADD: 0.72, + /** Runtime intensity = NIGHT.BRIGHTNESS + dayIntensity × DAY_ADD */ + INTENSITY_BASE: NIGHT.BRIGHTNESS, + INTENSITY_DAY_ADD: HEMISPHERE_DAY_TOTAL - NIGHT.BRIGHTNESS, /** Sky color lerped between NIGHT → DAY based on dayIntensity */ DAY_SKY_COLOR: [0.53, 0.81, 0.92] as readonly [number, number, number], @@ -109,16 +118,15 @@ export const HEMISPHERE_LIGHT = { // AMBIENT LIGHT (flat fill) // ============================================================================ +const AMBIENT_DAY_TOTAL = 0.5; + export const AMBIENT_LIGHT = { INITIAL_COLOR: 0x606070, INITIAL_INTENSITY: 0.5, - /** - * Runtime intensity = BASE + dayIntensity × DAY_ADD - * Night floor = 0.18, Day total = 0.5 (unchanged) - */ - INTENSITY_BASE: 0.18, - INTENSITY_DAY_ADD: 0.32, + /** Runtime intensity = NIGHT.BRIGHTNESS + dayIntensity × DAY_ADD */ + INTENSITY_BASE: NIGHT.BRIGHTNESS, + INTENSITY_DAY_ADD: AMBIENT_DAY_TOTAL - NIGHT.BRIGHTNESS, /** Color lerped between NIGHT → DAY */ DAY_COLOR: [1.0, 0.95, 0.95] as readonly [number, number, number], @@ -132,7 +140,7 @@ export const AMBIENT_LIGHT = { export const EXPOSURE = { DAY: 0.85, /** Slight boost at night */ - NIGHT: 1.0, + NIGHT: 1.1, /** Per-frame lerp speed toward target exposure */ LERP_SPEED: 0.03, } as const; From aa9567836357aaa3208e50df32a6dae3098731ab Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 17 Mar 2026 14:22:56 +0800 Subject: [PATCH 07/71] fix: remove emissive from arena materials so night blue tint shows through Lobby floor, hospital floor, hospital cross, and banner cloths had emissive properties that overpowered the blue-shifted scene lights at night. Removing emissive lets them respond to ambient/hemisphere lighting like all other standard materials. Made-with: Cursor --- .../shared/src/systems/client/DuelArenaVisualsSystem.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/shared/src/systems/client/DuelArenaVisualsSystem.ts b/packages/shared/src/systems/client/DuelArenaVisualsSystem.ts index a5ddb9658..d4ed676fc 100644 --- a/packages/shared/src/systems/client/DuelArenaVisualsSystem.ts +++ b/packages/shared/src/systems/client/DuelArenaVisualsSystem.ts @@ -1284,8 +1284,6 @@ export class DuelArenaVisualsSystem extends System { const clothMats = uniqueColors.map((c) => { const m = new MeshStandardNodeMaterial({ color: c, - emissive: c, - emissiveIntensity: 0.3, side: THREE.DoubleSide, }); this.materials.push(m); @@ -1348,8 +1346,6 @@ export class DuelArenaVisualsSystem extends System { const material = new MeshStandardNodeMaterial({ color: LOBBY_FLOOR_COLOR, map: tileTexture, - emissive: LOBBY_FLOOR_COLOR, - emissiveIntensity: 0.3, }); const floor = new THREE.Mesh(geometry, material); @@ -1457,8 +1453,6 @@ export class DuelArenaVisualsSystem extends System { const material = new MeshStandardNodeMaterial({ color: HOSPITAL_FLOOR_COLOR, - emissive: HOSPITAL_FLOOR_COLOR, - emissiveIntensity: 0.3, }); const floor = new THREE.Mesh(geometry, material); @@ -1495,8 +1489,6 @@ export class DuelArenaVisualsSystem extends System { const crossMaterial = new MeshStandardNodeMaterial({ color: 0xff0000, - emissive: 0xff0000, - emissiveIntensity: 0.5, }); this.materials.push(crossMaterial); From 02e299dc4d868bde08e13ca824010837cd2e4e37 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 17 Mar 2026 14:47:35 +0800 Subject: [PATCH 08/71] fix: move lobby/hospital pillars inside floor corners and shrink hospital Inset corner pillars by PILLAR_BASE_SIZE/2 so they sit flush at the floor edge. Reduce hospital floor from 30x25 to 28x23 to avoid terrain clipping at the edges. Made-with: Cursor --- .../src/systems/client/DuelArenaVisualsSystem.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/systems/client/DuelArenaVisualsSystem.ts b/packages/shared/src/systems/client/DuelArenaVisualsSystem.ts index d4ed676fc..54c720349 100644 --- a/packages/shared/src/systems/client/DuelArenaVisualsSystem.ts +++ b/packages/shared/src/systems/client/DuelArenaVisualsSystem.ts @@ -79,8 +79,8 @@ const LOBBY_LENGTH = 25; const HOSPITAL_CENTER_X = 65; const HOSPITAL_CENTER_Z = 62; -const HOSPITAL_WIDTH = 30; -const HOSPITAL_LENGTH = 25; +const HOSPITAL_WIDTH = 28; +const HOSPITAL_LENGTH = 23; const LOBBY_FLOOR_COLOR = 0xc9b896; const HOSPITAL_FLOOR_COLOR = 0xffffff; @@ -919,9 +919,9 @@ export class DuelArenaVisualsSystem extends System { ); } - // Lobby corner pillars (4) - const lobbyHW = LOBBY_WIDTH / 2; - const lobbyHL = LOBBY_LENGTH / 2; + // Lobby corner pillars (4) — inset by half the pillar base so they sit exactly at the floor corner + const lobbyHW = LOBBY_WIDTH / 2 - PILLAR_BASE_SIZE / 2; + const lobbyHL = LOBBY_LENGTH / 2 - PILLAR_BASE_SIZE / 2; for (const c of [ { x: LOBBY_CENTER_X - lobbyHW, z: LOBBY_CENTER_Z - lobbyHL }, { x: LOBBY_CENTER_X + lobbyHW, z: LOBBY_CENTER_Z - lobbyHL }, @@ -934,9 +934,9 @@ export class DuelArenaVisualsSystem extends System { }); } - // Hospital corner pillars (4) - const hospHW = HOSPITAL_WIDTH / 2; - const hospHL = HOSPITAL_LENGTH / 2; + // Hospital corner pillars (4) — same inset as lobby + const hospHW = HOSPITAL_WIDTH / 2 - PILLAR_BASE_SIZE / 2; + const hospHL = HOSPITAL_LENGTH / 2 - PILLAR_BASE_SIZE / 2; for (const c of [ { x: HOSPITAL_CENTER_X - hospHW, z: HOSPITAL_CENTER_Z - hospHL }, { x: HOSPITAL_CENTER_X + hospHW, z: HOSPITAL_CENTER_Z - hospHL }, From 298881ffc855fed9005d96fb47abde7de882dc82 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 17 Mar 2026 16:20:23 +0800 Subject: [PATCH 09/71] fix: sharpen leaf alpha to binary cutout to eliminate edge flickering Made-with: Cursor --- .../shared/src/systems/shared/world/GPUMaterials.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index 9a6d3b1ed..10728ab5a 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -1025,6 +1025,18 @@ export function createTreeDissolveMaterial( })(); } + // --- Alpha cutout sharpening for leaf materials --- + // Sharpen texture alpha to binary (0 or 1) so semi-transparent edge pixels + // are cleanly discarded instead of rendering as opaque fringe that flickers + // with wind animation. + if (isLeaf && material.map) { + const leafCutoutMap = material.map; + material.opacityNode = Fn(() => { + const uv = attribute("uv", "vec2"); + return step(float(0.5), texture(leafCutoutMap, uv).a); + })(); + } + // --- Sky-color fog (same as terrain/vegetation) --- const treeFogTex = texture(fogRenderTarget.texture, screenUV); const treeToCam = sub(cameraPosition, positionWorld); From c55610401c3ab828ef8fae6e22de5276e9c1f5c7 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 17 Mar 2026 17:49:04 +0800 Subject: [PATCH 10/71] refactor: remove coconut tree type from code Made-with: Cursor --- packages/shared/src/constants/TreeTypes.ts | 2 -- .../shared/src/systems/shared/world/TerrainBiomeTypes.ts | 6 ------ 2 files changed, 8 deletions(-) diff --git a/packages/shared/src/constants/TreeTypes.ts b/packages/shared/src/constants/TreeTypes.ts index 33c21e927..77077bbcf 100644 --- a/packages/shared/src/constants/TreeTypes.ts +++ b/packages/shared/src/constants/TreeTypes.ts @@ -27,7 +27,6 @@ export enum TreeId { Bamboo = "tree_bamboo", ChinaPine = "tree_chinaPine", Maple = "tree_maple", - Coconut = "tree_coconut", Palm = "tree_palm", Dead = "tree_dead", Cactus = "tree_cactus", @@ -85,7 +84,6 @@ export const TREE_TYPES = { bamboo: { name: "Bamboo Tree", levelRequired: 1 }, chinaPine: { name: "China Pine", levelRequired: 1 }, maple: { name: "Maple Tree", levelRequired: 45 }, - coconut: { name: "Coconut Palm", levelRequired: 1 }, palm: { name: "Desert Palm", levelRequired: 1 }, dead: { name: "Dead Tree", levelRequired: 1 }, cactus: { name: "Cactus", levelRequired: 1 }, diff --git a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts index 8bd408c3a..cd5d576bf 100644 --- a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts +++ b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts @@ -66,12 +66,6 @@ const CANYON_TREE_CONFIG: BiomeTreeConfig = { waterProximityHeight: 9, maxHeight: 15, }, - [TreeId.Coconut]: { - weight: 10, - waterAffinity: 0.6, - waterProximityHeight: 9, - maxHeight: 15, - }, }, density: 15, minSpacing: 18, From cabbb1f2a2b3869425f8ad39e1405891025e5fb9 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 17 Mar 2026 20:03:21 +0800 Subject: [PATCH 11/71] feat: add banana tree type and canyon biome allocation near water Made-with: Cursor --- packages/shared/src/constants/TreeTypes.ts | 2 ++ .../shared/src/systems/shared/world/TerrainBiomeTypes.ts | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/constants/TreeTypes.ts b/packages/shared/src/constants/TreeTypes.ts index 77077bbcf..a95c952ca 100644 --- a/packages/shared/src/constants/TreeTypes.ts +++ b/packages/shared/src/constants/TreeTypes.ts @@ -28,6 +28,7 @@ export enum TreeId { ChinaPine = "tree_chinaPine", Maple = "tree_maple", Palm = "tree_palm", + Banana = "tree_banana", Dead = "tree_dead", Cactus = "tree_cactus", Knotwood = "tree_knotwood", @@ -85,6 +86,7 @@ export const TREE_TYPES = { chinaPine: { name: "China Pine", levelRequired: 1 }, maple: { name: "Maple Tree", levelRequired: 45 }, palm: { name: "Desert Palm", levelRequired: 1 }, + banana: { name: "Banana Tree", levelRequired: 1 }, dead: { name: "Dead Tree", levelRequired: 1 }, cactus: { name: "Cactus", levelRequired: 1 }, knotwood: { name: "Knotwood Tree", levelRequired: 1 }, diff --git a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts index cd5d576bf..f584124ce 100644 --- a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts +++ b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts @@ -62,9 +62,16 @@ const CANYON_TREE_CONFIG: BiomeTreeConfig = { [TreeId.Dead]: { weight: 20, minHeight: 20 }, [TreeId.Palm]: { weight: 20, + waterAffinity: 0.5, + waterProximityHeight: 9, + maxHeight: 9, + }, + [TreeId.Banana]: { + weight: 15, waterAffinity: 0.3, waterProximityHeight: 9, - maxHeight: 15, + minHeight: 9, + maxHeight: 13, }, }, density: 15, From 48773013793332acbc1f4b8c41c2b473943702a0 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 17 Mar 2026 20:18:30 +0800 Subject: [PATCH 12/71] feat: add yucca tree type and canyon biome allocation Made-with: Cursor --- packages/shared/src/constants/TreeTypes.ts | 2 ++ packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/packages/shared/src/constants/TreeTypes.ts b/packages/shared/src/constants/TreeTypes.ts index a95c952ca..2bb83ab39 100644 --- a/packages/shared/src/constants/TreeTypes.ts +++ b/packages/shared/src/constants/TreeTypes.ts @@ -29,6 +29,7 @@ export enum TreeId { Maple = "tree_maple", Palm = "tree_palm", Banana = "tree_banana", + Yucca = "tree_yucca", Dead = "tree_dead", Cactus = "tree_cactus", Knotwood = "tree_knotwood", @@ -87,6 +88,7 @@ export const TREE_TYPES = { maple: { name: "Maple Tree", levelRequired: 45 }, palm: { name: "Desert Palm", levelRequired: 1 }, banana: { name: "Banana Tree", levelRequired: 1 }, + yucca: { name: "Yucca Tree", levelRequired: 1 }, dead: { name: "Dead Tree", levelRequired: 1 }, cactus: { name: "Cactus", levelRequired: 1 }, knotwood: { name: "Knotwood Tree", levelRequired: 1 }, diff --git a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts index f584124ce..fb36201b4 100644 --- a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts +++ b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts @@ -73,6 +73,7 @@ const CANYON_TREE_CONFIG: BiomeTreeConfig = { minHeight: 9, maxHeight: 13, }, + [TreeId.Yucca]: { weight: 15, avoidsWaterBelow: 3 }, }, density: 15, minSpacing: 18, From f91e22efbd5b5a029444c46cbd24dd3035b1270c Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 17 Mar 2026 21:13:59 +0800 Subject: [PATCH 13/71] feat: add windswept tree type and canyon biome allocation Made-with: Cursor --- packages/shared/src/constants/TreeTypes.ts | 2 ++ packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/packages/shared/src/constants/TreeTypes.ts b/packages/shared/src/constants/TreeTypes.ts index 2bb83ab39..7da1815ac 100644 --- a/packages/shared/src/constants/TreeTypes.ts +++ b/packages/shared/src/constants/TreeTypes.ts @@ -34,6 +34,7 @@ export enum TreeId { Cactus = "tree_cactus", Knotwood = "tree_knotwood", WindPine = "tree_windPine", + WindSwept = "tree_windSwept", } /** Extract the subtype key from a TreeId (e.g. TreeId.Oak → "oak") */ @@ -93,6 +94,7 @@ export const TREE_TYPES = { cactus: { name: "Cactus", levelRequired: 1 }, knotwood: { name: "Knotwood Tree", levelRequired: 1 }, windPine: { name: "Wind Pine", levelRequired: 1 }, + windSwept: { name: "Windswept Tree", levelRequired: 1 }, } as const satisfies Record; /** All valid tree subtype keys (e.g., "oak", "willow") */ diff --git a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts index fb36201b4..20bfebbe7 100644 --- a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts +++ b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts @@ -74,6 +74,7 @@ const CANYON_TREE_CONFIG: BiomeTreeConfig = { maxHeight: 13, }, [TreeId.Yucca]: { weight: 15, avoidsWaterBelow: 3 }, + [TreeId.WindSwept]: { weight: 25, minHeight: 15 }, }, density: 15, minSpacing: 18, From ee413597aac9f3ef5c8d77b4d7cd2b93828163c7 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Thu, 19 Mar 2026 02:05:32 +0800 Subject: [PATCH 14/71] camera far plane --- packages/shared/src/core/World.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/core/World.ts b/packages/shared/src/core/World.ts index 8a8ab7fb3..26c739c20 100644 --- a/packages/shared/src/core/World.ts +++ b/packages/shared/src/core/World.ts @@ -991,7 +991,7 @@ export class World extends EventEmitter { // - far (2000): Optimized draw distance for performance // Sky geometry is scaled to fit within this range (follows camera) // This prevents z-fighting without needing expensive logarithmic depth buffers - this.camera = new THREE.PerspectiveCamera(70, 16 / 9, 0.2, 800); + this.camera = new THREE.PerspectiveCamera(70, 16 / 9, 0.2, 10000); // Enable layer 1 for main camera (vegetation, water, grass, building walls are on layer 1) // Enable layer 2 for building floors (walkable surfaces for click-to-move) From 539f8e1f43f6e77b9d5b3f0a8f1fd989b396eab1 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Thu, 19 Mar 2026 03:26:47 +0800 Subject: [PATCH 15/71] feat: ceil-based terracing, global elevation offset, and biome profile tuning Replace floor-based terrace quantization with ceil-based (raises terrain to plateaus, never lowers). Add global ELEVATION_OFFSET, ELEVATION_NOISE_AMOUNT, and ELEVATION_NOISE_SCALE constants for large-scale elevation variation. Update biome noise profiles (tundra, forest, canyon) and increase island radius to 2000. Mirror all changes in the worker JS string builder. Made-with: Cursor --- .../shared/world/TerrainHeightParams.ts | 159 ++++++++++-------- 1 file changed, 88 insertions(+), 71 deletions(-) diff --git a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts index 965d8998f..817444b87 100644 --- a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts +++ b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts @@ -99,6 +99,13 @@ export const HEIGHT_POWER_CURVE = 1.1; /** Global terrace step count — shared across all biomes to prevent boundary artifacts. */ export const TERRACE_STEPS = 10; +/** Base height bias added after scaling to world units (in world units). */ +export const ELEVATION_OFFSET = 8; +/** Amplitude of large-scale elevation noise (in world units). */ +export const ELEVATION_NOISE_AMOUNT = 50; +/** Frequency of large-scale elevation noise. */ +export const ELEVATION_NOISE_SCALE = 0.0015; + export interface BiomeNoiseProfile { continentWeight: number; ridgeWeight: number; @@ -125,41 +132,41 @@ export interface BiomeNoiseProfile { export const TUNDRA_PROFILE: BiomeNoiseProfile = { continentWeight: 0.32, - ridgeWeight: 0.15, - hillWeight: 0.28, - erosionWeight: 0.1, - detailWeight: 0.1, - powerCurve: 1.1, - terraceStrength: 0.4, - terraceSharpness: 0.7, - terraceHeightScale: 2.5, - terraceSlope: 0.25, + ridgeWeight: 0.29, + hillWeight: 0.02, + erosionWeight: 0.2, + detailWeight: 0.02, + powerCurve: 1.42, + terraceStrength: 2, + terraceSharpness: 0.19, + terraceHeightScale: 11.4, + terraceSlope: 1.9, }; export const FOREST_PROFILE: BiomeNoiseProfile = { - continentWeight: 0.15, - ridgeWeight: 0.08, - hillWeight: 0.1, - erosionWeight: 0.05, - detailWeight: 0.05, - powerCurve: 1, - terraceStrength: 0, - terraceSharpness: 0, - terraceHeightScale: 1, - terraceSlope: 0, + continentWeight: 0.32, + ridgeWeight: 0.29, + hillWeight: 0, + erosionWeight: 0.2, + detailWeight: 0, + powerCurve: 1.42, + terraceStrength: 2, + terraceSharpness: 0.19, + terraceHeightScale: 9.1, + terraceSlope: 0.98, }; export const CANYON_PROFILE: BiomeNoiseProfile = { continentWeight: 0.32, - ridgeWeight: 0.25, - hillWeight: 0.18, + ridgeWeight: 0.29, + hillWeight: 0, erosionWeight: 0.2, - detailWeight: 0.05, - powerCurve: 1.45, - terraceStrength: 0.6, - terraceSharpness: 0.8, - terraceHeightScale: 7, - terraceSlope: 0.35, + detailWeight: 0, + powerCurve: 1.42, + terraceStrength: 2, + terraceSharpness: 0.82, + terraceHeightScale: 7.8, + terraceSlope: 0.48, }; export const BIOME_PROFILES: Record = { @@ -172,7 +179,7 @@ export const BIOME_PROFILES: Record = { // Island configuration // --------------------------------------------------------------------------- -export const ISLAND_RADIUS = 788; +export const ISLAND_RADIUS = 2000; export const ISLAND_FALLOFF = 450; export const ISLAND_DEEP_OCEAN_BUFFER = 113; export const BASE_ELEVATION = 0.42; @@ -235,8 +242,8 @@ export interface LandscapeFeatureDef { export const LANDSCAPE_FEATURES: LandscapeFeatureDef[] = [ { type: LandscapeType.Mountain, - x: -168.5, - z: -352.5, + x: -195.5, + z: -520.5, radius: 250, strength: 5.5, layers: 5, @@ -246,31 +253,18 @@ export const LANDSCAPE_FEATURES: LandscapeFeatureDef[] = [ noiseScale: 0.025, noiseAmount: 0.6, }, - { - type: LandscapeType.Mountain, - x: 265.5, - z: 322.5, - radius: 130, - strength: 2.5, - layers: 5, - shapePower: 1.3, - edgeSharpness: 0.3, - layerSlope: 0.55, - noiseScale: 0.025, - noiseAmount: 0.55, - }, { type: LandscapeType.Pond, - x: -28.5, - z: 327.5, - radius: 90, - strength: 1.5, - layers: 1, - shapePower: 3.5, - edgeSharpness: 0.1, - layerSlope: 0.8, + x: 198.5, + z: 917.5, + radius: 455, + strength: 4.5, + layers: 6, + shapePower: 3.7, + edgeSharpness: 0.75, + layerSlope: 0.71, noiseScale: 0.015, - noiseAmount: 0.06, + noiseAmount: 0.26, }, ]; @@ -472,21 +466,22 @@ export function computeBaseHeight( height = Math.max(0, Math.min(1, height)); height = Math.pow(height, pC); - // ── 4. Terracing — floor-quantize into flat shelves with cliff edges ─ - // tHS stretches shelf positions around 0.5 so cliffs are taller per biome. - // tSl blends natural slope back onto shelves (0 = flat, 1 = full natural slope). + // ── 4. Terracing — ceil-based (raises terrain to plateaus, never lowers) ─ const steps = TERRACE_STEPS; const ths = Math.max(1, tHS); if (tS > 0.01 && steps >= 2) { - const stepped = Math.floor(height * steps) / steps; - const nextStep = Math.min(1, stepped + 1 / steps); - const frac = (height - stepped) * steps; - const edgeBlend = frac < tSh ? 0 : (frac - tSh) / (1 - tSh + 0.001); - const flatStep = stepped + edgeBlend * (nextStep - stepped); - const slopedStep = stepped + frac * (nextStep - stepped); + const ceilStep = Math.ceil(height * steps) / steps; + const floorStep = Math.max(0, ceilStep - 1 / steps); + const frac = (height - floorStep) * steps; + + const flatThreshold = 1 - tSh; + const edgeBlend = frac > flatThreshold ? 1 : frac / (flatThreshold + 0.001); + const flatStep = floorStep + edgeBlend * (ceilStep - floorStep); + + const slopedStep = floorStep + frac * (ceilStep - floorStep); const terraced = flatStep + tSl * (slopedStep - flatStep); const scaled = Math.max(0, Math.min(1, 0.5 + (terraced - 0.5) * ths)); - height = height + (scaled - height) * tS; + height = Math.max(height, height + (scaled - height) * tS); } // ── 5. Coastline noise → island mask ──────────────────────────────── @@ -537,7 +532,23 @@ export function computeBaseHeight( height = OCEAN_FLOOR_HEIGHT; } - return height * maxHeight; + // ── 7. Scale to world units + global elevation offset ────────────── + height = height * maxHeight; + if (islandMask > 0) { + const eOff: number = ELEVATION_OFFSET; + const eNA: number = ELEVATION_NOISE_AMOUNT; + if (eNA > 0 || eOff !== 0) { + const elevNoise = + (noise.simplex2D( + worldX * ELEVATION_NOISE_SCALE, + worldZ * ELEVATION_NOISE_SCALE, + ) + + 1) * + 0.5; + height += (eOff + elevNoise * eNA) * islandMask; + } + } + return height; } export interface ShorelineConfig { @@ -729,15 +740,16 @@ export function buildGetBaseHeightAtJS(): string { var gSteps = ${TERRACE_STEPS}; var ths = Math.max(1, tHS); if (tS > 0.01 && gSteps >= 2) { - var gStepped = Math.floor(height * gSteps) / gSteps; - var gNextStep = Math.min(1, gStepped + 1 / gSteps); - var gFrac = (height - gStepped) * gSteps; - var gEdgeBlend = gFrac < tSh ? 0 : (gFrac - tSh) / (1 - tSh + 0.001); - var gFlatStep = gStepped + gEdgeBlend * (gNextStep - gStepped); - var gSlopedStep = gStepped + gFrac * (gNextStep - gStepped); + var ceilStep = Math.ceil(height * gSteps) / gSteps; + var floorStep = Math.max(0, ceilStep - 1 / gSteps); + var gFrac = (height - floorStep) * gSteps; + var flatThreshold = 1 - tSh; + var gEdgeBlend = gFrac > flatThreshold ? 1 : gFrac / (flatThreshold + 0.001); + var gFlatStep = floorStep + gEdgeBlend * (ceilStep - floorStep); + var gSlopedStep = floorStep + gFrac * (ceilStep - floorStep); var gTerraced = gFlatStep + tSl * (gSlopedStep - gFlatStep); var gScaled = Math.max(0, Math.min(1, 0.5 + (gTerraced - 0.5) * ths)); - height = height + (gScaled - height) * tS; + height = Math.max(height, height + (gScaled - height) * tS); } var distFromCenter = Math.sqrt(worldX * worldX + worldZ * worldZ); @@ -764,6 +776,11 @@ export function buildGetBaseHeightAtJS(): string { height = applyLandscapeFeatures(height, worldX, worldZ); if (islandMask === 0) { height = ${OCEAN_FLOOR_HEIGHT}; } - return height * MAX_HEIGHT; + height = height * MAX_HEIGHT; + if (islandMask > 0 && (${ELEVATION_NOISE_AMOUNT} > 0 || ${ELEVATION_OFFSET} !== 0)) { + var elevNoise = (noise.simplex2D(worldX * ${ELEVATION_NOISE_SCALE}, worldZ * ${ELEVATION_NOISE_SCALE}) + 1) * 0.5; + height += (${ELEVATION_OFFSET} + elevNoise * ${ELEVATION_NOISE_AMOUNT}) * islandMask; + } + return height; }`; } From 113e19a79529c9c09910a36de07f2fc85405bc6b Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Thu, 19 Mar 2026 03:27:06 +0800 Subject: [PATCH 16/71] refactor: centralize arena positioning into arena-layout.ts Create arena-layout.ts as the single source of truth for all duel arena coordinates and dimensions. Update duel-manifest, world-areas, DuelArenaVisualsSystem, and ClientCameraSystem to import from it instead of using scattered hardcoded values. Shift arena Z by +50 from original. Made-with: Cursor --- packages/shared/src/data/arena-layout.ts | 65 +++++++++++++++++++ packages/shared/src/data/duel-manifest.ts | 39 +++++++---- packages/shared/src/data/world-areas.ts | 15 +++-- .../src/systems/client/ClientCameraSystem.ts | 10 +-- .../systems/client/DuelArenaVisualsSystem.ts | 37 +++++------ 5 files changed, 125 insertions(+), 41 deletions(-) create mode 100644 packages/shared/src/data/arena-layout.ts diff --git a/packages/shared/src/data/arena-layout.ts b/packages/shared/src/data/arena-layout.ts new file mode 100644 index 000000000..cb2269fca --- /dev/null +++ b/packages/shared/src/data/arena-layout.ts @@ -0,0 +1,65 @@ +/** + * Arena Layout Constants — Single Source of Truth + * + * ALL arena positioning derives from the values in this file. + * To relocate the arena complex, edit the constants here and + * everything (visuals, server logic, zone bounds) follows automatically. + */ + +// --------------------------------------------------------------------------- +// Arena Grid +// --------------------------------------------------------------------------- +export const ARENA_BASE_X = 60; +export const ARENA_BASE_Z = 130; +export const ARENA_BASE_Y = 0.42; +export const ARENA_WIDTH = 20; +export const ARENA_LENGTH = 24; +export const ARENA_GAP = 4; +export const ARENA_COLUMNS = 2; +export const ARENA_ROWS = 3; +export const ARENA_COUNT = 6; +export const ARENA_SPAWN_OFFSET = 8; + +// --------------------------------------------------------------------------- +// Lobby (south of arenas, right side) +// --------------------------------------------------------------------------- +export const LOBBY_CENTER_X = 105; +export const LOBBY_CENTER_Z = 112; +export const LOBBY_WIDTH = 40; +export const LOBBY_LENGTH = 25; + +// --------------------------------------------------------------------------- +// Hospital (south of arenas, left side) +// --------------------------------------------------------------------------- +export const HOSPITAL_CENTER_X = 65; +export const HOSPITAL_CENTER_Z = 112; +export const HOSPITAL_WIDTH = 28; +export const HOSPITAL_LENGTH = 23; + +// --------------------------------------------------------------------------- +// Lobby Spawn Point (where players appear in the lobby) +// --------------------------------------------------------------------------- +export const LOBBY_SPAWN_X = 105; +export const LOBBY_SPAWN_Y = 0.42; +export const LOBBY_SPAWN_Z = 110; + +// --------------------------------------------------------------------------- +// Derived: overall zone bounds (encompasses arenas + lobby + hospital + margin) +// --------------------------------------------------------------------------- +const gridMaxX = + ARENA_BASE_X + ARENA_COLUMNS * ARENA_WIDTH + (ARENA_COLUMNS - 1) * ARENA_GAP; +const gridMaxZ = + ARENA_BASE_Z + ARENA_ROWS * ARENA_LENGTH + (ARENA_ROWS - 1) * ARENA_GAP; +const lobbyMinX = LOBBY_CENTER_X - LOBBY_WIDTH / 2; +const lobbyMaxX = LOBBY_CENTER_X + LOBBY_WIDTH / 2; +const lobbyMinZ = LOBBY_CENTER_Z - LOBBY_LENGTH / 2; +const hospMinX = HOSPITAL_CENTER_X - HOSPITAL_WIDTH / 2; +const hospMinZ = HOSPITAL_CENTER_Z - HOSPITAL_LENGTH / 2; + +const MARGIN = 15; +export const ZONE_BOUNDS_MIN_X = + Math.min(ARENA_BASE_X, lobbyMinX, hospMinX) - MARGIN; +export const ZONE_BOUNDS_MAX_X = Math.max(gridMaxX, lobbyMaxX) + MARGIN; +export const ZONE_BOUNDS_MIN_Z = + Math.min(ARENA_BASE_Z, lobbyMinZ, hospMinZ) - MARGIN; +export const ZONE_BOUNDS_MAX_Z = Math.max(gridMaxZ) + MARGIN; diff --git a/packages/shared/src/data/duel-manifest.ts b/packages/shared/src/data/duel-manifest.ts index 14ad6a94e..3f0e98bf6 100644 --- a/packages/shared/src/data/duel-manifest.ts +++ b/packages/shared/src/data/duel-manifest.ts @@ -238,10 +238,25 @@ export function areRulesCompatible( // ============================================================================ import { ALL_WORLD_AREAS } from "./world-areas"; +import { + ARENA_BASE_X, + ARENA_BASE_Z, + ARENA_BASE_Y, + ARENA_WIDTH, + ARENA_LENGTH, + ARENA_GAP, + ARENA_COLUMNS, + ARENA_ROWS, + ARENA_COUNT, + ARENA_SPAWN_OFFSET, + LOBBY_SPAWN_X, + LOBBY_SPAWN_Y, + LOBBY_SPAWN_Z, +} from "./arena-layout"; /** * Arena layout configuration derived from world-areas.json manifest. - * Single source of truth for arena positioning and dimensions. + * Positions come from arena-layout.ts (single source of truth). */ export interface DuelArenaConfig { /** Base X coordinate for arena grid */ @@ -272,17 +287,17 @@ export interface DuelArenaConfig { * Default arena config (fallback if manifest not loaded) */ const DEFAULT_ARENA_CONFIG: DuelArenaConfig = { - baseX: 60, - baseZ: 80, - baseY: 0.42, - arenaWidth: 20, - arenaLength: 24, - arenaGap: 4, - columns: 2, - rows: 3, - arenaCount: 6, - spawnOffset: 8, - lobbySpawnPoint: { x: 105, y: 0.42, z: 60 }, + baseX: ARENA_BASE_X, + baseZ: ARENA_BASE_Z, + baseY: ARENA_BASE_Y, + arenaWidth: ARENA_WIDTH, + arenaLength: ARENA_LENGTH, + arenaGap: ARENA_GAP, + columns: ARENA_COLUMNS, + rows: ARENA_ROWS, + arenaCount: ARENA_COUNT, + spawnOffset: ARENA_SPAWN_OFFSET, + lobbySpawnPoint: { x: LOBBY_SPAWN_X, y: LOBBY_SPAWN_Y, z: LOBBY_SPAWN_Z }, }; /** diff --git a/packages/shared/src/data/world-areas.ts b/packages/shared/src/data/world-areas.ts index a78dd6d2f..180329ef1 100644 --- a/packages/shared/src/data/world-areas.ts +++ b/packages/shared/src/data/world-areas.ts @@ -21,6 +21,12 @@ import type { StationLocation, WorldArea, } from "../types/core/core"; +import { + ZONE_BOUNDS_MIN_X, + ZONE_BOUNDS_MAX_X, + ZONE_BOUNDS_MIN_Z, + ZONE_BOUNDS_MAX_Z, +} from "./arena-layout"; // Re-export types from core export type { @@ -99,11 +105,10 @@ export const ALL_WORLD_AREAS: Record = { "A gladiatorial arena where players can engage in honorable combat. Stake items and fight!", difficultyLevel: 0, bounds: { - // Encompasses all arenas, lobby, and hospital - minX: 35, // Hospital left edge (65 - 15) - maxX: 145, // Lobby right edge (105 + 20 + some margin) - minZ: 37, // Lobby/hospital bottom edge (62 - 12.5) - maxZ: 140, // Arena 6 top edge + minX: ZONE_BOUNDS_MIN_X, + maxX: ZONE_BOUNDS_MAX_X, + minZ: ZONE_BOUNDS_MIN_Z, + maxZ: ZONE_BOUNDS_MAX_Z, }, biomeType: "plains", safeZone: true, // Lobby area is safe diff --git a/packages/shared/src/systems/client/ClientCameraSystem.ts b/packages/shared/src/systems/client/ClientCameraSystem.ts index 6f1484cdb..ef36059f3 100644 --- a/packages/shared/src/systems/client/ClientCameraSystem.ts +++ b/packages/shared/src/systems/client/ClientCameraSystem.ts @@ -16,6 +16,7 @@ import { isEmbeddedSpectatorViewport, isStreamPageRoute, } from "../../runtime/clientViewportMode"; +import { getDuelArenaConfig } from "../../data/duel-manifest"; import { RaycastService } from "./interaction/services/RaycastService"; // CameraTarget interface moved to shared types @@ -1254,11 +1255,10 @@ export class ClientCameraSystem extends SystemBase { /** Park the camera at the duel arena lobby when no entities exist. */ private positionCameraAtArenaFallback(): void { if (this._arenaFallbackApplied || !this.camera) return; - // Arena lobby: default { x: 105, y: 0.42, z: 60 } - // We offset the camera above and behind the lobby point. - const lobbyX = 105; - const lobbyY = 0.42; - const lobbyZ = 60; + const lobby = getDuelArenaConfig().lobbySpawnPoint; + const lobbyX = lobby.x; + const lobbyY = lobby.y; + const lobbyZ = lobby.z; this.camera.position.set(lobbyX - 15, lobbyY + 20, lobbyZ + 25); this.camera.lookAt(lobbyX, lobbyY + 2, lobbyZ); this._arenaFallbackApplied = true; diff --git a/packages/shared/src/systems/client/DuelArenaVisualsSystem.ts b/packages/shared/src/systems/client/DuelArenaVisualsSystem.ts index 54c720349..03f9fc483 100644 --- a/packages/shared/src/systems/client/DuelArenaVisualsSystem.ts +++ b/packages/shared/src/systems/client/DuelArenaVisualsSystem.ts @@ -18,7 +18,7 @@ * - 6 rectangular arenas in a 2x3 grid * - Each arena is 20m wide x 24m long * - 4m gap between arenas - * - Base coordinates: x=60, z=80 (near spawn) + * - Base coordinates: see arena-layout.ts (single source of truth) */ import THREE, { @@ -51,18 +51,27 @@ import type { Physics } from "../shared/interaction/Physics"; import type { PxRigidStatic } from "../../types/systems/physics"; import type { ParticleSystem } from "../shared/presentation/ParticleSystem"; import type { FlatZone } from "../../types/world/terrain"; +import { + ARENA_BASE_X, + ARENA_BASE_Z, + ARENA_WIDTH, + ARENA_LENGTH, + ARENA_GAP, + ARENA_COUNT, + LOBBY_CENTER_X, + LOBBY_CENTER_Z, + LOBBY_WIDTH, + LOBBY_LENGTH, + HOSPITAL_CENTER_X, + HOSPITAL_CENTER_Z, + HOSPITAL_WIDTH, + HOSPITAL_LENGTH, +} from "../../data/arena-layout"; // ============================================================================ -// Arena Configuration (matches ArenaPoolManager) +// Arena Configuration (layout from arena-layout.ts) // ============================================================================ -const ARENA_BASE_X = 60; -const ARENA_BASE_Z = 80; -const ARENA_WIDTH = 20; -const ARENA_LENGTH = 24; -const ARENA_GAP = 4; -const ARENA_COUNT = 6; - const FENCE_HEIGHT = 1.5; const FENCE_POST_SPACING = 2.0; const FENCE_POST_SIZE = 0.2; @@ -72,16 +81,6 @@ const FENCE_RAIL_HEIGHTS = [0.3, 0.75, 1.2]; const FLOOR_THICKNESS = 0.3; const FLOOR_HEIGHT_OFFSET = 0.27; -const LOBBY_CENTER_X = 105; -const LOBBY_CENTER_Z = 62; -const LOBBY_WIDTH = 40; -const LOBBY_LENGTH = 25; - -const HOSPITAL_CENTER_X = 65; -const HOSPITAL_CENTER_Z = 62; -const HOSPITAL_WIDTH = 28; -const HOSPITAL_LENGTH = 23; - const LOBBY_FLOOR_COLOR = 0xc9b896; const HOSPITAL_FLOOR_COLOR = 0xffffff; From b1c498562315934e7ac8f92986b15fe8308634dc Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Thu, 19 Mar 2026 03:52:53 +0800 Subject: [PATCH 17/71] fix: scale terrain shader noise and texture tiling for larger island MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce NOISE_SCALE (0.0008 → 0.0003) so dirt/grass patches don't visibly tile across the 4000-unit island. Reduce TERRAIN_TEX_TILE (0.08 → 0.025) to match. Shift shoreline height thresholds up to account for the global elevation offset (+8..+58 world units). Made-with: Cursor --- .../src/systems/shared/world/TerrainShader.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/shared/src/systems/shared/world/TerrainShader.ts b/packages/shared/src/systems/shared/world/TerrainShader.ts index 0f5bd877b..025f97137 100644 --- a/packages/shared/src/systems/shared/world/TerrainShader.ts +++ b/packages/shared/src/systems/shared/world/TerrainShader.ts @@ -48,15 +48,15 @@ import { SUN_LIGHT } from "./LightingConfig"; export const TERRAIN_SHADER_CONSTANTS = { TRIPLANAR_SCALE: 0.5, - SNOW_HEIGHT: 50.0, - NOISE_SCALE: 0.0008, + SNOW_HEIGHT: 90.0, + NOISE_SCALE: 0.0003, DIRT_THRESHOLD: 0.5, LOD_FULL_DETAIL: 100.0, LOD_MEDIUM_DETAIL: 200.0, WATER_LEVEL: 5.0, }; -const TERRAIN_TEX_TILE = 0.08; +const TERRAIN_TEX_TILE = 0.0125; const TERRAIN_TEX_DIR = "textures/terrain-biomes"; const TERRAIN_BIOME_TEXTURES = { @@ -270,7 +270,7 @@ export function computeTerrainBaseColor( // Sand near water (flat areas, stronger in canyon) const sandBlend = mul( - smoothstep(float(10.0), float(6.0), height), + smoothstep(float(18.0), float(12.0), height), smoothstep(float(0.25), float(0.0), slope), ); const sandStrength = mix(float(0.6), float(0.9), dW); @@ -280,17 +280,17 @@ export function computeTerrainBaseColor( c = mix( c, DIRT_DARK, - mul(smoothstep(float(14.0), float(8.0), height), float(0.4)), + mul(smoothstep(float(22.0), float(14.0), height), float(0.4)), ); c = mix( c, MUD_BROWN, - mul(smoothstep(float(9.0), float(6.0), height), float(0.7)), + mul(smoothstep(float(15.0), float(10.0), height), float(0.7)), ); c = mix( c, WATER_EDGE, - mul(smoothstep(float(6.5), float(5.0), height), float(0.9)), + mul(smoothstep(float(11.0), float(7.0), height), float(0.9)), ); return c; @@ -860,7 +860,7 @@ export function createTerrainMaterial(): THREE.Material & { // Sand near water (keep flat color - no sand texture) const sandBlend = mul( - smoothstep(float(10.0), float(6.0), height), + smoothstep(float(18.0), float(12.0), height), smoothstep(float(0.25), float(0.0), slope), ); const sandStrength = mix(float(0.6), float(0.9), dW); @@ -870,17 +870,17 @@ export function createTerrainMaterial(): THREE.Material & { baseColor = mix( baseColor, DIRT_DARK, - mul(smoothstep(float(14.0), float(8.0), height), float(0.4)), + mul(smoothstep(float(22.0), float(14.0), height), float(0.4)), ); baseColor = mix( baseColor, MUD_BROWN, - mul(smoothstep(float(9.0), float(6.0), height), float(0.7)), + mul(smoothstep(float(15.0), float(10.0), height), float(0.7)), ); baseColor = mix( baseColor, WATER_EDGE, - mul(smoothstep(float(6.5), float(5.0), height), float(0.9)), + mul(smoothstep(float(11.0), float(7.0), height), float(0.9)), ); // Anti-dithering noise variation (±4% brightness, ±2% color shift) From 4ffe34619b2826ae7aaee5e3da37c6022f50ec59 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Thu, 19 Mar 2026 04:10:31 +0800 Subject: [PATCH 18/71] feat: dual-scale texture blending to break terrain tiling repetition Sample each biome texture at two non-harmonic UV scales (1x and 0.13x) and blend between them using Perlin noise. This prevents the tile grid from being visually detectable across the terrain. Made-with: Cursor --- .../src/systems/shared/world/TerrainShader.ts | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/packages/shared/src/systems/shared/world/TerrainShader.ts b/packages/shared/src/systems/shared/world/TerrainShader.ts index 025f97137..b397ca647 100644 --- a/packages/shared/src/systems/shared/world/TerrainShader.ts +++ b/packages/shared/src/systems/shared/world/TerrainShader.ts @@ -56,7 +56,7 @@ export const TERRAIN_SHADER_CONSTANTS = { WATER_LEVEL: 5.0, }; -const TERRAIN_TEX_TILE = 0.0125; +const TERRAIN_TEX_TILE = 0.1; const TERRAIN_TEX_DIR = "textures/terrain-biomes"; const TERRAIN_BIOME_TEXTURES = { @@ -763,11 +763,18 @@ export function createTerrainMaterial(): THREE.Material & { const tSnowDirt = loadBiomeTex("snowDirt"); const tSnowCliff = loadBiomeTex("snowCliff"); - // UV projections (top-down for grass/dirt, triplanar for cliffs) + // UV projections — dual-scale blend to break visible texture tiling. + // Sample at primary scale and a non-harmonic secondary scale (×0.27), + // blend 50/50 with noise so the two grids never visually align. const tileScale = float(TERRAIN_TEX_TILE); + const tileScale2 = float(TERRAIN_TEX_TILE * 0.13); const uvFlat = mul(vec2(worldPos.x, worldPos.z), tileScale); const uvFront = mul(vec2(worldPos.x, worldPos.y), tileScale); const uvSide = mul(vec2(worldPos.z, worldPos.y), tileScale); + const uvFlat2 = mul(vec2(worldPos.x, worldPos.z), tileScale2); + const uvFront2 = mul(vec2(worldPos.x, worldPos.y), tileScale2); + const uvSide2 = mul(vec2(worldPos.z, worldPos.y), tileScale2); + const tileBlend = smoothstep(float(0.2), float(0.8), noiseValue); // Triplanar blend weights for cliff textures (^4 sharpening) const tnx = abs(worldNormal.x); @@ -781,20 +788,28 @@ export function createTerrainMaterial(): THREE.Material & { const twY = div(tw4y, twSum); const twZ = div(tw4z, twSum); - // Flat textures sampled top-down (grass/dirt on mostly-flat surfaces) - const sGrass = texture(tGrass, uvFlat).rgb; - const sDirt = texture(tDirt, uvFlat).rgb; - const sDesertGrass = texture(tDesertGrass, uvFlat).rgb; - const sDesertDirt = texture(tDesertDirt, uvFlat).rgb; - const sSnowGrass = texture(tSnowGrass, uvFlat).rgb; - const sSnowDirt = texture(tSnowDirt, uvFlat).rgb; - - // Cliff textures sampled triplanarly (avoids stretching on steep faces) - const triCliff = (t: THREE.Texture) => - add( + // Flat textures — blend two scales per biome texture + const dualFlat = (t: THREE.Texture) => + mix(texture(t, uvFlat).rgb, texture(t, uvFlat2).rgb, tileBlend); + const sGrass = dualFlat(tGrass); + const sDirt = dualFlat(tDirt); + const sDesertGrass = dualFlat(tDesertGrass); + const sDesertDirt = dualFlat(tDesertDirt); + const sSnowGrass = dualFlat(tSnowGrass); + const sSnowDirt = dualFlat(tSnowDirt); + + // Cliff textures — triplanar with dual-scale blend + const triCliff = (t: THREE.Texture) => { + const s1 = add( add(mul(texture(t, uvFlat).rgb, twY), mul(texture(t, uvSide).rgb, twX)), mul(texture(t, uvFront).rgb, twZ), ); + const s2 = add( + add(mul(texture(t, uvFlat2).rgb, twY), mul(texture(t, uvSide2).rgb, twX)), + mul(texture(t, uvFront2).rgb, twZ), + ); + return mix(s1, s2, tileBlend); + }; const sCliff = triCliff(tCliff); const sDesertCliff = triCliff(tDesertCliff); const sSnowCliff = triCliff(tSnowCliff); From 28638b5403b4236cf81f14c5976b63b140a87abf Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Thu, 19 Mar 2026 11:51:48 +0800 Subject: [PATCH 19/71] feat: slope-based terrain coloring, move arena/home, disable skull/road Terrain shader: slope drives dirt (low) vs cliff (high) on slopes, noise-driven dirt patches on flats, dual-scale texture blending, road overlay disabled. Move arena to (362, 434) and home to (444, 330). Disable skull markers and wilderness border bands. Made-with: Cursor --- packages/shared/src/data/arena-layout.ts | 16 +-- packages/shared/src/data/world-areas.ts | 8 +- .../src/systems/client/ZoneVisualsSystem.ts | 14 +- .../src/systems/shared/world/TerrainShader.ts | 123 ++++++------------ 4 files changed, 59 insertions(+), 102 deletions(-) diff --git a/packages/shared/src/data/arena-layout.ts b/packages/shared/src/data/arena-layout.ts index cb2269fca..59e08db77 100644 --- a/packages/shared/src/data/arena-layout.ts +++ b/packages/shared/src/data/arena-layout.ts @@ -9,8 +9,8 @@ // --------------------------------------------------------------------------- // Arena Grid // --------------------------------------------------------------------------- -export const ARENA_BASE_X = 60; -export const ARENA_BASE_Z = 130; +export const ARENA_BASE_X = 340; +export const ARENA_BASE_Z = 394; export const ARENA_BASE_Y = 0.42; export const ARENA_WIDTH = 20; export const ARENA_LENGTH = 24; @@ -23,25 +23,25 @@ export const ARENA_SPAWN_OFFSET = 8; // --------------------------------------------------------------------------- // Lobby (south of arenas, right side) // --------------------------------------------------------------------------- -export const LOBBY_CENTER_X = 105; -export const LOBBY_CENTER_Z = 112; +export const LOBBY_CENTER_X = 385; +export const LOBBY_CENTER_Z = 376; export const LOBBY_WIDTH = 40; export const LOBBY_LENGTH = 25; // --------------------------------------------------------------------------- // Hospital (south of arenas, left side) // --------------------------------------------------------------------------- -export const HOSPITAL_CENTER_X = 65; -export const HOSPITAL_CENTER_Z = 112; +export const HOSPITAL_CENTER_X = 345; +export const HOSPITAL_CENTER_Z = 376; export const HOSPITAL_WIDTH = 28; export const HOSPITAL_LENGTH = 23; // --------------------------------------------------------------------------- // Lobby Spawn Point (where players appear in the lobby) // --------------------------------------------------------------------------- -export const LOBBY_SPAWN_X = 105; +export const LOBBY_SPAWN_X = 385; export const LOBBY_SPAWN_Y = 0.42; -export const LOBBY_SPAWN_Z = 110; +export const LOBBY_SPAWN_Z = 374; // --------------------------------------------------------------------------- // Derived: overall zone bounds (encompasses arenas + lobby + hospital + margin) diff --git a/packages/shared/src/data/world-areas.ts b/packages/shared/src/data/world-areas.ts index 180329ef1..9242b7bac 100644 --- a/packages/shared/src/data/world-areas.ts +++ b/packages/shared/src/data/world-areas.ts @@ -50,10 +50,10 @@ export const ALL_WORLD_AREAS: Record = { description: "A peaceful area for new adventurers", difficultyLevel: 0, bounds: { - minX: -50, - maxX: 50, - minZ: -50, - maxZ: 50, + minX: 294, + maxX: 594, + minZ: 180, + maxZ: 480, }, biomeType: "plains", safeZone: true, diff --git a/packages/shared/src/systems/client/ZoneVisualsSystem.ts b/packages/shared/src/systems/client/ZoneVisualsSystem.ts index 5d3f4b1ed..0e9957aa7 100644 --- a/packages/shared/src/systems/client/ZoneVisualsSystem.ts +++ b/packages/shared/src/systems/client/ZoneVisualsSystem.ts @@ -142,10 +142,10 @@ export class ZoneVisualsSystem extends SystemBase { if (area.id === "duel_arena") { return "swords"; } - // Wilderness/PvP zones (not duel arena) get skull - if (!area.safeZone || (area.pvpEnabled && area.id !== "duel_arena")) { - return "skull"; - } + // Wilderness/PvP zones (not duel arena) — skull disabled for now + // if (!area.safeZone || (area.pvpEnabled && area.id !== "duel_arena")) { + // return "skull"; + // } // Safe towns get home if (area.safeZone && !area.pvpEnabled) { return "home"; @@ -156,9 +156,9 @@ export class ZoneVisualsSystem extends SystemBase { /** * Check if zone should have a red border band */ - private shouldHaveBorder(area: WorldArea): boolean { - // Only wilderness/dangerous zones get the red border (not duel arena) - return !area.safeZone && area.id !== "duel_arena"; + private shouldHaveBorder(_area: WorldArea): boolean { + // Borders disabled for now + return false; } /** diff --git a/packages/shared/src/systems/shared/world/TerrainShader.ts b/packages/shared/src/systems/shared/world/TerrainShader.ts index b397ca647..4fec81928 100644 --- a/packages/shared/src/systems/shared/world/TerrainShader.ts +++ b/packages/shared/src/systems/shared/world/TerrainShader.ts @@ -234,12 +234,6 @@ export function computeTerrainBaseColor( ); // Biome-blended dirt - const dirtPatchFactor = smoothstep( - float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD - 0.05), - float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD + 0.15), - noiseVal, - ); - const flatnessFactor = smoothstep(float(0.3), float(0.05), slope); const dirtVariation = smoothstep(float(0.3), float(0.7), noiseVal2); const tundraDirt = mix(TUNDRA_DIRT, TUNDRA_DIRT_DARK, dirtVariation); const forestDirt = mix(FOREST_DIRT, FOREST_DIRT_DARK, dirtVariation); @@ -248,16 +242,8 @@ export function computeTerrainBaseColor( add(mul(tundraDirt, tW), mul(forestDirt, fW)), mul(canyonDirt, dW), ); - c = mix(c, dirtColor, mul(dirtPatchFactor, flatnessFactor)); - - // Slope-based dirt — fades out at steep slopes where cliff color takes over - const dirtSlopeFactor = mul( - smoothstep(float(0.15), float(0.4), slope), - smoothstep(float(0.6), float(0.3), slope), - ); - c = mix(c, dirtColor, mul(dirtSlopeFactor, float(0.6))); - // Per-biome cliff color on steep slopes (terrace sides, rock faces) + // Per-biome cliff color const cliffVariation = smoothstep(float(0.3), float(0.7), noiseVal); const tundraCliff = mix(TUNDRA_CLIFF, TUNDRA_CLIFF_DARK, cliffVariation); const forestCliff = mix(FOREST_CLIFF, FOREST_CLIFF_DARK, cliffVariation); @@ -266,7 +252,22 @@ export function computeTerrainBaseColor( add(mul(tundraCliff, tW), mul(forestCliff, fW)), mul(canyonCliff, dW), ); - c = mix(c, cliffColor, smoothstep(float(0.3), float(0.55), slope)); + + // Noise-driven dirt patches on flat areas (subtle) + const nDirtFactor = mul( + smoothstep( + float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD + 0.05), + float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD + 0.25), + noiseVal, + ), + smoothstep(float(0.1), float(0.01), slope), + ); + c = mix(c, dirtColor, mul(nDirtFactor, float(0.4))); + + // Slopes: dirt at low elevation, cliff at high elevation + const sF = smoothstep(float(0.02), float(0.12), slope); + const hB = smoothstep(float(20.0), float(40.0), height); + c = mix(c, mix(dirtColor, cliffColor, hB), sF); // Sand near water (flat areas, stronger in canyon) const sandBlend = mul( @@ -830,13 +831,7 @@ export function createTerrainMaterial(): THREE.Material & { mul(canyonGrassC, dW), ); - // Biome-blended dirt patches - const dirtPatchFactor = smoothstep( - float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD - 0.05), - float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD + 0.15), - noiseValue, - ); - const flatnessFactor = smoothstep(float(0.3), float(0.05), slope); + // Biome-blended dirt const dirtVar = smoothstep(float(0.3), float(0.7), noiseValue2); const tundraDirtC = mix(sSnowDirt, mul(sSnowDirt, TEX_DARKEN), dirtVar); const forestDirtC = mix(sDirt, mul(sDirt, TEX_DARKEN), dirtVar); @@ -845,16 +840,8 @@ export function createTerrainMaterial(): THREE.Material & { add(mul(tundraDirtC, tW), mul(forestDirtC, fW)), mul(canyonDirtC, dW), ); - baseColor = mix(baseColor, dirtColor, mul(dirtPatchFactor, flatnessFactor)); - // Slope-based dirt - const dirtSlopeFactor = mul( - smoothstep(float(0.15), float(0.4), slope), - smoothstep(float(0.6), float(0.3), slope), - ); - baseColor = mix(baseColor, dirtColor, mul(dirtSlopeFactor, float(0.6))); - - // Per-biome cliff on steep slopes (triplanar textured) + // Per-biome cliff const cliffVar = smoothstep(float(0.3), float(0.7), noiseValue); const tundraCliffC = mix(sSnowCliff, mul(sSnowCliff, TEX_DARKEN), cliffVar); const forestCliffC = mix(sCliff, mul(sCliff, TEX_DARKEN), cliffVar); @@ -867,12 +854,26 @@ export function createTerrainMaterial(): THREE.Material & { add(mul(tundraCliffC, tW), mul(forestCliffC, fW)), mul(canyonCliffC, dW), ); + + // Noise-driven dirt patches on flat areas (subtle) + const dirtPatchFactor = smoothstep( + float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD + 0.05), + float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD + 0.25), + noiseValue, + ); + const flatnessFactor = smoothstep(float(0.1), float(0.01), slope); baseColor = mix( baseColor, - cliffColor, - smoothstep(float(0.3), float(0.55), slope), + dirtColor, + mul(mul(dirtPatchFactor, flatnessFactor), float(0.4)), ); + // Slopes: dirt at low elevation, cliff at high elevation + const slopeFactor = smoothstep(float(0.02), float(0.12), slope); + const heightBlend = smoothstep(float(20.0), float(40.0), height); + const slopeColor = mix(dirtColor, cliffColor, heightBlend); + baseColor = mix(baseColor, slopeColor, slopeFactor); + // Sand near water (keep flat color - no sand texture) const sandBlend = mul( smoothstep(float(18.0), float(12.0), height), @@ -910,55 +911,11 @@ export function createTerrainMaterial(): THREE.Material & { ), ); - // === ROAD OVERLAY === - // Roads are compacted dirt paths - reuse existing dirt colors for consistency - // Use shared road mask when available, fall back to per-vertex attribute - const roadInfluenceAttr = attribute("roadInfluence", "float"); - const roadMaskState = getRoadInfluenceTextureState(); - const roadHalfWorld = roadMaskState.uWorldSize.mul(0.5); - const roadUvX = worldPos.x - .sub(roadMaskState.uCenterX) - .add(roadHalfWorld) - .div(roadMaskState.uWorldSize); - const roadUvZ = worldPos.z - .sub(roadMaskState.uCenterZ) - .add(roadHalfWorld) - .div(roadMaskState.uWorldSize); - const roadUV = vec2(roadUvX.clamp(0.001, 0.999), roadUvZ.clamp(0.001, 0.999)); - const roadMask = roadMaskState.textureNode.sample(roadUV).r; - const hasRoadMask = smoothstep( - float(1.0), - float(2.0), - roadMaskState.uWorldSize, - ); - const dx = abs(worldPos.x.sub(roadMaskState.uCenterX)); - const dz = abs(worldPos.z.sub(roadMaskState.uCenterZ)); - const insideMask = step(dx, roadHalfWorld).mul(step(dz, roadHalfWorld)); - const useMask = hasRoadMask.mul(insideMask); - const roadInfluence = mix(roadInfluenceAttr, roadMask, useMask); - - // Reuse existing dirt colors with natural noise variation - const roadNoiseVar = mul(noiseValue2, float(0.5)); // Natural dirt variation - const roadBaseColor = mix(DIRT_BROWN, DIRT_DARK, roadNoiseVar); - - // Gravel/Cobblestone effect: High frequency noise for texture - // Use fineNoise (highest freq) to create small stones - const stoneNoise = smoothstep(float(0.4), float(0.7), fineNoise); - const stoneColor = mix(ROCK_GRAY, ROCK_DARK, float(0.5)); - - // Mix stones into dirt base - more stones in center of road - const roadDetailColor = mix( - roadBaseColor, - stoneColor, - mul(stoneNoise, float(0.6)), - ); - - // Road center is slightly worn/darker from foot traffic - const roadCenterDarken = mul(roadInfluence, float(0.08)); - const compactedRoadColor = sub(roadDetailColor, vec3(roadCenterDarken)); - - // Blend road color with terrain based on influence - const baseWithRoads = mix(variedColor, compactedRoadColor, roadInfluence); + // === ROAD OVERLAY (disabled) === + // const roadInfluenceAttr = attribute("roadInfluence", "float"); + // const roadMaskState = getRoadInfluenceTextureState(); + // ... road rendering commented out for now + const baseWithRoads = variedColor; // ============================================================================ // VERTEX LIGHTING (lampposts, torches, etc.) From 9bda8b0440f8aef2ae21eb847249d1c4df31a7df Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Thu, 19 Mar 2026 11:52:33 +0800 Subject: [PATCH 20/71] fix: reduce elevation noise frequency for smoother terrain variation Lower ELEVATION_NOISE_SCALE from 0.0015 to 0.0005 for broader, more natural elevation undulation across the island. Made-with: Cursor --- packages/shared/src/systems/shared/world/TerrainHeightParams.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts index 817444b87..61ee4dc1b 100644 --- a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts +++ b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts @@ -104,7 +104,7 @@ export const ELEVATION_OFFSET = 8; /** Amplitude of large-scale elevation noise (in world units). */ export const ELEVATION_NOISE_AMOUNT = 50; /** Frequency of large-scale elevation noise. */ -export const ELEVATION_NOISE_SCALE = 0.0015; +export const ELEVATION_NOISE_SCALE = 0.0005; export interface BiomeNoiseProfile { continentWeight: number; From bd45a0df28fece7b32aafa23ff1c81dc1f98f713 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Thu, 19 Mar 2026 11:54:27 +0800 Subject: [PATCH 21/71] fix: raise water threshold to match elevated terrain Increase WATER_THRESHOLD from 8.0 to 30.0 to account for the global elevation offset applied to the terrain. Made-with: Cursor --- packages/shared/src/constants/GameConstants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/constants/GameConstants.ts b/packages/shared/src/constants/GameConstants.ts index 96d7e87b0..2db7a91a2 100644 --- a/packages/shared/src/constants/GameConstants.ts +++ b/packages/shared/src/constants/GameConstants.ts @@ -83,7 +83,7 @@ export const TERRAIN_CONSTANTS = { * Terrain below this height is underwater and impassable. * Used by: TerrainSystem, VegetationSystem, DissolveMaterial, RoadNetworkSystem, ResourceSystem */ - WATER_THRESHOLD: 8.0, + WATER_THRESHOLD: 30.0, /** * Buffer distance above water where vegetation shouldn't spawn. From 631efb62f428af444183699638dc337d590c3c36 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Thu, 19 Mar 2026 12:19:41 +0800 Subject: [PATCH 22/71] slope threshold --- packages/shared/src/systems/shared/world/TerrainShader.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/systems/shared/world/TerrainShader.ts b/packages/shared/src/systems/shared/world/TerrainShader.ts index 4fec81928..5479ddc48 100644 --- a/packages/shared/src/systems/shared/world/TerrainShader.ts +++ b/packages/shared/src/systems/shared/world/TerrainShader.ts @@ -81,7 +81,7 @@ const TERRAIN_BIOME_TEXTURES = { fallback: [0.62, 0.28, 0.15] as [number, number, number], }, desertCliff: { - file: "desertCliff.png", + file: "desertDirt.png", fallback: [0.72, 0.38, 0.18] as [number, number, number], }, snowGrass: { @@ -265,7 +265,7 @@ export function computeTerrainBaseColor( c = mix(c, dirtColor, mul(nDirtFactor, float(0.4))); // Slopes: dirt at low elevation, cliff at high elevation - const sF = smoothstep(float(0.02), float(0.12), slope); + const sF = smoothstep(float(0.05), float(0.2), slope); const hB = smoothstep(float(20.0), float(40.0), height); c = mix(c, mix(dirtColor, cliffColor, hB), sF); @@ -869,7 +869,7 @@ export function createTerrainMaterial(): THREE.Material & { ); // Slopes: dirt at low elevation, cliff at high elevation - const slopeFactor = smoothstep(float(0.02), float(0.12), slope); + const slopeFactor = smoothstep(float(0.01), float(0.06), slope); const heightBlend = smoothstep(float(20.0), float(40.0), height); const slopeColor = mix(dirtColor, cliffColor, heightBlend); baseColor = mix(baseColor, slopeColor, slopeFactor); From acaae4aef112c6fbe81a5f2a9d4fbcd63cc5997b Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Thu, 19 Mar 2026 15:03:21 +0800 Subject: [PATCH 23/71] feat: extend tree/vegetation generation and visibility distances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Increase server chunk ranges (core 1→3, ring 1→5) so resource entities spawn up to ~1100m instead of 300m - Increase client chunk ranges to match (ring 3→5, horizon 5→7) - Decouple VegetationSystem fade from shadow maxFar so decorative trees render at distance without shadows instead of disappearing - Set GPU vegetation fade to 1000-1200m across all systems - Increase MAX_VEGETATION_TILE_RADIUS from 2 to 5 tiles - Increase LOD distances for all vegetation/resource categories - Note: VIEW_DISTANCE was dead code (unused checkPlayerMovement); the real tile loading uses coreChunkRange/ringChunkRange Made-with: Cursor --- .../src/systems/shared/world/GPUMaterials.ts | 4 +- .../src/systems/shared/world/LODConfig.ts | 92 +++++++++---------- .../src/systems/shared/world/TerrainSystem.ts | 32 +++---- .../systems/shared/world/VegetationSystem.ts | 20 ++-- 4 files changed, 73 insertions(+), 75 deletions(-) diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index 10728ab5a..97afcefbe 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -75,10 +75,10 @@ import { SUN_SHADE, SUN_LIGHT, NIGHT, applySunShade } from "./LightingConfig"; */ export const GPU_VEG_CONFIG = { /** Distance where far fade begins (fully opaque inside) - default for generic dissolve */ - FADE_START: 270, + FADE_START: 1000, /** Distance where fully invisible (far) - default for generic dissolve */ - FADE_END: 300, + FADE_END: 1200, /** Distance where near fade ends (fully opaque outside) */ NEAR_FADE_END: 3, diff --git a/packages/shared/src/systems/shared/world/LODConfig.ts b/packages/shared/src/systems/shared/world/LODConfig.ts index f1f17c342..28b842687 100644 --- a/packages/shared/src/systems/shared/world/LODConfig.ts +++ b/packages/shared/src/systems/shared/world/LODConfig.ts @@ -63,78 +63,78 @@ export interface LODDistancesWithSq extends LODDistances { * Categories can be customized based on object size and visual importance. */ export const LOD_DISTANCES: Record = { - // Large vegetation - aggressive LOD for performance + // Large vegetation tree: { - lod1Distance: 30, - lod2Distance: 60, - imposterDistance: 100, - fadeDistance: 180, + lod1Distance: 250, + lod2Distance: 500, + imposterDistance: 800, + fadeDistance: 1200, }, // Medium vegetation bush: { - lod1Distance: 40, - lod2Distance: 80, - imposterDistance: 120, - fadeDistance: 200, + lod1Distance: 200, + lod2Distance: 400, + imposterDistance: 650, + fadeDistance: 1000, }, fern: { - lod1Distance: 30, - lod2Distance: 55, - imposterDistance: 80, - fadeDistance: 120, + lod1Distance: 150, + lod2Distance: 300, + imposterDistance: 500, + fadeDistance: 800, }, rock: { - lod1Distance: 50, - lod2Distance: 100, - imposterDistance: 150, - fadeDistance: 250, + lod1Distance: 250, + lod2Distance: 500, + imposterDistance: 800, + fadeDistance: 1200, }, fallen_tree: { - lod1Distance: 45, - lod2Distance: 90, - imposterDistance: 130, - fadeDistance: 200, + lod1Distance: 200, + lod2Distance: 400, + imposterDistance: 650, + fadeDistance: 1000, }, - // Small vegetation (aggressive culling - skip LOD2, go straight to impostor) + // Small vegetation flower: { - lod1Distance: 25, - lod2Distance: 45, // Same as imposter - skip LOD2 - imposterDistance: 60, - fadeDistance: 100, + lod1Distance: 120, + lod2Distance: 250, + imposterDistance: 400, + fadeDistance: 650, }, mushroom: { - lod1Distance: 15, - lod2Distance: 30, // Same as imposter - skip LOD2 - imposterDistance: 40, - fadeDistance: 80, + lod1Distance: 100, + lod2Distance: 200, + imposterDistance: 350, + fadeDistance: 550, }, grass: { - lod1Distance: 10, - lod2Distance: 20, // Same as imposter - skip LOD2 - imposterDistance: 30, - fadeDistance: 60, + lod1Distance: 80, + lod2Distance: 160, + imposterDistance: 280, + fadeDistance: 450, }, // Resources (harvestable objects) resource: { - lod1Distance: 45, - lod2Distance: 85, - imposterDistance: 120, - fadeDistance: 200, + lod1Distance: 220, + lod2Distance: 450, + imposterDistance: 700, + fadeDistance: 1100, }, tree_resource: { - lod1Distance: 25, // Switch to LOD1 very early - lod2Distance: 50, - imposterDistance: 80, // Switch to impostor ASAP - fadeDistance: 150, // Fade out earlier + lod1Distance: 200, + lod2Distance: 400, + imposterDistance: 650, + fadeDistance: 1000, }, rock_resource: { - lod1Distance: 50, - lod2Distance: 100, - imposterDistance: 150, - fadeDistance: 250, + lod1Distance: 250, + lod2Distance: 500, + imposterDistance: 800, + fadeDistance: 1200, }, // Buildings - simple geometry, skip intermediate LODs and go directly to impostor diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts index a80a2af47..5026ed63a 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -1356,11 +1356,11 @@ export class TerrainSystem extends System { WATER_THRESHOLD: TERRAIN_CONSTANTS.WATER_THRESHOLD, // LOD (Level of Detail) - Resolution tiers based on distance - LOD_DISTANCES: [100, 200, 350], // Distance thresholds + LOD_DISTANCES: [200, 400, 700], // Distance thresholds LOD_RESOLUTIONS: [64, 32, 16, 8], // Resolution at each LOD level - // Chunking - Only adjacent tiles - VIEW_DISTANCE: 1, // Load only 1 tile in each direction (3x3 = 9 tiles) + // Chunking + VIEW_DISTANCE: 5, // Load 5 tiles in each direction (11x11 = 121 tiles, ~1100m) UPDATE_INTERVAL: 0.5, // Check player movement every 0.5 seconds // Movement Constraints @@ -6413,13 +6413,13 @@ export class TerrainSystem extends System { // Embedded spectator prioritizes first-frame time over long-range preload. if (isServerRuntime) { - // Server does not render horizon terrain, so keep chunk windows tight to - // avoid runaway memory when many autonomous agents are active. - this.coreChunkRange = 1; // 3x3 core grid - this.ringChunkRange = 1; // No extra preload ring - this.terrainOnlyChunkRange = 0; // Never load render-only distant tiles - this.maxTilesPerFrame = 2; - this.generationBudgetMsPerFrame = 4; + // Server generates resource entities for tiles within ringChunkRange. + // Must be large enough so clients see trees before reaching them. + this.coreChunkRange = 3; // 7x7 core grid (full simulation) + this.ringChunkRange = 5; // 11x11 ring — resource content generated here + this.terrainOnlyChunkRange = 0; // No render-only tiles on server + this.maxTilesPerFrame = 4; + this.generationBudgetMsPerFrame = 6; } else if (isEmbeddedSpectator) { this.coreChunkRange = 1; // 3x3 core grid this.ringChunkRange = 2; // Preload ring up to 5x5 @@ -6427,12 +6427,12 @@ export class TerrainSystem extends System { this.maxTilesPerFrame = 4; // Catch up faster after camera retargets this.generationBudgetMsPerFrame = 10; } else { - // Balanced load radius to reduce generation spikes when moving - this.coreChunkRange = 2; // 5x5 core grid - this.ringChunkRange = 3; // Preload ring up to ~7x7 - this.terrainOnlyChunkRange = 5; - this.maxTilesPerFrame = 2; - this.generationBudgetMsPerFrame = 6; + // Client: load tiles out to match LOD fade distances (~1000m) + this.coreChunkRange = 3; // 7x7 core grid + this.ringChunkRange = 5; // 11x11 ring — content generated here (~1100m) + this.terrainOnlyChunkRange = 7; // Terrain mesh horizon (~1500m) + this.maxTilesPerFrame = 3; + this.generationBudgetMsPerFrame = 8; } // Initialize tracking maps diff --git a/packages/shared/src/systems/shared/world/VegetationSystem.ts b/packages/shared/src/systems/shared/world/VegetationSystem.ts index 10fa136d3..d37b42375 100644 --- a/packages/shared/src/systems/shared/world/VegetationSystem.ts +++ b/packages/shared/src/systems/shared/world/VegetationSystem.ts @@ -117,10 +117,9 @@ const SKIP_LOD1_CATEGORIES = new Set([ const VEGETATION_CHUNK_SIZE = 64; // meters per chunk const MAX_INSTANCES_PER_CHUNK = 256; -// Default fade distances - overridden by shadow quality settings in start() -let FADE_START = 180; // Shader fade begins (fully opaque inside) -let FADE_END = 200; // Shader fully culls (invisible beyond) -let CHUNK_RENDER_DISTANCE = 300; // CPU hides chunks (buffer zone for loading) +let FADE_START = 1000; // Shader fade begins (fully opaque inside) +let FADE_END = 1200; // Shader fully culls (invisible beyond) +let CHUNK_RENDER_DISTANCE = 1400; // CPU hides chunks (buffer zone for loading) // NOTE: Per-category LOD distances are defined in LODConfig.ts LOD_DISTANCES // Access via getLODDistances(category) for actual LOD decisions. @@ -264,8 +263,7 @@ function getAssetLODConfig(category: string, boundingSize?: number) { } /** Max tile distance from player to generate vegetation (in tiles, not world units) */ -// PERFORMANCE: Reduced from 3 to 2 tiles - vegetation fades before this distance anyway -const MAX_VEGETATION_TILE_RADIUS = 2; +const MAX_VEGETATION_TILE_RADIUS = 5; /** Water threshold from centralized constants */ import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants"; @@ -772,11 +770,11 @@ export class VegetationSystem extends System { csmLevels[shadowsLevel as keyof typeof csmLevels] || csmLevels.med; const shadowMaxFar = csmConfig.maxFar; - // Sync fade distances with shadow range - // Vegetation fully dissolves AT shadow maxFar so we never see unshadowed trees - FADE_END = shadowMaxFar; // Fully dissolved at shadow cutoff - FADE_START = shadowMaxFar * 0.9; // Start dissolving at 90% of shadow range - CHUNK_RENDER_DISTANCE = shadowMaxFar * 1.2; // Chunks visible 20% beyond shadow range + // Fade distances are independent of shadow range — distant trees render + // without shadows rather than disappearing at the shadow cutoff. + FADE_START = 1000; + FADE_END = 1200; + CHUNK_RENDER_DISTANCE = 1400; // Imposter distances - switch to billboard before dissolve zone // LOD transition: 3D mesh -> Billboard imposter -> Dissolve -> Cull From d473e517f00084d0ba26a1ffa5ac1a5cb0c32f9d Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Thu, 19 Mar 2026 17:26:33 +0800 Subject: [PATCH 24/71] feat: water proximity weight boost for pond-shore trees and distance-aware initial LOD Boost Palm/Banana selection weight 20x near water so they dominate pond shores. Increase waterSearchRadius to 120m and waterMaxDistance to 100m for wider influence. Fix tree pop-in by computing correct initial LOD based on camera distance instead of always starting at LOD0. Made-with: Cursor --- .../shared/world/BiomeResourceGenerator.ts | 173 +++++++++++++++++- .../shared/world/GLBTreeBatchedInstancer.ts | 20 +- .../systems/shared/world/GLBTreeInstancer.ts | 20 +- .../systems/shared/world/TerrainBiomeTypes.ts | 71 +++---- 4 files changed, 241 insertions(+), 43 deletions(-) diff --git a/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts b/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts index bc719c7c3..783e07501 100644 --- a/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts +++ b/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts @@ -29,6 +29,88 @@ import { import type { TreePlacementRules } from "../../../constants/TreeTypes"; import { getTreeConfigForBiome } from "./TerrainBiomeTypes"; +// --------------------------------------------------------------------------- +// Species zoning — smooth 2D value noise so nearby trees tend to be the same type +// --------------------------------------------------------------------------- + +const SPECIES_ZONE_SCALE = 0.012; // ~80m species zones +const SPECIES_ZONE_BOOST = 10.0; // preferred species gets 10x weight +const WATER_PROXIMITY_BOOST = 20.0; // water-affinity trees get 20x weight near water +const WATER_HEIGHT_PRECHECK = 35; // only run expensive water search within this height above water + +const DENSITY_NOISE_SCALE = 0.006; // ~170m dense/sparse zones +const DENSITY_NOISE_MIN = 0.15; // sparse zones still get 15% of trees +const DENSITY_NOISE_POWER = 1.5; // push noise toward extremes + +function intHash2D(ix: number, iz: number): number { + let h = (ix * 374761393 + iz * 668265263) | 0; + h = ((h ^ (h >>> 13)) * 1274126177) | 0; + h = (h ^ (h >>> 16)) | 0; + return (h & 0x7fffffff) / 0x7fffffff; +} + +function speciesNoise2D(x: number, z: number): number { + const ix = Math.floor(x); + const iz = Math.floor(z); + const fx = x - ix; + const fz = z - iz; + const ux = fx * fx * (3 - 2 * fx); + const uz = fz * fz * (3 - 2 * fz); + const h00 = intHash2D(ix, iz); + const h10 = intHash2D(ix + 1, iz); + const h01 = intHash2D(ix, iz + 1); + const h11 = intHash2D(ix + 1, iz + 1); + return ( + h00 * (1 - ux) * (1 - uz) + + h10 * ux * (1 - uz) + + h01 * (1 - ux) * uz + + h11 * ux * uz + ); +} + +// --------------------------------------------------------------------------- +// Distance-to-water sampling — 8-direction radial search for nearest shoreline +// --------------------------------------------------------------------------- + +const WATER_SEARCH_DIRECTIONS = 8; +const WATER_SEARCH_STEP = 5; // meters between samples + +/** + * Find the horizontal distance from (worldX, worldZ) to the nearest water cell. + * Casts rays in 8 directions, stepping every WATER_SEARCH_STEP meters. + * Returns the distance in meters, or Infinity if no water found within searchRadius. + */ +function distanceToWater( + worldX: number, + worldZ: number, + getHeightAt: (x: number, z: number) => number, + waterThreshold: number, + searchRadius: number, +): number { + let nearest = Infinity; + const angleStep = (Math.PI * 2) / WATER_SEARCH_DIRECTIONS; + const maxSteps = Math.ceil(searchRadius / WATER_SEARCH_STEP); + + for (let dir = 0; dir < WATER_SEARCH_DIRECTIONS; dir++) { + const angle = dir * angleStep; + const dx = Math.cos(angle) * WATER_SEARCH_STEP; + const dz = Math.sin(angle) * WATER_SEARCH_STEP; + + for (let step = 1; step <= maxSteps; step++) { + const dist = step * WATER_SEARCH_STEP; + if (dist >= nearest) break; // can't beat current best + const sx = worldX + dx * step; + const sz = worldZ + dz * step; + if (getHeightAt(sx, sz) < waterThreshold) { + nearest = dist; + break; // this direction found water, move to next + } + } + } + + return nearest; +} + /** * Context provided by TerrainSystem for resource generation. */ @@ -258,6 +340,17 @@ export function generateTrees( if (dhdx * dhdx + dhdz * dhdz > maxSlope * maxSlope) continue; } + // Density noise — creates natural dense groves and sparse clearings. + // Uses a different noise offset (+500) to decouple from species zoning. + const rawDensity = speciesNoise2D( + (worldX + 500) * DENSITY_NOISE_SCALE, + (worldZ + 500) * DENSITY_NOISE_SCALE, + ); + const densityChance = + DENSITY_NOISE_MIN + + (1 - DENSITY_NOISE_MIN) * Math.pow(rawDensity, DENSITY_NOISE_POWER); + if (rng() > densityChance) continue; + // Resolve the tree map for THIS position. If we have a per-position // biome callback, use it to get the actual biome here instead of // relying on the single tile-center biome. @@ -279,12 +372,64 @@ export function generateTrees( } } - // Select tree type based on weighted distribution + // Water proximity check — cheap height pre-check then expensive radial scan. + // Cached per-position so the post-selection water rejection can reuse it. + const heightAboveWater = height - ctx.waterThreshold; + let posDistToWater = Infinity; + let waterChecked = false; + + if (heightAboveWater <= WATER_HEIGHT_PRECHECK) { + // Find the max search radius among all water-affinity trees in this map + let maxSearch = 0; + for (const treeType of treeTypes) { + const cfg = activeTreeMap[treeType]; + if (cfg.waterAffinity && cfg.waterAffinity > 0) { + maxSearch = Math.max(maxSearch, cfg.waterSearchRadius ?? 40); + } + } + if (maxSearch > 0) { + posDistToWater = distanceToWater( + worldX, + worldZ, + ctx.getHeightAt, + ctx.waterThreshold, + maxSearch, + ); + waterChecked = true; + } + } + + const nearWater = waterChecked && posDistToWater < Infinity; + + // Select tree type — species zoning + water proximity boost + const zoneVal = speciesNoise2D( + worldX * SPECIES_ZONE_SCALE, + worldZ * SPECIES_ZONE_SCALE, + ); + const preferredIdx = + Math.floor(zoneVal * treeTypes.length) % treeTypes.length; + const preferredSpecies = treeTypes[preferredIdx]; + + let boostedTotal = 0; + for (const treeType of treeTypes) { + let w = activeTreeMap[treeType].weight; + if (treeType === preferredSpecies) w *= SPECIES_ZONE_BOOST; + if (nearWater && activeTreeMap[treeType].waterAffinity) { + w *= WATER_PROXIMITY_BOOST; + } + boostedTotal += w; + } + let selectedTreeId = treeTypes[0]; - const roll = rng() * totalWeight; + const roll = rng() * boostedTotal; let cumulative = 0; for (const treeType of treeTypes) { - cumulative += activeTreeMap[treeType].weight; + let w = activeTreeMap[treeType].weight; + if (treeType === preferredSpecies) w *= SPECIES_ZONE_BOOST; + if (nearWater && activeTreeMap[treeType].waterAffinity) { + w *= WATER_PROXIMITY_BOOST; + } + cumulative += w; if (roll < cumulative) { selectedTreeId = treeType; break; @@ -295,8 +440,6 @@ export function generateTrees( // Apply per-tree placement rules from the merged config const rules: TreePlacementRules | undefined = activeTreeMap[selectedTreeId]; if (rules) { - const heightAboveWater = height - ctx.waterThreshold; - if (rules.minHeight !== undefined && height < rules.minHeight) continue; if (rules.maxHeight !== undefined && height > rules.maxHeight) continue; @@ -307,9 +450,23 @@ export function generateTrees( continue; if (rules.waterAffinity && rules.waterAffinity > 0) { - const proximityLimit = rules.waterProximityHeight ?? 10; - if (heightAboveWater > proximityLimit) { - if (rng() < rules.waterAffinity) continue; + // Reuse cached distance if available, otherwise compute fresh + const maxDist = rules.waterMaxDistance ?? 30; + let dist = posDistToWater; + if (!waterChecked) { + const searchRadius = rules.waterSearchRadius ?? 40; + dist = distanceToWater( + worldX, + worldZ, + ctx.getHeightAt, + ctx.waterThreshold, + searchRadius, + ); + } + if (dist === Infinity) continue; + if (dist > maxDist) { + if (rules.waterAffinity >= 0.5 || rng() < rules.waterAffinity) + continue; } } } diff --git a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts index 27178df4e..9306bc910 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts @@ -657,6 +657,20 @@ export async function addInstance( depletedModelPath, ); + // Pick initial LOD based on camera distance to avoid LOD0 pop-in at range + let initialLOD: 0 | 1 | 2 = 0; + if (world?.camera) { + const cp = world.camera.position; + const dx = cp.x - position.x; + const dz = cp.z - position.z; + const distSq = dx * dx + dz * dz; + if (distSq >= resourceLOD.lod2DistanceSq) { + initialLOD = pool.lod2 ? 2 : pool.lod1 ? 1 : 0; + } else if (distSq >= resourceLOD.lod1DistanceSq) { + initialLOD = pool.lod1 ? 1 : 0; + } + } + const slot: TreeSlot = { entityId, position: position.clone(), @@ -664,7 +678,7 @@ export async function addInstance( scale, depletedScale: depletedScale ?? scale, yOffset: pool.yOffset, - currentLOD: 0, + currentLOD: initialLOD, depleted: false, variantIndex, }; @@ -673,7 +687,9 @@ export async function addInstance( entityToTreeType.set(entityId, treeType); const mat = composeInstanceMatrix(position, rotation, scale, pool.yOffset); - if (pool.lod0) addToPool(pool.lod0, entityId, mat, variantIndex); + const initialPool = + initialLOD === 0 ? pool.lod0 : initialLOD === 1 ? pool.lod1 : pool.lod2; + if (initialPool) addToPool(initialPool, entityId, mat, variantIndex); return true; } catch (error) { diff --git a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts index 91f4239bd..523433539 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts @@ -478,6 +478,20 @@ export async function addInstance( return false; } + // Pick initial LOD based on camera distance to avoid LOD0 pop-in at range + let initialLOD: 0 | 1 | 2 = 0; + if (world?.camera) { + const cp = world.camera.position; + const dx = cp.x - position.x; + const dz = cp.z - position.z; + const distSq = dx * dx + dz * dz; + if (distSq >= resourceLOD.lod2DistanceSq) { + initialLOD = pool.lod2 ? 2 : pool.lod1 ? 1 : 0; + } else if (distSq >= resourceLOD.lod1DistanceSq) { + initialLOD = pool.lod1 ? 1 : 0; + } + } + const slot: TreeSlot = { entityId, position: position.clone(), @@ -485,7 +499,7 @@ export async function addInstance( scale, depletedScale: depletedScale ?? scale, yOffset: pool.yOffset, - currentLOD: 0, + currentLOD: initialLOD, depleted: false, }; @@ -493,7 +507,9 @@ export async function addInstance( entityToModel.set(entityId, modelPath); const mat = composeInstanceMatrix(position, rotation, scale, pool.yOffset); - addToPool(pool.lod0!, entityId, mat); + const initialPool = + initialLOD === 0 ? pool.lod0 : initialLOD === 1 ? pool.lod1 : pool.lod2; + if (initialPool) addToPool(initialPool, entityId, mat); return true; } catch (error) { diff --git a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts index 20bfebbe7..49f8dc339 100644 --- a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts +++ b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts @@ -39,17 +39,29 @@ export function buildBiomeConstantsJS(): string { const FOREST_TREE_CONFIG: BiomeTreeConfig = { enabled: true, trees: { - [TreeId.Knotwood]: { weight: 40, maxHeight: 30 }, - [TreeId.Oak]: { weight: 20, maxHeight: 30 }, - [TreeId.Birch]: { weight: 20, maxHeight: 30 }, - [TreeId.Maple]: { weight: 40, maxHeight: 30 }, - [TreeId.Fir]: { weight: 15, maxHeight: 30 }, - [TreeId.Pine]: { weight: 15, maxHeight: 30 }, - [TreeId.ChinaPine]: { weight: 15, minHeight: 30, maxHeight: 60 }, - [TreeId.Bamboo]: { weight: 15, minHeight: 35 }, + [TreeId.Oak]: { weight: 30, minHeight: 33.6, maxHeight: 60 }, + [TreeId.Birch]: { weight: 25, minHeight: 33.6, maxHeight: 60 }, + [TreeId.Pine]: { weight: 20, minHeight: 60 }, + [TreeId.Knotwood]: { weight: 5, minHeight: 35, maxHeight: 60 }, + [TreeId.Maple]: { weight: 5, minHeight: 35, maxHeight: 60 }, + // [TreeId.Fir]: { weight: 20, minHeight: 48, maxHeight: 80 }, + // [TreeId.Bamboo]: { weight: 15, minHeight: 31, maxHeight: 42 }, + // [TreeId.ChinaPine]: { weight: 10, minHeight: 58, maxHeight: 85 }, + [TreeId.Palm]: { + weight: 25, + waterAffinity: 0.8, + waterSearchRadius: 100, + waterMaxDistance: 80, + }, + [TreeId.Banana]: { + weight: 25, + waterAffinity: 0.7, + waterSearchRadius: 100, + waterMaxDistance: 80, + }, }, - density: 15, - minSpacing: 12, + density: 10, + minSpacing: 50, clustering: false, scaleVariation: [0.8, 1.2], maxSlope: 1.5, @@ -58,41 +70,38 @@ const FOREST_TREE_CONFIG: BiomeTreeConfig = { const CANYON_TREE_CONFIG: BiomeTreeConfig = { enabled: true, trees: { - [TreeId.Cactus]: { weight: 20, avoidsWaterBelow: 3 }, - [TreeId.Dead]: { weight: 20, minHeight: 20 }, + [TreeId.Oak]: { weight: 20 }, + [TreeId.Dead]: { weight: 25, minHeight: 60 }, [TreeId.Palm]: { - weight: 20, - waterAffinity: 0.5, - waterProximityHeight: 9, - maxHeight: 9, + weight: 25, + waterAffinity: 0.8, + waterSearchRadius: 100, + waterMaxDistance: 80, }, [TreeId.Banana]: { - weight: 15, - waterAffinity: 0.3, - waterProximityHeight: 9, - minHeight: 9, - maxHeight: 13, + weight: 25, + waterAffinity: 0.8, + waterSearchRadius: 100, + waterMaxDistance: 80, }, - [TreeId.Yucca]: { weight: 15, avoidsWaterBelow: 3 }, - [TreeId.WindSwept]: { weight: 25, minHeight: 15 }, }, - density: 15, - minSpacing: 18, + density: 6, + minSpacing: 50, clustering: false, scaleVariation: [0.7, 1.3], - maxSlope: 2.0, + maxSlope: 0.1, }; const TUNDRA_TREE_CONFIG: BiomeTreeConfig = { enabled: true, trees: { - [TreeId.WindPine]: { weight: 40, minHeight: 15 }, - [TreeId.Fir]: { weight: 30, minHeight: 10 }, - [TreeId.Pine]: { weight: 25, minHeight: 8 }, - [TreeId.Birch]: { weight: 10 }, + [TreeId.WindPine]: { weight: 40, minHeight: 38 }, + [TreeId.Fir]: { weight: 30, minHeight: 35 }, + [TreeId.Pine]: { weight: 20, minHeight: 35 }, + [TreeId.Birch]: { weight: 10, maxHeight: 55 }, }, density: 10, - minSpacing: 12, + minSpacing: 50, clustering: false, scaleVariation: [0.6, 1.0], maxSlope: 1.5, From 8a239e89d19b15c9abdfa2281f7c133952228c1f Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Thu, 19 Mar 2026 17:35:59 +0800 Subject: [PATCH 25/71] feat: update LOD distances, shadow frustum, fog config, and water placement rules Increase LOD distances for all vegetation/resource categories. Expand single shadow map frustum to 200m and size to 4096. Update fog config. Add waterSearchRadius and waterMaxDistance to TreePlacementRules. Made-with: Cursor --- packages/shared/src/constants/TreeTypes.ts | 9 +++- .../src/systems/shared/world/Environment.ts | 4 +- .../src/systems/shared/world/FogConfig.ts | 4 +- .../src/systems/shared/world/LODConfig.ts | 50 +++++++++---------- 4 files changed, 36 insertions(+), 31 deletions(-) diff --git a/packages/shared/src/constants/TreeTypes.ts b/packages/shared/src/constants/TreeTypes.ts index 7da1815ac..94e07f28b 100644 --- a/packages/shared/src/constants/TreeTypes.ts +++ b/packages/shared/src/constants/TreeTypes.ts @@ -47,10 +47,15 @@ export interface TreePlacementRules { /** * How strongly this tree prefers water-adjacent placement (0–1). * 0 = no preference, 1 = only spawns near water. - * At intermediate values, spawn probability scales with water proximity. + * When > 0, a radial distance-to-water search is performed and trees + * beyond waterMaxDistance are rejected with probability waterAffinity. */ waterAffinity?: number; - /** If waterAffinity > 0, the max height above water to consider "near water" */ + /** Horizontal search radius (meters) when looking for nearby water. Default 40. */ + waterSearchRadius?: number; + /** Max horizontal distance from shore (meters) before rejection kicks in. Default 30. */ + waterMaxDistance?: number; + /** @deprecated Use waterMaxDistance instead. Kept for backward compat. */ waterProximityHeight?: number; /** Reject placement if position is below this height above water threshold */ avoidsWaterBelow?: number; diff --git a/packages/shared/src/systems/shared/world/Environment.ts b/packages/shared/src/systems/shared/world/Environment.ts index c5e3552f3..105f151ff 100644 --- a/packages/shared/src/systems/shared/world/Environment.ts +++ b/packages/shared/src/systems/shared/world/Environment.ts @@ -61,8 +61,8 @@ export function isCsmEnabled(): boolean { return false; } -const SINGLE_SHADOW_MAP_SIZE = 2048; -const SINGLE_SHADOW_FRUSTUM = 80; +const SINGLE_SHADOW_MAP_SIZE = 4096; +const SINGLE_SHADOW_FRUSTUM = 200; // IMPORTANT: Vegetation fade distances should be <= maxFar so trees don't appear unshadowed export const csmLevels = { diff --git a/packages/shared/src/systems/shared/world/FogConfig.ts b/packages/shared/src/systems/shared/world/FogConfig.ts index 06aed1082..ff1706ab5 100644 --- a/packages/shared/src/systems/shared/world/FogConfig.ts +++ b/packages/shared/src/systems/shared/world/FogConfig.ts @@ -48,8 +48,8 @@ import { // Fog distance parameters // smoothstep(NEAR_SQ, FAR_SQ, distSq) gives 0% fog at NEAR, 100% at FAR. // --------------------------------------------------------------------------- -export const FOG_NEAR = 60; -export const FOG_FAR = 150; +export const FOG_NEAR = 450; +export const FOG_FAR = 600; // Pre-computed squared distances — avoids per-fragment sqrt on the GPU. // Shaders compare dot(toCamera, toCamera) directly against these. diff --git a/packages/shared/src/systems/shared/world/LODConfig.ts b/packages/shared/src/systems/shared/world/LODConfig.ts index 28b842687..67ca1fa8c 100644 --- a/packages/shared/src/systems/shared/world/LODConfig.ts +++ b/packages/shared/src/systems/shared/world/LODConfig.ts @@ -65,74 +65,74 @@ export interface LODDistancesWithSq extends LODDistances { export const LOD_DISTANCES: Record = { // Large vegetation tree: { - lod1Distance: 250, - lod2Distance: 500, - imposterDistance: 800, - fadeDistance: 1200, + lod1Distance: 800, + lod2Distance: 1000, + imposterDistance: 1200, + fadeDistance: 1800, }, // Medium vegetation bush: { - lod1Distance: 200, - lod2Distance: 400, + lod1Distance: 350, + lod2Distance: 500, imposterDistance: 650, fadeDistance: 1000, }, fern: { - lod1Distance: 150, - lod2Distance: 300, + lod1Distance: 250, + lod2Distance: 400, imposterDistance: 500, fadeDistance: 800, }, rock: { - lod1Distance: 250, - lod2Distance: 500, + lod1Distance: 400, + lod2Distance: 600, imposterDistance: 800, fadeDistance: 1200, }, fallen_tree: { - lod1Distance: 200, - lod2Distance: 400, + lod1Distance: 350, + lod2Distance: 500, imposterDistance: 650, fadeDistance: 1000, }, // Small vegetation flower: { - lod1Distance: 120, - lod2Distance: 250, + lod1Distance: 200, + lod2Distance: 300, imposterDistance: 400, fadeDistance: 650, }, mushroom: { - lod1Distance: 100, - lod2Distance: 200, + lod1Distance: 180, + lod2Distance: 280, imposterDistance: 350, fadeDistance: 550, }, grass: { - lod1Distance: 80, - lod2Distance: 160, + lod1Distance: 150, + lod2Distance: 220, imposterDistance: 280, fadeDistance: 450, }, // Resources (harvestable objects) resource: { - lod1Distance: 220, - lod2Distance: 450, + lod1Distance: 380, + lod2Distance: 550, imposterDistance: 700, fadeDistance: 1100, }, tree_resource: { - lod1Distance: 200, - lod2Distance: 400, - imposterDistance: 650, + lod1Distance: 400, + lod2Distance: 550, + imposterDistance: 700, fadeDistance: 1000, }, rock_resource: { - lod1Distance: 250, - lod2Distance: 500, + lod1Distance: 400, + lod2Distance: 600, imposterDistance: 800, fadeDistance: 1200, }, From 2ab13d62f8209edeae9ae6fbfc1738ed9c70a6aa Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Thu, 19 Mar 2026 18:28:06 +0800 Subject: [PATCH 26/71] refactor: remove unused tree types from TreeTypes enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove Bamboo, ChinaPine, Yucca, Cactus, and WindSwept — none are referenced in any biome config. Made-with: Cursor --- packages/shared/src/constants/TreeTypes.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/shared/src/constants/TreeTypes.ts b/packages/shared/src/constants/TreeTypes.ts index 94e07f28b..30e59362d 100644 --- a/packages/shared/src/constants/TreeTypes.ts +++ b/packages/shared/src/constants/TreeTypes.ts @@ -24,17 +24,12 @@ export enum TreeId { Pine = "tree_pine", Oak = "tree_oak", Birch = "tree_birch", - Bamboo = "tree_bamboo", - ChinaPine = "tree_chinaPine", Maple = "tree_maple", Palm = "tree_palm", Banana = "tree_banana", - Yucca = "tree_yucca", Dead = "tree_dead", - Cactus = "tree_cactus", Knotwood = "tree_knotwood", WindPine = "tree_windPine", - WindSwept = "tree_windSwept", } /** Extract the subtype key from a TreeId (e.g. TreeId.Oak → "oak") */ @@ -89,17 +84,12 @@ export const TREE_TYPES = { pine: { name: "Pine Tree", levelRequired: 1 }, oak: { name: "Oak Tree", levelRequired: 15 }, birch: { name: "Birch Tree", levelRequired: 1 }, - bamboo: { name: "Bamboo Tree", levelRequired: 1 }, - chinaPine: { name: "China Pine", levelRequired: 1 }, maple: { name: "Maple Tree", levelRequired: 45 }, palm: { name: "Desert Palm", levelRequired: 1 }, banana: { name: "Banana Tree", levelRequired: 1 }, - yucca: { name: "Yucca Tree", levelRequired: 1 }, dead: { name: "Dead Tree", levelRequired: 1 }, - cactus: { name: "Cactus", levelRequired: 1 }, knotwood: { name: "Knotwood Tree", levelRequired: 1 }, windPine: { name: "Wind Pine", levelRequired: 1 }, - windSwept: { name: "Windswept Tree", levelRequired: 1 }, } as const satisfies Record; /** All valid tree subtype keys (e.g., "oak", "willow") */ From ec42db4489eb06524fff713769cee147d308075f Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Thu, 19 Mar 2026 18:43:44 +0800 Subject: [PATCH 27/71] fix: sync-bootstrap nearby terrain chunks to prevent holes on initial load Workers take multiple frames to JIT-compile and return results on first page load, leaving terrain holes until they finish. Now the nearest 30 chunks (within 1200m) are generated synchronously on the main thread when workers haven't returned yet. Also adds burst-mode processing, distance- based priority sorting, and updates fog distances (300/1200). Made-with: Cursor --- .../src/systems/shared/world/FogConfig.ts | 4 +- .../shared/world/TerrainVisualManager.ts | 105 ++++++++++++++++-- 2 files changed, 100 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/systems/shared/world/FogConfig.ts b/packages/shared/src/systems/shared/world/FogConfig.ts index ff1706ab5..4c96c6e17 100644 --- a/packages/shared/src/systems/shared/world/FogConfig.ts +++ b/packages/shared/src/systems/shared/world/FogConfig.ts @@ -48,8 +48,8 @@ import { // Fog distance parameters // smoothstep(NEAR_SQ, FAR_SQ, distSq) gives 0% fog at NEAR, 100% at FAR. // --------------------------------------------------------------------------- -export const FOG_NEAR = 450; -export const FOG_FAR = 600; +export const FOG_NEAR = 300; +export const FOG_FAR = 1200; // Pre-computed squared distances — avoids per-fragment sqrt on the GPU. // Shaders compare dot(toCamera, toCamera) directly against these. diff --git a/packages/shared/src/systems/shared/world/TerrainVisualManager.ts b/packages/shared/src/systems/shared/world/TerrainVisualManager.ts index 9f4391f3d..31ab69920 100644 --- a/packages/shared/src/systems/shared/world/TerrainVisualManager.ts +++ b/packages/shared/src/systems/shared/world/TerrainVisualManager.ts @@ -74,11 +74,15 @@ export class TerrainVisualManager implements QuadTreeListener { private cancelledNodeIds = new Set(); /** Tracks generation failure count per node ID for bounded retry */ private failedAttempts = new Map(); + /** Whether initial sync bootstrap has run for the current tree structure */ + private syncBootstrapped = false; private maxSyncChunksPerFrame: number; private maxAssembliesPerFrame: number; - private static MAX_GENERATION_RETRIES = 3; + private framesSinceInit = 0; + private static BURST_FRAMES = 30; + private static MAX_GENERATION_RETRIES = 5; constructor( config: Partial, @@ -116,9 +120,24 @@ export class TerrainVisualManager implements QuadTreeListener { update(playerX: number, playerZ: number): void { this.playerX = playerX; this.playerZ = playerZ; - this.quadTree.update(playerX, playerZ); + const structureChanged = this.quadTree.update(playerX, playerZ); + if (structureChanged) { + this.framesSinceInit = 0; + this.syncBootstrapped = false; + } + + if ( + !this.syncBootstrapped && + this.framesSinceInit >= 1 && + this.chunks.size === 0 && + this.pendingNodeIds.size > 0 + ) { + this.syncBootstrapNearbyChunks(); + } + this.processSettledResults(); this.processSyncQueue(); + this.framesSinceInit++; } dispose(): void { @@ -240,7 +259,24 @@ export class TerrainVisualManager implements QuadTreeListener { private processSettledResults(): void { if (this.settledResults.length === 0) return; - const batch = this.settledResults.splice(0, this.maxAssembliesPerFrame); + // Sort by distance to player (nearest first) for minimal visible holes. + const px = this.playerX; + const pz = this.playerZ; + this.settledResults.sort((a, b) => { + const da = (a.node.centerX - px) ** 2 + (a.node.centerZ - pz) ** 2; + const db = (b.node.centerX - px) ** 2 + (b.node.centerZ - pz) ** 2; + return da - db; + }); + + // During initial burst or when many results are pending, process all + // of them to avoid prolonged holes. + const isBurst = + this.framesSinceInit < TerrainVisualManager.BURST_FRAMES || + this.settledResults.length > this.maxAssembliesPerFrame * 3; + const limit = isBurst + ? this.settledResults.length + : this.maxAssembliesPerFrame; + const batch = this.settledResults.splice(0, limit); for (const entry of batch) { if (this.cancelledNodeIds.has(entry.nodeId)) { @@ -265,11 +301,21 @@ export class TerrainVisualManager implements QuadTreeListener { private processSyncQueue(): void { if (this.syncQueue.length === 0) return; + const px = this.playerX; + const pz = this.playerZ; + this.syncQueue.sort((a, b) => { + const da = (a.centerX - px) ** 2 + (a.centerZ - pz) ** 2; + const db = (b.centerX - px) ** 2 + (b.centerZ - pz) ** 2; + return da - db; + }); + + const isBurst = + this.framesSinceInit < TerrainVisualManager.BURST_FRAMES || + this.syncQueue.length > this.maxSyncChunksPerFrame * 3; + const limit = isBurst ? this.syncQueue.length : this.maxSyncChunksPerFrame; + let generated = 0; - while ( - this.syncQueue.length > 0 && - generated < this.maxSyncChunksPerFrame - ) { + while (this.syncQueue.length > 0 && generated < limit) { const node = this.syncQueue.shift()!; if (!node.isFinal || node.visualChunkKey !== null) continue; this.generateChunkSync(node); @@ -277,6 +323,51 @@ export class TerrainVisualManager implements QuadTreeListener { } } + // ========================================================================= + // Sync bootstrap — generate nearest chunks synchronously to avoid holes + // during initial load while workers are still spinning up. + // ========================================================================= + + private static SYNC_BOOTSTRAP_MAX = 30; + private static SYNC_BOOTSTRAP_RADIUS_SQ = 1200 * 1200; + + private syncBootstrapNearbyChunks(): void { + this.syncBootstrapped = true; + + const leafNodes = this.quadTree + .getFinalNodes() + .filter((n) => n.isFinal && n.visualChunkKey === null); + + if (leafNodes.length === 0) return; + + const px = this.playerX; + const pz = this.playerZ; + leafNodes.sort((a, b) => { + const da = (a.centerX - px) ** 2 + (a.centerZ - pz) ** 2; + const db = (b.centerX - px) ** 2 + (b.centerZ - pz) ** 2; + return da - db; + }); + + const radiusSq = TerrainVisualManager.SYNC_BOOTSTRAP_RADIUS_SQ; + const maxCount = TerrainVisualManager.SYNC_BOOTSTRAP_MAX; + let count = 0; + + for (const node of leafNodes) { + if (count >= maxCount) break; + const dx = node.centerX - px; + const dz = node.centerZ - pz; + if (dx * dx + dz * dz > radiusSq) break; + + if (this.pendingNodeIds.has(node.id)) { + this.cancelledNodeIds.add(node.id); + this.pendingNodeIds.delete(node.id); + } + + this.generateChunkSync(node); + count++; + } + } + // ========================================================================= // Chunk assembly // ========================================================================= From fca3f25b43b4fc6dc0fd5e86e27f5a386ef9c036 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Thu, 19 Mar 2026 20:19:11 +0800 Subject: [PATCH 28/71] feat: water depth opacity, reflection distance fade, terrain shader and fog tuning Water shader uses portfolio-style depth opacity curve (pow/saturate) so deep water fully hides underwater terrain. Reflection fades out beyond 500m to avoid artifacts at distance. Terrain texture tiling and slope thresholds adjusted. Fog range tightened for better atmosphere. Made-with: Cursor --- .../src/systems/shared/world/FogConfig.ts | 4 +- .../systems/shared/world/TerrainBiomeTypes.ts | 3 -- .../src/systems/shared/world/TerrainShader.ts | 4 +- .../src/systems/shared/world/WaterSystem.ts | 40 +++++++++++-------- 4 files changed, 28 insertions(+), 23 deletions(-) diff --git a/packages/shared/src/systems/shared/world/FogConfig.ts b/packages/shared/src/systems/shared/world/FogConfig.ts index 4c96c6e17..b31ca34fd 100644 --- a/packages/shared/src/systems/shared/world/FogConfig.ts +++ b/packages/shared/src/systems/shared/world/FogConfig.ts @@ -48,8 +48,8 @@ import { // Fog distance parameters // smoothstep(NEAR_SQ, FAR_SQ, distSq) gives 0% fog at NEAR, 100% at FAR. // --------------------------------------------------------------------------- -export const FOG_NEAR = 300; -export const FOG_FAR = 1200; +export const FOG_NEAR = 600; +export const FOG_FAR = 800; // Pre-computed squared distances — avoids per-fragment sqrt on the GPU. // Shaders compare dot(toCamera, toCamera) directly against these. diff --git a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts index 49f8dc339..38d73b65c 100644 --- a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts +++ b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts @@ -44,9 +44,6 @@ const FOREST_TREE_CONFIG: BiomeTreeConfig = { [TreeId.Pine]: { weight: 20, minHeight: 60 }, [TreeId.Knotwood]: { weight: 5, minHeight: 35, maxHeight: 60 }, [TreeId.Maple]: { weight: 5, minHeight: 35, maxHeight: 60 }, - // [TreeId.Fir]: { weight: 20, minHeight: 48, maxHeight: 80 }, - // [TreeId.Bamboo]: { weight: 15, minHeight: 31, maxHeight: 42 }, - // [TreeId.ChinaPine]: { weight: 10, minHeight: 58, maxHeight: 85 }, [TreeId.Palm]: { weight: 25, waterAffinity: 0.8, diff --git a/packages/shared/src/systems/shared/world/TerrainShader.ts b/packages/shared/src/systems/shared/world/TerrainShader.ts index 5479ddc48..599f0e6dd 100644 --- a/packages/shared/src/systems/shared/world/TerrainShader.ts +++ b/packages/shared/src/systems/shared/world/TerrainShader.ts @@ -56,7 +56,7 @@ export const TERRAIN_SHADER_CONSTANTS = { WATER_LEVEL: 5.0, }; -const TERRAIN_TEX_TILE = 0.1; +const TERRAIN_TEX_TILE = 0.3; const TERRAIN_TEX_DIR = "textures/terrain-biomes"; const TERRAIN_BIOME_TEXTURES = { @@ -265,7 +265,7 @@ export function computeTerrainBaseColor( c = mix(c, dirtColor, mul(nDirtFactor, float(0.4))); // Slopes: dirt at low elevation, cliff at high elevation - const sF = smoothstep(float(0.05), float(0.2), slope); + const sF = smoothstep(float(0.01), float(0.06), slope); const hB = smoothstep(float(20.0), float(40.0), height); c = mix(c, mix(dirtColor, cliffColor, hB), sF); diff --git a/packages/shared/src/systems/shared/world/WaterSystem.ts b/packages/shared/src/systems/shared/world/WaterSystem.ts index e5c575c48..21ddb948c 100644 --- a/packages/shared/src/systems/shared/world/WaterSystem.ts +++ b/packages/shared/src/systems/shared/world/WaterSystem.ts @@ -30,6 +30,7 @@ import THREE, { max, smoothstep, clamp, + saturate, Fn, output, attribute, @@ -94,16 +95,17 @@ const WATER = { // Detail normals blending DETAIL_NORMAL_STRENGTH: 0.5, // Detail normal contribution to final normal - // Opacity + // Opacity (portfolio-style: op = 1 - pow(saturate(1 - depth/scale), falloff)) EDGE_FADE_DISTANCE: 0.4, // Shoreline edge transparency ramp (metres) - DEPTH_FADE_NEAR: 0.4, // Depth opacity ramp start (metres) - DEPTH_FADE_FAR: 6.0, // Depth opacity ramp end (metres) - OPACITY_MIN: 0.2, // Opacity at shoreline - OPACITY_MAX: 0.7, // Opacity at full depth + OP_DEPTH_SCALE: 3.0, // Depth scale for opacity curve (metres) + OP_DEPTH_FALLOFF: 15.0, // Power exponent — higher = sharper opaque transition FRESNEL_OPACITY_MIN: 0.85, // Fresnel opacity at normal incidence FRESNEL_OPACITY_MAX: 1.0, // Fresnel opacity at glancing angles FRESNEL_OPACITY_POWER: 3, // Fresnel opacity falloff exponent + // Distance fade — reflection fades out beyond this range + REFLECTION_FADE_DISTANCE: 500.0, // Reflection fully gone at this distance (metres) + // Vertex wave damping WAVE_DAMP_DISTANCE: 6, // Waves fully active beyond this shore distance (metres) @@ -846,9 +848,16 @@ export class WaterSystem { ); })(); + // Fade reflection to zero beyond REFLECTION_FADE_DISTANCE + const reflDistFade = clamp( + sub(float(1), div(length(toCam), float(WATER.REFLECTION_FADE_DISTANCE))), + float(0), + float(1), + ); + const reflectionEmissive = mul( reflectionNode, - mul(fresnelNode, uReflectionIntensity), + mul(mul(fresnelNode, uReflectionIntensity), reflDistFade), ); material.emissiveNode = reflectionEmissive; @@ -876,17 +885,16 @@ export class WaterSystem { float(WATER.EDGE_FADE_DISTANCE), shoreDist, ); - // Depth fade - more transparent overall to see bottom - const depthFade = smoothstep( - float(WATER.DEPTH_FADE_NEAR), - float(WATER.DEPTH_FADE_FAR), - shoreDist, - ); - const depthOpacity = mix( - float(WATER.OPACITY_MIN), - float(WATER.OPACITY_MAX), - depthFade, + + // Portfolio-style depth opacity: pow(saturate(1 - depth/scale), falloff) + // Shallow → opDepth ≈ 1 → op ≈ 0 (transparent, see bottom) + // Deep → opDepth ≈ 0 → op ≈ 1 (fully opaque, hides terrain) + const opDepth = pow( + saturate(sub(float(1), div(shoreDist, float(WATER.OP_DEPTH_SCALE)))), + float(WATER.OP_DEPTH_FALLOFF), ); + const depthOpacity = sub(float(1), opDepth); + // Fresnel - more opaque at glancing angles const NdotV = max(dot(vec3(0, 1, 0), V), float(0)); const fresnelOpacity = mix( From 0d240b2cc4b5505daf0164bab6d62eec40c78a18 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Sat, 21 Mar 2026 23:07:55 +0800 Subject: [PATCH 29/71] fix: restore TSL reflector for water reflections and overhaul depth color Replace the broken manual planar reflection system with the Three.js TSL reflector, positioned at the correct water level. The previous "sheet shape" artifact was caused by the reflector target being at Y=0 instead of Y=waterLevel. - Use TSL reflector() which handles camera mirroring, render target, and oblique clipping internally via updateBefore() - Position reflector target at waterLevel and sync on setWaterLevel() - Remove manual renderReflection(), reflection camera, texture matrix, and all associated temp vectors - Replace Beer-Lambert absorption with depth-fade color formula (pow(saturate(1 - depth/scale), falloff)) for smoother results - Add distance-based color fade so far water shows uniform deep color instead of depth-buffer noise artifacts - Increase sky dome radius to 5000 so the reflected camera stays inside Made-with: Cursor --- .../src/systems/client/ClientGraphics.ts | 2 - .../src/systems/shared/world/SkySystem.ts | 8 +- .../src/systems/shared/world/WaterSystem.ts | 289 +++++------------- 3 files changed, 79 insertions(+), 220 deletions(-) diff --git a/packages/shared/src/systems/client/ClientGraphics.ts b/packages/shared/src/systems/client/ClientGraphics.ts index 8697b49ad..4803240fa 100644 --- a/packages/shared/src/systems/client/ClientGraphics.ts +++ b/packages/shared/src/systems/client/ClientGraphics.ts @@ -358,10 +358,8 @@ export class ClientGraphics extends System { render() { if (!this.usePostprocessing || !this.composer) { - // Direct rendering without post-processing this.renderer.render(this.world.stage.scene, this.world.camera); } else { - // Render with post-processing (bloom via TSL) this.composer.render(); } this.hasRendered = true; diff --git a/packages/shared/src/systems/shared/world/SkySystem.ts b/packages/shared/src/systems/shared/world/SkySystem.ts index 10903ab3f..4290f81c4 100644 --- a/packages/shared/src/systems/shared/world/SkySystem.ts +++ b/packages/shared/src/systems/shared/world/SkySystem.ts @@ -531,11 +531,9 @@ export class SkySystem extends System { private createSkyDome(): void { if (!this.group) return; - // Use high segment count to prevent color banding - // Sky sphere sized to fit within camera far plane (600) - // Sky follows camera so this creates infinite sky illusion - // Sky sphere must fit inside camera far plane (600) - use 500 - const skyGeom = new THREE.SphereGeometry(500, 128, 64); + // High segment count prevents color banding. Large radius ensures the + // water reflection camera (mirrored below water level) stays inside the dome. + const skyGeom = new THREE.SphereGeometry(5000, 128, 64); // Create TSL uniforms const uTime = uniform(float(0)); diff --git a/packages/shared/src/systems/shared/world/WaterSystem.ts b/packages/shared/src/systems/shared/world/WaterSystem.ts index 21ddb948c..8bd2cc7fc 100644 --- a/packages/shared/src/systems/shared/world/WaterSystem.ts +++ b/packages/shared/src/systems/shared/world/WaterSystem.ts @@ -10,6 +10,7 @@ import THREE, { texture, positionWorld, positionLocal, + reflector, screenUV, cameraPosition, uniform, @@ -36,7 +37,6 @@ import THREE, { attribute, exp, length, - reflector, viewportDepthTexture, linearDepth, cameraNear, @@ -68,10 +68,14 @@ const WATER = { // Reflection REFLECTION_INTENSITY: 0.4, // Planar reflection strength (fresnel-weighted) - // Colour / absorption (Beer-Lambert) + // Depth-based colour: pow(saturate(1 - depth/scale), falloff) SHALLOW_COLOR: { r: 0.15, g: 0.42, b: 0.48 }, // Colour near shore - DEEP_COLOR: { r: 0.02, g: 0.08, b: 0.12 }, // Colour far from shore - ABSORPTION: { r: 0.45, g: 0.09, b: 0.06 }, // Per-channel absorption rate + DEEP_COLOR: { r: 0.12, g: 0.28, b: 0.38 }, // Colour far from shore (brighter than before) + COLOR_DEPTH_SCALE: 50, // Depth scale for colour gradient (metres) + COLOR_DEPTH_FALLOFF: 3, // Power exponent for colour depth curve + COLOR_DIST_FADE: 200, // Camera distance where depth colour effect fades out (metres) + // Ocean/general absorption (still used by ocean material and depth clamping) + ABSORPTION: { r: 0.45, g: 0.09, b: 0.06 }, // Per-channel absorption rate (ocean) MAX_DEPTH: 30, // Clamp depth for absorption calc (metres) // Subsurface scattering @@ -95,17 +99,14 @@ const WATER = { // Detail normals blending DETAIL_NORMAL_STRENGTH: 0.5, // Detail normal contribution to final normal - // Opacity (portfolio-style: op = 1 - pow(saturate(1 - depth/scale), falloff)) + // Opacity: op = 1 - pow(saturate(1 - depth/scale), falloff) EDGE_FADE_DISTANCE: 0.4, // Shoreline edge transparency ramp (metres) - OP_DEPTH_SCALE: 3.0, // Depth scale for opacity curve (metres) - OP_DEPTH_FALLOFF: 15.0, // Power exponent — higher = sharper opaque transition + OP_DEPTH_SCALE: 15.0, // Depth scale for opacity curve (metres) + OP_DEPTH_FALLOFF: 3.0, // Power exponent for opacity depth curve FRESNEL_OPACITY_MIN: 0.85, // Fresnel opacity at normal incidence FRESNEL_OPACITY_MAX: 1.0, // Fresnel opacity at glancing angles FRESNEL_OPACITY_POWER: 3, // Fresnel opacity falloff exponent - // Distance fade — reflection fades out beyond this range - REFLECTION_FADE_DISTANCE: 500.0, // Reflection fully gone at this distance (metres) - // Vertex wave damping WAVE_DAMP_DISTANCE: 6, // Waves fully active beyond this shore distance (metres) @@ -126,9 +127,6 @@ const WATER_LOD = { MEDIUM_DISTANCE: 200, // Distance threshold for medium->low LOD }; -// Maximum distance for reflection camera to be active (only for lakes within this range) -const REFLECTION_MAX_DISTANCE = 200; - type WaveParams = { w: number; phi: number; @@ -185,12 +183,6 @@ export type WaterUniforms = { reflectionIntensity: UniformFloat; }; -// Reflector node type from TSL -type ReflectorNode = ReturnType & { - target: THREE.Object3D; - uvNode: ReturnType; -}; - /** * Water body type - determines shader and visual characteristics * - lake: Inland water bodies with planar reflections (when enabled) @@ -213,17 +205,11 @@ export class WaterSystem { private normalTex2?: THREE.Texture; private foamTex?: THREE.Texture; - // Planar reflection using TSL reflector - private reflection?: ReflectorNode; + // TSL planar reflection (Three.js ReflectorNode handles camera, RT, clipping) + private reflection?: ReturnType; private waterLevel = 5; private waterMeshes: THREE.Mesh[] = []; - // Frustum culling for reflection optimization - private frustum = new THREE.Frustum(); - private projScreenMatrix = new THREE.Matrix4(); - private tempSphere = new THREE.Sphere(); - - // Reflection state tracking for DevStats private reflectionActive = false; // User preference for reflections (can be toggled) @@ -252,17 +238,13 @@ export class WaterSystem { // Update reflection intensity uniform - this actually disables reflections in the shader if (this.uniforms) { - this.uniforms.reflectionIntensity.value = enabled ? 0.4 : 0.0; + this.uniforms.reflectionIntensity.value = enabled + ? WATER.REFLECTION_INTENSITY + : 0.0; } - // Update reflector visibility based on setting - if (this.reflection?.target) { - // When disabled, hide the reflector to save GPU resources - if (!enabled) { - this.reflection.target.visible = false; - this.reflectionActive = false; - } - // When enabled, visibility will be controlled by frustum culling in update() + if (!enabled) { + this.reflectionActive = false; } console.log( @@ -296,6 +278,31 @@ export class WaterSystem { return this.reflectionActive ? 1 : 0; } + /** + * Set the Y level used for the reflection mirror plane. + */ + setWaterLevel(y: number): void { + this.waterLevel = y; + if (this.reflection?.target) { + this.reflection.target.position.y = y; + } + } + + /** + * Register an externally-created water mesh for reflection visibility tracking. + */ + registerWaterMesh(mesh: THREE.Mesh): void { + this.waterMeshes.push(mesh); + } + + /** + * Unregister an externally-created water mesh from reflection tracking. + */ + unregisterWaterMesh(mesh: THREE.Mesh): void { + const idx = this.waterMeshes.indexOf(mesh); + if (idx !== -1) this.waterMeshes.splice(idx, 1); + } + /** * Returns the total number of water meshes being tracked */ @@ -325,14 +332,11 @@ export class WaterSystem { this.normalTex2 = await this.createNormalMap(128, 2.0, 137); this.foamTex = await this.createFoamTexture(128); - // Create TSL reflector for planar reflections (lake water only) - // This handles all the reflection camera, render target, and UV calculation automatically - this.reflection = reflector({ resolutionScale: 0.45 }) as ReflectorNode; - // Rotate to face upward (water is horizontal plane) + // TSL reflector: handles render target, camera mirroring, oblique clipping + this.reflection = reflector({ resolutionScale: 0.5 }); this.reflection.target.rotateX(-Math.PI / 2); - this.reflection.target.name = "WaterReflector"; + this.reflection.target.position.y = this.waterLevel; - // Create lake material with reflections AFTER reflector is set up this.lakeMaterial = this.createLakeMaterial(); // Create ocean material without reflections (different visual style) @@ -344,40 +348,11 @@ export class WaterSystem { } /** - * Add reflector target to scene - must be called after init + * Add water system to scene — adds the reflector target so the mirror plane is active. */ addToScene(scene: THREE.Scene): void { if (this.reflection?.target) { scene.add(this.reflection.target); - - // Configure reflector's virtual camera to only see layer 0 (terrain) - // This excludes grass (layer 1), flowers, and other vegetation from reflections - // The reflector internally creates a camera we need to configure - const reflectorObj = this.reflection.target as THREE.Object3D & { - camera?: THREE.Camera; - }; - if (reflectorObj.camera) { - reflectorObj.camera.layers.set(0); // Only see layer 0 (terrain, buildings) - reflectorObj.camera.layers.enable(2); // Also see floors - console.log( - "[WaterSystem] Reflector camera configured to ignore grass (layer 1)", - ); - } - - // Set up callbacks to track when reflection camera is rendering - // This allows LOD systems to force impostor mode during reflection passes - const world = this.world; - this.reflection.target.onBeforeRender = () => { - world.isRenderingReflection = true; - }; - this.reflection.target.onAfterRender = () => { - world.isRenderingReflection = false; - }; - - console.log( - "[WaterSystem] Added reflector target to scene at y=", - this.reflection.target.position.y, - ); } } @@ -542,10 +517,8 @@ export class WaterSystem { const uSunDir = uniform(vec3(0.4, 0.8, 0.4)); const uWind = uniform(float(1.0)); const fogTexNode = texture(fogRenderTarget.texture, screenUV); - // Reflection intensity uniform - allows disabling reflections via settings - // Default 0.4 matches the hardcoded value we had before const uReflectionIntensity = uniform( - float(this._reflectionsEnabled ? 0.4 : 0.0), + float(this._reflectionsEnabled ? WATER.REFLECTION_INTENSITY : 0.0), ); this.uniforms = { @@ -571,18 +544,14 @@ export class WaterSystem { const normalTex2 = this.normalTex2!; const foamTex = this.foamTex!; - // Get the TSL reflector - use directly like in the example - const reflectionNode = this.reflection!; - - // Add normal-based UV distortion to reflection for ripple effect - // Like in the example: reflection.uvNode = reflection.uvNode.add( floorNormalOffset ); + // Reflection: TSL reflector handles camera mirroring, RT, oblique clipping. + // Add normal-based distortion to the reflector's screen-UV for ripple effect. + const reflNode = this.reflection!; const worldUV = vec2(positionWorld.x, positionWorld.z); const normalOffset = texture(normalTex1, mul(worldUV, float(0.02))).xy; const normalDistortion = sub(mul(normalOffset, float(2)), float(1)); - (reflectionNode as { uvNode: ShaderNode }).uvNode = add( - reflectionNode.uvNode, - mul(normalDistortion, float(0.015)), - ); + reflNode.uvNode = reflNode.uvNode!.add(mul(normalDistortion, float(0.015))); + const reflectionNode = reflNode; const wavePhase = ( wp: ShaderNodeInput, @@ -636,12 +605,20 @@ export class WaterSystem { const gpuShoreDist = Fn(() => { const sceneDepth = linearDepth(viewportDepthTexture()); const waterDepth = linearDepth(); - // Depth difference in [0,1], convert to world units const depthDiff = sub(sceneDepth, waterDepth); const worldDist = mul(depthDiff, sub(cameraFar, cameraNear)); return clamp(worldDist, float(0), float(WATER.MAX_DEPTH)); })(); + // Distance fade: beyond COLOR_DIST_FADE the depth-based colour effect + // fades out, giving a uniform deep-water look at distance. + const distToCam = length(sub(cameraPosition, positionWorld)); + const waterOpColorLerp = clamp( + sub(float(1), div(distToCam, float(WATER.COLOR_DIST_FADE))), + float(0.01), + float(1.0), + ); + // ======================================================================== // FRAGMENT: Use reflection in emissiveNode like the example // ======================================================================== @@ -709,8 +686,13 @@ export class WaterSystem { const NdotH = max(dot(N, H), float(0)); const VdotH = max(dot(V, H), float(0)); - // Beer-Lambert absorption for water depth color - const depth = clamp(shoreDist, float(0), float(WATER.MAX_DEPTH)); + // Depth-based colour: colorDepth=1 at shore (shallow), colorDepth→0 in deep water + const colorDepth = pow( + saturate(sub(float(1), div(shoreDist, float(WATER.COLOR_DEPTH_SCALE)))), + float(WATER.COLOR_DEPTH_FALLOFF), + ); + // At distance, fade to uniform deep colour + const colorLerp = mul(colorDepth, waterOpColorLerp); const shallowColor = vec3( WATER.SHALLOW_COLOR.r, WATER.SHALLOW_COLOR.g, @@ -721,23 +703,7 @@ export class WaterSystem { WATER.DEEP_COLOR.g, WATER.DEEP_COLOR.b, ); - const waterColor = vec3( - mix( - deepColor.x, - shallowColor.x, - exp(mul(float(-WATER.ABSORPTION.r), depth)), - ), - mix( - deepColor.y, - shallowColor.y, - exp(mul(float(-WATER.ABSORPTION.g), depth)), - ), - mix( - deepColor.z, - shallowColor.z, - exp(mul(float(-WATER.ABSORPTION.b), depth)), - ), - ); + const waterColor = mix(deepColor, shallowColor, colorLerp); // Subsurface scattering approximation const sssView = pow( @@ -848,16 +814,9 @@ export class WaterSystem { ); })(); - // Fade reflection to zero beyond REFLECTION_FADE_DISTANCE - const reflDistFade = clamp( - sub(float(1), div(length(toCam), float(WATER.REFLECTION_FADE_DISTANCE))), - float(0), - float(1), - ); - const reflectionEmissive = mul( reflectionNode, - mul(mul(fresnelNode, uReflectionIntensity), reflDistFade), + mul(fresnelNode, uReflectionIntensity), ); material.emissiveNode = reflectionEmissive; @@ -886,7 +845,7 @@ export class WaterSystem { shoreDist, ); - // Portfolio-style depth opacity: pow(saturate(1 - depth/scale), falloff) + // Depth opacity: pow(saturate(1 - depth/scale), falloff) // Shallow → opDepth ≈ 1 → op ≈ 0 (transparent, see bottom) // Deep → opDepth ≈ 0 → op ≈ 1 (fully opaque, hides terrain) const opDepth = pow( @@ -956,7 +915,7 @@ export class WaterSystem { time: uTime, sunDirection: uSunDir as unknown as UniformVec3, windStrength: uWind, - reflectionIntensity: uReflectionIntensity, // Always 0 for ocean + reflectionIntensity: uReflectionIntensity, }; const material = new MeshStandardNodeMaterial(); @@ -1240,11 +1199,6 @@ export class WaterSystem { ): THREE.Mesh | null { this.waterLevel = waterThreshold; - // Position the reflector at water level (only needed for lake water) - if (waterType === "lake" && this.reflection?.target) { - this.reflection.target.position.y = waterThreshold; - } - if (!getHeightAt) { const mesh = this.createFallbackMesh( tile, @@ -1523,105 +1477,14 @@ export class WaterSystem { } /** - * Check if any lake water meshes are in the camera frustum, within range, - * and enable/disable the reflection render pass accordingly. This saves a - * full scene render when no lake water is visible or nearby. - * Ocean water NEVER uses reflections regardless of distance. + * Check if reflections should be active this frame. */ private updateReflectionVisibility(): void { - if (!this.reflection?.target) { - this.reflectionActive = false; - return; - } - - // If reflections are disabled by user preference, hide the reflector - if (!this._reflectionsEnabled) { - this.reflection.target.visible = false; - this.reflectionActive = false; - return; - } - - const camera = this.world.camera; - if (!camera) { - // No camera, assume water might be visible - this.reflection.target.visible = true; - this.reflectionActive = true; - return; - } - - // If no water meshes exist, disable reflection - if (this.waterMeshes.length === 0) { - this.reflection.target.visible = false; + if (!this._reflectionsEnabled || !this.reflection) { this.reflectionActive = false; return; } - - // Build frustum from camera - // IMPORTANT: updateMatrixWorld() updates matrixWorld but NOT matrixWorldInverse - // We must explicitly compute matrixWorldInverse from matrixWorld - camera.updateMatrixWorld(); - camera.matrixWorldInverse.copy(camera.matrixWorld).invert(); - this.projScreenMatrix.multiplyMatrices( - camera.projectionMatrix, - camera.matrixWorldInverse, - ); - this.frustum.setFromProjectionMatrix(this.projScreenMatrix); - - // Get camera position for distance checks - const cameraPos = camera.position; - - // Check if any LAKE water mesh is visible, in frustum, AND within range - // Ocean water NEVER uses reflections regardless of distance - // OPTIMIZATION: Cap meshes checked per frame to prevent frame drops with many tiles - const MAX_MESH_CHECKS = 20; - let anyLakeWaterVisible = false; - let checked = 0; - - for (const mesh of this.waterMeshes) { - if (checked >= MAX_MESH_CHECKS) { - // Over budget - assume visible to be safe (avoids reflection popping) - anyLakeWaterVisible = this.reflectionActive; // Maintain last state - break; - } - - // Skip meshes that have been removed from scene or are hidden - if (!mesh.parent || !mesh.visible) continue; - - // Skip ocean water - it NEVER uses reflections - if (mesh.userData.waterType === "ocean") continue; - - checked++; - - // Ensure bounding sphere exists (computeBoundingSphere can be expensive) - if (!mesh.geometry.boundingSphere) { - mesh.geometry.computeBoundingSphere(); - } - - const boundingSphere = mesh.geometry.boundingSphere; - if (!boundingSphere) continue; - - // Transform bounding sphere to world space - this.tempSphere.copy(boundingSphere); - this.tempSphere.applyMatrix4(mesh.matrixWorld); - - // Check distance from camera to lake water (use sphere center) - // Only enable reflections for lakes within REFLECTION_MAX_DISTANCE (200m) - const distanceToLake = cameraPos.distanceTo(this.tempSphere.center); - if (distanceToLake > REFLECTION_MAX_DISTANCE + this.tempSphere.radius) { - // Lake is too far away - skip it - continue; - } - - // Check if in frustum - if (this.frustum.intersectsSphere(this.tempSphere)) { - anyLakeWaterVisible = true; - break; - } - } - - // Enable/disable reflector based on lake water visibility - this.reflection.target.visible = anyLakeWaterVisible; - this.reflectionActive = anyLakeWaterVisible; + this.reflectionActive = this.waterMeshes.length > 0; } destroy(): void { From 982534ca94e191af5279ed8880a832a8bd8dc723 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Sat, 21 Mar 2026 23:10:10 +0800 Subject: [PATCH 30/71] feat: add quadtree-driven water mesh generation Introduce WaterVisualManager that listens to the terrain quad-tree and generates flat water meshes per leaf node, replacing per-tile water when USE_QUADTREE_LOD is enabled. - Add WaterVisualManager: creates PlaneGeometry water chunks aligned with quad-tree nodes, with resolution scaling by tree depth - Add CompositeQuadTreeListener to forward events to both terrain and water visual managers from the same quad-tree - Gate per-tile generateWaterMeshes() behind !USE_QUADTREE_LOD - Clean up water visual manager in TerrainSystem.destroy() Made-with: Cursor --- .../systems/shared/world/TerrainQuadTree.ts | 19 +++ .../src/systems/shared/world/TerrainSystem.ts | 43 ++++- .../shared/world/WaterVisualManager.ts | 153 ++++++++++++++++++ 3 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 packages/shared/src/systems/shared/world/WaterVisualManager.ts diff --git a/packages/shared/src/systems/shared/world/TerrainQuadTree.ts b/packages/shared/src/systems/shared/world/TerrainQuadTree.ts index 52b1e7f60..322a775dc 100644 --- a/packages/shared/src/systems/shared/world/TerrainQuadTree.ts +++ b/packages/shared/src/systems/shared/world/TerrainQuadTree.ts @@ -333,6 +333,25 @@ export interface QuadTreeListener { onNodeDestroyGeometry(node: TerrainQuadNode): void; } +/** + * Forwards quad-tree events to multiple listeners (e.g. terrain + water). + */ +export class CompositeQuadTreeListener implements QuadTreeListener { + private listeners: QuadTreeListener[] = []; + + add(listener: QuadTreeListener): void { + this.listeners.push(listener); + } + + onNodeNeedsGeometry(node: TerrainQuadNode): void { + for (const l of this.listeners) l.onNodeNeedsGeometry(node); + } + + onNodeDestroyGeometry(node: TerrainQuadNode): void { + for (const l of this.listeners) l.onNodeDestroyGeometry(node); + } +} + /** * Manages the top-level quad-tree: root chunk grid, split/unsplit, * neighbor resolution, and player tracking. diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts index 5026ed63a..a493e680b 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -120,6 +120,8 @@ import { TerrainVisualManager, type VisualManagerTerrainProvider, } from "./TerrainVisualManager"; +import { WaterVisualManager } from "./WaterVisualManager"; +import { CompositeQuadTreeListener } from "./TerrainQuadTree"; import type { QuadChunkWorkerConfig } from "../../../utils/workers/QuadChunkWorker"; import { terminateQuadChunkWorkerPool } from "../../../utils/workers/QuadChunkWorker"; @@ -242,7 +244,7 @@ export class TerrainSystem extends System { private lamppostActiveLights: VertexLight[] = []; private lamppostLightIndices: number[] = []; private lamppostLightDistances: number[] = []; - private waterSystem?: WaterSystem; + waterSystem?: WaterSystem; private roadNetworkSystem?: RoadNetworkSystem; private _cachedRoadTileX = Number.NaN; private _cachedRoadTileZ = Number.NaN; @@ -256,6 +258,7 @@ export class TerrainSystem extends System { // Quad-tree LOD visual manager (client-only, when CONFIG.USE_QUADTREE_LOD is true) private quadTreeVisualManager: TerrainVisualManager | null = null; + private waterVisualManager: WaterVisualManager | null = null; // Unified terrain generator from @hyperscape/procgen // Provides deterministic height/biome calculation independent of rendering @@ -1137,7 +1140,9 @@ export class TerrainSystem extends System { this.generateTileResources(tile); } this.generateVisualFeatures(tile); - this.generateWaterMeshes(tile); + if (!this.CONFIG.USE_QUADTREE_LOD) { + this.generateWaterMeshes(tile); + } // Queue resource instances for deferred creation (spreads work across frames) if (tile.resources.length > 0 && tile.mesh) { @@ -1891,6 +1896,31 @@ export class TerrainSystem extends System { this.CONFIG.QUADTREE_MAX_ASSEMBLIES_PER_FRAME, ); + // Water quad-tree visual manager — flat water meshes aligned with terrain chunks + if (this.waterSystem) { + const waterContainer = new THREE.Group(); + waterContainer.name = "QuadTreeWaterContainer"; + if (this.terrainContainer) { + this.terrainContainer.parent?.add(waterContainer); + } + + this.waterSystem.setWaterLevel(this.CONFIG.WATER_THRESHOLD); + + this.waterVisualManager = new WaterVisualManager( + waterContainer, + this.waterSystem, + (x: number, z: number) => this.getHeightAt(x, z), + (x: number, z: number) => this.getIslandMask(x, z), + this.CONFIG.WATER_THRESHOLD, + ); + + // Wire both terrain and water managers to the same quad-tree via composite + const composite = new CompositeQuadTreeListener(); + composite.add(this.quadTreeVisualManager); + composite.add(this.waterVisualManager); + this.quadTreeVisualManager.getQuadTree().setListener(composite); + } + console.log( "[TerrainSystem] Quad-tree LOD visual manager initialized " + `(minSize=${this.CONFIG.QUADTREE_MIN_SIZE}, maxDepth=${this.CONFIG.QUADTREE_MAX_DEPTH}, ` + @@ -2408,7 +2438,9 @@ export class TerrainSystem extends System { // Generate visual features and water meshes this.generateVisualFeatures(tile); - this.generateWaterMeshes(tile); + if (!this.CONFIG.USE_QUADTREE_LOD) { + this.generateWaterMeshes(tile); + } // NOTE: Grass rendering is handled by ProceduralGrassSystem // Queue resource instances for deferred creation (spreads work across frames) @@ -6206,6 +6238,11 @@ export class TerrainSystem extends System { this.quadTreeVisualManager = null; } + if (this.waterVisualManager) { + this.waterVisualManager.destroy(); + this.waterVisualManager = null; + } + // Terminate worker pools to free resources terminateTerrainWorkerPool(); terminateQuadChunkWorkerPool(); diff --git a/packages/shared/src/systems/shared/world/WaterVisualManager.ts b/packages/shared/src/systems/shared/world/WaterVisualManager.ts new file mode 100644 index 000000000..0f597264d --- /dev/null +++ b/packages/shared/src/systems/shared/world/WaterVisualManager.ts @@ -0,0 +1,153 @@ +/** + * WaterVisualManager — Generates flat water meshes aligned with the terrain + * quad-tree. Each quad-tree leaf node that contains any underwater area gets + * a simple PlaneGeometry at WATER_THRESHOLD height. + * + * The terrain quad-tree drives split/merge; this manager only reacts to + * onNodeNeedsGeometry / onNodeDestroyGeometry events. + * + * CLIENT-ONLY: Only used when USE_QUADTREE_LOD is true. + */ + +import THREE from "../../../extras/three/three"; +import type { TerrainQuadNode, QuadTreeListener } from "./TerrainQuadTree"; +import type { WaterSystem, WaterBodyType } from "./WaterSystem"; + +const WATER_RESOLUTION_BY_DEPTH: Record = { + 0: 2, + 1: 4, + 2: 8, + 3: 12, + 4: 16, +}; + +const SHORE_SAMPLE_GRID = 5; + +interface WaterChunk { + nodeId: number; + mesh: THREE.Mesh; +} + +export class WaterVisualManager implements QuadTreeListener { + private container: THREE.Group; + private waterSystem: WaterSystem; + private getHeightAt: (x: number, z: number) => number; + private getIslandMask: (x: number, z: number) => number; + private waterThreshold: number; + private chunks = new Map(); + + constructor( + container: THREE.Group, + waterSystem: WaterSystem, + getHeightAt: (x: number, z: number) => number, + getIslandMask: (x: number, z: number) => number, + waterThreshold: number, + ) { + this.container = container; + this.waterSystem = waterSystem; + this.getHeightAt = getHeightAt; + this.getIslandMask = getIslandMask; + this.waterThreshold = waterThreshold; + } + + // -- QuadTreeListener ------------------------------------------------- + + onNodeNeedsGeometry(node: TerrainQuadNode): void { + const key = this.chunkKey(node); + if (this.chunks.has(key)) return; + + if (!this.hasUnderwaterArea(node)) return; + + const resolution = + WATER_RESOLUTION_BY_DEPTH[node.depth] ?? + Math.min(16, Math.max(2, node.depth * 4)); + + const geom = new THREE.PlaneGeometry( + node.size, + node.size, + resolution, + resolution, + ); + geom.rotateX(-Math.PI / 2); + + const count = geom.attributes.position.count; + const shores = new Float32Array(count).fill(50); + geom.setAttribute("shoreDistance", new THREE.BufferAttribute(shores, 1)); + + const normals = new Float32Array(count * 3); + for (let i = 0; i < normals.length; i += 3) { + normals[i + 1] = 1; + } + geom.setAttribute("normal", new THREE.BufferAttribute(normals, 3)); + + const waterType = this.determineWaterType(node); + const material = this.waterSystem.getMaterial(waterType); + if (!material) return; + + const mesh = new THREE.Mesh(geom, material); + mesh.position.set(node.centerX, this.waterThreshold, node.centerZ); + mesh.name = `WaterQT_${waterType}_${key}`; + mesh.renderOrder = 100; + mesh.userData = { + type: "water", + waterType, + walkable: false, + clickable: false, + }; + mesh.layers.set(1); + + this.container.add(mesh); + this.waterSystem.registerWaterMesh(mesh); + this.chunks.set(key, { nodeId: node.id, mesh }); + } + + onNodeDestroyGeometry(node: TerrainQuadNode): void { + const key = this.chunkKey(node); + const chunk = this.chunks.get(key); + if (!chunk) return; + + this.waterSystem.unregisterWaterMesh(chunk.mesh); + if (chunk.mesh.parent) chunk.mesh.parent.remove(chunk.mesh); + chunk.mesh.geometry.dispose(); + this.chunks.delete(key); + } + + // -- Helpers ---------------------------------------------------------- + + private chunkKey(node: TerrainQuadNode): string { + return `wq_${node.id}_d${node.depth}_${node.centerX}_${node.centerZ}`; + } + + private hasUnderwaterArea(node: TerrainQuadNode): boolean { + const half = node.halfSize; + const step = node.size / (SHORE_SAMPLE_GRID - 1); + const startX = node.centerX - half; + const startZ = node.centerZ - half; + + for (let i = 0; i < SHORE_SAMPLE_GRID; i++) { + for (let j = 0; j < SHORE_SAMPLE_GRID; j++) { + const wx = startX + i * step; + const wz = startZ + j * step; + if (this.getHeightAt(wx, wz) < this.waterThreshold) { + return true; + } + } + } + return false; + } + + private determineWaterType(node: TerrainQuadNode): WaterBodyType { + const mask = this.getIslandMask(node.centerX, node.centerZ); + return mask < 0.3 ? "ocean" : "lake"; + } + + destroy(): void { + for (const [, chunk] of this.chunks) { + this.waterSystem.unregisterWaterMesh(chunk.mesh); + if (chunk.mesh.parent) chunk.mesh.parent.remove(chunk.mesh); + chunk.mesh.geometry.dispose(); + } + this.chunks.clear(); + if (this.container.parent) this.container.parent.remove(this.container); + } +} From 23354fd9acf027f748140f973c777239ea67d200 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Sat, 21 Mar 2026 23:26:22 +0800 Subject: [PATCH 31/71] style: tune water colors to match portfolio hue ratios Scale down portfolio cosine gradient endpoint colors for PBR compatibility while preserving the key hue characteristics: turquoise-green shallow water and ocean-blue deep water. Made-with: Cursor --- packages/shared/src/systems/shared/world/WaterSystem.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/systems/shared/world/WaterSystem.ts b/packages/shared/src/systems/shared/world/WaterSystem.ts index 8bd2cc7fc..209923683 100644 --- a/packages/shared/src/systems/shared/world/WaterSystem.ts +++ b/packages/shared/src/systems/shared/world/WaterSystem.ts @@ -69,8 +69,8 @@ const WATER = { REFLECTION_INTENSITY: 0.4, // Planar reflection strength (fresnel-weighted) // Depth-based colour: pow(saturate(1 - depth/scale), falloff) - SHALLOW_COLOR: { r: 0.15, g: 0.42, b: 0.48 }, // Colour near shore - DEEP_COLOR: { r: 0.12, g: 0.28, b: 0.38 }, // Colour far from shore (brighter than before) + SHALLOW_COLOR: { r: 0.1, g: 0.5, b: 0.36 }, // Colour near shore (turquoise-green) + DEEP_COLOR: { r: 0.08, g: 0.3, b: 0.44 }, // Colour far from shore (ocean blue) COLOR_DEPTH_SCALE: 50, // Depth scale for colour gradient (metres) COLOR_DEPTH_FALLOFF: 3, // Power exponent for colour depth curve COLOR_DIST_FADE: 200, // Camera distance where depth colour effect fades out (metres) From 7801dbde23209419e3d8e62a224e547057db4467 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Mon, 23 Mar 2026 13:19:03 +0800 Subject: [PATCH 32/71] feat: refactor terrain to per-biome independent height generation Replace shared-noise-with-weight-blending architecture with independent per-biome height functions (normal mode + canyon mode) matching the threejs-examples reference. Each biome now has its own BiomeTerrainConfig with full control over FBM, erosion, altitude, rivers, terracing, etc. - Add simplexFbm2D to NoiseGenerator + worker mirror - Add smoothstep, mapRangeSmooth, normalizeFbmRange, pingpong utilities - Replace BiomeNoiseProfile/NoiseLayerDef with BiomeTerrainConfig - Add per-biome noise sets (main/variation/erosion per biome) - Rewrite computeBaseHeight + worker JS mirror for per-biome blending - Update WATER_THRESHOLD=16.9, TERRAIN_SCALE=32, BASE_OFFSET=22, FEATURE_SCALE=1.4, ISLAND_RADIUS=2419 - Disable landscape features pending recalibration Made-with: Cursor --- .../shared/src/constants/GameConstants.ts | 2 +- .../shared/world/TerrainHeightParams.ts | 740 ++++++++++-------- .../src/systems/shared/world/TerrainSystem.ts | 47 +- packages/shared/src/utils/NoiseGenerator.ts | 55 ++ .../src/utils/workers/QuadChunkWorker.ts | 3 + .../shared/src/utils/workers/TerrainWorker.ts | 4 + .../src/utils/workers/TerrainWorkerShared.ts | 35 + 7 files changed, 554 insertions(+), 332 deletions(-) diff --git a/packages/shared/src/constants/GameConstants.ts b/packages/shared/src/constants/GameConstants.ts index 2db7a91a2..b0cad5384 100644 --- a/packages/shared/src/constants/GameConstants.ts +++ b/packages/shared/src/constants/GameConstants.ts @@ -83,7 +83,7 @@ export const TERRAIN_CONSTANTS = { * Terrain below this height is underwater and impassable. * Used by: TerrainSystem, VegetationSystem, DissolveMaterial, RoadNetworkSystem, ResourceSystem */ - WATER_THRESHOLD: 30.0, + WATER_THRESHOLD: 16.9, /** * Buffer distance above water where vegetation shouldn't spawn. diff --git a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts index 61ee4dc1b..1167abb8b 100644 --- a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts +++ b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts @@ -11,6 +11,12 @@ import { BiomeType, DEFAULT_BIOME } from "./TerrainBiomeTypes"; import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants"; +import { + smoothstep, + mapRangeSmooth, + normalizeFbmRange, + pingpong, +} from "../../../utils/NoiseGenerator"; // --------------------------------------------------------------------------- // Core terrain generation constants @@ -41,151 +47,180 @@ export const SHORELINE_CONFIG = { } as const; // --------------------------------------------------------------------------- -// Noise layer definitions — drive getBaseHeightAt() +// Per-biome terrain config — each biome is a complete height function // --------------------------------------------------------------------------- -export interface NoiseLayerDef { - scale: number; - weight: number; - octaves?: number; - persistence?: number; - lacunarity?: number; - /** Only for erosion noise */ - iterations?: number; +export interface BiomeTerrainConfig { + seedOffset: number; + frequency: number; + amplitude: number; + octaves: number; + gain: number; + lacunarity: number; + noiseOffset: number; + altitude: number; + altitudeVariation: number; + erosion: number; + erosionSoftness: number; + rivers: number; + riverWidth: number; + lakes: number; + lakesFalloff: number; + heightScale: number; + powerCurve: number; + smoothLowerPlanes: number; + canyonMode: boolean; + canyonFreqScale: number; + canyonAmpScale: number; + cliffLow: number; + cliffHigh: number; + terraceSteps: number; + terraceStrength: number; + terraceSharpness: number; + terraceHeightScale: number; + terraceSlope: number; } -export const CONTINENT_LAYER: NoiseLayerDef = { - scale: 0.0004, - octaves: 5, - persistence: 0.7, - lacunarity: 2.0, - weight: 0.35, -}; - -export const RIDGE_LAYER: NoiseLayerDef = { - scale: 0.0015, - weight: 0.15, -}; +export interface BiomeNoiseAdapter { + simplexFbm2D( + x: number, + y: number, + octaves: number, + amplitude: number, + frequency: number, + gain: number, + lacunarity: number, + offset: number, + ): number; +} -export const HILL_LAYER: NoiseLayerDef = { - scale: 0.008, - octaves: 4, - persistence: 0.6, - lacunarity: 2.2, - weight: 0.25, -}; +export interface BiomeNoiseSet { + main: BiomeNoiseAdapter; + variation: BiomeNoiseAdapter; + erosion: BiomeNoiseAdapter; +} -export const EROSION_LAYER: NoiseLayerDef = { - scale: 0.0025, - iterations: 3, - weight: 0.1, -}; +// --------------------------------------------------------------------------- +// Global terrain shaping constants +// --------------------------------------------------------------------------- -export const DETAIL_LAYER: NoiseLayerDef = { - scale: 0.02, - octaves: 2, - persistence: 0.3, - lacunarity: 2.5, - weight: 0.08, -}; +export const TERRAIN_SCALE = 32; +export const BASE_OFFSET = 22; +export const FEATURE_SCALE = 1.4; -/** Power curve applied after blending noise layers and normalizing to [0,1] */ -export const HEIGHT_POWER_CURVE = 1.1; +const NOISE_COORD_SCALE = 55 / 2450; // --------------------------------------------------------------------------- -// Biome noise profiles — per-biome noise weight blending (Option A) +// Per-biome config defaults // --------------------------------------------------------------------------- -/** Global terrace step count — shared across all biomes to prevent boundary artifacts. */ -export const TERRACE_STEPS = 10; - -/** Base height bias added after scaling to world units (in world units). */ -export const ELEVATION_OFFSET = 8; -/** Amplitude of large-scale elevation noise (in world units). */ -export const ELEVATION_NOISE_AMOUNT = 50; -/** Frequency of large-scale elevation noise. */ -export const ELEVATION_NOISE_SCALE = 0.0005; - -export interface BiomeNoiseProfile { - continentWeight: number; - ridgeWeight: number; - hillWeight: number; - erosionWeight: number; - detailWeight: number; - powerCurve: number; - /** 0–1 blend from smooth to terraced (0 = disabled, higher = more visible) */ - terraceStrength: number; - /** 0–1 flat zone per step (0.8 = 80% flat shelf, 20% cliff transition) */ - terraceSharpness: number; - /** - * Stretches shelf positions around the midpoint to make cliffs taller. - * 1 = normal (5m cliffs at MAX_HEIGHT=50, TERRACE_STEPS=10) - * 3 = 3x taller cliffs (~15m). Vary per biome for visual diversity. - */ - terraceHeightScale: number; - /** - * 0–1 how much natural terrain slope is preserved on each shelf. - * 0 = perfectly flat shelves, 0.3 = 30% of natural slope, 1 = no flattening. - */ - terraceSlope: number; -} - -export const TUNDRA_PROFILE: BiomeNoiseProfile = { - continentWeight: 0.32, - ridgeWeight: 0.29, - hillWeight: 0.02, - erosionWeight: 0.2, - detailWeight: 0.02, - powerCurve: 1.42, - terraceStrength: 2, - terraceSharpness: 0.19, - terraceHeightScale: 11.4, - terraceSlope: 1.9, +export const FOREST_CONFIG: BiomeTerrainConfig = { + seedOffset: 0, + frequency: 0.07, + amplitude: 0.5, + octaves: 10, + gain: 0.5, + lacunarity: 2.0, + noiseOffset: 0.25, + altitude: 0.1, + altitudeVariation: 1.4, + erosion: 0.6, + erosionSoftness: 0.3, + rivers: 0, + riverWidth: 0, + lakes: 0, + lakesFalloff: 0, + heightScale: 2.8, + powerCurve: 1.0, + smoothLowerPlanes: 0, + canyonMode: false, + canyonFreqScale: 0.3, + canyonAmpScale: 1.5, + cliffLow: 0.5, + cliffHigh: 0.8, + terraceSteps: 10, + terraceStrength: 0, + terraceSharpness: 0, + terraceHeightScale: 1, + terraceSlope: 0, }; -export const FOREST_PROFILE: BiomeNoiseProfile = { - continentWeight: 0.32, - ridgeWeight: 0.29, - hillWeight: 0, - erosionWeight: 0.2, - detailWeight: 0, - powerCurve: 1.42, - terraceStrength: 2, - terraceSharpness: 0.19, - terraceHeightScale: 9.1, - terraceSlope: 0.98, +export const TUNDRA_CONFIG: BiomeTerrainConfig = { + seedOffset: 100, + frequency: 0.07, + amplitude: 0.5, + octaves: 10, + gain: 0.5, + lacunarity: 2.0, + noiseOffset: 0.25, + altitude: 0.1, + altitudeVariation: 1.4, + erosion: 0.6, + erosionSoftness: 0.3, + rivers: 0, + riverWidth: 0, + lakes: 0, + lakesFalloff: 0, + heightScale: 2.8, + powerCurve: 1.0, + smoothLowerPlanes: 0, + canyonMode: false, + canyonFreqScale: 0.3, + canyonAmpScale: 1.5, + cliffLow: 0.5, + cliffHigh: 0.8, + terraceSteps: 10, + terraceStrength: 0, + terraceSharpness: 0, + terraceHeightScale: 1, + terraceSlope: 0, }; -export const CANYON_PROFILE: BiomeNoiseProfile = { - continentWeight: 0.32, - ridgeWeight: 0.29, - hillWeight: 0, - erosionWeight: 0.2, - detailWeight: 0, - powerCurve: 1.42, - terraceStrength: 2, - terraceSharpness: 0.82, - terraceHeightScale: 7.8, - terraceSlope: 0.48, +export const CANYON_CONFIG: BiomeTerrainConfig = { + seedOffset: 200, + frequency: 0.07, + amplitude: 0.5, + octaves: 10, + gain: 0.5, + lacunarity: 2.0, + noiseOffset: 0.25, + altitude: 0.0, + altitudeVariation: 0.8, + erosion: 0.0, + erosionSoftness: 0.3, + rivers: 0, + riverWidth: 0, + lakes: 0, + lakesFalloff: 0, + heightScale: 2.8, + powerCurve: 1.0, + smoothLowerPlanes: 0, + canyonMode: true, + canyonFreqScale: 0.3, + canyonAmpScale: 1.5, + cliffLow: 0.5, + cliffHigh: 0.8, + terraceSteps: 10, + terraceStrength: 0, + terraceSharpness: 0, + terraceHeightScale: 1, + terraceSlope: 0, }; -export const BIOME_PROFILES: Record = { - [BiomeType.Tundra]: TUNDRA_PROFILE, - [BiomeType.Forest]: FOREST_PROFILE, - [BiomeType.Canyon]: CANYON_PROFILE, +export const BIOME_CONFIGS: Record = { + [BiomeType.Tundra]: TUNDRA_CONFIG, + [BiomeType.Forest]: FOREST_CONFIG, + [BiomeType.Canyon]: CANYON_CONFIG, }; // --------------------------------------------------------------------------- // Island configuration // --------------------------------------------------------------------------- -export const ISLAND_RADIUS = 2000; +export const ISLAND_RADIUS = 2419; export const ISLAND_FALLOFF = 450; export const ISLAND_DEEP_OCEAN_BUFFER = 113; -export const BASE_ELEVATION = 0.42; -export const OCEAN_FLOOR_HEIGHT = 0.05; -/** height = terrain * HEIGHT_TERRAIN_MIX + BASE_ELEVATION * islandMask */ -export const HEIGHT_TERRAIN_MIX = 0.2; +export const OCEAN_FLOOR_HEIGHT = 2.5; export const BEACH_PROFILE_POWER = 3.0; // --------------------------------------------------------------------------- @@ -224,49 +259,10 @@ export interface LandscapeFeatureDef { } /** - * Predefined landscape features — add/remove entries here to control - * exactly where mountains, ponds, and plateaus appear on the island. - * - * Algorithm: radial envelope × domain-warped noise → terrace quantization. - * The envelope defines the feature's footprint; noise drives internal terrain. - * Terracing follows noise contours, producing organic (non-circular) layers. - * - * Parameter guide: - * layers - number of terrace levels (1 = single plateau, 6+ = tiered mountain) - * shapePower - envelope falloff (0.3 = dome, 1 = cone, 4+ = flat-topped mesa) - * edgeSharpness - terrace cliff sharpness (0 = smooth ramp, 1 = hard cliff) - * layerSlope - incline within each shelf (0 = flat, 0.5 = gentle slope, 1 = full slope) - * noiseScale - frequency of internal terrain (0.02 = broad ridges, 0.05 = fine detail) - * noiseAmount - noise vs envelope blend (0 = smooth dome, 0.6 = organic ridges) + * Predefined landscape features — currently disabled while per-biome terrain + * is being tuned. Re-enable by adding entries back to this array. */ -export const LANDSCAPE_FEATURES: LandscapeFeatureDef[] = [ - { - type: LandscapeType.Mountain, - x: -195.5, - z: -520.5, - radius: 250, - strength: 5.5, - layers: 5, - shapePower: 2.0, - edgeSharpness: 0.2, - layerSlope: 0.9, - noiseScale: 0.025, - noiseAmount: 0.6, - }, - { - type: LandscapeType.Pond, - x: 198.5, - z: 917.5, - radius: 455, - strength: 4.5, - layers: 6, - shapePower: 3.7, - edgeSharpness: 0.75, - layerSlope: 0.71, - noiseScale: 0.015, - noiseAmount: 0.26, - }, -]; +export const LANDSCAPE_FEATURES: LandscapeFeatureDef[] = []; // --------------------------------------------------------------------------- // Coastline noise — varies the island radius for irregular shoreline @@ -389,6 +385,144 @@ export function applyLandscapeFeaturesPure( return height; } +// --------------------------------------------------------------------------- +// Per-biome height functions — normal mode (reference defaultTerrain) +// --------------------------------------------------------------------------- + +function computeNormalHeight( + x: number, + z: number, + cfg: BiomeTerrainConfig, + ns: BiomeNoiseSet, + coordScale: number, +): { y: number; water: number } { + const { main, variation, erosion } = ns; + const nx = x * coordScale; + const nz = z * coordScale; + + let terrainNoise = main.simplexFbm2D( + nx, + nz, + cfg.octaves, + cfg.amplitude, + cfg.frequency, + cfg.gain, + cfg.lacunarity, + cfg.noiseOffset, + ); + + const erosionVariation = + variation.simplexFbm2D(nx + 500, nz + 500, 1, 1.0, 0.012, 0.5, 2.0, 0) * + 0.6 - + 0.1; + const erosionSoft = erosionVariation + cfg.erosionSoftness; + let ero = erosion.simplexFbm2D(nx, nz, 3, 0.2, cfg.frequency, 0.5, 1.8, 0.3); + ero = smoothstep(ero, 0, 1); + ero = Math.pow(ero, 1 + erosionSoft); + ero = Math.max(0, pingpong(ero * 2, 1) - 0.3); + terrainNoise *= 1 - cfg.erosion + cfg.erosion * ero; + + const altitudeNoise = + variation.simplexFbm2D(nx, nz, 1, 1.0, 0.012, 0.5, 2.0, 0) * + cfg.altitudeVariation - + 0.75; + terrainNoise += cfg.altitude + altitudeNoise; + + const water = + mapRangeSmooth( + terrainNoise, + -(1 - cfg.lakes), + -(1 - cfg.lakes) + cfg.lakesFalloff, + 3, + 0, + ) * 0.2; + + terrainNoise = + terrainNoise * terrainNoise * (1 - cfg.smoothLowerPlanes) + + terrainNoise * terrainNoise * terrainNoise * cfg.smoothLowerPlanes; + + const y = + terrainNoise * (1 - Math.max(0, Math.min(1, water * cfg.rivers * 3))) + + -3 * Math.max(0, Math.min(1, water * cfg.rivers * 3)); + + return { y, water }; +} + +// --------------------------------------------------------------------------- +// Per-biome height: canyon mode (reference desertTerrain) +// --------------------------------------------------------------------------- + +function computeCanyonHeight( + x: number, + z: number, + cfg: BiomeTerrainConfig, + ns: BiomeNoiseSet, + coordScale: number, +): { y: number; water: number } { + const { main, variation } = ns; + const nx = x * coordScale; + const nz = z * coordScale; + + const canyonFbm = main.simplexFbm2D( + nx, + nz, + cfg.octaves, + cfg.amplitude * cfg.canyonAmpScale, + cfg.frequency * cfg.canyonFreqScale, + cfg.gain, + cfg.lacunarity, + cfg.noiseOffset, + ); + let terrainNoise = normalizeFbmRange(Math.abs(canyonFbm - cfg.noiseOffset)); + + const riverWidthVar = normalizeFbmRange( + variation.simplexFbm2D(nx + 1000, nz + 1000, 1, 1.0, 0.012, 0.5, 2.0, 0), + ); + const rw = cfg.riverWidth; + const edge1 = (0.2 + rw * 0.25) * (0.75 + riverWidthVar * 0.4); + const edge2 = (0.3 + rw * 0.25) * (0.75 + riverWidthVar * 0.4); + const water = mapRangeSmooth(terrainNoise, edge1, edge2, 1, 0) * 0.2; + + const cliffs = mapRangeSmooth( + terrainNoise, + cfg.cliffLow, + cfg.cliffHigh, + 0, + 1, + ); + const y = cliffs - water; + + return { y, water: water * 5 }; +} + +// --------------------------------------------------------------------------- +// Per-biome height dispatcher + power curve +// --------------------------------------------------------------------------- + +function computeBiomeHeight( + x: number, + z: number, + cfg: BiomeTerrainConfig, + ns: BiomeNoiseSet, + coordScale: number, +): { y: number; water: number } { + const raw = cfg.canyonMode + ? computeCanyonHeight(x, z, cfg, ns, coordScale) + : computeNormalHeight(x, z, cfg, ns, coordScale); + + let y = raw.y * cfg.heightScale; + + if (cfg.powerCurve !== 1.0) { + y = Math.sign(y) * Math.pow(Math.abs(y), cfg.powerCurve); + } + + return { y, water: raw.water }; +} + +// --------------------------------------------------------------------------- +// Main: computeBaseHeight — blends independent per-biome heights +// --------------------------------------------------------------------------- + /** * THE height generation algorithm. Main thread calls this directly. * Workers use the JS-string mirror (buildGetBaseHeightAtJS) which @@ -397,114 +531,45 @@ export function applyLandscapeFeaturesPure( export function computeBaseHeight( worldX: number, worldZ: number, - noise: TerrainNoiseAdapter, + sharedNoise: TerrainNoiseAdapter, + biomeNoiseSets: Record, biomeWeights: Record, features: ReadonlyArray, - maxHeight: number, ): number { - // ── 1. Sample noise layers ────────────────────────────────────────── - const cN = noise.fractal2D( - worldX * CONTINENT_LAYER.scale, - worldZ * CONTINENT_LAYER.scale, - CONTINENT_LAYER.octaves!, - CONTINENT_LAYER.persistence!, - CONTINENT_LAYER.lacunarity!, - ); - const rN = noise.ridgeNoise2D( - worldX * RIDGE_LAYER.scale, - worldZ * RIDGE_LAYER.scale, - ); - const hN = noise.fractal2D( - worldX * HILL_LAYER.scale, - worldZ * HILL_LAYER.scale, - HILL_LAYER.octaves!, - HILL_LAYER.persistence!, - HILL_LAYER.lacunarity!, - ); - const eN = noise.erosionNoise2D( - worldX * EROSION_LAYER.scale, - worldZ * EROSION_LAYER.scale, - EROSION_LAYER.iterations!, - ); - const dN = noise.fractal2D( - worldX * DETAIL_LAYER.scale, - worldZ * DETAIL_LAYER.scale, - DETAIL_LAYER.octaves!, - DETAIL_LAYER.persistence!, - DETAIL_LAYER.lacunarity!, - ); - - // ── 2. Blend noise using biome-weighted profiles ──────────────────── - let cW = 0, - rW = 0, - hW = 0, - eW = 0, - dW = 0, - pC = 0, - tS = 0, - tSh = 0, - tHS = 0, - tSl = 0; + // ── 1. Blend per-biome heights ────────────────────────────────────── + const coordScale = NOISE_COORD_SCALE * FEATURE_SCALE; + let height = 0; for (const key of Object.keys(biomeWeights)) { const w = biomeWeights[key]; - const p = BIOME_PROFILES[key] ?? BIOME_PROFILES[DEFAULT_BIOME]; - cW += p.continentWeight * w; - rW += p.ridgeWeight * w; - hW += p.hillWeight * w; - eW += p.erosionWeight * w; - dW += p.detailWeight * w; - pC += p.powerCurve * w; - tS += p.terraceStrength * w; - tSh += p.terraceSharpness * w; - tHS += p.terraceHeightScale * w; - tSl += p.terraceSlope * w; - } - - // ── 3. Combine, normalize, power curve ────────────────────────────── - let height = cN * cW + rN * rW + hN * hW + eN * eW + dN * dW; - height = (height + 1) * 0.5; - height = Math.max(0, Math.min(1, height)); - height = Math.pow(height, pC); - - // ── 4. Terracing — ceil-based (raises terrain to plateaus, never lowers) ─ - const steps = TERRACE_STEPS; - const ths = Math.max(1, tHS); - if (tS > 0.01 && steps >= 2) { - const ceilStep = Math.ceil(height * steps) / steps; - const floorStep = Math.max(0, ceilStep - 1 / steps); - const frac = (height - floorStep) * steps; - - const flatThreshold = 1 - tSh; - const edgeBlend = frac > flatThreshold ? 1 : frac / (flatThreshold + 0.001); - const flatStep = floorStep + edgeBlend * (ceilStep - floorStep); - - const slopedStep = floorStep + frac * (ceilStep - floorStep); - const terraced = flatStep + tSl * (slopedStep - flatStep); - const scaled = Math.max(0, Math.min(1, 0.5 + (terraced - 0.5) * ths)); - height = Math.max(height, height + (scaled - height) * tS); + if (w < 0.01) continue; + const biomeCfg = BIOME_CONFIGS[key] ?? BIOME_CONFIGS[DEFAULT_BIOME]; + const ns = biomeNoiseSets[key]; + if (!ns) continue; + const result = computeBiomeHeight(worldX, worldZ, biomeCfg, ns, coordScale); + height += result.y * w; } - // ── 5. Coastline noise → island mask ──────────────────────────────── + // ── 2. Coastline noise → island mask ──────────────────────────────── const distFromCenter = Math.sqrt(worldX * worldX + worldZ * worldZ); const angle = Math.atan2(worldZ, worldX); const cnx = Math.cos(angle) * COASTLINE_CIRCLE_SAMPLE_RADIUS; const cnz = Math.sin(angle) * COASTLINE_CIRCLE_SAMPLE_RADIUS; - const cst1 = noise.fractal2D( + const cst1 = sharedNoise.fractal2D( cnx, cnz, COAST_LARGE.octaves, COAST_LARGE.persistence, COAST_LARGE.lacunarity, ); - const cst2 = noise.fractal2D( + const cst2 = sharedNoise.fractal2D( cnx * COAST_MEDIUM.freqMultiplier, cnz * COAST_MEDIUM.freqMultiplier, COAST_MEDIUM.octaves, COAST_MEDIUM.persistence, COAST_MEDIUM.lacunarity, ); - const cst3 = noise.simplex2D( + const cst3 = sharedNoise.simplex2D( cnx * COAST_SMALL.freqMultiplier, cnz * COAST_SMALL.freqMultiplier, ); @@ -524,30 +589,23 @@ export function computeBaseHeight( islandMask = 0; } - // ── 6. Island mask + landscape features ───────────────────────────── - height = height * islandMask; - height = applyLandscapeFeaturesPure(height, worldX, worldZ, features, noise); + // ── 3. Island mask → scale → offset → landscape features ─────────── + height *= islandMask; + height *= TERRAIN_SCALE; + height += BASE_OFFSET * islandMask; + + height = applyLandscapeFeaturesPure( + height, + worldX, + worldZ, + features, + sharedNoise, + ); if (islandMask === 0) { height = OCEAN_FLOOR_HEIGHT; } - // ── 7. Scale to world units + global elevation offset ────────────── - height = height * maxHeight; - if (islandMask > 0) { - const eOff: number = ELEVATION_OFFSET; - const eNA: number = ELEVATION_NOISE_AMOUNT; - if (eNA > 0 || eOff !== 0) { - const elevNoise = - (noise.simplex2D( - worldX * ELEVATION_NOISE_SCALE, - worldZ * ELEVATION_NOISE_SCALE, - ) + - 1) * - 0.5; - height += (eOff + elevNoise * eNA) * islandMask; - } - } return height; } @@ -698,58 +756,121 @@ export function buildApplyLandscapeFeaturesJS(): string { }`; } -// Biome profile constants baked into JS for workers -const PROFILES_JS = ` - var BIOME_PROFILES = {}; - BIOME_PROFILES[BT_TUNDRA] = { cW: ${TUNDRA_PROFILE.continentWeight}, rW: ${TUNDRA_PROFILE.ridgeWeight}, hW: ${TUNDRA_PROFILE.hillWeight}, eW: ${TUNDRA_PROFILE.erosionWeight}, dW: ${TUNDRA_PROFILE.detailWeight}, pC: ${TUNDRA_PROFILE.powerCurve}, tS: ${TUNDRA_PROFILE.terraceStrength}, tSh: ${TUNDRA_PROFILE.terraceSharpness}, tHS: ${TUNDRA_PROFILE.terraceHeightScale}, tSl: ${TUNDRA_PROFILE.terraceSlope} }; - BIOME_PROFILES[BT_FOREST] = { cW: ${FOREST_PROFILE.continentWeight}, rW: ${FOREST_PROFILE.ridgeWeight}, hW: ${FOREST_PROFILE.hillWeight}, eW: ${FOREST_PROFILE.erosionWeight}, dW: ${FOREST_PROFILE.detailWeight}, pC: ${FOREST_PROFILE.powerCurve}, tS: ${FOREST_PROFILE.terraceStrength}, tSh: ${FOREST_PROFILE.terraceSharpness}, tHS: ${FOREST_PROFILE.terraceHeightScale}, tSl: ${FOREST_PROFILE.terraceSlope} }; - BIOME_PROFILES[BT_CANYON] = { cW: ${CANYON_PROFILE.continentWeight}, rW: ${CANYON_PROFILE.ridgeWeight}, hW: ${CANYON_PROFILE.hillWeight}, eW: ${CANYON_PROFILE.erosionWeight}, dW: ${CANYON_PROFILE.detailWeight}, pC: ${CANYON_PROFILE.powerCurve}, tS: ${CANYON_PROFILE.terraceStrength}, tSh: ${CANYON_PROFILE.terraceSharpness}, tHS: ${CANYON_PROFILE.terraceHeightScale}, tSl: ${CANYON_PROFILE.terraceSlope} }; +/** + * Bake BiomeTerrainConfig per-biome into worker JS. + */ +function biomeConfigToJS(name: string, cfg: BiomeTerrainConfig): string { + return `BIOME_CONFIGS[${name}] = { + seedOffset:${cfg.seedOffset}, frequency:${cfg.frequency}, amplitude:${cfg.amplitude}, + octaves:${cfg.octaves}, gain:${cfg.gain}, lacunarity:${cfg.lacunarity}, noiseOffset:${cfg.noiseOffset}, + altitude:${cfg.altitude}, altitudeVariation:${cfg.altitudeVariation}, + erosion:${cfg.erosion}, erosionSoftness:${cfg.erosionSoftness}, + rivers:${cfg.rivers}, riverWidth:${cfg.riverWidth}, lakes:${cfg.lakes}, lakesFalloff:${cfg.lakesFalloff}, + heightScale:${cfg.heightScale}, powerCurve:${cfg.powerCurve}, smoothLowerPlanes:${cfg.smoothLowerPlanes}, + canyonMode:${cfg.canyonMode}, canyonFreqScale:${cfg.canyonFreqScale}, canyonAmpScale:${cfg.canyonAmpScale}, + cliffLow:${cfg.cliffLow}, cliffHigh:${cfg.cliffHigh}, + terraceSteps:${cfg.terraceSteps}, terraceStrength:${cfg.terraceStrength}, + terraceSharpness:${cfg.terraceSharpness}, terraceHeightScale:${cfg.terraceHeightScale}, terraceSlope:${cfg.terraceSlope} + };`; +} + +const BIOME_CONFIGS_JS = ` + var BIOME_CONFIGS = {}; + ${biomeConfigToJS("BT_TUNDRA", TUNDRA_CONFIG)} + ${biomeConfigToJS("BT_FOREST", FOREST_CONFIG)} + ${biomeConfigToJS("BT_CANYON", CANYON_CONFIG)} `; /** - * JS source — worker mirror of computeBaseHeight(). - * Accepts biome weights and blends noise per-biome. - * MUST stay in sync with computeBaseHeight() above. + * JS source — worker mirror of computeBaseHeight() and per-biome height functions. + * MUST stay in sync with the TS functions above. + * + * Expects in worker scope: noise, biomeNoiseSets, BIOME_CONFIGS, BT_DEFAULT, + * computeBiomeWeightsByPosition, applyLandscapeFeatures, landscapeFeatures. */ export function buildGetBaseHeightAtJS(): string { return ` - ${PROFILES_JS} + ${BIOME_CONFIGS_JS} - function getBaseHeightAt(worldX, worldZ, biomeWeights) { - var bw = biomeWeights || computeBiomeWeightsByPosition(worldX, worldZ); + var NOISE_COORD_SCALE = ${NOISE_COORD_SCALE}; + var TERRAIN_SCALE_VAL = ${TERRAIN_SCALE}; + var BASE_OFFSET_VAL = ${BASE_OFFSET}; + var FEATURE_SCALE_VAL = ${FEATURE_SCALE}; + + function _smoothstep(x, edge0, edge1) { + var t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0))); + return t * t * (3 - 2 * t); + } + function _mapRangeSmooth(val, a1, a2, b1, b2) { + return b1 + _smoothstep(val, a1, a2) * (b2 - b1); + } + function _normalizeFbmRange(fbmNoise) { + return Math.min(1, Math.max(0, (fbmNoise + 0.4) / 1.3)); + } + function _pingpong(x, length) { + var t = x % (length * 2); + return length - Math.abs(t - length); + } - var cN = noise.fractal2D(worldX * ${CONTINENT_LAYER.scale}, worldZ * ${CONTINENT_LAYER.scale}, ${CONTINENT_LAYER.octaves}, ${CONTINENT_LAYER.persistence}, ${CONTINENT_LAYER.lacunarity}); - var rN = noise.ridgeNoise2D(worldX * ${RIDGE_LAYER.scale}, worldZ * ${RIDGE_LAYER.scale}); - var hN = noise.fractal2D(worldX * ${HILL_LAYER.scale}, worldZ * ${HILL_LAYER.scale}, ${HILL_LAYER.octaves}, ${HILL_LAYER.persistence}, ${HILL_LAYER.lacunarity}); - var eN = noise.erosionNoise2D(worldX * ${EROSION_LAYER.scale}, worldZ * ${EROSION_LAYER.scale}, ${EROSION_LAYER.iterations}); - var dN = noise.fractal2D(worldX * ${DETAIL_LAYER.scale}, worldZ * ${DETAIL_LAYER.scale}, ${DETAIL_LAYER.octaves}, ${DETAIL_LAYER.persistence}, ${DETAIL_LAYER.lacunarity}); + function computeNormalHeight(x, z, cfg, ns, coordScale) { + var nx = x * coordScale; + var nz = z * coordScale; + var terrainNoise = ns.main.simplexFbm2D(nx, nz, cfg.octaves, cfg.amplitude, cfg.frequency, cfg.gain, cfg.lacunarity, cfg.noiseOffset); + + var erosionVariation = ns.variation.simplexFbm2D(nx + 500, nz + 500, 1, 1.0, 0.012, 0.5, 2.0, 0) * 0.6 - 0.1; + var erosionSoft = erosionVariation + cfg.erosionSoftness; + var ero = ns.erosion.simplexFbm2D(nx, nz, 3, 0.2, cfg.frequency, 0.5, 1.8, 0.3); + ero = _smoothstep(ero, 0, 1); + ero = Math.pow(ero, 1 + erosionSoft); + ero = Math.max(0, _pingpong(ero * 2, 1) - 0.3); + terrainNoise *= (1 - cfg.erosion) + cfg.erosion * ero; + + var altitudeNoise = ns.variation.simplexFbm2D(nx, nz, 1, 1.0, 0.012, 0.5, 2.0, 0) * cfg.altitudeVariation - 0.75; + terrainNoise += cfg.altitude + altitudeNoise; + + var water = _mapRangeSmooth(terrainNoise, -(1 - cfg.lakes), -(1 - cfg.lakes) + cfg.lakesFalloff, 3, 0) * 0.2; + terrainNoise = terrainNoise * terrainNoise * (1 - cfg.smoothLowerPlanes) + terrainNoise * terrainNoise * terrainNoise * cfg.smoothLowerPlanes; + var y = terrainNoise * (1 - Math.max(0, Math.min(1, water * cfg.rivers * 3))) + (-3) * Math.max(0, Math.min(1, water * cfg.rivers * 3)); + return { y: y, water: water }; + } - var cW = 0, rW = 0, hW = 0, eW = 0, dW = 0, pC = 0, tS = 0, tSh = 0, tHS = 0, tSl = 0; - for (var key in bw) { - var w = bw[key]; - var p = BIOME_PROFILES[key] || BIOME_PROFILES[BT_DEFAULT]; - cW += p.cW * w; rW += p.rW * w; hW += p.hW * w; eW += p.eW * w; dW += p.dW * w; - pC += p.pC * w; tS += p.tS * w; tSh += p.tSh * w; tHS += p.tHS * w; tSl += p.tSl * w; + function computeCanyonHeight(x, z, cfg, ns, coordScale) { + var nx = x * coordScale; + var nz = z * coordScale; + var canyonFbm = ns.main.simplexFbm2D(nx, nz, cfg.octaves, cfg.amplitude * cfg.canyonAmpScale, cfg.frequency * cfg.canyonFreqScale, cfg.gain, cfg.lacunarity, cfg.noiseOffset); + var terrainNoise = _normalizeFbmRange(Math.abs(canyonFbm - cfg.noiseOffset)); + + var riverWidthVar = _normalizeFbmRange(ns.variation.simplexFbm2D(nx + 1000, nz + 1000, 1, 1.0, 0.012, 0.5, 2.0, 0)); + var rw = cfg.riverWidth; + var edge1 = (0.2 + rw * 0.25) * (0.75 + riverWidthVar * 0.4); + var edge2 = (0.3 + rw * 0.25) * (0.75 + riverWidthVar * 0.4); + var water = _mapRangeSmooth(terrainNoise, edge1, edge2, 1, 0) * 0.2; + var cliffs = _mapRangeSmooth(terrainNoise, cfg.cliffLow, cfg.cliffHigh, 0, 1); + var y = cliffs - water; + return { y: y, water: water * 5 }; + } + + function computeBiomeHeight(x, z, cfg, ns, coordScale) { + var raw = cfg.canyonMode ? computeCanyonHeight(x, z, cfg, ns, coordScale) : computeNormalHeight(x, z, cfg, ns, coordScale); + var y = raw.y * cfg.heightScale; + if (cfg.powerCurve !== 1.0) { + y = (y >= 0 ? 1 : -1) * Math.pow(Math.abs(y), cfg.powerCurve); } + return { y: y, water: raw.water }; + } - var height = cN * cW + rN * rW + hN * hW + eN * eW + dN * dW; - height = (height + 1) * 0.5; - height = Math.max(0, Math.min(1, height)); - height = Math.pow(height, pC); - - var gSteps = ${TERRACE_STEPS}; - var ths = Math.max(1, tHS); - if (tS > 0.01 && gSteps >= 2) { - var ceilStep = Math.ceil(height * gSteps) / gSteps; - var floorStep = Math.max(0, ceilStep - 1 / gSteps); - var gFrac = (height - floorStep) * gSteps; - var flatThreshold = 1 - tSh; - var gEdgeBlend = gFrac > flatThreshold ? 1 : gFrac / (flatThreshold + 0.001); - var gFlatStep = floorStep + gEdgeBlend * (ceilStep - floorStep); - var gSlopedStep = floorStep + gFrac * (ceilStep - floorStep); - var gTerraced = gFlatStep + tSl * (gSlopedStep - gFlatStep); - var gScaled = Math.max(0, Math.min(1, 0.5 + (gTerraced - 0.5) * ths)); - height = Math.max(height, height + (gScaled - height) * tS); + function getBaseHeightAt(worldX, worldZ, biomeWeights) { + var bw = biomeWeights || computeBiomeWeightsByPosition(worldX, worldZ); + var coordScale = NOISE_COORD_SCALE * FEATURE_SCALE_VAL; + var height = 0; + for (var key in bw) { + var w = bw[key]; + if (w < 0.01) continue; + var biomeCfg = BIOME_CONFIGS[key] || BIOME_CONFIGS[BT_DEFAULT]; + var ns = biomeNoiseSets[key]; + if (!ns) continue; + var result = computeBiomeHeight(worldX, worldZ, biomeCfg, ns, coordScale); + height += result.y * w; } var distFromCenter = Math.sqrt(worldX * worldX + worldZ * worldZ); @@ -772,15 +893,12 @@ export function buildGetBaseHeightAtJS(): string { islandMask = 0; } - height = height * islandMask; + height *= islandMask; + height *= TERRAIN_SCALE_VAL; + height += BASE_OFFSET_VAL * islandMask; height = applyLandscapeFeatures(height, worldX, worldZ); if (islandMask === 0) { height = ${OCEAN_FLOOR_HEIGHT}; } - height = height * MAX_HEIGHT; - if (islandMask > 0 && (${ELEVATION_NOISE_AMOUNT} > 0 || ${ELEVATION_OFFSET} !== 0)) { - var elevNoise = (noise.simplex2D(worldX * ${ELEVATION_NOISE_SCALE}, worldZ * ${ELEVATION_NOISE_SCALE}) + 1) * 0.5; - height += (${ELEVATION_OFFSET} + elevNoise * ${ELEVATION_NOISE_AMOUNT}) * islandMask; - } return height; }`; } diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts index a493e680b..3b623659f 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -32,10 +32,12 @@ import { WATER_LEVEL_NORMALIZED, SHORELINE_CONFIG, BIOME_CONFIG, + BIOME_CONFIGS, } from "./TerrainHeightParams"; import type { LandscapeFeatureDef, ShorelineConfig, + BiomeNoiseSet, } from "./TerrainHeightParams"; import { BiomeType, DEFAULT_BIOME, BIOME_LIST } from "./TerrainBiomeTypes"; // Import terrain generator from procgen package @@ -186,6 +188,7 @@ export class TerrainSystem extends System { private updateTimer = 0; private terrainTime = 0; // For animated caustics private noise!: NoiseGenerator; + private biomeNoiseSets: Record = {}; private landscapeFeatures: LandscapeFeatureDef[] = []; private _loggedWorkerTileBiome = 0; private _loggedSyncTileBiome = 0; @@ -1449,8 +1452,8 @@ export class TerrainSystem extends System { this.runtimeIsServer = runtimeRole.isServer; this.runtimeIsClient = runtimeRole.isClient; - // Initialize deterministic noise from world id - this.noise = new NoiseGenerator(this.computeSeedFromWorldId()); + // Initialize deterministic noise from world id + per-biome noise sets + this.ensureNoiseInitialized(); this.initializeLandscapeFeatures(); @@ -1559,11 +1562,8 @@ export class TerrainSystem extends System { } async start(): Promise { - // Initialize noise generator if not already initialized (failsafe) - if (!this.noise) { - this.noise = new NoiseGenerator(this.computeSeedFromWorldId()); - this.initializeLandscapeFeatures(); - } + this.ensureNoiseInitialized(); + this.initializeLandscapeFeatures(); // CRITICAL: Wait for DataManager to initialize BIOMES data before generating terrain // DataManager is initialized in registerSystems() which happens asynchronously @@ -3291,14 +3291,27 @@ export class TerrainSystem extends System { * Get base terrain height WITHOUT mountain biome boost. * Delegates to the single source of truth in TerrainHeightParams. */ + private ensureNoiseInitialized(): void { + if (!this.noise) { + const seed = this.computeSeedFromWorldId(); + this.noise = new NoiseGenerator(seed); + for (const [key, cfg] of Object.entries(BIOME_CONFIGS)) { + const base = seed + cfg.seedOffset; + this.biomeNoiseSets[key] = { + main: new NoiseGenerator(base), + variation: new NoiseGenerator(base + 4), + erosion: new NoiseGenerator(base + 1), + }; + } + } + } + private getBaseHeightAt( worldX: number, worldZ: number, biomeWeights?: Record, ): number { - if (!this.noise) { - this.noise = new NoiseGenerator(this.computeSeedFromWorldId()); - } + this.ensureNoiseInitialized(); const weights = biomeWeights ?? this.computeBiomeWeightsByPosition(worldX, worldZ); @@ -3306,16 +3319,14 @@ export class TerrainSystem extends System { worldX, worldZ, this.noise, + this.biomeNoiseSets, weights, this.landscapeFeatures, - MAX_HEIGHT, ); } private getHeightAtWithoutShore(worldX: number, worldZ: number): number { - if (!this.noise) { - this.noise = new NoiseGenerator(this.computeSeedFromWorldId()); - } + this.ensureNoiseInitialized(); const flatHeight = this.getFlatZoneHeight(worldX, worldZ); if (flatHeight !== null) { @@ -5898,9 +5909,7 @@ export class TerrainSystem extends System { worldZ: number, overrideDifficultyLevel?: number, ): DifficultySample { - if (!this.noise) { - this.noise = new NoiseGenerator(this.computeSeedFromWorldId()); - } + this.ensureNoiseInitialized(); const biome = this.getBiomeAtWorldPosition(worldX, worldZ); const biomeData = BIOMES[biome]; @@ -6062,9 +6071,7 @@ export class TerrainSystem extends System { private generateBossHotspots(): void { if (this.bossHotspots.length > 0) return; - if (!this.noise) { - this.noise = new NoiseGenerator(this.computeSeedFromWorldId()); - } + this.ensureNoiseInitialized(); const worldSizeMeters = this.getActiveWorldSizeMeters(); const halfWorld = worldSizeMeters / 2; diff --git a/packages/shared/src/utils/NoiseGenerator.ts b/packages/shared/src/utils/NoiseGenerator.ts index fdcbdf7c2..6d428b8bb 100644 --- a/packages/shared/src/utils/NoiseGenerator.ts +++ b/packages/shared/src/utils/NoiseGenerator.ts @@ -235,6 +235,33 @@ export class NoiseGenerator { return (this.fractal2D(x * 0.002, y * 0.002, 4) + 1) * 0.5; } + /** + * Simplex-based FBM matching the reference's createFbmNoise. + * Returns raw sum + offset (not normalized). + */ + simplexFbm2D( + x: number, + y: number, + octaves: number, + amplitude: number, + frequency: number, + gain: number, + lacunarity: number, + offset: number, + ): number { + let value = 0; + let amp = amplitude; + let fx = x * frequency; + let fy = y * frequency; + for (let i = 0; i < octaves; i++) { + value += amp * this.simplex2D(fx, fy); + fx *= lacunarity; + fy *= lacunarity; + amp *= gain; + } + return value + offset; + } + // Helper functions private fade(t: number): number { return t * t * t * (t * (t * 6 - 15) + 10); @@ -481,3 +508,31 @@ export class TerrainFeatureGenerator { : 0; } } + +// --------------------------------------------------------------------------- +// Standalone math utilities for per-biome terrain generation +// --------------------------------------------------------------------------- + +export function smoothstep(x: number, edge0: number, edge1: number): number { + const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0))); + return t * t * (3 - 2 * t); +} + +export function mapRangeSmooth( + val: number, + a1: number, + a2: number, + b1: number, + b2: number, +): number { + return b1 + smoothstep(val, a1, a2) * (b2 - b1); +} + +export function normalizeFbmRange(fbmNoise: number): number { + return Math.min(1, Math.max(0, (fbmNoise + 0.4) / 1.3)); +} + +export function pingpong(x: number, length: number): number { + const t = x % (length * 2); + return length - Math.abs(t - length); +} diff --git a/packages/shared/src/utils/workers/QuadChunkWorker.ts b/packages/shared/src/utils/workers/QuadChunkWorker.ts index e41c9d970..83ed90061 100644 --- a/packages/shared/src/utils/workers/QuadChunkWorker.ts +++ b/packages/shared/src/utils/workers/QuadChunkWorker.ts @@ -24,6 +24,7 @@ import { buildNoiseGeneratorJS, buildHeightHelpersJS, buildBiomeInfluencesJS, + buildCreateBiomeNoiseSetsJS, } from "./TerrainWorkerShared"; export interface QuadChunkWorkerConfig { @@ -113,6 +114,8 @@ function generateQuadChunk(input) { ${buildComputeBiomeWeightsJS()} ${buildApplyLandscapeFeaturesJS()} ${buildGetBaseHeightAtJS()} + ${buildCreateBiomeNoiseSetsJS()} + var biomeNoiseSets = createBiomeNoiseSets(seed); ${buildHeightHelpersJS()} ${buildBiomeInfluencesJS()} diff --git a/packages/shared/src/utils/workers/TerrainWorker.ts b/packages/shared/src/utils/workers/TerrainWorker.ts index 0dc59aba0..93cae6aa2 100644 --- a/packages/shared/src/utils/workers/TerrainWorker.ts +++ b/packages/shared/src/utils/workers/TerrainWorker.ts @@ -20,6 +20,7 @@ import { buildNoiseGeneratorJS, buildHeightHelpersJS, buildBiomeInfluencesJS, + buildCreateBiomeNoiseSetsJS, } from "./TerrainWorkerShared"; // Types for terrain generation @@ -149,6 +150,9 @@ function generateHeightmap(input) { ${buildApplyLandscapeFeaturesJS()} ${buildGetBaseHeightAtJS()} + ${buildCreateBiomeNoiseSetsJS()} + var biomeNoiseSets = createBiomeNoiseSets(seed); + ${buildHeightHelpersJS()} ${buildBiomeInfluencesJS()} diff --git a/packages/shared/src/utils/workers/TerrainWorkerShared.ts b/packages/shared/src/utils/workers/TerrainWorkerShared.ts index 40c984c81..886837612 100644 --- a/packages/shared/src/utils/workers/TerrainWorkerShared.ts +++ b/packages/shared/src/utils/workers/TerrainWorkerShared.ts @@ -138,6 +138,20 @@ class NoiseGenerator { } return height; } + + simplexFbm2D(x, y, octaves, amplitude, frequency, gain, lacunarity, offset) { + var value = 0; + var amp = amplitude; + var fx = x * frequency; + var fy = y * frequency; + for (var i = 0; i < octaves; i++) { + value += amp * this.simplex2D(fx, fy); + fx *= lacunarity; + fy *= lacunarity; + amp *= gain; + } + return value + offset; + } }`; } @@ -201,6 +215,27 @@ export function buildHeightHelpersJS(): string { }`; } +/** + * Creates per-biome noise sets in the worker. + * Expects: seed (number), BIOME_CONFIGS (object) to be in scope. + */ +export function buildCreateBiomeNoiseSetsJS(): string { + return ` + function createBiomeNoiseSets(seed) { + var sets = {}; + for (var key in BIOME_CONFIGS) { + var cfg = BIOME_CONFIGS[key]; + var base = seed + cfg.seedOffset; + sets[key] = { + main: new NoiseGenerator(base), + variation: new NoiseGenerator(base + 4), + erosion: new NoiseGenerator(base + 1) + }; + } + return sets; + }`; +} + /** * Biome influence function used by both workers. * From 733221c1b3df77a613b9bfba6fa459c894d05046 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Mon, 23 Mar 2026 13:26:09 +0800 Subject: [PATCH 33/71] feat: refactor terrain shader coloring to match visualizer thresholds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align cliff, dirt, and grass allocation with the terrain visualizer: - NOISE_SCALE 0.0003 → 0.0008 - Dirt patches: wider noise band, gentler flatness gate, full intensity - Add moderate-slope dirt transition (bell curve 0.15–0.6) - Cliff purely slope-driven (smoothstep 0.3–0.55), remove height blend Made-with: Cursor --- .../src/systems/shared/world/TerrainShader.ts | 53 +++++++++++-------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/packages/shared/src/systems/shared/world/TerrainShader.ts b/packages/shared/src/systems/shared/world/TerrainShader.ts index 599f0e6dd..96508434a 100644 --- a/packages/shared/src/systems/shared/world/TerrainShader.ts +++ b/packages/shared/src/systems/shared/world/TerrainShader.ts @@ -49,7 +49,7 @@ import { SUN_LIGHT } from "./LightingConfig"; export const TERRAIN_SHADER_CONSTANTS = { TRIPLANAR_SCALE: 0.5, SNOW_HEIGHT: 90.0, - NOISE_SCALE: 0.0003, + NOISE_SCALE: 0.0008, DIRT_THRESHOLD: 0.5, LOD_FULL_DETAIL: 100.0, LOD_MEDIUM_DETAIL: 200.0, @@ -253,21 +253,26 @@ export function computeTerrainBaseColor( mul(canyonCliff, dW), ); - // Noise-driven dirt patches on flat areas (subtle) + // Noise-driven dirt patches on flat areas const nDirtFactor = mul( smoothstep( - float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD + 0.05), - float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD + 0.25), + float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD - 0.05), + float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD + 0.15), noiseVal, ), - smoothstep(float(0.1), float(0.01), slope), + smoothstep(float(0.3), float(0.05), slope), ); - c = mix(c, dirtColor, mul(nDirtFactor, float(0.4))); + c = mix(c, dirtColor, nDirtFactor); - // Slopes: dirt at low elevation, cliff at high elevation - const sF = smoothstep(float(0.01), float(0.06), slope); - const hB = smoothstep(float(20.0), float(40.0), height); - c = mix(c, mix(dirtColor, cliffColor, hB), sF); + // Dirt on moderate slopes (bell curve peaking ~0.15–0.6) + const dirtSlopeF = mul( + smoothstep(float(0.15), float(0.4), slope), + smoothstep(float(0.6), float(0.3), slope), + ); + c = mix(c, dirtColor, mul(dirtSlopeF, float(0.6))); + + // Cliff on steep slopes + c = mix(c, cliffColor, smoothstep(float(0.3), float(0.55), slope)); // Sand near water (flat areas, stronger in canyon) const sandBlend = mul( @@ -855,25 +860,29 @@ export function createTerrainMaterial(): THREE.Material & { mul(canyonCliffC, dW), ); - // Noise-driven dirt patches on flat areas (subtle) + // Noise-driven dirt patches on flat areas const dirtPatchFactor = smoothstep( - float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD + 0.05), - float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD + 0.25), + float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD - 0.05), + float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD + 0.15), noiseValue, ); - const flatnessFactor = smoothstep(float(0.1), float(0.01), slope); + const flatnessFactor = smoothstep(float(0.3), float(0.05), slope); + baseColor = mix(baseColor, dirtColor, mul(dirtPatchFactor, flatnessFactor)); + + // Dirt on moderate slopes (bell curve peaking ~0.15–0.6) + const dirtSlopeFactor = mul( + smoothstep(float(0.15), float(0.4), slope), + smoothstep(float(0.6), float(0.3), slope), + ); + baseColor = mix(baseColor, dirtColor, mul(dirtSlopeFactor, float(0.6))); + + // Cliff on steep slopes baseColor = mix( baseColor, - dirtColor, - mul(mul(dirtPatchFactor, flatnessFactor), float(0.4)), + cliffColor, + smoothstep(float(0.3), float(0.55), slope), ); - // Slopes: dirt at low elevation, cliff at high elevation - const slopeFactor = smoothstep(float(0.01), float(0.06), slope); - const heightBlend = smoothstep(float(20.0), float(40.0), height); - const slopeColor = mix(dirtColor, cliffColor, heightBlend); - baseColor = mix(baseColor, slopeColor, slopeFactor); - // Sand near water (keep flat color - no sand texture) const sandBlend = mul( smoothstep(float(18.0), float(12.0), height), From 053d666395121e725cd5dfe216507df03c8253d4 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Mon, 23 Mar 2026 14:04:00 +0800 Subject: [PATCH 34/71] feat: port reference distortCoords coloring and extend fog distance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Noise-distorted cliff/dirt/shoreline boundaries for organic edges - Height-based ground color gradient (warm low → cool high elevation) - Low-frequency ground variation overlay for patchy terrain color - Rock texture variation on cliff faces - Saturation boost (1.35x) for more vibrant terrain colors - Extend fog near/far from 600/800 to 6000/8000 Made-with: Cursor --- .../src/systems/shared/world/FogConfig.ts | 4 +- .../src/systems/shared/world/TerrainShader.ts | 206 ++++++++++++++---- 2 files changed, 169 insertions(+), 41 deletions(-) diff --git a/packages/shared/src/systems/shared/world/FogConfig.ts b/packages/shared/src/systems/shared/world/FogConfig.ts index b31ca34fd..5557b0dff 100644 --- a/packages/shared/src/systems/shared/world/FogConfig.ts +++ b/packages/shared/src/systems/shared/world/FogConfig.ts @@ -48,8 +48,8 @@ import { // Fog distance parameters // smoothstep(NEAR_SQ, FAR_SQ, distSq) gives 0% fog at NEAR, 100% at FAR. // --------------------------------------------------------------------------- -export const FOG_NEAR = 600; -export const FOG_FAR = 800; +export const FOG_NEAR = 500; +export const FOG_FAR = 1000; // Pre-computed squared distances — avoids per-fragment sqrt on the GPU. // Shaders compare dot(toCamera, toCamera) directly against these. diff --git a/packages/shared/src/systems/shared/world/TerrainShader.ts b/packages/shared/src/systems/shared/world/TerrainShader.ts index 96508434a..7574c07c7 100644 --- a/packages/shared/src/systems/shared/world/TerrainShader.ts +++ b/packages/shared/src/systems/shared/world/TerrainShader.ts @@ -37,6 +37,10 @@ import { abs, sin, cos, + pow, + clamp, + max, + floor, Fn, output, type ShaderNode, @@ -54,6 +58,11 @@ export const TERRAIN_SHADER_CONSTANTS = { LOD_FULL_DETAIL: 100.0, LOD_MEDIUM_DETAIL: 200.0, WATER_LEVEL: 5.0, + DISTORT_NOISE_SCALE: 0.067, + VARIATION_NOISE_SCALE: 0.0015, + ROCK_DISTORT_STRENGTH: 0.5, + HEIGHT_DISTORT_STRENGTH: 8.0, + SATURATION_BOOST: 1.35, }; const TERRAIN_TEX_TILE = 0.3; @@ -166,6 +175,8 @@ function createTerrainBiomeTex( // --- Tundra palette: snowy white-blue with frozen grey stone --- const TUNDRA_GRASS = vec3(0.78, 0.82, 0.85); const TUNDRA_GRASS_DARK = vec3(0.65, 0.7, 0.75); +const TUNDRA_GRASS_HIGH = vec3(0.68, 0.72, 0.78); +const TUNDRA_VARIATION = vec3(0.6, 0.64, 0.7); const TUNDRA_DIRT = vec3(0.55, 0.55, 0.58); const TUNDRA_DIRT_DARK = vec3(0.42, 0.42, 0.45); const TUNDRA_CLIFF = vec3(0.5, 0.52, 0.56); @@ -174,6 +185,8 @@ const TUNDRA_CLIFF_DARK = vec3(0.38, 0.4, 0.44); // --- Forest palette: vibrant energetic greens with warm brown earth --- const FOREST_GRASS = vec3(0.3, 0.58, 0.15); const FOREST_GRASS_DARK = vec3(0.18, 0.42, 0.08); +const FOREST_GRASS_HIGH = vec3(0.24, 0.45, 0.18); +const FOREST_VARIATION = vec3(0.15, 0.35, 0.1); const FOREST_DIRT = vec3(0.35, 0.24, 0.12); const FOREST_DIRT_DARK = vec3(0.22, 0.15, 0.08); const FOREST_CLIFF = vec3(0.4, 0.38, 0.32); @@ -182,11 +195,15 @@ const FOREST_CLIFF_DARK = vec3(0.28, 0.26, 0.22); // --- Canyon palette: red-orange sand with deep crimson rock --- const CANYON_SAND = vec3(0.82, 0.52, 0.28); const CANYON_SAND_DARK = vec3(0.72, 0.42, 0.2); +const CANYON_SAND_HIGH = vec3(0.62, 0.38, 0.22); +const CANYON_VARIATION = vec3(0.58, 0.34, 0.16); const CANYON_ROCK = vec3(0.62, 0.28, 0.15); const CANYON_ROCK_DARK = vec3(0.48, 0.2, 0.1); const CANYON_CLIFF = vec3(0.72, 0.38, 0.18); const CANYON_CLIFF_DARK = vec3(0.55, 0.25, 0.12); +const CLIFF_TINT = vec3(0.28, 0.3, 0.36); + // Legacy aliases used by road overlay and other shader sections (default = forest) const GRASS_GREEN = FOREST_GRASS; const GRASS_DARK = FOREST_GRASS_DARK; @@ -201,21 +218,16 @@ const WATER_EDGE = vec3(0.08, 0.06, 0.04); /** * Compute the procedural terrain base color at a world position. - * This is the exact same logic the terrain shader uses (height + slope + noise), - * extracted so the tree shader can call it for ground-blending. - * - * @param height - positionWorld.y - * @param slope - 1 - abs(normalWorld.y) (0 = flat, 1 = vertical) - * @param noiseVal - primary Perlin noise sample (noiseTex @ worldXZ * NOISE_SCALE) - * @param noiseVal2 - derived noise: sin(noiseVal * 6.28) * 0.3 + 0.5 - * @param forestWeight - biome weight for forest [0..1] - * @param canyonWeight - biome weight for canyon [0..1] + * Uses noise-distorted coordinate mapping (ported from reference) for organic + * cliff/dirt/shoreline boundaries instead of clean smoothstep bands. */ export function computeTerrainBaseColor( height: any, slope: any, noiseVal: any, noiseVal2: any, + distortNoise: any, + variationNoise: any, forestWeight?: any, canyonWeight?: any, ) { @@ -223,6 +235,25 @@ export function computeTerrainBaseColor( const dW = canyonWeight ?? float(0.0); const tW = sub(float(1.0), add(fW, dW)); + // Distorted slope: offset normalY by noise for organic cliff edges + const distortedNY = add( + sub(float(1.0), slope), + mul( + sub(distortNoise, float(0.5)), + float(TERRAIN_SHADER_CONSTANTS.ROCK_DISTORT_STRENGTH), + ), + ); + const dSlope = sub(float(1.0), distortedNY); + + // Distorted height for organic sand/shoreline boundaries + const dHeight = add( + height, + mul( + sub(distortNoise, float(0.5)), + float(TERRAIN_SHADER_CONSTANTS.HEIGHT_DISTORT_STRENGTH), + ), + ); + // Biome-blended grass const grassVariation = smoothstep(float(0.4), float(0.6), noiseVal2); const tundraGrass = mix(TUNDRA_GRASS, TUNDRA_GRASS_DARK, grassVariation); @@ -233,6 +264,29 @@ export function computeTerrainBaseColor( mul(canyonGrass, dW), ); + // Height-based ground color gradient (subtle shift at altitude) + const heightGrad = mul( + smoothstep(float(25.0), float(55.0), height), + float(0.3), + ); + const grassHigh = add( + add(mul(TUNDRA_GRASS_HIGH, tW), mul(FOREST_GRASS_HIGH, fW)), + mul(CANYON_SAND_HIGH, dW), + ); + c = mix(c, grassHigh, heightGrad); + + // Low-frequency ground variation overlay (patchy color areas) + const gVar = clamp( + pow(add(variationNoise, float(0.3)), float(5.0)), + float(0.0), + float(1.0), + ); + const varColor = add( + add(mul(TUNDRA_VARIATION, tW), mul(FOREST_VARIATION, fW)), + mul(CANYON_VARIATION, dW), + ); + c = mix(c, varColor, mul(gVar, float(0.25))); + // Biome-blended dirt const dirtVariation = smoothstep(float(0.3), float(0.7), noiseVal2); const tundraDirt = mix(TUNDRA_DIRT, TUNDRA_DIRT_DARK, dirtVariation); @@ -243,62 +297,69 @@ export function computeTerrainBaseColor( mul(canyonDirt, dW), ); - // Per-biome cliff color + // Per-biome cliff color with rock texture variation const cliffVariation = smoothstep(float(0.3), float(0.7), noiseVal); const tundraCliff = mix(TUNDRA_CLIFF, TUNDRA_CLIFF_DARK, cliffVariation); const forestCliff = mix(FOREST_CLIFF, FOREST_CLIFF_DARK, cliffVariation); const canyonCliff = mix(CANYON_CLIFF, CANYON_CLIFF_DARK, cliffVariation); - const cliffColor = add( + let cliffColor: any = add( add(mul(tundraCliff, tW), mul(forestCliff, fW)), mul(canyonCliff, dW), ); + const rockTexVar = mul(pow(distortNoise, float(0.5)), float(0.3)); + cliffColor = mix(cliffColor, CLIFF_TINT, rockTexVar); - // Noise-driven dirt patches on flat areas + // Noise-driven dirt patches on flat areas (using distorted slope) const nDirtFactor = mul( smoothstep( float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD - 0.05), float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD + 0.15), noiseVal, ), - smoothstep(float(0.3), float(0.05), slope), + smoothstep(float(0.3), float(0.05), dSlope), ); c = mix(c, dirtColor, nDirtFactor); - // Dirt on moderate slopes (bell curve peaking ~0.15–0.6) + // Dirt on moderate slopes (bell curve, using distorted slope) const dirtSlopeF = mul( - smoothstep(float(0.15), float(0.4), slope), - smoothstep(float(0.6), float(0.3), slope), + smoothstep(float(0.15), float(0.4), dSlope), + smoothstep(float(0.6), float(0.3), dSlope), ); c = mix(c, dirtColor, mul(dirtSlopeF, float(0.6))); - // Cliff on steep slopes - c = mix(c, cliffColor, smoothstep(float(0.3), float(0.55), slope)); + // Cliff on steep slopes (using distorted slope) + c = mix(c, cliffColor, smoothstep(float(0.3), float(0.55), dSlope)); - // Sand near water (flat areas, stronger in canyon) + // Sand near water (flat areas, stronger in canyon — using distorted height) const sandBlend = mul( - smoothstep(float(18.0), float(12.0), height), + smoothstep(float(18.0), float(12.0), dHeight), smoothstep(float(0.25), float(0.0), slope), ); const sandStrength = mix(float(0.6), float(0.9), dW); c = mix(c, SAND_YELLOW, mul(sandBlend, sandStrength)); - // Shoreline transitions + // Shoreline transitions (using distorted height) c = mix( c, DIRT_DARK, - mul(smoothstep(float(22.0), float(14.0), height), float(0.4)), + mul(smoothstep(float(22.0), float(14.0), dHeight), float(0.4)), ); c = mix( c, MUD_BROWN, - mul(smoothstep(float(15.0), float(10.0), height), float(0.7)), + mul(smoothstep(float(15.0), float(10.0), dHeight), float(0.7)), ); c = mix( c, WATER_EDGE, - mul(smoothstep(float(11.0), float(7.0), height), float(0.9)), + mul(smoothstep(float(11.0), float(7.0), dHeight), float(0.9)), ); + // Saturation boost: pull color away from grey + const luma = dot(c, vec3(0.299, 0.587, 0.114)); + const grey = vec3(luma, luma, luma); + c = mix(grey, c, float(TERRAIN_SHADER_CONSTANTS.SATURATION_BOOST)); + return c; } @@ -735,6 +796,20 @@ export function createTerrainMaterial(): THREE.Material & { float(0.5), ); + // Distortion noise (high-freq) for organic cliff/shoreline edges + const distortNoiseUV = mul( + vec2(worldPos.x, worldPos.z), + float(TERRAIN_SHADER_CONSTANTS.DISTORT_NOISE_SCALE), + ); + const distortNoise = texture(noiseTex, distortNoiseUV).r; + + // Variation noise (low-freq) for large-scale ground color patches + const variationNoiseUV = mul( + vec2(worldPos.x, worldPos.z), + float(TERRAIN_SHADER_CONSTANTS.VARIATION_NOISE_SCALE), + ); + const variationNoise = texture(noiseTex, variationNoiseUV).r; + // Fine detail noise (LOD-gated) const closeEnough = smoothstep(float(12000.0), float(8000.0), distSq); const noiseUV3 = mul(vec2(worldPos.x, worldPos.z), float(0.12)); @@ -822,6 +897,25 @@ export function createTerrainMaterial(): THREE.Material & { const TEX_DARKEN = float(0.65); + // Distorted slope: offset normalY by noise for organic cliff edges + const distortedNY = add( + abs(worldNormal.y), + mul( + sub(distortNoise, float(0.5)), + float(TERRAIN_SHADER_CONSTANTS.ROCK_DISTORT_STRENGTH), + ), + ); + const dSlope = sub(float(1.0), distortedNY); + + // Distorted height for organic sand/shoreline boundaries + const dHeight = add( + height, + mul( + sub(distortNoise, float(0.5)), + float(TERRAIN_SHADER_CONSTANTS.HEIGHT_DISTORT_STRENGTH), + ), + ); + // Biome-blended grass (textured) const grassVar = smoothstep(float(0.4), float(0.6), noiseValue2); const tundraGrassC = mix(sSnowGrass, mul(sSnowGrass, TEX_DARKEN), grassVar); @@ -836,6 +930,29 @@ export function createTerrainMaterial(): THREE.Material & { mul(canyonGrassC, dW), ); + // Height-based ground color gradient (subtle shift at altitude) + const heightGrad = mul( + smoothstep(float(25.0), float(55.0), height), + float(0.3), + ); + const grassHighC = add( + add(mul(TUNDRA_GRASS_HIGH, tW), mul(FOREST_GRASS_HIGH, fW)), + mul(CANYON_SAND_HIGH, dW), + ); + baseColor = mix(baseColor, grassHighC, heightGrad); + + // Low-frequency ground variation overlay (patchy color areas) + const gVariation = clamp( + pow(add(variationNoise, float(0.3)), float(5.0)), + float(0.0), + float(1.0), + ); + const varColorC = add( + add(mul(TUNDRA_VARIATION, tW), mul(FOREST_VARIATION, fW)), + mul(CANYON_VARIATION, dW), + ); + baseColor = mix(baseColor, varColorC, mul(gVariation, float(0.25))); + // Biome-blended dirt const dirtVar = smoothstep(float(0.3), float(0.7), noiseValue2); const tundraDirtC = mix(sSnowDirt, mul(sSnowDirt, TEX_DARKEN), dirtVar); @@ -846,7 +963,7 @@ export function createTerrainMaterial(): THREE.Material & { mul(canyonDirtC, dW), ); - // Per-biome cliff + // Per-biome cliff with rock texture variation const cliffVar = smoothstep(float(0.3), float(0.7), noiseValue); const tundraCliffC = mix(sSnowCliff, mul(sSnowCliff, TEX_DARKEN), cliffVar); const forestCliffC = mix(sCliff, mul(sCliff, TEX_DARKEN), cliffVar); @@ -855,57 +972,68 @@ export function createTerrainMaterial(): THREE.Material & { mul(sDesertCliff, TEX_DARKEN), cliffVar, ); - const cliffColor = add( + let cliffColor: any = add( add(mul(tundraCliffC, tW), mul(forestCliffC, fW)), mul(canyonCliffC, dW), ); + const rockTexVarC = mul(pow(distortNoise, float(0.5)), float(0.3)); + cliffColor = mix(cliffColor, CLIFF_TINT, rockTexVarC); - // Noise-driven dirt patches on flat areas + // Noise-driven dirt patches on flat areas (using distorted slope) const dirtPatchFactor = smoothstep( float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD - 0.05), float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD + 0.15), noiseValue, ); - const flatnessFactor = smoothstep(float(0.3), float(0.05), slope); + const flatnessFactor = smoothstep(float(0.3), float(0.05), dSlope); baseColor = mix(baseColor, dirtColor, mul(dirtPatchFactor, flatnessFactor)); - // Dirt on moderate slopes (bell curve peaking ~0.15–0.6) + // Dirt on moderate slopes (bell curve, using distorted slope) const dirtSlopeFactor = mul( - smoothstep(float(0.15), float(0.4), slope), - smoothstep(float(0.6), float(0.3), slope), + smoothstep(float(0.15), float(0.4), dSlope), + smoothstep(float(0.6), float(0.3), dSlope), ); baseColor = mix(baseColor, dirtColor, mul(dirtSlopeFactor, float(0.6))); - // Cliff on steep slopes + // Cliff on steep slopes (using distorted slope) baseColor = mix( baseColor, cliffColor, - smoothstep(float(0.3), float(0.55), slope), + smoothstep(float(0.3), float(0.55), dSlope), ); - // Sand near water (keep flat color - no sand texture) + // Sand near water (flat areas, stronger in canyon — using distorted height) const sandBlend = mul( - smoothstep(float(18.0), float(12.0), height), + smoothstep(float(18.0), float(12.0), dHeight), smoothstep(float(0.25), float(0.0), slope), ); const sandStrength = mix(float(0.6), float(0.9), dW); baseColor = mix(baseColor, SAND_YELLOW, mul(sandBlend, sandStrength)); - // Shoreline transitions (keep flat colors) + // Shoreline transitions (using distorted height) baseColor = mix( baseColor, DIRT_DARK, - mul(smoothstep(float(22.0), float(14.0), height), float(0.4)), + mul(smoothstep(float(22.0), float(14.0), dHeight), float(0.4)), ); baseColor = mix( baseColor, MUD_BROWN, - mul(smoothstep(float(15.0), float(10.0), height), float(0.7)), + mul(smoothstep(float(15.0), float(10.0), dHeight), float(0.7)), ); baseColor = mix( baseColor, WATER_EDGE, - mul(smoothstep(float(11.0), float(7.0), height), float(0.9)), + mul(smoothstep(float(11.0), float(7.0), dHeight), float(0.9)), + ); + + // Saturation boost: pull color away from grey + const lumaB = dot(baseColor, vec3(0.299, 0.587, 0.114)); + const greyB = vec3(lumaB, lumaB, lumaB); + baseColor = mix( + greyB, + baseColor, + float(TERRAIN_SHADER_CONSTANTS.SATURATION_BOOST), ); // Anti-dithering noise variation (±4% brightness, ±2% color shift) From 8ec835ca64bd44b22ee8043455355f654473fd63 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Mon, 23 Mar 2026 14:43:32 +0800 Subject: [PATCH 35/71] refactor: replace Mountain/Pond landscape features with Lake, enable Forest biome water - Remove Mountain from LandscapeType, rename Pond to Lake - Simplify LandscapeFeatureDef: replace layers/edgeSharpness/layerSlope with lakes/lakesFalloff to match biome-level water algorithm (mapRangeSmooth) - Rename POND_* legacy constants to LAKE_* - Rename ISLAND_POND to ISLAND_LAKE in ProceduralDocks - Enable Forest biome water: rivers=0.11, lakes=0.34, lakesFalloff=0.27 - Lower WATER_THRESHOLD from 16.9 to 16 - Update worker mirrors and TerrainSystem feature mapping Made-with: Cursor --- .../shared/src/constants/GameConstants.ts | 2 +- .../shared/src/runtime/createClientWorld.ts | 2 +- .../systems/shared/world/ProceduralDocks.ts | 32 ++--- .../shared/world/TerrainHeightParams.ts | 131 ++++++------------ .../src/systems/shared/world/TerrainSystem.ts | 13 +- .../shared/src/utils/workers/TerrainWorker.ts | 5 +- 6 files changed, 65 insertions(+), 120 deletions(-) diff --git a/packages/shared/src/constants/GameConstants.ts b/packages/shared/src/constants/GameConstants.ts index b0cad5384..bc6bd7f08 100644 --- a/packages/shared/src/constants/GameConstants.ts +++ b/packages/shared/src/constants/GameConstants.ts @@ -83,7 +83,7 @@ export const TERRAIN_CONSTANTS = { * Terrain below this height is underwater and impassable. * Used by: TerrainSystem, VegetationSystem, DissolveMaterial, RoadNetworkSystem, ResourceSystem */ - WATER_THRESHOLD: 16.9, + WATER_THRESHOLD: 16, /** * Buffer distance above water where vegetation shouldn't spawn. diff --git a/packages/shared/src/runtime/createClientWorld.ts b/packages/shared/src/runtime/createClientWorld.ts index b2727e0dd..ef43617e4 100644 --- a/packages/shared/src/runtime/createClientWorld.ts +++ b/packages/shared/src/runtime/createClientWorld.ts @@ -359,7 +359,7 @@ export function createClientWorld() { // ============================================================================ // DOCK SYSTEM // ============================================================================ - // Procedural docks for ponds and lakes + // Procedural docks for lakes // TEMPORARILY DISABLED // world.register("docks", ProceduralDocks); diff --git a/packages/shared/src/systems/shared/world/ProceduralDocks.ts b/packages/shared/src/systems/shared/world/ProceduralDocks.ts index 9130fd29b..9dc600299 100644 --- a/packages/shared/src/systems/shared/world/ProceduralDocks.ts +++ b/packages/shared/src/systems/shared/world/ProceduralDocks.ts @@ -1,10 +1,10 @@ /** * ProceduralDocks.ts - Dock Generation System * - * System for generating procedural docks on water bodies (ponds, lakes). + * System for generating procedural docks on water bodies (lakes). * * Architecture: - * - Detects pond shorelines using terrain height sampling + * - Detects lake shorelines using terrain height sampling * - Scores potential dock placement locations * - Generates dock geometry using DockGenerator * - Integrates with collision system for walkable surfaces @@ -35,10 +35,10 @@ const SHORELINE_SAMPLES = 64; const MAX_SLOPE_FOR_DOCK = 0.4; const MIN_WATER_DEPTH = 1.5; -/** Known pond on the island */ -const ISLAND_POND: WaterBody = { - id: "island-pond", - type: "pond", +/** Known lake on the island */ +const ISLAND_LAKE: WaterBody = { + id: "island-lake", + type: "lake", center: { x: -80, z: 60 }, radius: 50, }; @@ -51,7 +51,7 @@ function findShorelinePoints( ): ShorelinePoint[] { const shorelinePoints: ShorelinePoint[] = []; - // Sample points in concentric rings around the pond center + // Sample points in concentric rings around the lake center // We're looking for where terrain crosses from below to above water threshold const { center, radius } = waterBody; @@ -62,7 +62,7 @@ function findShorelinePoints( const dirZ = Math.sin(angle); // Binary search along the radial line to find shoreline - let minDist = radius * 0.3; // Start from inner pond + let minDist = radius * 0.3; // Start from inner lake let maxDist = radius * 1.5; // Extend beyond nominal radius let foundShoreline = false; @@ -292,8 +292,8 @@ export class ProceduralDocks extends System { private isTerrainReady(): boolean { if (!this.terrainSystem) return false; - // Test multiple points around the pond to ensure terrain is fully loaded - const { center, radius } = ISLAND_POND; + // Test multiple points around the lake to ensure terrain is fully loaded + const { center, radius } = ISLAND_LAKE; const testPoints = [ { x: center.x, z: center.z }, // Center { x: center.x + radius * 0.5, z: center.z }, // East @@ -314,7 +314,7 @@ export class ProceduralDocks extends System { } /** - * Generate docks for the island pond + * Generate docks for the island lake * Called automatically when terrain is ready */ generateDocks(seed: string = "island-docks"): void { @@ -330,10 +330,10 @@ export class ProceduralDocks extends System { return; } - console.log("[ProceduralDocks] Generating docks for island pond..."); + console.log("[ProceduralDocks] Generating docks for island lake..."); - // Generate dock for the main island pond - const dock = this.generateDockForWaterBody(ISLAND_POND, seed); + // Generate dock for the main island lake + const dock = this.generateDockForWaterBody(ISLAND_LAKE, seed); if (dock) { console.log( @@ -431,7 +431,7 @@ export class ProceduralDocks extends System { // Generate the dock const recipe: DockRecipe = { ...DEFAULT_DOCK_PARAMS, - label: "Pond Dock", + label: "Lake Dock", }; const dock = this.generator.generate(recipe, selected.point, { @@ -558,4 +558,4 @@ export class ProceduralDocks extends System { } export type { ShorelinePoint, WaterBody, ItemCollisionData }; -export { ISLAND_POND, WATER_THRESHOLD, WATER_LEVEL }; +export { ISLAND_LAKE, WATER_THRESHOLD, WATER_LEVEL }; diff --git a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts index 1167abb8b..39b3cba91 100644 --- a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts +++ b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts @@ -126,10 +126,10 @@ export const FOREST_CONFIG: BiomeTerrainConfig = { altitudeVariation: 1.4, erosion: 0.6, erosionSoftness: 0.3, - rivers: 0, + rivers: 0.11, riverWidth: 0, - lakes: 0, - lakesFalloff: 0, + lakes: 0.34, + lakesFalloff: 0.27, heightScale: 2.8, powerCurve: 1.0, smoothLowerPlanes: 0, @@ -236,12 +236,11 @@ export const BIOME_CONFIG = { } as const; // --------------------------------------------------------------------------- -// Landscape features — mountains & ponds, independent of biomes +// Landscape features — positioned lakes, independent of biomes // --------------------------------------------------------------------------- export enum LandscapeType { - Mountain = "mountain", - Pond = "pond", + Lake = "lake", } export interface LandscapeFeatureDef { @@ -250,18 +249,13 @@ export interface LandscapeFeatureDef { z: number; radius: number; strength: number; - layers: number; shapePower: number; - edgeSharpness: number; - layerSlope: number; noiseScale: number; noiseAmount: number; + lakes: number; + lakesFalloff: number; } -/** - * Predefined landscape features — currently disabled while per-biome terrain - * is being tuned. Re-enable by adding entries back to this array. - */ export const LANDSCAPE_FEATURES: LandscapeFeatureDef[] = []; // --------------------------------------------------------------------------- @@ -294,14 +288,14 @@ export const COAST_SMALL = { // Legacy exports — kept for backward compatibility with TerrainWorker.ts // --------------------------------------------------------------------------- -/** @deprecated Landscape features replace hardcoded pond */ -export const POND_RADIUS = 50; -/** @deprecated Landscape features replace hardcoded pond */ -export const POND_DEPTH = 0.55; -/** @deprecated Landscape features replace hardcoded pond */ -export const POND_CENTER_X = -80; -/** @deprecated Landscape features replace hardcoded pond */ -export const POND_CENTER_Z = 60; +/** @deprecated Landscape features replace hardcoded lake */ +export const LAKE_RADIUS = 50; +/** @deprecated Landscape features replace hardcoded lake */ +export const LAKE_DEPTH = 0.55; +/** @deprecated Landscape features replace hardcoded lake */ +export const LAKE_CENTER_X = -80; +/** @deprecated Landscape features replace hardcoded lake */ +export const LAKE_CENTER_Z = 60; // ═══════════════════════════════════════════════════════════════════════════ // SINGLE SOURCE OF TRUTH — pure TypeScript functions @@ -343,44 +337,23 @@ export function applyLandscapeFeaturesPure( const t = Math.max(0, 1 - dist / feat.radius); const envelope = Math.pow(t, feat.shapePower); - const warpScale = feat.noiseScale * 0.4; - const warpStr = feat.radius * feat.noiseAmount * 0.3; - const warpX = - noise.simplex2D(worldX * warpScale, worldZ * warpScale) * warpStr; - const warpZ = - noise.simplex2D(worldX * warpScale + 31.7, worldZ * warpScale + 47.3) * - warpStr; - - const sx = (worldX + warpX) * feat.noiseScale; - const sz = (worldZ + warpZ) * feat.noiseScale; - - const ridgeN = noise.ridgeNoise2D(sx, sz); - const detailN = noise.fractal2D(sx * 2.3, sz * 2.3, 3, 0.5, 2.0); - const mNoise = (ridgeN * 0.6 + detailN * 0.4 + 1) * 0.5; - - let rawH = envelope * (1 - feat.noiseAmount + feat.noiseAmount * mNoise); - rawH = Math.max(0, Math.min(1, rawH)); - - let influence: number; - if (feat.layers >= 1) { - const stepped = Math.floor(rawH * feat.layers) / feat.layers; - const nextStep = Math.min(1, stepped + 1 / feat.layers); - const frac = (rawH - stepped) * feat.layers; - const blendStart = 1 - feat.edgeSharpness; - const edgeBlend = - frac <= blendStart ? 0 : (frac - blendStart) / (1 - blendStart); - const flatStep = stepped + edgeBlend * (nextStep - stepped); - const slopedStep = stepped + frac * (nextStep - stepped); - influence = flatStep + feat.layerSlope * (slopedStep - flatStep); - } else { - influence = rawH; - } + const sx = worldX * feat.noiseScale; + const sz = worldZ * feat.noiseScale; + const n = noise.fractal2D(sx, sz, 3, 0.5, 2.0); - if (feat.type === LandscapeType.Pond) { - height -= influence * feat.strength; - } else { - height += influence * feat.strength; - } + const localTerrain = -( + envelope * (1 - feat.noiseAmount) + + n * feat.noiseAmount + ); + const water = mapRangeSmooth( + localTerrain, + -(1 - feat.lakes), + -(1 - feat.lakes) + feat.lakesFalloff, + 1, + 0, + ); + + height -= water * envelope * feat.strength; } return height; } @@ -717,40 +690,14 @@ export function buildApplyLandscapeFeaturesJS(): string { var t = Math.max(0, 1 - dist / feat.radius); var envelope = Math.pow(t, feat.shapePower); - var warpScale = feat.noiseScale * 0.4; - var warpStr = feat.radius * feat.noiseAmount * 0.3; - var warpX = noise.simplex2D(worldX * warpScale, worldZ * warpScale) * warpStr; - var warpZ = noise.simplex2D(worldX * warpScale + 31.7, worldZ * warpScale + 47.3) * warpStr; - - var sx = (worldX + warpX) * feat.noiseScale; - var sz = (worldZ + warpZ) * feat.noiseScale; - - var ridgeN = noise.ridgeNoise2D(sx, sz); - var detailN = noise.fractal2D(sx * 2.3, sz * 2.3, 3, 0.5, 2.0); - var mNoise = (ridgeN * 0.6 + detailN * 0.4 + 1) * 0.5; - - var rawH = envelope * (1 - feat.noiseAmount + feat.noiseAmount * mNoise); - rawH = Math.max(0, Math.min(1, rawH)); - - var influence; - if (feat.layers >= 1) { - var stepped = Math.floor(rawH * feat.layers) / feat.layers; - var nextStep = Math.min(1, stepped + 1 / feat.layers); - var frac = (rawH - stepped) * feat.layers; - var blendStart = 1 - feat.edgeSharpness; - var edgeBlend = frac <= blendStart ? 0 : (frac - blendStart) / (1 - blendStart); - var flatStep = stepped + edgeBlend * (nextStep - stepped); - var slopedStep = stepped + frac * (nextStep - stepped); - influence = flatStep + feat.layerSlope * (slopedStep - flatStep); - } else { - influence = rawH; - } - - if (feat.type === '${LandscapeType.Pond}') { - height -= influence * feat.strength; - } else { - height += influence * feat.strength; - } + var sx = worldX * feat.noiseScale; + var sz = worldZ * feat.noiseScale; + var n = noise.fractal2D(sx, sz, 3, 0.5, 2.0); + + var localTerrain = -(envelope * (1 - feat.noiseAmount) + n * feat.noiseAmount); + var water = _mapRangeSmooth(localTerrain, -(1 - feat.lakes), -(1 - feat.lakes) + feat.lakesFalloff, 1, 0); + + height -= water * envelope * feat.strength; } return height; }`; diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts index 3b623659f..195ef0fd4 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -18,10 +18,10 @@ import { type TerrainWorkerOutput, } from "../../../utils/workers"; import { - POND_RADIUS, - POND_DEPTH, - POND_CENTER_X, - POND_CENTER_Z, + LAKE_RADIUS, + LAKE_DEPTH, + LAKE_CENTER_X, + LAKE_CENTER_Z, LANDSCAPE_FEATURES, ISLAND_RADIUS, computeBaseHeight, @@ -784,12 +784,11 @@ export class TerrainSystem extends System { z: f.z, radius: f.radius, strength: f.strength, - layers: f.layers, shapePower: f.shapePower, - edgeSharpness: f.edgeSharpness, - layerSlope: f.layerSlope, noiseScale: f.noiseScale, noiseAmount: f.noiseAmount, + lakes: f.lakes, + lakesFalloff: f.lakesFalloff, })), }; diff --git a/packages/shared/src/utils/workers/TerrainWorker.ts b/packages/shared/src/utils/workers/TerrainWorker.ts index 93cae6aa2..9458bbd05 100644 --- a/packages/shared/src/utils/workers/TerrainWorker.ts +++ b/packages/shared/src/utils/workers/TerrainWorker.ts @@ -51,12 +51,11 @@ export interface TerrainWorkerConfig { z: number; radius: number; strength: number; - layers: number; shapePower: number; - edgeSharpness: number; - layerSlope: number; noiseScale: number; noiseAmount: number; + lakes: number; + lakesFalloff: number; }>; } From 594397088b641363d1ef4a0c62e726073a89808e Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Mon, 23 Mar 2026 16:12:13 +0800 Subject: [PATCH 36/71] feat: overhaul water shader and fix sky dome scaling Water: switch to MeshStandardNodeMaterial with tree-style outputNode pattern, use applySunShade + nightDim from LightingConfig for proper day/night behavior, rewrite normal map generation with FBM value noise and finite differences for organic ripple patterns (512px), darken water color via COLOR_DARKEN multiplier, restore foam and Gerstner wave vertex displacement. Sky: unify celestial body radii around SKY_DOME_RADIUS constant. Made-with: Cursor --- .../src/systems/shared/world/SkySystem.ts | 37 +- .../src/systems/shared/world/WaterSystem.ts | 1042 ++++++++--------- 2 files changed, 511 insertions(+), 568 deletions(-) diff --git a/packages/shared/src/systems/shared/world/SkySystem.ts b/packages/shared/src/systems/shared/world/SkySystem.ts index 4290f81c4..8362cac84 100644 --- a/packages/shared/src/systems/shared/world/SkySystem.ts +++ b/packages/shared/src/systems/shared/world/SkySystem.ts @@ -40,6 +40,8 @@ import type { World, WorldOptions } from "../../../types"; import { fogRenderTarget } from "./FogConfig"; import { DAY_CYCLE, SUN_LIGHT } from "./LightingConfig"; +const SKY_DOME_RADIUS = 5000; + // ----------------------------- // Utility: Procedural noise textures (avoids external deps) // ----------------------------- @@ -317,9 +319,7 @@ export class SkySystem extends System { private createSun(): void { if (!this.group) return; - // Sun disc geometry - bright core (scaled for 600 far plane) - // Sun disc sized to fit within 600 far plane - const sunGeom = new THREE.CircleGeometry(15, 32); + const sunGeom = new THREE.CircleGeometry(SKY_DOME_RADIUS * 0.03, 32); // TSL uniform for opacity control const uOpacity = uniform(float(1.0)); @@ -365,9 +365,7 @@ export class SkySystem extends System { this.sun.layers.set(1); // Main camera only, not minimap this.group.add(this.sun); - // Inner glow - medium sized, intense (scaled for 600 far plane) - // Inner glow sized to fit within 600 far plane - const innerGlowGeom = new THREE.CircleGeometry(50, 32); + const innerGlowGeom = new THREE.CircleGeometry(SKY_DOME_RADIUS * 0.1, 32); const innerGlowColorNode = Fn(() => { const uvCoord = uv(); const center = vec3(0.5, 0.5, 0.0); @@ -404,9 +402,7 @@ export class SkySystem extends System { (this.group as THREE.Group & { sunInnerGlow?: THREE.Mesh }).sunInnerGlow = innerGlow; - // Outer glow - large, soft halo for atmosphere effect (scaled for 600 far plane) - // Outer glow sized to fit within 600 far plane - const outerGlowGeom = new THREE.CircleGeometry(100, 32); + const outerGlowGeom = new THREE.CircleGeometry(SKY_DOME_RADIUS * 0.2, 32); const outerGlowColorNode = Fn(() => { const uvCoord = uv(); const center = vec3(0.5, 0.5, 0.0); @@ -451,8 +447,10 @@ export class SkySystem extends System { private createMoon(): void { if (!this.group) return; - // Moon sized for 600 far plane - const moonGeom = new THREE.PlaneGeometry(35, 35); + const moonGeom = new THREE.PlaneGeometry( + SKY_DOME_RADIUS * 0.07, + SKY_DOME_RADIUS * 0.07, + ); // TSL uniform for opacity control const uOpacity = uniform(float(1.0)); @@ -486,8 +484,7 @@ export class SkySystem extends System { this.moon.layers.set(1); // Main camera only, not minimap this.group.add(this.moon); - // Moon glow effect - soft halo around moon (scaled for 600 far plane) - const moonGlowGeom = new THREE.CircleGeometry(50, 32); + const moonGlowGeom = new THREE.CircleGeometry(SKY_DOME_RADIUS * 0.1, 32); const moonGlowColorNode = Fn(() => { const uvCoord = uv(); @@ -531,9 +528,7 @@ export class SkySystem extends System { private createSkyDome(): void { if (!this.group) return; - // High segment count prevents color banding. Large radius ensures the - // water reflection camera (mirrored below water level) stays inside the dome. - const skyGeom = new THREE.SphereGeometry(5000, 128, 64); + const skyGeom = new THREE.SphereGeometry(SKY_DOME_RADIUS, 128, 64); // Create TSL uniforms const uTime = uniform(float(0)); @@ -787,7 +782,7 @@ export class SkySystem extends System { this.fogScene = new THREE.Scene(); this.fogCamera = this.world.camera.clone() as THREE.PerspectiveCamera; - const fogSkyGeom = new THREE.SphereGeometry(500, 64, 32); + const fogSkyGeom = new THREE.SphereGeometry(SKY_DOME_RADIUS, 64, 32); const uSunPosition = uniform(vec3(0, 1, 0)); const uDayCycleProgress = uniform(float(0)); @@ -900,9 +895,7 @@ export class SkySystem extends System { private createClouds(): void { if (!this.group) return; - // Cloud distances scaled for 600 far plane - // Sky must fit inside camera far plane (600) - use 500 to leave some margin - const SKY_RADIUS = 500; + const SKY_RADIUS = SKY_DOME_RADIUS; const BASE_SIZE = 160; // Create a group to hold all cloud meshes (for rotation) @@ -1118,9 +1111,7 @@ export class SkySystem extends System { this.skyUniforms.sunPosition.value.copy(this._sunDir); this.skyUniforms.dayCycleProgress.value = dayPhase; - // Position sun/moon (scaled for 600 far plane) - // Position celestial bodies inside sky dome (must fit in camera far plane) - const radius = 450; + const radius = SKY_DOME_RADIUS * 0.9; if (this.sun) { this.sun.position.set( this._sunDir.x * radius, diff --git a/packages/shared/src/systems/shared/world/WaterSystem.ts b/packages/shared/src/systems/shared/world/WaterSystem.ts index 209923683..8f0b56dfe 100644 --- a/packages/shared/src/systems/shared/world/WaterSystem.ts +++ b/packages/shared/src/systems/shared/world/WaterSystem.ts @@ -35,7 +35,6 @@ import THREE, { Fn, output, attribute, - exp, length, viewportDepthTexture, linearDepth, @@ -48,6 +47,7 @@ import type { World } from "../../../types"; import type { TerrainTile } from "../../../types/world/terrain"; import type { Wind } from "./Wind"; import { FOG_NEAR_SQ, FOG_FAR_SQ, fogRenderTarget } from "./FogConfig"; +import { SUN_SHADE, NIGHT, applySunShade } from "./LightingConfig"; // ============================================================================ // CONFIGURATION @@ -57,65 +57,51 @@ const GRAVITY = 9.81; const PI = Math.PI; const TWO_PI = PI * 2; -// ---- Water visual tuning ---- +// ---- Water visual tuning (portfolio-style) ---- const WATER = { - // Fresnel & specular - F0: 0.02, // Fresnel reflectance at normal incidence - ROUGHNESS: 0.02, // GGX specular roughness (lower = sharper sun highlights) - SPECULAR_MULTIPLIER: 2.5, // Sun specular highlight intensity - SUN_COLOR: { r: 1.0, g: 0.98, b: 0.92 }, // Sunlight tint on specular - - // Reflection - REFLECTION_INTENSITY: 0.4, // Planar reflection strength (fresnel-weighted) - - // Depth-based colour: pow(saturate(1 - depth/scale), falloff) - SHALLOW_COLOR: { r: 0.1, g: 0.5, b: 0.36 }, // Colour near shore (turquoise-green) - DEEP_COLOR: { r: 0.08, g: 0.3, b: 0.44 }, // Colour far from shore (ocean blue) - COLOR_DEPTH_SCALE: 50, // Depth scale for colour gradient (metres) - COLOR_DEPTH_FALLOFF: 3, // Power exponent for colour depth curve - COLOR_DIST_FADE: 200, // Camera distance where depth colour effect fades out (metres) - // Ocean/general absorption (still used by ocean material and depth clamping) - ABSORPTION: { r: 0.45, g: 0.09, b: 0.06 }, // Per-channel absorption rate (ocean) - MAX_DEPTH: 30, // Clamp depth for absorption calc (metres) - - // Subsurface scattering - SSS_INTENSITY: 0.35, // SSS glow strength - SSS_POWER: 3, // SSS falloff exponent - SSS_RANGE_FAR: 8, // SSS starts fading at this distance (metres) - SSS_RANGE_NEAR: 0.5, // SSS fully active below this distance - SSS_COLOR: { r: 0.1, g: 0.35, b: 0.3 }, // SSS tint + REFLECTION_INTENSITY: 0.4, + WAVE_DAMP_DISTANCE: 6, + MAX_DEPTH: 30, + + // Fresnel (Schlick, rf0 = 0.3 matches portfolio) + RF0: 0.3, + + // Phong sun lighting + SPECULAR_SHININESS: 100, + SPECULAR_STRENGTH: 5.0, + DIFFUSE_STRENGTH: 0.5, + + // Depth-based opacity: op = 1 - pow(sat(1 - depth/scale), falloff) + OP_DEPTH_SCALE: 15, + OP_DEPTH_FALLOFF: 3, + + // Depth-based colour gradient + COLOR_DEPTH_SCALE: 50, + COLOR_DEPTH_FALLOFF: 3, + COLOR_DIST_FADE: 200, + + // Cosine gradient colour parameters (portfolio values) + COS_PHASES: [0.28, 0.5, 0.07] as const, + COS_AMPLITUDES: [4.02, 0.34, 0.65] as const, + COS_FREQUENCIES: [0.0, 0.48, 0.08] as const, + COS_OFFSETS: [0.0, 0.25, 0.0] as const, + + // Multiplier applied to the cosine gradient to darken overall water color + COLOR_DARKEN: 0.18, + + // Normal noise strength (xz multiplier for surface normal) + NORMAL_STRENGTH: 1.5, // Foam - FOAM_SHORE_DISTANCE: 2.5, // Shore foam appears within this distance (metres) - FOAM_CREST_MIN: 0.15, // Wave crest foam threshold (low) - FOAM_CREST_MAX: 0.4, // Wave crest foam threshold (high) - FOAM_CREST_MULTIPLIER: 0.6, // Crest foam intensity relative to shore foam - FOAM_COLOR: { r: 0.92, g: 0.94, b: 0.96 }, // Foam colour (near-white) - FOAM_MAX_OPACITY: 0.85, // Maximum foam blend factor - FOAM_SCROLL_X: 0.02, // Foam texture scroll speed X - FOAM_SCROLL_Y: 0.015, // Foam texture scroll speed Y - FOAM_SCALE: 0.1, // Foam texture UV scale - - // Detail normals blending - DETAIL_NORMAL_STRENGTH: 0.5, // Detail normal contribution to final normal - - // Opacity: op = 1 - pow(saturate(1 - depth/scale), falloff) - EDGE_FADE_DISTANCE: 0.4, // Shoreline edge transparency ramp (metres) - OP_DEPTH_SCALE: 15.0, // Depth scale for opacity curve (metres) - OP_DEPTH_FALLOFF: 3.0, // Power exponent for opacity depth curve - FRESNEL_OPACITY_MIN: 0.85, // Fresnel opacity at normal incidence - FRESNEL_OPACITY_MAX: 1.0, // Fresnel opacity at glancing angles - FRESNEL_OPACITY_POWER: 3, // Fresnel opacity falloff exponent - - // Vertex wave damping - WAVE_DAMP_DISTANCE: 6, // Waves fully active beyond this shore distance (metres) - - // Normal map scrolling - NORMAL_UV1_SPEED: { x: 0.005, y: 0.003 }, - NORMAL_UV1_SCALE: 0.02, - NORMAL_UV2_SPEED: { x: -0.004, y: 0.006 }, - NORMAL_UV2_SCALE: 0.035, - NORMAL_BLEND_STRENGTH: 0.4, // Normal map XZ strength (Y stays 1.0) + FOAM_SHORE_DISTANCE: 2.5, + FOAM_CREST_MIN: 0.15, + FOAM_CREST_MAX: 0.4, + FOAM_CREST_MULTIPLIER: 0.6, + FOAM_COLOR: { r: 0.92, g: 0.94, b: 0.96 }, + FOAM_MAX_OPACITY: 0.85, + FOAM_SCROLL_X: 0.02, + FOAM_SCROLL_Y: 0.015, + FOAM_SCALE: 0.1, }; // LOD configuration for water mesh resolution @@ -162,13 +148,6 @@ const WAVES: WaveParams[] = [ }; }); -// Reduced from 4 to 2 layers for better performance (2 texture samples instead of 4) -const NORMAL_LAYERS: [number, number, number][] = [ - [0.015, 0.005, 0.003], - [0.04, -0.008, 0.005], -]; -const NORMAL_WEIGHTS = [0.6, 0.4]; - // ============================================================================ // TYPES // ============================================================================ @@ -181,6 +160,8 @@ export type WaterUniforms = { sunDirection: UniformVec3; windStrength: UniformFloat; reflectionIntensity: UniformFloat; + dayIntensity: UniformFloat; + sunIntensity: UniformFloat; }; /** @@ -197,12 +178,11 @@ export type WaterBodyType = "lake" | "ocean"; export class WaterSystem { private world: World; private waterTime = 0; - private lakeMaterial?: MeshStandardNodeMaterial; // Lake/pond water with reflections - private oceanMaterial?: MeshStandardNodeMaterial; // Ocean water without reflections + private lakeMaterial?: MeshStandardNodeMaterial; + private oceanMaterial?: MeshStandardNodeMaterial; private uniforms: WaterUniforms | null = null; private oceanUniforms: WaterUniforms | null = null; - private normalTex1?: THREE.Texture; - private normalTex2?: THREE.Texture; + private normalTex?: THREE.Texture; private foamTex?: THREE.Texture; // TSL planar reflection (Three.js ReflectorNode handles camera, RT, clipping) @@ -326,10 +306,7 @@ export class WaterSystem { async init(): Promise { if (this.world.isServer) return; - // Create procedural textures with yielding (prevents main thread blocking) - // These are CPU-intensive nested loops that can block for 50-100ms without yielding - this.normalTex1 = await this.createNormalMap(256, 1.0, 42); - this.normalTex2 = await this.createNormalMap(128, 2.0, 137); + this.normalTex = await this.createNormalMap(512, 1.0, 42); this.foamTex = await this.createFoamTexture(128); // TSL reflector: handles render target, camera mirroring, oblique clipping @@ -364,65 +341,105 @@ export class WaterSystem { * Create a procedural normal map texture. * Processes rows in batches, yielding between batches to prevent main thread blocking. */ + /** + * Generate a seamless water normal map using FBM value noise + finite + * differences. Produces organic ripple patterns similar to the portfolio's + * waterNormal.png (510×511 tangent-space normal map). + * + * Target channel statistics (matching portfolio texture): + * R: mean ~128, range ~48–206 (X derivative) + * G: mean ~128, range ~52–193 (Y derivative) + * B: mean ~250, range ~226–254 (up component) + */ private async createNormalMap( size: number, - freq: number, + _freq: number, seed: number, ): Promise { - const data = new Uint8Array(size * size * 4); const TAU = Math.PI * 2; - const ROW_BATCH_SIZE = 32; // Process 32 rows per batch + const ROW_BATCH = 32; + + // ---- Integer hash (Murmur-ish, deterministic) ---- + const hash = (x: number, y: number, s: number) => { + let h = (x * 374761393 + y * 668265263 + s * 1274126177) | 0; + h = Math.imul(h ^ (h >>> 13), 1103515245); + h = Math.imul(h ^ (h >>> 16), 2654435769); + return ((h ^ (h >>> 13)) >>> 0) / 0xffffffff; + }; - for (let yBatch = 0; yBatch < size; yBatch += ROW_BATCH_SIZE) { - const yEnd = Math.min(yBatch + ROW_BATCH_SIZE, size); + // ---- Smooth value noise (quintic interp for C2 continuity) ---- + const vnoise = (px: number, py: number, s: number) => { + const ix = Math.floor(px), + iy = Math.floor(py); + const fx = px - ix, + fy = py - iy; + const u = fx * fx * fx * (fx * (fx * 6 - 15) + 10); + const v = fy * fy * fy * (fy * (fy * 6 - 15) + 10); + const a = hash(ix, iy, s); + const b = hash(ix + 1, iy, s); + const c = hash(ix, iy + 1, s); + const d = hash(ix + 1, iy + 1, s); + return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v; + }; + // ---- FBM on torus (seamless tiling via 4D embedding) ---- + const fbm = (nx: number, ny: number) => { + const cx = Math.cos(nx * TAU), + sx = Math.sin(nx * TAU); + const cy = Math.cos(ny * TAU), + sy = Math.sin(ny * TAU); + let val = 0, + amp = 1, + freq = 2; + for (let o = 0; o < 6; o++) { + const px = cx * freq + sy * freq * 0.618; + const py = sx * freq + cy * freq * 0.618; + val += vnoise(px, py, seed + o * 137) * amp; + amp *= 0.5; + freq *= 2.0; + } + return val; + }; + + // ---- Build seamless height field ---- + const heights = new Float32Array(size * size); + for (let yBatch = 0; yBatch < size; yBatch += ROW_BATCH) { + const yEnd = Math.min(yBatch + ROW_BATCH, size); for (let y = yBatch; y < yEnd; y++) { for (let x = 0; x < size; x++) { - const nx = x / size, - ny = y / size; - const cx = Math.cos(nx * TAU), - sx = Math.sin(nx * TAU); - const cy = Math.cos(ny * TAU), - sy = Math.sin(ny * TAU); - - let dx = 0, - dy = 0; - for (let oct = 0; oct < 4; oct++) { - const f = freq * (1 << oct); - const amp = 0.4 / (1 << oct); - const s = seed + oct * 100; - const x4 = cx * f, - y4 = sx * f, - z4 = cy * f * 0.618, - w4 = sy * f * 0.618; - dx += - (Math.sin(x4 + s + Math.cos(z4 * 0.7 + s * 0.3)) * 0.3 + - Math.cos(y4 + s * 0.5 + Math.sin(w4 * 1.3 + s * 0.7)) * 0.3 + - Math.sin(z4 * 1.1 + x4 * 0.8 + s * 0.2) * 0.2 + - Math.cos(w4 * 0.9 + y4 * 0.6 + s * 0.9) * 0.2) * - amp; - dy += - (Math.sin(x4 + s + 50 + Math.cos(z4 * 0.7 + s * 0.3 + 50)) * 0.3 + - Math.cos( - y4 + s * 0.5 + 50 + Math.sin(w4 * 1.3 + s * 0.7 + 50), - ) * - 0.3 + - Math.sin(z4 * 1.1 + x4 * 0.8 + s * 0.2 + 50) * 0.2 + - Math.cos(w4 * 0.9 + y4 * 0.6 + s * 0.9 + 50) * 0.2) * - amp; - } - - const idx = (y * size + x) * 4; - data[idx] = Math.floor(Math.max(0, Math.min(255, 128 + dx * 80))); - data[idx + 1] = Math.floor(Math.max(0, Math.min(255, 128 + dy * 80))); - data[idx + 2] = 220; - data[idx + 3] = 255; + heights[y * size + x] = fbm(x / size, y / size); } } - - // Yield to main thread between batches if (yEnd < size) { - await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((r) => setTimeout(r, 0)); + } + } + + // ---- Normal map via central finite differences ---- + const data = new Uint8Array(size * size * 4); + const strength = 6.0; + + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const xp = (x + 1) % size, + xm = (x - 1 + size) % size; + const yp = (y + 1) % size, + ym = (y - 1 + size) % size; + const dx = (heights[y * size + xp] - heights[y * size + xm]) * strength; + const dy = (heights[yp * size + x] - heights[ym * size + x]) * strength; + const len = Math.sqrt(dx * dx + dy * dy + 1); + + const idx = (y * size + x) * 4; + data[idx] = Math.max( + 0, + Math.min(255, ((-dx / len) * 127.5 + 127.5) | 0), + ); + data[idx + 1] = Math.max( + 0, + Math.min(255, ((-dy / len) * 127.5 + 127.5) | 0), + ); + data[idx + 2] = Math.max(0, Math.min(255, ((1 / len) * 255) | 0)); + data[idx + 3] = 255; } } @@ -435,15 +452,10 @@ export class WaterSystem { return tex; } - /** - * Create a procedural foam texture. - * Processes rows in batches, yielding between batches to prevent main thread blocking. - */ private async createFoamTexture(size: number): Promise { const data = new Uint8Array(size * size * 4); - const ROW_BATCH_SIZE = 16; // Process 16 rows per batch (foam has more computation per pixel) + const ROW_BATCH_SIZE = 16; - // Pre-generate cells (small operation, no yield needed) const cells: { x: number; y: number }[] = []; let s = 12345; for (let i = 0; i < 32; i++) { @@ -490,7 +502,6 @@ export class WaterSystem { } } - // Yield to main thread between batches if (yEnd < size) { await new Promise((resolve) => setTimeout(resolve, 0)); } @@ -500,6 +511,7 @@ export class WaterSystem { tex.wrapS = tex.wrapT = THREE.RepeatWrapping; tex.magFilter = THREE.LinearFilter; tex.minFilter = THREE.LinearMipmapLinearFilter; + tex.colorSpace = THREE.LinearSRGBColorSpace; tex.generateMipmaps = true; tex.needsUpdate = true; return tex; @@ -510,12 +522,16 @@ export class WaterSystem { // ========================================================================== /** - * Create lake/pond water material with planar reflections + * Create lake water material — follows the EXACT same pattern as the tree shader: + * MeshStandardNodeMaterial + outputNode override + applySunShade + nightDim. */ private createLakeMaterial(): MeshStandardNodeMaterial { const uTime = uniform(float(0)); const uSunDir = uniform(vec3(0.4, 0.8, 0.4)); const uWind = uniform(float(1.0)); + const uDayIntensity = uniform(float(1.0)); + const uSunIntensity = uniform(float(1.0)); + const uShadeColor = uniform(new THREE.Color(...SUN_SHADE.TINT_COLOR)); const fogTexNode = texture(fogRenderTarget.texture, screenUV); const uReflectionIntensity = uniform( float(this._reflectionsEnabled ? WATER.REFLECTION_INTENSITY : 0.0), @@ -526,29 +542,24 @@ export class WaterSystem { sunDirection: uSunDir as unknown as UniformVec3, windStrength: uWind, reflectionIntensity: uReflectionIntensity, + dayIntensity: uDayIntensity, + sunIntensity: uSunIntensity, }; const material = new MeshStandardNodeMaterial(); material.transparent = true; material.depthWrite = true; material.side = THREE.DoubleSide; - material.roughness = WATER.ROUGHNESS; + material.roughness = 0.8; material.metalness = 0.0; - material.fog = false; // Water should not be affected by scene fog - // Disable environment map completely - use planar reflection only - // NOTE: Don't set envMap = null - it causes WebGPU texture cache corruption - // Setting envMapIntensity to 0 is sufficient to disable the environment map effect - material.envMapIntensity = 0; - - const normalTex1 = this.normalTex1!; - const normalTex2 = this.normalTex2!; + material.fog = false; + + const nTex = this.normalTex!; const foamTex = this.foamTex!; - // Reflection: TSL reflector handles camera mirroring, RT, oblique clipping. - // Add normal-based distortion to the reflector's screen-UV for ripple effect. const reflNode = this.reflection!; - const worldUV = vec2(positionWorld.x, positionWorld.z); - const normalOffset = texture(normalTex1, mul(worldUV, float(0.02))).xy; + const worldUV0 = vec2(positionWorld.x, positionWorld.z); + const normalOffset = texture(nTex, mul(worldUV0, float(0.02))).xy; const normalDistortion = sub(mul(normalOffset, float(2)), float(1)); reflNode.uvNode = reflNode.uvNode!.add(mul(normalDistortion, float(0.015))); const reflectionNode = reflNode; @@ -559,7 +570,6 @@ export class WaterSystem { w: ShaderNodeInput, wave: WaveParams, ) => { - // Cast to ShaderNode for swizzle access const wpNode = wp as ShaderNode; const dotDP = add( mul(wpNode.x, float(wave.Dx)), @@ -568,9 +578,7 @@ export class WaterSystem { return add(mul(float(wave.w), dotDP), mul(mul(float(wave.phi), t), w)); }; - // ======================================================================== // VERTEX: Gerstner Displacement - // ======================================================================== material.positionNode = Fn(() => { const pos = positionLocal.xyz; const wp = positionWorld; @@ -599,9 +607,7 @@ export class WaterSystem { ); })(); - // Screen-space water depth: difference between terrain depth and water - // surface depth, converted to world-space metres. Used by all fragment - // effects (absorption, foam, SSS, opacity). + // Screen-space water depth const gpuShoreDist = Fn(() => { const sceneDepth = linearDepth(viewportDepthTexture()); const waterDepth = linearDepth(); @@ -610,8 +616,6 @@ export class WaterSystem { return clamp(worldDist, float(0), float(WATER.MAX_DEPTH)); })(); - // Distance fade: beyond COLOR_DIST_FADE the depth-based colour effect - // fades out, giving a uniform deep-water look at distance. const distToCam = length(sub(cameraPosition, positionWorld)); const waterOpColorLerp = clamp( sub(float(1), div(distToCam, float(WATER.COLOR_DIST_FADE))), @@ -619,22 +623,117 @@ export class WaterSystem { float(1.0), ); - // ======================================================================== - // FRAGMENT: Use reflection in emissiveNode like the example - // ======================================================================== + // OPACITY (feeds into PBR → output.a, same as tree pattern) + material.opacityNode = Fn(() => { + const shoreDist = gpuShoreDist; + const opDepth = pow( + saturate(sub(float(1), div(shoreDist, float(WATER.OP_DEPTH_SCALE)))), + float(WATER.OP_DEPTH_FALLOFF), + ); + return sub(float(1), opDepth); + })(); - // Base water color node - const waterColorNode = Fn(() => { + // OUTPUT: Same pattern as tree shader — pbrOut = output, replace RGB, keep pbrOut.a + material.outputNode = Fn(() => { + const pbrOut = output; const wp = positionWorld; const shoreDist = gpuShoreDist; + const wUV = vec2(wp.x, wp.z); + + // --- Cosine gradient water colour --- + const colorDepth = pow( + saturate(sub(float(1), div(shoreDist, float(WATER.COLOR_DEPTH_SCALE)))), + float(WATER.COLOR_DEPTH_FALLOFF), + ); + const colorLerp = mul(colorDepth, waterOpColorLerp); + + const TAU = Math.PI * 2; + const [pR, pG, pB] = WATER.COS_PHASES; + const [aR, aG, aB] = WATER.COS_AMPLITUDES; + const [fR, fG, fB] = WATER.COS_FREQUENCIES; + const [oR, oG, oB] = WATER.COS_OFFSETS; + const cosR = clamp( + add( + float(oR), + add( + mul( + float(aR * 0.5), + cos(add(mul(colorLerp, float(TAU * fR)), float(TAU * pR))), + ), + float(0.5), + ), + ), + float(0), + float(1), + ); + const cosG = clamp( + add( + float(oG), + add( + mul( + float(aG * 0.5), + cos(add(mul(colorLerp, float(TAU * fG)), float(TAU * pG))), + ), + float(0.5), + ), + ), + float(0), + float(1), + ); + const cosB = clamp( + add( + float(oB), + add( + mul( + float(aB * 0.5), + cos(add(mul(colorLerp, float(TAU * fB)), float(TAU * pB))), + ), + float(0.5), + ), + ), + float(0), + float(1), + ); + const waterColor = mul(vec3(cosR, cosG, cosB), float(WATER.COLOR_DARKEN)); + + // --- 4-layer scrolling normal noise --- + const baseUV = mul(wUV, float(5)); + const nUV0 = add( + div(baseUV, float(103)), + vec2(div(uTime, float(17)), div(uTime, float(29))), + ); + const nUV1 = add( + div(baseUV, float(107)), + vec2(div(uTime, float(19)), mul(div(uTime, float(31)), float(-1))), + ); + const nUV2 = add( + vec2(div(baseUV.x, float(8907)), div(baseUV.y, float(9803))), + vec2(div(uTime, float(101)), div(uTime, float(97))), + ); + const nUV3 = add( + vec2(div(baseUV.x, float(1091)), div(baseUV.y, float(1027))), + vec2(mul(div(uTime, float(109)), float(-1)), div(uTime, float(113))), + ); + const n0 = texture(nTex, nUV0); + const n1 = texture(nTex, nUV1); + const n2 = texture(nTex, nUV2); + const n3 = texture(nTex, nUV3); + const noiseSum = add(add(add(n0, n1), n2), n3); + const noise = sub(mul(noiseSum, float(0.5)), float(1)); + const surfaceNormal = normalize( + vec3( + mul(noise.x, float(WATER.NORMAL_STRENGTH)), + noise.z, + mul(noise.y, float(WATER.NORMAL_STRENGTH)), + ), + ); + + // --- Gerstner wave normals (for foam crest detection) --- const shoreMask = smoothstep( float(0), float(WATER.WAVE_DAMP_DISTANCE), shoreDist, ); - const wUV = vec2(wp.x, wp.z); - - // Wave normals for specular let nx: ShaderNode = float(0), nz: ShaderNode = float(0); for (const wave of WAVES) { @@ -645,112 +744,40 @@ export class WaterSystem { nx = mul(nx, shoreMask); nz = mul(nz, shoreMask); - // Detail normals (2 layers for performance) - let detailX: ShaderNode = float(0), - detailZ: ShaderNode = float(0); - const textures = [normalTex1, normalTex2]; - for (let i = 0; i < NORMAL_LAYERS.length; i++) { - const [scale, sx, sy] = NORMAL_LAYERS[i]; - const uv = mul( - vec2( - add(wUV.x, mul(uTime, float(sx))), - add(wUV.y, mul(uTime, float(sy))), - ), - float(scale), - ); - const n = sub(mul(texture(textures[i], uv).rgb, float(2)), float(1)); - detailX = add(detailX, mul(n.x, float(NORMAL_WEIGHTS[i]))); - detailZ = add(detailZ, mul(n.z, float(NORMAL_WEIGHTS[i]))); - } - - const N = normalize( - vec3( - mul( - add(nx, mul(detailX, float(WATER.DETAIL_NORMAL_STRENGTH))), - float(-1), - ), - float(1), - mul( - add(nz, mul(detailZ, float(WATER.DETAIL_NORMAL_STRENGTH))), - float(-1), - ), - ), - ); - - // View vectors + // --- Phong sun lighting --- const V = normalize(sub(cameraPosition, wp)); const L = normalize(uSunDir); - const H = normalize(add(V, L)); - const NdotV = max(dot(N, V), float(0.001)); - const NdotL = max(dot(N, L), float(0)); - const NdotH = max(dot(N, H), float(0)); - const VdotH = max(dot(V, H), float(0)); - - // Depth-based colour: colorDepth=1 at shore (shallow), colorDepth→0 in deep water - const colorDepth = pow( - saturate(sub(float(1), div(shoreDist, float(WATER.COLOR_DEPTH_SCALE)))), - float(WATER.COLOR_DEPTH_FALLOFF), - ); - // At distance, fade to uniform deep colour - const colorLerp = mul(colorDepth, waterOpColorLerp); - const shallowColor = vec3( - WATER.SHALLOW_COLOR.r, - WATER.SHALLOW_COLOR.g, - WATER.SHALLOW_COLOR.b, - ); - const deepColor = vec3( - WATER.DEEP_COLOR.r, - WATER.DEEP_COLOR.g, - WATER.DEEP_COLOR.b, - ); - const waterColor = mix(deepColor, shallowColor, colorLerp); - - // Subsurface scattering approximation - const sssView = pow( - clamp(dot(V, mul(L, float(-1))), float(0), float(1)), - float(WATER.SSS_POWER), + const lightColor = vec3(1, 1, 1); + const negL = mul(L, float(-1)); + const NdotL = dot(surfaceNormal, L); + const reflectDir = normalize( + add(negL, mul(surfaceNormal, mul(float(2), NdotL))), ); - const sssIntensity = mul( + const specDir = max(dot(V, reflectDir), float(0)); + const specularLight = mul( + lightColor, mul( - sssView, - smoothstep( - float(WATER.SSS_RANGE_FAR), - float(WATER.SSS_RANGE_NEAR), - shoreDist, - ), + pow(specDir, float(WATER.SPECULAR_SHININESS)), + float(WATER.SPECULAR_STRENGTH), ), - float(WATER.SSS_INTENSITY), - ); - - // GGX specular - const alpha = WATER.ROUGHNESS * WATER.ROUGHNESS; - const alpha2 = alpha * alpha; - const NdotH2 = mul(NdotH, NdotH); - const denom = add(mul(NdotH2, float(alpha2 - 1)), float(1)); - const D_GGX = div(float(alpha2), mul(float(PI), mul(denom, denom))); - const k = (WATER.ROUGHNESS + 1) / 8; - const G1_V = div(NdotV, add(mul(NdotV, float(1 - k)), float(k))); - const G1_L = div(NdotL, add(mul(NdotL, float(1 - k)), float(k))); - const F_spec = add( - float(WATER.F0), - mul(float(1 - WATER.F0), pow(sub(float(1), VdotH), float(5))), ); - const specular = div( - mul(mul(D_GGX, mul(G1_V, G1_L)), F_spec), - max(mul(mul(float(4), NdotV), NdotL), float(0.001)), + const diffuseLight = mul( + lightColor, + mul(max(NdotL, float(0)), float(WATER.DIFFUSE_STRENGTH)), ); - const sunColor = vec3( - WATER.SUN_COLOR.r, - WATER.SUN_COLOR.g, - WATER.SUN_COLOR.b, - ); - const sunSpec = mul( - sunColor, - mul(mul(specular, float(WATER.SPECULAR_MULTIPLIER)), NdotL), + // --- Reflection + Fresnel --- + const reflectionSample = reflectionNode.xyz; + const theta = max(dot(V, surfaceNormal), float(0)); + const reflectance = add( + float(WATER.RF0), + mul(float(1 - WATER.RF0), pow(sub(float(1), theta), float(5))), ); - // Foam (simplified - single texture sample for performance) + // --- Scatter --- + const scatter = mul(waterColor, max(dot(surfaceNormal, V), float(0))); + + // --- Foam --- const shoreFoam = smoothstep( float(WATER.FOAM_SHORE_DISTANCE), float(0), @@ -774,161 +801,84 @@ export class WaterSystem { foamPattern, ); - // Composite base color (without reflection - that goes to emissive) - let color: ShaderNode = waterColor; - color = add(color, sunSpec); - color = add( - color, - mul( - vec3(WATER.SSS_COLOR.r, WATER.SSS_COLOR.g, WATER.SSS_COLOR.b), - sssIntensity, - ), + // --- Composite --- + const diffusePart = add(mul(diffuseLight, float(0.3)), scatter); + const reflectPart = add( + add(vec3(0.1, 0.1, 0.1), mul(reflectionSample, float(0.9))), + mul(reflectionSample, specularLight), + ); + const albedo = mix( + diffusePart, + mul(reflectPart, uReflectionIntensity), + reflectance, ); + let color: ShaderNode = mix(albedo, waterColor, float(0.8)); + + // Foam color = mix( color, vec3(WATER.FOAM_COLOR.r, WATER.FOAM_COLOR.g, WATER.FOAM_COLOR.b), clamp(foamIntensity, float(0), float(WATER.FOAM_MAX_OPACITY)), ); - return color; - })(); - - // === DISTANCE FOG (smoothstep with squared distances — avoids per-fragment sqrt) === - const toCam = sub(cameraPosition, positionWorld); - const fogDistSq = dot(toCam, toCam); - const fogFactor = smoothstep( - float(FOG_NEAR_SQ), - float(FOG_FAR_SQ), - fogDistSq, - ); - - material.colorNode = waterColorNode; - - // Reflection (fresnel-weighted emissive) - const fresnelNode = Fn(() => { - const V = normalize(sub(cameraPosition, positionWorld)); - const NdotV = max(dot(vec3(0, 1, 0), V), float(0.001)); - return add( - float(WATER.F0), - mul(float(1 - WATER.F0), pow(sub(float(1), NdotV), float(5))), + // --- applySunShade (same as tree shader) --- + color = applySunShade(color, uDayIntensity, vec3(uShadeColor)); + + // --- nightDim (same as tree shader: mix(NIGHT.BRIGHTNESS, 1.0, dayFactor)) --- + const dayFactor = div(clamp(uSunIntensity, float(0), float(2)), float(2)); + const nightDim = mix(float(NIGHT.BRIGHTNESS), float(1.0), dayFactor); + color = mul(color, nightDim); + + // --- Fog --- + const toCam = sub(cameraPosition, wp); + const fogDistSq = dot(toCam, toCam); + const fogFactor = smoothstep( + float(FOG_NEAR_SQ), + float(FOG_FAR_SQ), + fogDistSq, ); - })(); - - const reflectionEmissive = mul( - reflectionNode, - mul(fresnelNode, uReflectionIntensity), - ); - - material.emissiveNode = reflectionEmissive; + const foggedColor = mix(color, fogTexNode.rgb, fogFactor); + const foggedAlpha = mix(pbrOut.a, float(1.0), fogFactor); - // Apply fog AFTER PBR lighting via outputNode so fog color isn't darkened. - // Alpha pushed to 1.0 at fog distance to prevent transparent water - // from showing terrain with mismatched fog behind it. - material.outputNode = Fn(() => { - const litColor = output; - const foggedColor = mix(litColor.rgb, fogTexNode.rgb, fogFactor); - const foggedAlpha = mix(litColor.a, float(1.0), fogFactor); return vec4(foggedColor, foggedAlpha); })(); - // ======================================================================== - // OPACITY - // ======================================================================== - material.opacityNode = Fn(() => { - const shoreDist = gpuShoreDist; - const V = normalize(sub(cameraPosition, positionWorld)); - - // Edge fade for shoreline transparency - const edgeFade = smoothstep( - float(0), - float(WATER.EDGE_FADE_DISTANCE), - shoreDist, - ); - - // Depth opacity: pow(saturate(1 - depth/scale), falloff) - // Shallow → opDepth ≈ 1 → op ≈ 0 (transparent, see bottom) - // Deep → opDepth ≈ 0 → op ≈ 1 (fully opaque, hides terrain) - const opDepth = pow( - saturate(sub(float(1), div(shoreDist, float(WATER.OP_DEPTH_SCALE)))), - float(WATER.OP_DEPTH_FALLOFF), - ); - const depthOpacity = sub(float(1), opDepth); - - // Fresnel - more opaque at glancing angles - const NdotV = max(dot(vec3(0, 1, 0), V), float(0)); - const fresnelOpacity = mix( - float(WATER.FRESNEL_OPACITY_MIN), - float(WATER.FRESNEL_OPACITY_MAX), - pow(sub(float(1), NdotV), float(WATER.FRESNEL_OPACITY_POWER)), - ); - - return mul(mul(edgeFade, depthOpacity), fresnelOpacity); - })(); - - // ======================================================================== - // NORMAL MAP - // ======================================================================== - material.normalNode = Fn(() => { - const wp = positionWorld; - const wUV = vec2(wp.x, wp.z); - const uv1 = mul( - vec2( - add(wUV.x, mul(uTime, float(WATER.NORMAL_UV1_SPEED.x))), - add(wUV.y, mul(uTime, float(WATER.NORMAL_UV1_SPEED.y))), - ), - float(WATER.NORMAL_UV1_SCALE), - ); - const uv2 = mul( - vec2( - sub(wUV.x, mul(uTime, float(WATER.NORMAL_UV2_SPEED.x))), - add(wUV.y, mul(uTime, float(WATER.NORMAL_UV2_SPEED.y))), - ), - float(WATER.NORMAL_UV2_SCALE), - ); - const n1 = sub(mul(texture(normalTex1, uv1).rgb, float(2)), float(1)); - const n2 = sub(mul(texture(normalTex2, uv2).rgb, float(2)), float(1)); - const blended = normalize(add(n1, n2)); - return normalize( - vec3( - mul(blended.x, float(WATER.NORMAL_BLEND_STRENGTH)), - float(1), - mul(blended.z, float(WATER.NORMAL_BLEND_STRENGTH)), - ), - ); - })(); - return material; } /** - * Create ocean water material - no reflections, deeper colors, larger waves - * Ocean water is meant for world boundary/edge areas + * Create ocean water material — same portfolio style but no planar reflections, + * deeper blue tint, and larger wave amplitude for world boundary water. + * Uses MeshBasicNodeMaterial with ALL computation in outputNode (no PBR). */ private createOceanMaterial(): MeshStandardNodeMaterial { const uTime = uniform(float(0)); const uSunDir = uniform(vec3(0.4, 0.8, 0.4)); - const uWind = uniform(float(1.2)); // Slightly windier for ocean - // Ocean never has reflections, but we include the uniform for type consistency + const uWind = uniform(float(1.2)); + const uDayIntensity = uniform(float(1.0)); + const uSunIntensity = uniform(float(1.0)); + const uShadeColor = uniform(new THREE.Color(...SUN_SHADE.TINT_COLOR)); const uReflectionIntensity = uniform(float(0)); + const fogTexNode = texture(fogRenderTarget.texture, screenUV); this.oceanUniforms = { time: uTime, sunDirection: uSunDir as unknown as UniformVec3, windStrength: uWind, reflectionIntensity: uReflectionIntensity, + dayIntensity: uDayIntensity, + sunIntensity: uSunIntensity, }; const material = new MeshStandardNodeMaterial(); material.transparent = true; material.depthWrite = true; material.side = THREE.DoubleSide; - material.roughness = WATER.ROUGHNESS; + material.roughness = 0.8; material.metalness = 0.0; material.fog = false; - material.envMapIntensity = 0; - const normalTex1 = this.normalTex1!; - const normalTex2 = this.normalTex2!; + const nTex = this.normalTex!; const foamTex = this.foamTex!; const wavePhase = ( @@ -945,9 +895,7 @@ export class WaterSystem { return add(mul(float(wave.w), dotDP), mul(mul(float(wave.phi), t), w)); }; - // ======================================================================== - // VERTEX: Gerstner Displacement (larger waves for ocean) - // ======================================================================== + // VERTEX: Gerstner Displacement (1.3x larger for ocean) material.positionNode = Fn(() => { const pos = positionLocal.xyz; const wp = positionWorld; @@ -964,7 +912,6 @@ export class WaterSystem { const phase = wavePhase(wp, uTime, uWind, wave); const c = cos(phase), s = sin(phase); - // Larger wave amplitude for ocean (1.3x) dx = add(dx, mul(float(wave.QADx * 1.3), c)); dy = add(dy, mul(float(wave.A * 1.3), s)); dz = add(dz, mul(float(wave.QADz * 1.3), c)); @@ -977,16 +924,116 @@ export class WaterSystem { ); })(); - // ======================================================================== - // FRAGMENT: Ocean color (deeper, bluer, no reflection) - // ======================================================================== - const oceanColorNode = Fn(() => { + // OPACITY (feeds into PBR → output.a) + material.opacityNode = Fn(() => { + const shoreDist = attribute("shoreDistance", "float"); + const edgeFade = smoothstep(float(0), float(0.4), shoreDist); + const depthFade = smoothstep(float(0.4), float(8.0), shoreDist); + const depthOpacity = mix(float(0.3), float(0.85), depthFade); + const V0 = normalize(sub(cameraPosition, positionWorld)); + const NdotV0 = max(dot(vec3(0, 1, 0), V0), float(0)); + const fresnelOpacity = mix( + float(0.9), + float(1.0), + pow(sub(float(1), NdotV0), float(3)), + ); + return mul(mul(edgeFade, depthOpacity), fresnelOpacity); + })(); + + // OUTPUT: Same pattern as tree shader — pbrOut = output, replace RGB, keep pbrOut.a + material.outputNode = Fn(() => { + const pbrOut = output; const wp = positionWorld; const shoreDist = attribute("shoreDistance", "float"); const shoreMask = smoothstep(float(0), float(6), shoreDist); const wUV = vec2(wp.x, wp.z); - // Wave normals for specular + // --- Cosine gradient — deeper bias for ocean --- + const colorDepth = pow( + saturate(sub(float(1), div(shoreDist, float(80)))), + float(4), + ); + const TAU = Math.PI * 2; + const [pR, pG, pB] = WATER.COS_PHASES; + const [aR, aG, aB] = WATER.COS_AMPLITUDES; + const [fR, fG, fB] = WATER.COS_FREQUENCIES; + const [oR, oG, oB] = WATER.COS_OFFSETS; + const cosR = clamp( + add( + float(oR), + add( + mul( + float(aR * 0.5), + cos(add(mul(colorDepth, float(TAU * fR)), float(TAU * pR))), + ), + float(0.5), + ), + ), + float(0), + float(1), + ); + const cosG = clamp( + add( + float(oG), + add( + mul( + float(aG * 0.5), + cos(add(mul(colorDepth, float(TAU * fG)), float(TAU * pG))), + ), + float(0.5), + ), + ), + float(0), + float(1), + ); + const cosB = clamp( + add( + float(oB), + add( + mul( + float(aB * 0.5), + cos(add(mul(colorDepth, float(TAU * fB)), float(TAU * pB))), + ), + float(0.5), + ), + ), + float(0), + float(1), + ); + const waterColor = mul(vec3(cosR, cosG, cosB), float(WATER.COLOR_DARKEN)); + + // --- 4-layer normal noise --- + const baseUV = mul(wUV, float(5)); + const nUV0 = add( + div(baseUV, float(103)), + vec2(div(uTime, float(17)), div(uTime, float(29))), + ); + const nUV1 = add( + div(baseUV, float(107)), + vec2(div(uTime, float(19)), mul(div(uTime, float(31)), float(-1))), + ); + const nUV2 = add( + vec2(div(baseUV.x, float(8907)), div(baseUV.y, float(9803))), + vec2(div(uTime, float(101)), div(uTime, float(97))), + ); + const nUV3 = add( + vec2(div(baseUV.x, float(1091)), div(baseUV.y, float(1027))), + vec2(mul(div(uTime, float(109)), float(-1)), div(uTime, float(113))), + ); + const noiseSum = add( + add(add(texture(nTex, nUV0), texture(nTex, nUV1)), texture(nTex, nUV2)), + texture(nTex, nUV3), + ); + const noise = sub(mul(noiseSum, float(0.5)), float(1)); + const surfaceNormal = normalize( + vec3( + mul(noise.x, float(WATER.NORMAL_STRENGTH)), + noise.z, + mul(noise.y, float(WATER.NORMAL_STRENGTH)), + ), + ); + + // --- Gerstner wave normals for foam --- let nx: ShaderNode = float(0), nz: ShaderNode = float(0); for (const wave of WAVES) { @@ -997,95 +1044,43 @@ export class WaterSystem { nx = mul(nx, shoreMask); nz = mul(nz, shoreMask); - // Detail normals - let detailX: ShaderNode = float(0), - detailZ: ShaderNode = float(0); - const textures = [normalTex1, normalTex2]; - for (let i = 0; i < NORMAL_LAYERS.length; i++) { - const [scale, sx, sy] = NORMAL_LAYERS[i]; - const uv = mul( - vec2( - add(wUV.x, mul(uTime, float(sx))), - add(wUV.y, mul(uTime, float(sy))), - ), - float(scale), - ); - const n = sub(mul(texture(textures[i], uv).rgb, float(2)), float(1)); - detailX = add(detailX, mul(n.x, float(NORMAL_WEIGHTS[i]))); - detailZ = add(detailZ, mul(n.z, float(NORMAL_WEIGHTS[i]))); - } - - const N = normalize( - vec3( - mul(add(nx, mul(detailX, float(0.5))), float(-1)), - float(1), - mul(add(nz, mul(detailZ, float(0.5))), float(-1)), - ), - ); - - // View vectors + // --- Phong lighting --- const V = normalize(sub(cameraPosition, wp)); const L = normalize(uSunDir); - const H = normalize(add(V, L)); - const NdotV = max(dot(N, V), float(0.001)); - const NdotL = max(dot(N, L), float(0)); - const NdotH = max(dot(N, H), float(0)); - const VdotH = max(dot(V, H), float(0)); - - // Ocean colors - deeper and bluer than lake water - const depth = clamp(shoreDist, float(0), float(50)); // Extended depth for ocean - const shallowColor = vec3(0.08, 0.32, 0.45); // More blue-green - const deepColor = vec3(0.01, 0.04, 0.08); // Very deep blue - const oceanColor = vec3( - mix( - deepColor.x, - shallowColor.x, - exp(mul(float(-WATER.ABSORPTION.r * 0.7), depth)), - ), - mix( - deepColor.y, - shallowColor.y, - exp(mul(float(-WATER.ABSORPTION.g * 0.7), depth)), - ), - mix( - deepColor.z, - shallowColor.z, - exp(mul(float(-WATER.ABSORPTION.b * 0.7), depth)), - ), + const negL = mul(L, float(-1)); + const NdotL = dot(surfaceNormal, L); + const reflectDir = normalize( + add(negL, mul(surfaceNormal, mul(float(2), NdotL))), ); - - // Subsurface scattering - const sssView = pow( - clamp(dot(V, mul(L, float(-1))), float(0), float(1)), - float(3), + const specDir = max(dot(V, reflectDir), float(0)); + const specularLight = mul( + pow(specDir, float(WATER.SPECULAR_SHININESS)), + float(WATER.SPECULAR_STRENGTH), ); - const sssIntensity = mul( - mul(sssView, smoothstep(float(8), float(0.5), shoreDist)), - float(0.25), // Less SSS for ocean + const diffuseLight = mul( + max(NdotL, float(0)), + float(WATER.DIFFUSE_STRENGTH), ); - // GGX specular - const alpha = WATER.ROUGHNESS * WATER.ROUGHNESS; - const alpha2 = alpha * alpha; - const NdotH2 = mul(NdotH, NdotH); - const denom = add(mul(NdotH2, float(alpha2 - 1)), float(1)); - const D_GGX = div(float(alpha2), mul(float(PI), mul(denom, denom))); - const k = (WATER.ROUGHNESS + 1) / 8; - const G1_V = div(NdotV, add(mul(NdotV, float(1 - k)), float(k))); - const G1_L = div(NdotL, add(mul(NdotL, float(1 - k)), float(k))); - const F_spec = add( - float(WATER.F0), - mul(float(1 - WATER.F0), pow(sub(float(1), VdotH), float(5))), - ); - const specular = div( - mul(mul(D_GGX, mul(G1_V, G1_L)), F_spec), - max(mul(mul(float(4), NdotV), NdotL), float(0.001)), + // --- Scatter --- + const scatter = mul(waterColor, max(dot(surfaceNormal, V), float(0))); + + // --- Composite (no reflection for ocean) --- + const albedo = add( + mul(vec3(1, 1, 1), mul(diffuseLight, float(0.3))), + scatter, ); + let color: ShaderNode = mix(albedo, waterColor, float(0.8)); - const sunColor = vec3(1.0, 0.98, 0.92); - const sunSpec = mul(sunColor, mul(mul(specular, float(2.0)), NdotL)); + // Fresnel sky approximation + const NdotV = max(dot(surfaceNormal, V), float(0)); + const fresnelSky = pow(sub(float(1), NdotV), float(4)); + color = add( + color, + mul(vec3(0.4, 0.5, 0.65), mul(fresnelSky, float(0.2))), + ); - // Ocean foam (more whitecaps) + // --- Foam (more whitecaps on ocean) --- const crestFoam = smoothstep( float(0.12), float(0.35), @@ -1100,79 +1095,32 @@ export class WaterSystem { ); const foamPattern = texture(foamTex, foamUV).r; const foamIntensity = mul(crestFoam, foamPattern); - - // Composite color - no reflection for ocean - let color: ShaderNode = oceanColor; - color = add(color, sunSpec); - color = add(color, mul(vec3(0.05, 0.25, 0.3), sssIntensity)); color = mix( color, vec3(0.9, 0.92, 0.95), clamp(foamIntensity, float(0), float(0.75)), ); - // Add sky reflection approximation (simple fresnel-based) - const skyColor = vec3(0.5, 0.6, 0.75); - const fresnelSky = pow(sub(float(1), NdotV), float(4)); - color = add(color, mul(skyColor, mul(fresnelSky, float(0.15)))); - - return color; - })(); - - material.colorNode = oceanColorNode; - - // No emissive reflection for ocean - just use the base color - - // ======================================================================== - // OPACITY - // ======================================================================== - material.opacityNode = Fn(() => { - const shoreDist = attribute("shoreDistance", "float"); - const V = normalize(sub(cameraPosition, positionWorld)); - - const edgeFade = smoothstep(float(0), float(0.4), shoreDist); - const depthFade = smoothstep(float(0.4), float(8.0), shoreDist); // Deeper fade for ocean - const depthOpacity = mix(float(0.3), float(0.85), depthFade); // More opaque overall - const NdotV = max(dot(vec3(0, 1, 0), V), float(0)); - const fresnelOpacity = mix( - float(0.9), - float(1.0), - pow(sub(float(1), NdotV), float(3)), + // --- applySunShade (same as tree shader) --- + color = applySunShade(color, uDayIntensity, vec3(uShadeColor)); + + // --- nightDim (same as tree shader) --- + const dayFactor = div(clamp(uSunIntensity, float(0), float(2)), float(2)); + const nightDim = mix(float(NIGHT.BRIGHTNESS), float(1.0), dayFactor); + color = mul(color, nightDim); + + // --- Fog --- + const toCam = sub(cameraPosition, wp); + const fogDistSq = dot(toCam, toCam); + const fogFactor = smoothstep( + float(FOG_NEAR_SQ), + float(FOG_FAR_SQ), + fogDistSq, ); + const foggedColor = mix(color, fogTexNode.rgb, fogFactor); + const foggedAlpha = mix(pbrOut.a, float(1.0), fogFactor); - return mul(mul(edgeFade, depthOpacity), fresnelOpacity); - })(); - - // ======================================================================== - // NORMAL MAP - // ======================================================================== - material.normalNode = Fn(() => { - const wp = positionWorld; - const wUV = vec2(wp.x, wp.z); - const uv1 = mul( - vec2( - add(wUV.x, mul(uTime, float(0.006))), - add(wUV.y, mul(uTime, float(0.004))), - ), - float(0.018), - ); - const uv2 = mul( - vec2( - sub(wUV.x, mul(uTime, float(0.005))), - add(wUV.y, mul(uTime, float(0.007))), - ), - float(0.03), - ); - const n1 = sub(mul(texture(normalTex1, uv1).rgb, float(2)), float(1)); - const n2 = sub(mul(texture(normalTex2, uv2).rgb, float(2)), float(1)); - const blended = normalize(add(n1, n2)); - return normalize( - vec3( - mul(blended.x, float(0.35)), - float(1), - mul(blended.z, float(0.35)), - ), - ); + return vec4(foggedColor, foggedAlpha); })(); return material; @@ -1440,39 +1388,43 @@ export class WaterSystem { typeof deltaTime === "number" && isFinite(deltaTime) ? deltaTime : 1 / 60; this.waterTime += dt; - // Get wind system reference lazily (it's registered before water) if (!this.windSystem) { this.windSystem = (this.world.getSystem("wind") as Wind | undefined) ?? null; } - const sunAngle = this.waterTime * 0.005; - const sunX = Math.cos(sunAngle) * 0.4; - const sunY = 0.75 + Math.sin(sunAngle * 0.3) * 0.1; - const sunZ = Math.sin(sunAngle) * 0.4; + // Get lighting data from Environment system (same as trees) + const env = this.world.getSystem("environment") as { + getDayIntensity?: () => number; + sunLight?: { intensity: number }; + lightDirection?: THREE.Vector3; + } | null; - // Use wind system strength with subtle wave oscillation overlay const baseWindStrength = this.windSystem?.uniforms.windStrength.value ?? 1.0; const waveOscillation = Math.sin(this.waterTime * 0.1) * 0.1; const windStrength = baseWindStrength * (0.9 + waveOscillation); - // Update lake water uniforms - if (this.uniforms) { - this.uniforms.time.value = this.waterTime; - this.uniforms.sunDirection.value.set(sunX, sunY, sunZ).normalize(); - this.uniforms.windStrength.value = windStrength; - } + const dayIntensity = env?.getDayIntensity?.() ?? 1; + const sunIntensity = env?.sunLight + ? Math.min(env.sunLight.intensity, 2.0) + : 1.0; - // Update ocean water uniforms - if (this.oceanUniforms) { - this.oceanUniforms.time.value = this.waterTime; - this.oceanUniforms.sunDirection.value.set(sunX, sunY, sunZ).normalize(); - this.oceanUniforms.windStrength.value = windStrength * 1.2; // Ocean is windier - } + const updateUniforms = (u: WaterUniforms, windMul: number) => { + u.time.value = this.waterTime; + u.windStrength.value = windStrength * windMul; + u.dayIntensity.value = dayIntensity; + u.sunIntensity.value = sunIntensity; + + // Sun direction: negate lightDirection (points FROM sun → TO sun) + if (env?.lightDirection) { + u.sunDirection.value.copy(env.lightDirection).negate().normalize(); + } + }; + + if (this.uniforms) updateUniforms(this.uniforms, 1.0); + if (this.oceanUniforms) updateUniforms(this.oceanUniforms, 1.2); - // Frustum culling: disable reflection camera when no lake water is visible - // (or when reflections are disabled) this.updateReflectionVisibility(); } From 020c768f0e1e27fe2e55b60d666d37be24f80722 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Mon, 23 Mar 2026 19:09:04 +0800 Subject: [PATCH 37/71] feat: overhaul cloud shader with per-fragment horizon fog blend - Port cloud sprite atlas UV, B-channel alpha dissolve, and sun proximity lighting from reference project - Add per-fragment horizon fog using positionWorld.y and fogRenderTarget texture sampling (same technique as applySkyFog for terrain/trees) - Add applyCloudFog helper to FogConfig for elevation-based cloud fog - Remove cloud group rotation and scale animation (movement from shader) - Remove "portfolio" references from WaterSystem comments Made-with: Cursor --- .../src/systems/shared/world/FogConfig.ts | 18 + .../src/systems/shared/world/SkySystem.ts | 556 +++++++++++++----- .../src/systems/shared/world/WaterSystem.ts | 14 +- 3 files changed, 449 insertions(+), 139 deletions(-) diff --git a/packages/shared/src/systems/shared/world/FogConfig.ts b/packages/shared/src/systems/shared/world/FogConfig.ts index 5557b0dff..7e11d434a 100644 --- a/packages/shared/src/systems/shared/world/FogConfig.ts +++ b/packages/shared/src/systems/shared/world/FogConfig.ts @@ -39,6 +39,7 @@ import { mix, dot, sub, + mul, smoothstep, Fn, output, @@ -108,3 +109,20 @@ export function applySkyFog(material: { return vec4(mix(litColor.rgb, fogTex.rgb, fogFactor), litColor.a); })(); } + +// --------------------------------------------------------------------------- +// HELPER: Apply elevation-based fade to cloud materials. +// Reduces cloud opacity based on elevation angle so lower clouds +// gradually dissolve into the sky at the horizon. +// fadeAmount: 0 = fully visible, 1 = fully transparent +// --------------------------------------------------------------------------- +export function applyCloudFog( + material: { fog: boolean; outputNode: unknown }, + fadeAmount: number, +): void { + material.fog = false; + material.outputNode = Fn(() => { + const litColor = output; + return vec4(litColor.rgb, mul(litColor.a, float(1.0 - fadeAmount))); + })(); +} diff --git a/packages/shared/src/systems/shared/world/SkySystem.ts b/packages/shared/src/systems/shared/world/SkySystem.ts index 8362cac84..719357bf3 100644 --- a/packages/shared/src/systems/shared/world/SkySystem.ts +++ b/packages/shared/src/systems/shared/world/SkySystem.ts @@ -16,16 +16,23 @@ import { add, clamp, cos, + distance, + div, dot, float, Fn, length, + max, + min, MeshBasicNodeMaterial, mix, mul, normalize, positionLocal, + positionWorld, pow, + screenUV, + sin, smoothstep, sub, texture, @@ -37,7 +44,7 @@ import { type ShaderNode, } from "../../../extras/three/three"; import type { World, WorldOptions } from "../../../types"; -import { fogRenderTarget } from "./FogConfig"; +import { applyCloudFog, fogRenderTarget } from "./FogConfig"; import { DAY_CYCLE, SUN_LIGHT } from "./LightingConfig"; const SKY_DOME_RADIUS = 5000; @@ -63,24 +70,304 @@ function createNoiseTexture(size = 128): THREE.DataTexture { } // ----------------------------- -// Cloud configuration with texture atlas sampling -// The cloud textures are 2x4 sprite sheets (8 clouds per texture) -// UV offset selects which cloud sprite to use -// PERFORMANCE: Reduced from 6 to 4 clouds (barely noticeable, saves draw calls + GPU) +// Cloud configuration — 28 clouds on a sky-dome ring, 2x4 sprite atlas per texture. +// Each entry specifies (az, el) spherical coordinates on SKY_DOME_RADIUS. // ----------------------------- type CloudDef = { - az: number; // azimuth in degrees - el: number; // elevation in degrees - tex: number; // which texture (1-4) - sprite: number; // which sprite in atlas (0-7) - scale: number; // size multiplier + az: number; + el: number; + tex: number; // 1-4 (cloud1.png through cloud4.png) + sprite: number; // 0-7 within 2x4 atlas + w: number; // width in world units + h: number; // height in world units + dSpeed: number; // distortion speed + dRange: number; // distortion range }; const CLOUD_DEFS: CloudDef[] = [ - { az: 30, el: 25, tex: 1, sprite: 0, scale: 1.3 }, - { az: 120, el: 32, tex: 2, sprite: 2, scale: 1.1 }, - { az: 210, el: 28, tex: 1, sprite: 4, scale: 1.4 }, - { az: 300, el: 35, tex: 2, sprite: 5, scale: 1.0 }, + // --- Texture 3 (cloud3) — 12 low-altitude horizon clouds --- + { + az: 14.4, + el: 4.1, + tex: 3, + sprite: 0, + w: 300, + h: 200, + dSpeed: 0.12, + dRange: 0.1, + }, + { + az: 0, + el: 4.1, + tex: 3, + sprite: 6, + w: 300, + h: 200, + dSpeed: 0.11, + dRange: 0.05, + }, + { + az: 50.4, + el: 4.1, + tex: 3, + sprite: 1, + w: 300, + h: 180, + dSpeed: 0.12, + dRange: 0.1, + }, + { + az: 28.8, + el: 4.1, + tex: 3, + sprite: 6, + w: 300, + h: 180, + dSpeed: 0.1, + dRange: 0.0, + }, + { + az: 100.8, + el: 4.1, + tex: 3, + sprite: 2, + w: 400, + h: 200, + dSpeed: 0.12, + dRange: 0.1, + }, + { + az: 108, + el: 4.1, + tex: 3, + sprite: 6, + w: 400, + h: 200, + dSpeed: 0.12, + dRange: 0.05, + }, + { + az: 162, + el: 4.1, + tex: 3, + sprite: 3, + w: 400, + h: 200, + dSpeed: 0.12, + dRange: 0.1, + }, + { + az: 180, + el: 4.1, + tex: 3, + sprite: 7, + w: 400, + h: 200, + dSpeed: 0.1, + dRange: 0.1, + }, + { + az: 248.4, + el: 4.1, + tex: 3, + sprite: 4, + w: 350, + h: 175, + dSpeed: 0.12, + dRange: 0.1, + }, + { + az: 270, + el: 4.1, + tex: 3, + sprite: 6, + w: 350, + h: 175, + dSpeed: 0.12, + dRange: 0.0, + }, + { + az: 288, + el: 4.1, + tex: 3, + sprite: 5, + w: 350, + h: 175, + dSpeed: 0.12, + dRange: 0.1, + }, + { + az: 306, + el: 4.1, + tex: 3, + sprite: 7, + w: 500, + h: 200, + dSpeed: 0.1, + dRange: 0.05, + }, + // --- Texture 1 (cloud1) — 8 mid/high altitude clouds --- + { + az: 0, + el: 20.2, + tex: 1, + sprite: 0, + w: 230, + h: 115, + dSpeed: 0.1, + dRange: 0.5, + }, + { + az: 54, + el: 29.9, + tex: 1, + sprite: 1, + w: 180, + h: 90, + dSpeed: 0.12, + dRange: 0.4, + }, + { + az: 82.8, + el: 37.9, + tex: 1, + sprite: 2, + w: 210, + h: 105, + dSpeed: 0.13, + dRange: 0.35, + }, + { + az: 122.4, + el: 7.9, + tex: 1, + sprite: 3, + w: 250, + h: 125, + dSpeed: 0.15, + dRange: 0.4, + }, + { + az: 165.6, + el: 20.7, + tex: 1, + sprite: 4, + w: 230, + h: 115, + dSpeed: 0.16, + dRange: 0.35, + }, + { + az: 208.8, + el: 30.3, + tex: 1, + sprite: 5, + w: 290, + h: 145, + dSpeed: 0.12, + dRange: 0.4, + }, + { + az: 270, + el: 21.2, + tex: 1, + sprite: 6, + w: 150, + h: 75, + dSpeed: 0.2, + dRange: 0.45, + }, + { + az: 324, + el: 20.5, + tex: 1, + sprite: 7, + w: 240, + h: 120, + dSpeed: 0.17, + dRange: 0.5, + }, + // --- Texture 4 (cloud4) — 8 mid/high altitude clouds --- + { + az: 216, + el: 5.8, + tex: 4, + sprite: 7, + w: 300, + h: 150, + dSpeed: 0.1, + dRange: 0.5, + }, + { + az: 72, + el: 5.8, + tex: 4, + sprite: 6, + w: 200, + h: 100, + dSpeed: 0.12, + dRange: 0.4, + }, + { + az: 129.6, + el: 30.4, + tex: 4, + sprite: 5, + w: 250, + h: 120, + dSpeed: 0.13, + dRange: 0.35, + }, + { + az: 180, + el: 36.6, + tex: 4, + sprite: 4, + w: 280, + h: 170, + dSpeed: 0.15, + dRange: 0.4, + }, + { + az: 248.4, + el: 30.3, + tex: 4, + sprite: 3, + w: 350, + h: 200, + dSpeed: 0.16, + dRange: 0.35, + }, + { + az: 284.4, + el: 40.8, + tex: 4, + sprite: 2, + w: 390, + h: 200, + dSpeed: 0.12, + dRange: 0.4, + }, + { + az: 306, + el: 29.3, + tex: 4, + sprite: 1, + w: 380, + h: 190, + dSpeed: 0.2, + dRange: 0.45, + }, + { + az: 342, + el: 38.9, + tex: 4, + sprite: 0, + w: 150, + h: 100, + dSpeed: 0.17, + dRange: 0.5, + }, ]; // ----------------------------- @@ -107,7 +394,7 @@ type SkyMaterialUniforms = { type CloudMaterialUniforms = { uTime: TSLUniformFloat; uSunPosition: TSLUniformVec3; - uDayIntensity: TSLUniformFloat; + uCloudRadius: TSLUniformFloat; }; type SunMaterialUniforms = { @@ -888,147 +1175,172 @@ export class SkySystem extends System { private cloudGroup: THREE.Group | null = null; /** - * Create cloud billboards using cloud textures - * Each cloud samples from a sprite atlas (2 columns x 4 rows = 8 sprites per texture) - * Clouds dissolve at night using noise-based alpha erosion + * Create cloud billboards with custom shader: + * - noise UV distortion for organic movement + * - B-channel alpha with sin-based oscillation + * - Day/night coloring from sun height + * - Sun proximity brightness boost + G-channel additive glow + * - R-channel dark/bright color interpolation */ private createClouds(): void { if (!this.group) return; - const SKY_RADIUS = SKY_DOME_RADIUS; - const BASE_SIZE = 160; + const R = SKY_DOME_RADIUS; - // Create a group to hold all cloud meshes (for rotation) this.cloudGroup = new THREE.Group(); this.cloudGroup.name = "CloudGroup"; - this.cloudGroup.layers.set(1); // Main camera only, not minimap + this.cloudGroup.layers.set(1); - // Get textures array for easy lookup const textures = [this.cloud1, this.cloud2, this.cloud3, this.cloud4]; + const noiseTex = this.noiseB; // noise2.png for UV distortion - // Reference noise texture for dissolve effect - const noiseRef = this.noiseA; - - // Shared uniforms for all clouds + // Shared uniforms const uTime = uniform(float(0)); - const uSunDir = uniform(vec3(0, 1, 0)); - const uDayIntensity = uniform(float(1.0)); // For dissolve effect at night + const uSunPos = uniform(vec3(0, R, 0)); + const uCloudRadius = uniform(float(R)); - // Store uniforms at class level for updates this.cloudMaterialUniforms = { uTime, - uSunPosition: uSunDir, - uDayIntensity, + uSunPosition: uSunPos, + uCloudRadius, } as CloudMaterialUniforms; for (let i = 0; i < CLOUD_DEFS.length; i++) { const def = CLOUD_DEFS[i]; - const tex = textures[def.tex - 1]; // tex is 1-indexed - + const tex = textures[def.tex - 1]; if (!tex) continue; - // Calculate UV offset for sprite in atlas (2 cols x 4 rows) + // Sprite atlas UV offset (2 cols x 4 rows) const col = def.sprite % 2; const row = Math.floor(def.sprite / 2); - const uOffset = col * 0.5; - const vOffset = 0.75 - row * 0.25; // rows go from top + const uOff = col * 0.5; + const vOff = 0.75 - row * 0.25; - // Create geometry with adjusted UVs for this sprite const geom = new THREE.PlaneGeometry(1, 1); const uvAttr = geom.attributes.uv; for (let j = 0; j < uvAttr.count; j++) { - const u = uvAttr.getX(j) * 0.5 + uOffset; - const v = uvAttr.getY(j) * 0.25 + vOffset; - uvAttr.setXY(j, u, v); + uvAttr.setXY( + j, + uvAttr.getX(j) * 0.5 + uOff, + uvAttr.getY(j) * 0.25 + vOff, + ); } uvAttr.needsUpdate = true; - // Create material with perlin noise dissolve fade in/out - // Each cloud gets a unique phase offset based on its index - const cloudIndex = float(i); + // Compute cloud world position on the sky sphere (group-local) + const azRad = (def.az * Math.PI) / 180; + const elRad = (def.el * Math.PI) / 180; + const cx = R * Math.cos(elRad) * Math.sin(azRad); + const cy = R * Math.sin(elRad); + const cz = R * Math.cos(elRad) * Math.cos(azRad); + + // Per-cloud uniforms + const uDistSpeed = float(def.dSpeed); + const uDistRange = float((1 - def.dRange) * 2); + const uCloudPos = vec3(cx, cy, cz); - // PERFORMANCE: Simplified cloud shader - removed UV swirl animation - // Keeps day/night fade and simple dissolve, removes expensive per-fragment cos/sin - const cloudColorNode = Fn(() => { + // ---- Cloud shader (TSL) ---- + const cloudOutputNode = Fn(() => { const uvCoord = uv(); - const cloudTex = texture(tex, uvCoord); - - // Day/night color - clouds are bright during day - const dayColor = vec3(1.0, 1.0, 1.0); - const nightColor = vec3(0.3, 0.35, 0.45); - const cloudColor = mix(nightColor, dayColor, uDayIntensity); - // Simple drifting noise UVs (no swirl rotation) - const noiseUV = add( - mul(uvCoord, float(2.0)), - mul(vec2(uTime, mul(uTime, float(0.6))), float(0.03)), + // Noise UV distortion for organic cloud morphing + const noiseUV = vec2( + add(uvCoord.x, mul(uTime, mul(uDistSpeed, float(0.1)))), + add(uvCoord.y, mul(uTime, mul(uDistSpeed, float(0.2)))), ); - const noiseSample = noiseRef - ? texture(noiseRef, noiseUV) + const noiseSample = noiseTex + ? texture(noiseTex, noiseUV) : vec4(0.5, 0.5, 0.5, 1.0); - const noiseValue = noiseSample.r; - - // Simple fade cycle (uses cos but only once per cloud, not per-pixel) - const fadeSpeed = float(0.025); - const cloudPhase = mul(cloudIndex, float(2.5)); - const fadeCycle = add(mul(uTime, fadeSpeed), cloudPhase); - const fadeProgress = mul(add(cos(fadeCycle), float(1.0)), float(0.5)); - - // Combined dissolve (day/night and cycle in one pass) - const nightFade = mul(sub(float(1.0), uDayIntensity), float(0.7)); - const dissolveThreshold = add(sub(float(1.0), fadeProgress), nightFade); - const dissolveMask = smoothstep( - dissolveThreshold, - add(dissolveThreshold, float(0.2)), - noiseValue, + const distortedUV = add( + uvCoord, + mul(vec2(noiseSample.r, noiseSample.b), float(0.01)), ); - const finalAlpha = mul(cloudTex.a, dissolveMask); - return vec4(cloudColor, finalAlpha); + const cloud = texture(tex, distortedUV); + + // B-channel alpha dissolve — sin-based oscillation morphs the cloud shape over time + const alphaLerp = mix( + add( + mul(sin(mul(uTime, uDistSpeed)), float(0.78)), + mul(float(0.78), uDistRange), + ), + float(1.0), + float(0.1), + ); + const cloudStep = sub(float(1.0), alphaLerp); + const cloudLerp = smoothstep(float(0.95), float(1.0), alphaLerp); + const alphaBase = smoothstep( + clamp(sub(cloudStep, float(0.1)), float(0.0), float(1.0)), + cloudStep, + cloud.b, + ); + const cloudAlpha = clamp( + mix(alphaBase, cloud.a, cloudLerp), + float(0.0), + cloud.a, + ); + + // Day/night color from sun height + const sunNightStep = smoothstep( + float(-0.3), + float(0.25), + div(uSunPos.y, uCloudRadius), + ); + const brightColor = mix( + vec3(0.141, 0.607, 0.94), + vec3(1.0, 1.0, 1.0), + sunNightStep, + ); + const darkColor = mix( + vec3(0.024, 0.32, 0.59), + vec3(0.141, 0.807, 0.94), + sunNightStep, + ); + + // Sun proximity brightness + const sunDist = distance(uCloudPos, uSunPos); + const brightLerp = smoothstep(float(0.0), uCloudRadius, sunDist); + const bright = mix(float(2.0), float(1.0), brightLerp); + + // R-channel color interpolation + G-channel additive sun glow + const cloudColor = add( + mul(mix(darkColor, brightColor, cloud.r), bright), + mul(cloud.g, sub(float(1.0), brightLerp)), + ); + + // Per-fragment horizon fog: sample sky fog texture, blend by world Y + const fogTex = texture(fogRenderTarget.texture, screenUV); + const worldElev = clamp( + div(positionWorld.y, float(5000.0)), + float(0.0), + float(1.0), + ); + const fogStr = smoothstep(float(0.6), float(0.0), worldElev); + const finalColor = mix(cloudColor, fogTex.rgb, fogStr); + + return vec4(finalColor, cloudAlpha); })(); const mat = new MeshBasicNodeMaterial(); - mat.colorNode = cloudColorNode; + mat.colorNode = cloudOutputNode; mat.side = THREE.DoubleSide; mat.transparent = true; mat.depthWrite = false; - mat.depthTest = true; // Check depth buffer - don't render over closer objects + mat.depthTest = true; mat.toneMapped = false; - mat.fog = false; // Don't let scene fog affect clouds + mat.fog = false; - // Create mesh const mesh = new THREE.Mesh(geom, mat); mesh.frustumCulled = false; - mesh.renderOrder = -995; // Render after sky dome but before scene objects - - // Position on sky dome - const azRad = (def.az * Math.PI) / 180; - const elRad = (def.el * Math.PI) / 180; - - const x = SKY_RADIUS * Math.cos(elRad) * Math.sin(azRad); - const y = SKY_RADIUS * Math.sin(elRad); - const z = SKY_RADIUS * Math.cos(elRad) * Math.cos(azRad); - - mesh.position.set(x, y, z); - - // Rotate to face center (billboard) + mesh.renderOrder = -995; + mesh.position.set(cx, cy, cz); mesh.rotation.y = azRad + Math.PI; - - // Scale - const w = BASE_SIZE * def.scale * 1.5; - const h = BASE_SIZE * def.scale * 0.7; - mesh.scale.set(w, h, 1); - - // Store base scale for animation - mesh.userData.baseScale = new THREE.Vector3(w, h, 1); - - // Main camera only, not minimap + mesh.scale.set(def.w * 10, def.h * 10, 1); mesh.layers.set(1); this.cloudGroup.add(mesh); } - // Store reference (use first mesh for uniform updates) this.clouds = this.cloudGroup.children[0] as THREE.InstancedMesh; this.group.add(this.cloudGroup); } @@ -1186,42 +1498,22 @@ export class SkySystem extends System { this.skyTSLUniforms.uDayIntensity.value = this._dayIntensity; // Sharp transition value } - // Update cloud material uniforms via class-level reference + // Update cloud material uniforms — sun position as world-space point on sky sphere if (this.cloudMaterialUniforms) { this.cloudMaterialUniforms.uTime.value = this.elapsed; - this.cloudMaterialUniforms.uSunPosition.value.copy(this._sunDir); - this.cloudMaterialUniforms.uDayIntensity.value = this._dayIntensity; + this.cloudMaterialUniforms.uSunPosition.value.set( + this._sunDir.x * SKY_DOME_RADIUS, + this._sunDir.y * SKY_DOME_RADIUS, + this._sunDir.z * SKY_DOME_RADIUS, + ); } // Ensure render order - render AFTER terrain so depth test works (terrain occludes celestials) if (this.sun) this.sun.renderOrder = 1000; if (this.moon) this.moon.renderOrder = 1000; - // Gently rotate cloud cover and animate scale - if (this.cloudGroup) { - // ~1 full rotation per 20 minutes (0.005 radians/sec) - subtle movement - this.cloudGroup.rotation.y += delta * 0.005; - - // Animate each cloud's scale for gentle breathing effect - this.cloudGroup.children.forEach((mesh, i) => { - if (mesh instanceof THREE.Mesh) { - const baseScale = mesh.userData.baseScale as - | THREE.Vector3 - | undefined; - if (baseScale) { - // Each cloud has different phase - const phase = this.elapsed * 0.3 + i * 1.5; - // Scale oscillates between 95% and 105% - const scaleMod = 1.0 + Math.sin(phase) * 0.05; - mesh.scale.set( - baseScale.x * scaleMod, - baseScale.y * scaleMod, - baseScale.z, - ); - } - } - }); - } + // Clouds are static on the ring — movement comes from the shader's + // noise UV distortion and alpha oscillation. } override lateUpdate(_delta: number): void { diff --git a/packages/shared/src/systems/shared/world/WaterSystem.ts b/packages/shared/src/systems/shared/world/WaterSystem.ts index 8f0b56dfe..732701942 100644 --- a/packages/shared/src/systems/shared/world/WaterSystem.ts +++ b/packages/shared/src/systems/shared/world/WaterSystem.ts @@ -57,13 +57,13 @@ const GRAVITY = 9.81; const PI = Math.PI; const TWO_PI = PI * 2; -// ---- Water visual tuning (portfolio-style) ---- +// ---- Water visual tuning ---- const WATER = { REFLECTION_INTENSITY: 0.4, WAVE_DAMP_DISTANCE: 6, MAX_DEPTH: 30, - // Fresnel (Schlick, rf0 = 0.3 matches portfolio) + // Fresnel (Schlick approximation, rf0 = 0.3) RF0: 0.3, // Phong sun lighting @@ -80,7 +80,7 @@ const WATER = { COLOR_DEPTH_FALLOFF: 3, COLOR_DIST_FADE: 200, - // Cosine gradient colour parameters (portfolio values) + // Cosine gradient colour parameters COS_PHASES: [0.28, 0.5, 0.07] as const, COS_AMPLITUDES: [4.02, 0.34, 0.65] as const, COS_FREQUENCIES: [0.0, 0.48, 0.08] as const, @@ -343,10 +343,10 @@ export class WaterSystem { */ /** * Generate a seamless water normal map using FBM value noise + finite - * differences. Produces organic ripple patterns similar to the portfolio's - * waterNormal.png (510×511 tangent-space normal map). + * differences. Produces organic ripple patterns matching a tangent-space + * normal map (510x511). * - * Target channel statistics (matching portfolio texture): + * Target channel statistics: * R: mean ~128, range ~48–206 (X derivative) * G: mean ~128, range ~52–193 (Y derivative) * B: mean ~250, range ~226–254 (up component) @@ -847,7 +847,7 @@ export class WaterSystem { } /** - * Create ocean water material — same portfolio style but no planar reflections, + * Create ocean water material — no planar reflections, * deeper blue tint, and larger wave amplitude for world boundary water. * Uses MeshBasicNodeMaterial with ALL computation in outputNode (no PBR). */ From d9c8aed0b48ec488f63c34753bce985a5d17c3f0 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Mon, 23 Mar 2026 19:17:55 +0800 Subject: [PATCH 38/71] fix: decouple wave phase speed from wind and match normal scroll rates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wind now scales wave amplitude instead of phase speed, preventing jerky acceleration/deceleration when wind oscillates - Normal texture UV scroll speeds reduced to match Gerstner wave motion so surface detail moves in sync with vertex displacement - Smoother wind oscillation cycle (209s period, ±8% range) - Applied to both lake and ocean shaders Made-with: Cursor --- .../src/systems/shared/world/WaterSystem.ts | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/shared/src/systems/shared/world/WaterSystem.ts b/packages/shared/src/systems/shared/world/WaterSystem.ts index 732701942..aec0ffa27 100644 --- a/packages/shared/src/systems/shared/world/WaterSystem.ts +++ b/packages/shared/src/systems/shared/world/WaterSystem.ts @@ -564,10 +564,11 @@ export class WaterSystem { reflNode.uvNode = reflNode.uvNode!.add(mul(normalDistortion, float(0.015))); const reflectionNode = reflNode; + // Wind affects amplitude only — phase speed is purely from dispersion relation const wavePhase = ( wp: ShaderNodeInput, t: ShaderNodeInput, - w: ShaderNodeInput, + _w: ShaderNodeInput, wave: WaveParams, ) => { const wpNode = wp as ShaderNode; @@ -575,7 +576,7 @@ export class WaterSystem { mul(wpNode.x, float(wave.Dx)), mul(wpNode.z, float(wave.Dz)), ); - return add(mul(float(wave.w), dotDP), mul(mul(float(wave.phi), t), w)); + return add(mul(float(wave.w), dotDP), mul(float(wave.phi), t)); }; // VERTEX: Gerstner Displacement @@ -596,7 +597,7 @@ export class WaterSystem { const c = cos(phase), s = sin(phase); dx = add(dx, mul(float(wave.QADx), c)); - dy = add(dy, mul(float(wave.A), s)); + dy = add(dy, mul(mul(float(wave.A), uWind), s)); dz = add(dz, mul(float(wave.QADz), c)); } @@ -696,23 +697,23 @@ export class WaterSystem { ); const waterColor = mul(vec3(cosR, cosG, cosB), float(WATER.COLOR_DARKEN)); - // --- 4-layer scrolling normal noise --- + // --- 4-layer scrolling normal noise (speeds matched to Gerstner phase) --- const baseUV = mul(wUV, float(5)); const nUV0 = add( div(baseUV, float(103)), - vec2(div(uTime, float(17)), div(uTime, float(29))), + vec2(mul(uTime, float(0.012)), mul(uTime, float(0.008))), ); const nUV1 = add( div(baseUV, float(107)), - vec2(div(uTime, float(19)), mul(div(uTime, float(31)), float(-1))), + vec2(mul(uTime, float(0.01)), mul(uTime, float(-0.007))), ); const nUV2 = add( vec2(div(baseUV.x, float(8907)), div(baseUV.y, float(9803))), - vec2(div(uTime, float(101)), div(uTime, float(97))), + vec2(mul(uTime, float(0.003)), mul(uTime, float(0.004))), ); const nUV3 = add( vec2(div(baseUV.x, float(1091)), div(baseUV.y, float(1027))), - vec2(mul(div(uTime, float(109)), float(-1)), div(uTime, float(113))), + vec2(mul(uTime, float(-0.004)), mul(uTime, float(0.003))), ); const n0 = texture(nTex, nUV0); const n1 = texture(nTex, nUV1); @@ -884,7 +885,7 @@ export class WaterSystem { const wavePhase = ( wp: ShaderNodeInput, t: ShaderNodeInput, - w: ShaderNodeInput, + _w: ShaderNodeInput, wave: WaveParams, ) => { const wpNode = wp as ShaderNode; @@ -892,7 +893,7 @@ export class WaterSystem { mul(wpNode.x, float(wave.Dx)), mul(wpNode.z, float(wave.Dz)), ); - return add(mul(float(wave.w), dotDP), mul(mul(float(wave.phi), t), w)); + return add(mul(float(wave.w), dotDP), mul(float(wave.phi), t)); }; // VERTEX: Gerstner Displacement (1.3x larger for ocean) @@ -913,7 +914,7 @@ export class WaterSystem { const c = cos(phase), s = sin(phase); dx = add(dx, mul(float(wave.QADx * 1.3), c)); - dy = add(dy, mul(float(wave.A * 1.3), s)); + dy = add(dy, mul(mul(float(wave.A * 1.3), uWind), s)); dz = add(dz, mul(float(wave.QADz * 1.3), c)); } @@ -1002,23 +1003,23 @@ export class WaterSystem { ); const waterColor = mul(vec3(cosR, cosG, cosB), float(WATER.COLOR_DARKEN)); - // --- 4-layer normal noise --- + // --- 4-layer normal noise (speeds matched to Gerstner phase) --- const baseUV = mul(wUV, float(5)); const nUV0 = add( div(baseUV, float(103)), - vec2(div(uTime, float(17)), div(uTime, float(29))), + vec2(mul(uTime, float(0.012)), mul(uTime, float(0.008))), ); const nUV1 = add( div(baseUV, float(107)), - vec2(div(uTime, float(19)), mul(div(uTime, float(31)), float(-1))), + vec2(mul(uTime, float(0.01)), mul(uTime, float(-0.007))), ); const nUV2 = add( vec2(div(baseUV.x, float(8907)), div(baseUV.y, float(9803))), - vec2(div(uTime, float(101)), div(uTime, float(97))), + vec2(mul(uTime, float(0.003)), mul(uTime, float(0.004))), ); const nUV3 = add( vec2(div(baseUV.x, float(1091)), div(baseUV.y, float(1027))), - vec2(mul(div(uTime, float(109)), float(-1)), div(uTime, float(113))), + vec2(mul(uTime, float(-0.004)), mul(uTime, float(0.003))), ); const noiseSum = add( add(add(texture(nTex, nUV0), texture(nTex, nUV1)), texture(nTex, nUV2)), @@ -1402,8 +1403,8 @@ export class WaterSystem { const baseWindStrength = this.windSystem?.uniforms.windStrength.value ?? 1.0; - const waveOscillation = Math.sin(this.waterTime * 0.1) * 0.1; - const windStrength = baseWindStrength * (0.9 + waveOscillation); + const waveOscillation = Math.sin(this.waterTime * 0.03) * 0.08; + const windStrength = baseWindStrength * (0.95 + waveOscillation); const dayIntensity = env?.getDayIntensity?.() ?? 1; const sunIntensity = env?.sunLight From b2d94877141ea490d98a1cb0603950ef286d0898 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Mon, 23 Mar 2026 19:48:02 +0800 Subject: [PATCH 39/71] fix: restore reference water color gradient and revert noise scroll speeds - Restore original cosine gradient params with darkened offsets for proper shallow green and deep blue tones - Remove COLOR_DARKEN multiplier hack - Revert normal noise scroll speeds to reference values - Revert specular lighting to unscaled (sunShade handles darkening) - Wind still affects wave amplitude, not phase speed Made-with: Cursor --- .../src/systems/shared/world/WaterSystem.ts | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/packages/shared/src/systems/shared/world/WaterSystem.ts b/packages/shared/src/systems/shared/world/WaterSystem.ts index aec0ffa27..14dc3da0a 100644 --- a/packages/shared/src/systems/shared/world/WaterSystem.ts +++ b/packages/shared/src/systems/shared/world/WaterSystem.ts @@ -80,14 +80,11 @@ const WATER = { COLOR_DEPTH_FALLOFF: 3, COLOR_DIST_FADE: 200, - // Cosine gradient colour parameters + // Cosine gradient colour parameters (based on reference, darkened offsets) COS_PHASES: [0.28, 0.5, 0.07] as const, COS_AMPLITUDES: [4.02, 0.34, 0.65] as const, COS_FREQUENCIES: [0.0, 0.48, 0.08] as const, - COS_OFFSETS: [0.0, 0.25, 0.0] as const, - - // Multiplier applied to the cosine gradient to darken overall water color - COLOR_DARKEN: 0.18, + COS_OFFSETS: [-0.25, 0.0, -0.15] as const, // Normal noise strength (xz multiplier for surface normal) NORMAL_STRENGTH: 1.5, @@ -695,25 +692,25 @@ export class WaterSystem { float(0), float(1), ); - const waterColor = mul(vec3(cosR, cosG, cosB), float(WATER.COLOR_DARKEN)); + const waterColor = vec3(cosR, cosG, cosB); - // --- 4-layer scrolling normal noise (speeds matched to Gerstner phase) --- + // --- 4-layer scrolling normal noise (matches reference scroll speeds) --- const baseUV = mul(wUV, float(5)); const nUV0 = add( div(baseUV, float(103)), - vec2(mul(uTime, float(0.012)), mul(uTime, float(0.008))), + vec2(div(uTime, float(17)), div(uTime, float(29))), ); const nUV1 = add( div(baseUV, float(107)), - vec2(mul(uTime, float(0.01)), mul(uTime, float(-0.007))), + vec2(div(uTime, float(19)), mul(div(uTime, float(31)), float(-1))), ); const nUV2 = add( vec2(div(baseUV.x, float(8907)), div(baseUV.y, float(9803))), - vec2(mul(uTime, float(0.003)), mul(uTime, float(0.004))), + vec2(div(uTime, float(101)), div(uTime, float(97))), ); const nUV3 = add( vec2(div(baseUV.x, float(1091)), div(baseUV.y, float(1027))), - vec2(mul(uTime, float(-0.004)), mul(uTime, float(0.003))), + vec2(mul(div(uTime, float(109)), float(-1)), div(uTime, float(113))), ); const n0 = texture(nTex, nUV0); const n1 = texture(nTex, nUV1); @@ -1001,25 +998,25 @@ export class WaterSystem { float(0), float(1), ); - const waterColor = mul(vec3(cosR, cosG, cosB), float(WATER.COLOR_DARKEN)); + const waterColor = vec3(cosR, cosG, cosB); - // --- 4-layer normal noise (speeds matched to Gerstner phase) --- + // --- 4-layer normal noise (matches reference scroll speeds) --- const baseUV = mul(wUV, float(5)); const nUV0 = add( div(baseUV, float(103)), - vec2(mul(uTime, float(0.012)), mul(uTime, float(0.008))), + vec2(div(uTime, float(17)), div(uTime, float(29))), ); const nUV1 = add( div(baseUV, float(107)), - vec2(mul(uTime, float(0.01)), mul(uTime, float(-0.007))), + vec2(div(uTime, float(19)), mul(div(uTime, float(31)), float(-1))), ); const nUV2 = add( vec2(div(baseUV.x, float(8907)), div(baseUV.y, float(9803))), - vec2(mul(uTime, float(0.003)), mul(uTime, float(0.004))), + vec2(div(uTime, float(101)), div(uTime, float(97))), ); const nUV3 = add( vec2(div(baseUV.x, float(1091)), div(baseUV.y, float(1027))), - vec2(mul(uTime, float(-0.004)), mul(uTime, float(0.003))), + vec2(mul(div(uTime, float(109)), float(-1)), div(uTime, float(113))), ); const noiseSum = add( add(add(texture(nTex, nUV0), texture(nTex, nUV1)), texture(nTex, nUV2)), From 39a38c7920aff7a5eb952b08e98ade4ed5743b2c Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Mon, 23 Mar 2026 19:52:06 +0800 Subject: [PATCH 40/71] fix: darken water color offsets and refactor tree toon to 4-band Ghibli style - Shift water cosine gradient offsets darker for deeper blue tones - Refactor tree dissolve material from 3-band toon to 4-band Ghibli toon lighting with warm highlights and cool shadows Made-with: Cursor --- .../src/systems/shared/world/GPUMaterials.ts | 35 +++++++++++-------- .../src/systems/shared/world/WaterSystem.ts | 2 +- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index 97afcefbe..f3ae1acb6 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -994,11 +994,9 @@ export function createTreeDissolveMaterial( const HL_BRIGHTEN = 0.08; const HL_RIM_POWER = 2.5; const HL_RIM_STRENGTH = 0.4; - const TOON_DARK = 0.5; - const TOON_MID = 0.75; - const TOON_BRIGHT = 1.0; + const TOON_BRIGHT_EDGE = 0.7; + const TOON_MID_EDGE = 0.35; const TOON_SHADOW_EDGE = 0.0; - const TOON_MID_EDGE = 0.5; const TOON_RIM_THRESHOLD = 0.3; const TOON_RIM_BRIGHT = 1.3; const NIGHT_MIN_BRIGHTNESS = NIGHT.BRIGHTNESS; @@ -1077,21 +1075,28 @@ export function createTreeDissolveMaterial( // ---- Sun shade on albedo (driven by dayIntensity to match scene light timing) ---- baseAlbedo = applySunShade(baseAlbedo, uDayIntensity, vec3(uShadeColor)); - // ---- 3-band toon lighting (hard-edged shadow / mid / bright) ---- + // ---- 4-band Ghibli toon lighting (warm highlights → cool shadows) ---- + // Derive 4 hue-shifted color variants from sampled texture albedo. + // Highlights shift warm (golden), shadows shift cool (teal). const L = normalize(vec3(uSunDir)); const N = normalize(normalWorldGeometry); const NdotL = dot(N, L); - const band1 = step(float(TOON_SHADOW_EDGE), NdotL); - const band2 = step(float(TOON_MID_EDGE), NdotL); - const toonLight = add( - float(TOON_DARK), - add( - mul(band1, float(TOON_MID - TOON_DARK)), - mul(band2, float(TOON_BRIGHT - TOON_MID)), - ), - ); + + const band0Color = mul(baseAlbedo, vec3(1.35, 1.08, 0.82)); + const band1Color = baseAlbedo; + const band2Color = mul(baseAlbedo, vec3(0.65, 0.78, 0.82)); + const band3Color = mul(baseAlbedo, vec3(0.38, 0.52, 0.68)); + + const s0 = step(float(TOON_BRIGHT_EDGE), NdotL); + const s1 = step(float(TOON_MID_EDGE), NdotL); + const s2 = step(float(TOON_SHADOW_EDGE), NdotL); + + const toonStep0 = mix(band3Color, band2Color, s2); + const toonStep1 = mix(toonStep0, band1Color, s1); + const toonColor = mix(toonStep1, band0Color, s0); + const nightDim = mix(float(NIGHT_MIN_BRIGHTNESS), float(1.0), dayFactor); - let result: any = mul(baseAlbedo, mul(toonLight, nightDim)); + let result: any = mul(toonColor, nightDim); // ---- SSS + hard-edged toon rim (leaf only, scaled by dayFactor) ---- if (isLeaf) { diff --git a/packages/shared/src/systems/shared/world/WaterSystem.ts b/packages/shared/src/systems/shared/world/WaterSystem.ts index 14dc3da0a..8427eb59e 100644 --- a/packages/shared/src/systems/shared/world/WaterSystem.ts +++ b/packages/shared/src/systems/shared/world/WaterSystem.ts @@ -84,7 +84,7 @@ const WATER = { COS_PHASES: [0.28, 0.5, 0.07] as const, COS_AMPLITUDES: [4.02, 0.34, 0.65] as const, COS_FREQUENCIES: [0.0, 0.48, 0.08] as const, - COS_OFFSETS: [-0.25, 0.0, -0.15] as const, + COS_OFFSETS: [-0.3, -0.05, -0.2] as const, // Normal noise strength (xz multiplier for surface normal) NORMAL_STRENGTH: 1.5, From 0acdeebdb2ea93db7358116aa890b325fc50489c Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Mon, 23 Mar 2026 20:10:55 +0800 Subject: [PATCH 41/71] feat: add half-lambert anime shade and fresnel rim to terrain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply Genshin-style cool shadow tint (half-lambert N·L wrap) and view-dependent fresnel rim highlight to terrain albedo before PBR, giving a warm-lit / cool-shadow anime look that matches tree shader. Made-with: Cursor --- .../src/systems/shared/world/TerrainShader.ts | 55 +++++++++++++++++-- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/systems/shared/world/TerrainShader.ts b/packages/shared/src/systems/shared/world/TerrainShader.ts index 7574c07c7..9318c33d7 100644 --- a/packages/shared/src/systems/shared/world/TerrainShader.ts +++ b/packages/shared/src/systems/shared/world/TerrainShader.ts @@ -41,6 +41,7 @@ import { clamp, max, floor, + normalize, Fn, output, type ShaderNode, @@ -48,7 +49,7 @@ import { import { getRoadInfluenceTextureState } from "./RoadInfluenceMask"; import { getLamppostLightTextureState } from "./LamppostLightMask"; import { FOG_NEAR_SQ, FOG_FAR_SQ, fogRenderTarget } from "./FogConfig"; -import { SUN_LIGHT } from "./LightingConfig"; +import { SUN_LIGHT, SUN_SHADE } from "./LightingConfig"; export const TERRAIN_SHADER_CONSTANTS = { TRIPLANAR_SCALE: 0.5, @@ -65,6 +66,18 @@ export const TERRAIN_SHADER_CONSTANTS = { SATURATION_BOOST: 1.35, }; +/** + * Half-lambert anime shade: wraps N·L to [0,1] for soft fill, then + * tints the shadow side with a cool blue-teal hue shift (Genshin-style). + * Applied to albedo before PBR so the colour shift survives lighting. + */ +export const TERRAIN_SHADE = { + TINT_COLOR: SUN_SHADE.TINT_COLOR, + STRENGTH: 0.7, + FRESNEL_POWER: 3.0, + FRESNEL_INTENSITY: 0.2, +}; + const TERRAIN_TEX_TILE = 0.3; const TERRAIN_TEX_DIR = "textures/terrain-biomes"; @@ -1054,6 +1067,37 @@ export function createTerrainMaterial(): THREE.Material & { // ... road rendering commented out for now const baseWithRoads = variedColor; + // ============================================================================ + // HALF-LAMBERT ANIME SHADE (cool shadow tint — Genshin-style) + // Wraps N·L to [0,1] for soft wrapped lighting, then tints shadow side + // with cool blue-teal hue. Applied to albedo before PBR so the hue shift + // survives through subsequent lighting calculations. + // ============================================================================ + const sunDir = normalize(vec3(sunDirectionUniform)); + const NdotL = dot(worldNormal, sunDir); + const halfLambert = add(mul(NdotL, float(0.5)), float(0.5)); + const shadeFactor = sub(float(1.0), halfLambert); + const coolTint = vec3(...TERRAIN_SHADE.TINT_COLOR); + const tintedBase = mul(baseWithRoads, coolTint); + const shadedBase = mix( + baseWithRoads, + tintedBase, + mul(shadeFactor, float(TERRAIN_SHADE.STRENGTH)), + ); + + // Fresnel rim highlight at grazing angles (subtle painterly edge glow) + const viewDir = normalize(sub(worldPos, cameraPosition)); + const rim = clamp( + add(float(1.0), dot(viewDir, worldNormal)), + float(0.0), + float(1.0), + ); + const fresnelRim = mul( + pow(rim, float(TERRAIN_SHADE.FRESNEL_POWER)), + float(TERRAIN_SHADE.FRESNEL_INTENSITY), + ); + const animeBase = add(shadedBase, vec3(fresnelRim, fresnelRim, fresnelRim)); + // ============================================================================ // VERTEX LIGHTING (lampposts, torches, etc.) // Simple additive point lights with smooth attenuation @@ -1190,7 +1234,7 @@ export function createTerrainMaterial(): THREE.Material & { // Apply vertex lighting additively (multiply base by (1 + lightAccum)) // This brightens terrain near lights without washing out colors - const litTerrain = mul(baseWithRoads, add(vec3(1, 1, 1), lightAccum)); + const litTerrain = mul(animeBase, add(vec3(1, 1, 1), lightAccum)); // === DISTANCE FOG (smoothstep with squared distances — avoids per-fragment sqrt) === const baseFogFactor = smoothstep( @@ -1202,9 +1246,10 @@ export function createTerrainMaterial(): THREE.Material & { const fogColor = fogTexNode.rgb; // === CREATE MATERIAL === - // No custom sun shade here — terrain is a standard PBR material, - // so it gets its night blue tint from the scene lights (moon, hemisphere, - // ambient) which are already blue-shifted at night in LightingConfig. + // Half-lambert anime shade (cool shadow tint + fresnel rim) is baked into + // the albedo above. PBR still applies Lambertian diffuse on top, which + // darkens the shadow side further — the hue shift in the albedo is what + // gives the Genshin-style warm-lit / cool-shadow look. const material = new MeshStandardNodeMaterial(); material.colorNode = litTerrain; material.roughness = 1.0; From da6a6eafab6980b10826692897c5bf30b5c8027c Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Wed, 25 Mar 2026 01:32:03 +0800 Subject: [PATCH 42/71] refactor: unify water level constants and convert docks to dev-assigned positions - Create DockDefinition.ts mirroring BridgeDefinition pattern - Refactor ProceduralDocks to use ISLAND_DOCKS array instead of WaterBodyRegistry auto-detection - Remove shoreline search, scoring, and candidate selection dead code - Unify hardcoded WATER_LEVEL=5.0 across shared package to TERRAIN_CONSTANTS.WATER_THRESHOLD - Create procgen/terrain/constants.ts as single source of truth (DEFAULT_WATER_THRESHOLD, GAME_WATER_LEVEL) - Fix stale town DEFAULT_WATER_THRESHOLD (was 9.0, now 5.4 from constants) - Remove projectOntoRiverJS from terrain workers - Restore server chunk ranges (core=3, ring=5) for far-distance loading - Bump RoadNetworkSystem maxPathIterations to 80000 Made-with: Cursor --- .../src/building/town/TownGenerator.ts | 4 +- .../procgen/src/building/town/constants.ts | 5 +- .../procgen/src/items/dock/DockGenerator.ts | 5 +- .../procgen/src/terrain/TerrainGenerator.ts | 5 +- .../procgen/src/terrain/TerrainShaderTSL.ts | 2 +- packages/procgen/src/terrain/constants.ts | 26 ++ packages/procgen/src/terrain/index.ts | 7 + packages/procgen/src/terrain/presets.ts | 9 +- packages/procgen/src/vegetation/types.ts | 4 +- packages/shared/src/data/world-structure.ts | 2 +- .../systems/shared/world/DockDefinition.ts | 44 +++ .../systems/shared/world/ProceduralDocks.ts | 365 ++---------------- .../src/systems/shared/world/TerrainShader.ts | 3 +- .../src/systems/shared/world/WaterSystem.ts | 3 +- 14 files changed, 142 insertions(+), 342 deletions(-) create mode 100644 packages/procgen/src/terrain/constants.ts create mode 100644 packages/shared/src/systems/shared/world/DockDefinition.ts diff --git a/packages/procgen/src/building/town/TownGenerator.ts b/packages/procgen/src/building/town/TownGenerator.ts index 1a5c337f8..d42cce5ef 100644 --- a/packages/procgen/src/building/town/TownGenerator.ts +++ b/packages/procgen/src/building/town/TownGenerator.ts @@ -38,6 +38,7 @@ import { WATER_CHECK_DIRECTIONS, WATER_CHECK_MAX_DISTANCE, WATER_CHECK_STEP, + DEFAULT_WATER_THRESHOLD, } from "./constants"; // Import grid alignment utilities from building generator @@ -212,7 +213,8 @@ export class TownGenerator { const terrain = createTerrainProviderFromGenerator(terrainGenerator); // Extract water threshold from terrain generator if available - const waterThreshold = terrainGenerator.getWaterThreshold?.() ?? 5.4; + const waterThreshold = + terrainGenerator.getWaterThreshold?.() ?? DEFAULT_WATER_THRESHOLD; return new TownGenerator({ ...options, diff --git a/packages/procgen/src/building/town/constants.ts b/packages/procgen/src/building/town/constants.ts index 9225a57bd..a2452afd6 100644 --- a/packages/procgen/src/building/town/constants.ts +++ b/packages/procgen/src/building/town/constants.ts @@ -21,9 +21,8 @@ export const DEFAULT_WORLD_SIZE = 10000; export const DEFAULT_MIN_TOWN_SPACING = 800; export const DEFAULT_FLATNESS_SAMPLE_RADIUS = 40; export const DEFAULT_FLATNESS_SAMPLE_COUNT = 16; -// IMPORTANT: Water threshold must match TERRAIN_CONSTANTS.WATER_THRESHOLD (9.0) -// This ensures town candidates are placed on actual land, not underwater areas -export const DEFAULT_WATER_THRESHOLD = 9.0; +import { DEFAULT_WATER_THRESHOLD } from "../../terrain/constants"; +export { DEFAULT_WATER_THRESHOLD }; export const DEFAULT_OPTIMAL_WATER_DISTANCE_MIN = 30; export const DEFAULT_OPTIMAL_WATER_DISTANCE_MAX = 150; diff --git a/packages/procgen/src/items/dock/DockGenerator.ts b/packages/procgen/src/items/dock/DockGenerator.ts index e88a0722d..47a915b89 100644 --- a/packages/procgen/src/items/dock/DockGenerator.ts +++ b/packages/procgen/src/items/dock/DockGenerator.ts @@ -47,8 +47,9 @@ import { createRng, type RNG } from "../../math/Random.js"; // CONSTANTS // ============================================================================ -/** Default water level (Y coordinate) */ -const DEFAULT_WATER_LEVEL = 5.0; +import { GAME_WATER_LEVEL } from "../../terrain/constants"; + +const DEFAULT_WATER_LEVEL = GAME_WATER_LEVEL; /** Default water floor depth below water level */ const DEFAULT_WATER_FLOOR_DEPTH = 3.0; diff --git a/packages/procgen/src/terrain/TerrainGenerator.ts b/packages/procgen/src/terrain/TerrainGenerator.ts index 7c5d72634..dd6103c8c 100644 --- a/packages/procgen/src/terrain/TerrainGenerator.ts +++ b/packages/procgen/src/terrain/TerrainGenerator.ts @@ -11,6 +11,7 @@ import { NoiseGenerator, createTileRNG } from "./NoiseGenerator"; import { BiomeSystem } from "./BiomeSystem"; import { IslandMask, DEFAULT_ISLAND_CONFIG } from "./IslandMask"; +import { DEFAULT_MAX_HEIGHT, DEFAULT_WATER_THRESHOLD } from "./constants"; import type { TerrainConfig, TerrainNoiseConfig, @@ -76,8 +77,8 @@ export const DEFAULT_TERRAIN_CONFIG: TerrainConfig = { tileSize: 100, worldSize: 100, tileResolution: 64, - maxHeight: 30, - waterThreshold: 5.4, + maxHeight: DEFAULT_MAX_HEIGHT, + waterThreshold: DEFAULT_WATER_THRESHOLD, seed: 0, noise: DEFAULT_NOISE_CONFIG, biomes: { diff --git a/packages/procgen/src/terrain/TerrainShaderTSL.ts b/packages/procgen/src/terrain/TerrainShaderTSL.ts index e0e36bebb..93b817417 100644 --- a/packages/procgen/src/terrain/TerrainShaderTSL.ts +++ b/packages/procgen/src/terrain/TerrainShaderTSL.ts @@ -52,7 +52,7 @@ export const TERRAIN_CONSTANTS = { DIRT_THRESHOLD: 0.5, LOD_FULL_DETAIL: 100.0, LOD_MEDIUM_DETAIL: 200.0, - WATER_LEVEL: 5.0, + WATER_LEVEL: 16, // Overridden at runtime by game; standalone default for procgen previews FOG_COLOR: new THREE.Color(0xd4c8b8), } as const; diff --git a/packages/procgen/src/terrain/constants.ts b/packages/procgen/src/terrain/constants.ts new file mode 100644 index 000000000..eeb44a606 --- /dev/null +++ b/packages/procgen/src/terrain/constants.ts @@ -0,0 +1,26 @@ +/** + * Procgen Terrain Constants — single source of truth for terrain defaults. + * + * These values define the procgen package's default terrain parameters. + * All files in @hyperscape/procgen should reference these instead of + * hardcoding magic numbers. + * + * NOTE: The game runtime in @hyperscape/shared has its own + * TERRAIN_CONSTANTS.WATER_THRESHOLD (= 16) and MAX_HEIGHT (= 50). + * The procgen defaults below use a different height scale (maxHeight=30). + * When procgen is used inside the game, the game passes its own values + * via config overrides — these are just standalone defaults. + */ + +/** Default max terrain height in world units */ +export const DEFAULT_MAX_HEIGHT = 30; + +/** Default water threshold in world units (terrain below this is underwater) */ +export const DEFAULT_WATER_THRESHOLD = 5.4; + +/** + * Water level in world-space Y for the game runtime. + * Must match TERRAIN_CONSTANTS.WATER_THRESHOLD in @hyperscape/shared. + * Used by systems that need the actual game water Y (e.g. DockGenerator). + */ +export const GAME_WATER_LEVEL = 16; diff --git a/packages/procgen/src/terrain/index.ts b/packages/procgen/src/terrain/index.ts index 4c0591302..7a2757990 100644 --- a/packages/procgen/src/terrain/index.ts +++ b/packages/procgen/src/terrain/index.ts @@ -21,6 +21,13 @@ * ``` */ +// Shared constants (single source of truth for procgen terrain defaults) +export { + DEFAULT_MAX_HEIGHT, + DEFAULT_WATER_THRESHOLD, + GAME_WATER_LEVEL, +} from "./constants"; + // Core generator export { TerrainGenerator, diff --git a/packages/procgen/src/terrain/presets.ts b/packages/procgen/src/terrain/presets.ts index b8ffa0899..232bc9a8d 100644 --- a/packages/procgen/src/terrain/presets.ts +++ b/packages/procgen/src/terrain/presets.ts @@ -7,6 +7,7 @@ import type { TerrainConfig, TerrainPreset } from "./types"; import { DEFAULT_TERRAIN_CONFIG } from "./TerrainGenerator"; +import { DEFAULT_MAX_HEIGHT, DEFAULT_WATER_THRESHOLD } from "./constants"; /** * Small Island preset @@ -20,8 +21,8 @@ export const SMALL_ISLAND_PRESET: TerrainPreset = { config: { tileSize: 100, worldSize: 10, // 1km x 1km - maxHeight: 30, - waterThreshold: 5.4, + maxHeight: DEFAULT_MAX_HEIGHT, + waterThreshold: DEFAULT_WATER_THRESHOLD, island: { enabled: true, maxWorldSizeTiles: 10, @@ -52,8 +53,8 @@ export const LARGE_ISLAND_PRESET: TerrainPreset = { config: { tileSize: 100, worldSize: 100, // 10km x 10km - maxHeight: 30, - waterThreshold: 5.4, + maxHeight: DEFAULT_MAX_HEIGHT, + waterThreshold: DEFAULT_WATER_THRESHOLD, island: { enabled: true, maxWorldSizeTiles: 100, diff --git a/packages/procgen/src/vegetation/types.ts b/packages/procgen/src/vegetation/types.ts index 6a2ce7517..e926c0d8f 100644 --- a/packages/procgen/src/vegetation/types.ts +++ b/packages/procgen/src/vegetation/types.ts @@ -169,8 +169,8 @@ export const DEFAULT_PLACER_CONFIG: VegetationPlacerConfig = { steepSlopeThreshold: 0.6, }; -/** Default water threshold when not provided */ -export const DEFAULT_WATER_THRESHOLD = 5.4; +import { DEFAULT_WATER_THRESHOLD } from "../terrain/constants"; +export { DEFAULT_WATER_THRESHOLD }; /** Generator-like interface for terrain provider adapter */ export interface TerrainGeneratorLike { diff --git a/packages/shared/src/data/world-structure.ts b/packages/shared/src/data/world-structure.ts index 719a31567..f76ebc930 100644 --- a/packages/shared/src/data/world-structure.ts +++ b/packages/shared/src/data/world-structure.ts @@ -117,7 +117,7 @@ export function getTerrainHeight(_x: number, _z: number): number { export const WORLD_STRUCTURE_CONSTANTS = { GRID_SIZE: 4, // Block size for grid-based movement DEFAULT_SPAWN_HEIGHT: 2, - WATER_LEVEL: 0, + WATER_LEVEL: 16, // Must match TERRAIN_CONSTANTS.WATER_THRESHOLD MAX_BUILD_HEIGHT: 100, SAFE_ZONE_RADIUS: 15, // Radius around starter towns with no hostile mobs // Note: Death/respawn timing constants are in COMBAT_CONSTANTS (tick-based) diff --git a/packages/shared/src/systems/shared/world/DockDefinition.ts b/packages/shared/src/systems/shared/world/DockDefinition.ts new file mode 100644 index 000000000..4b9dcf87c --- /dev/null +++ b/packages/shared/src/systems/shared/world/DockDefinition.ts @@ -0,0 +1,44 @@ +/** + * DockDefinition — data for dock placements. + * + * Each dock specifies a position, direction, and dimensions. + * Devs assign exact positions — no automatic shoreline detection. + * + * The direction is a cardinal: "north" | "south" | "east" | "west", + * indicating which way the dock extends out over water. + */ + +export type DockDirection = "north" | "south" | "east" | "west"; + +export interface DockDefinition { + id: string; + /** Shore-side anchor X (where the dock meets land) */ + x: number; + /** Shore-side anchor Z */ + z: number; + /** Cardinal direction the dock extends over water */ + direction: DockDirection; + /** Deck width in meters (tiles across, default 3) */ + width?: number; + /** Deck length in meters (tiles into water, default 12) */ + length?: number; + /** Label shown on interaction (default "Dock") */ + label?: string; +} + +/** + * Dock placements — devs define exact positions and directions. + * Add entries here to place docks anywhere on the map. + */ +export const ISLAND_DOCKS: DockDefinition[] = [ + // Example placements — update coordinates to match your terrain: + // { + // id: "dock_harbor", + // x: 100, + // z: -200, + // direction: "south", + // width: 3, + // length: 14, + // label: "Harbor Dock", + // }, +]; diff --git a/packages/shared/src/systems/shared/world/ProceduralDocks.ts b/packages/shared/src/systems/shared/world/ProceduralDocks.ts index 615a6adad..09d023b70 100644 --- a/packages/shared/src/systems/shared/world/ProceduralDocks.ts +++ b/packages/shared/src/systems/shared/world/ProceduralDocks.ts @@ -27,7 +27,7 @@ import { type GeneratedDock, type DockRecipe, } from "@hyperscape/procgen/items/dock"; -import type { WaterBodyRegistry } from "./WaterBodyRegistry"; +import { ISLAND_DOCKS, type DockDefinition } from "./DockDefinition"; import { CollisionFlag, getOppositeWallFlag } from "../movement/CollisionFlags"; // TSL imports for dock material (client-only, same pattern as BridgeSystem) @@ -50,9 +50,9 @@ import { min as tslMin, } from "three/tsl"; -// Constants +// Constants — single source of truth from GameConstants const WATER_THRESHOLD = TERRAIN_CONSTANTS.WATER_THRESHOLD; -const WATER_LEVEL = 5.0; +const WATER_LEVEL = TERRAIN_CONSTANTS.WATER_THRESHOLD; // ── Dock geometry constants (matching BridgeSystem visual quality) ── const DOCK_POST_CAP_OVERHANG = 0.05; @@ -127,10 +127,6 @@ const dockWoodUV = Fn(() => { return mix(vertUV, deckUV, horiz); }); -const SHORELINE_SAMPLES = 64; -const MAX_SLOPE_FOR_DOCK = 0.4; -const MIN_WATER_DEPTH = 1.5; - /** Pre-allocated test points for isTerrainReady(). */ const TERRAIN_READY_TEST_POINTS: ReadonlyArray<{ x: number; z: number }> = [ { x: 0, z: 0 }, @@ -142,179 +138,6 @@ function dockTileKey(tx: number, tz: number): number { return ((tx + 32768) << 16) | (tz + 32768); } -function snapToCardinal(dx: number, dz: number): { x: number; z: number } { - if (Math.abs(dx) >= Math.abs(dz)) { - return { x: dx >= 0 ? 1 : -1, z: 0 }; - } - return { x: 0, z: dz >= 0 ? 1 : -1 }; -} - -/** Find shoreline points around a water body using binary search */ -function findShorelinePoints( - waterBody: WaterBody, - getTerrainHeight: (x: number, z: number) => number, - samples: number = SHORELINE_SAMPLES, - waterLevel: number = WATER_THRESHOLD, -): ShorelinePoint[] { - const shorelinePoints: ShorelinePoint[] = []; - const { center, radius } = waterBody; - - for (let i = 0; i < samples; i++) { - const angle = (i / samples) * Math.PI * 2; - const dirX = Math.cos(angle); - const dirZ = Math.sin(angle); - - let minDist = radius * 0.3; - let maxDist = radius * 1.5; - let foundShoreline = false; - - for (let step = 0; step < 20; step++) { - const midDist = (minDist + maxDist) / 2; - const x = center.x + dirX * midDist; - const z = center.z + dirZ * midDist; - const height = getTerrainHeight(x, z); - - if (height < waterLevel) { - minDist = midDist; - } else { - maxDist = midDist; - foundShoreline = true; - } - - if (maxDist - minDist < 0.5) break; - } - - if (!foundShoreline) continue; - - const shorelineDist = (minDist + maxDist) / 2; - const shoreX = center.x + dirX * shorelineDist; - const shoreZ = center.z + dirZ * shorelineDist; - const height = getTerrainHeight(shoreX, shoreZ); - - const sampleDist = 1.0; - const heightInward = getTerrainHeight( - shoreX - dirX * sampleDist, - shoreZ - dirZ * sampleDist, - ); - const heightOutward = getTerrainHeight( - shoreX + dirX * sampleDist, - shoreZ + dirZ * sampleDist, - ); - const slope = Math.abs(heightOutward - heightInward) / (sampleDist * 2); - - shorelinePoints.push({ - position: { x: shoreX, y: height, z: shoreZ }, - landwardNormal: { x: dirX, z: dirZ }, - waterwardNormal: { x: -dirX, z: -dirZ }, - height, - slope, - distanceFromCenter: shorelineDist, - }); - } - - return shorelinePoints; -} - -interface PlacementCandidate { - point: ShorelinePoint; - score: number; - components: { - flatness: number; - waterDepth: number; - clearance: number; - orientation: number; - }; -} - -/** Score a shoreline point for dock placement */ -function scorePlacementPoint( - point: ShorelinePoint, - getTerrainHeight: (x: number, z: number) => number, - checkObstacle: (x: number, z: number) => boolean, - dockLength: number, - waterLevel: number = WATER_THRESHOLD, -): PlacementCandidate { - const components = { - flatness: 0, - waterDepth: 0, - clearance: 0, - orientation: 0, - }; - - components.flatness = Math.max(0, 1 - point.slope / MAX_SLOPE_FOR_DOCK); - - const endX = point.position.x + point.waterwardNormal.x * dockLength; - const endZ = point.position.z + point.waterwardNormal.z * dockLength; - const endHeight = getTerrainHeight(endX, endZ); - const waterDepth = waterLevel - endHeight; - - if (waterDepth < MIN_WATER_DEPTH) { - components.waterDepth = 0; - } else { - components.waterDepth = Math.min(1, waterDepth / (MIN_WATER_DEPTH * 2)); - } - - let obstacleCount = 0; - for (let d = 1; d <= 5; d++) { - const checkX = point.position.x + point.landwardNormal.x * d; - const checkZ = point.position.z + point.landwardNormal.z * d; - if (checkObstacle(checkX, checkZ)) { - obstacleCount++; - } - } - components.clearance = Math.max(0, 1 - obstacleCount / 3); - - const facingSouth = point.waterwardNormal.z; - components.orientation = 0.5 + facingSouth * 0.5; - - const weights = { - flatness: 0.35, - waterDepth: 0.3, - clearance: 0.2, - orientation: 0.15, - }; - - const score = - components.flatness * weights.flatness + - components.waterDepth * weights.waterDepth + - components.clearance * weights.clearance + - components.orientation * weights.orientation; - - return { point, score, components }; -} - -/** Select the best dock placement from candidates using weighted random */ -function selectBestPlacement( - candidates: PlacementCandidate[], - seed: number, -): PlacementCandidate | null { - if (candidates.length === 0) return null; - - const viableCandidates = candidates.filter((c) => c.score > 0.4); - if (viableCandidates.length === 0) { - candidates.sort((a, b) => b.score - a.score); - return candidates[0]; - } - - viableCandidates.sort((a, b) => b.score - a.score); - - const topCandidates = viableCandidates.slice(0, 3); - const totalScore = topCandidates.reduce((sum, c) => sum + c.score, 0); - - const seededRandom = (seed * 9301 + 49297) % 233280; - const threshold = (seededRandom / 233280) * totalScore; - - let accumulated = 0; - for (const candidate of topCandidates) { - accumulated += candidate.score; - if (accumulated >= threshold) { - return candidate; - } - } - - return topCandidates[0]; -} - interface DockInstance { id: string; waterBodyId: string; @@ -324,7 +147,6 @@ interface DockInstance { interface TerrainSystemInterface { getHeightAt(x: number, z: number): number; - getWaterBodyRegistry(): WaterBodyRegistry | null; } interface StageSystemInterface { @@ -349,11 +171,7 @@ export class ProceduralDocks extends System { private dockMeshes: THREE.Mesh[] = []; /** Pending dock generation queue — processed one per tick to avoid spikes. */ - private pendingDockQueue: Array<{ - waterBody: WaterBody; - seed: string; - waterSurfaceY: number; - }> = []; + private pendingDockQueue: DockDefinition[] = []; constructor(world: World) { super(world); @@ -408,142 +226,62 @@ export class ProceduralDocks extends System { } /** - * Enqueue docks for all water bodies (ponds + river banks). + * Enqueue docks from ISLAND_DOCKS definitions. * Builds a queue that update() drains one dock per tick to avoid spikes. */ - private enqueueDocks(seed: string = "island-docks"): void { - if (!this.terrainSystem) return; - - const registry = this.terrainSystem.getWaterBodyRegistry(); - if (!registry) return; - - // Elevated ponds/lakes from WaterBodyRegistry - for (const body of registry.getAllBodies()) { - this.pendingDockQueue.push({ - waterBody: { - id: body.id, - type: "pond", - center: { x: body.centerX, z: body.centerZ }, - radius: body.radius, - }, - seed: `${seed}-${body.id}`, - waterSurfaceY: body.surfaceY, - }); + private enqueueDocks(): void { + for (const def of ISLAND_DOCKS) { + this.pendingDockQueue.push(def); } - - // Docks are only placed at elevated ponds/lakes — no rivers or ocean. } /** - * Generate a dock for a specific water body. + * Generate a dock from a developer-assigned DockDefinition. * Works on both client (mesh + collision) and server (collision only). */ - generateDockForWaterBody( - waterBody: WaterBody, - seed: string, - waterSurfaceY?: number, - ): GeneratedDock | null { + generateDockFromDefinition(def: DockDefinition): GeneratedDock | null { if (!this.terrainSystem) return null; - const waterLevel = waterSurfaceY ?? WATER_LEVEL; - - const getTerrainHeight = (x: number, z: number): number => { - return this.terrainSystem!.getHeightAt(x, z); - }; - - const checkObstacle = (x: number, z: number): boolean => { - const height = getTerrainHeight(x, z); - if (height < waterLevel) return true; - - const sampleDist = 0.5; - const heightN = getTerrainHeight(x, z + sampleDist); - const heightS = getTerrainHeight(x, z - sampleDist); - const heightE = getTerrainHeight(x + sampleDist, z); - const heightW = getTerrainHeight(x - sampleDist, z); + const waterLevel = WATER_LEVEL; - const slopeNS = Math.abs(heightN - heightS) / (sampleDist * 2); - const slopeEW = Math.abs(heightE - heightW) / (sampleDist * 2); - const maxSlope = Math.max(slopeNS, slopeEW); - return maxSlope > 0.5; + // Direction vector from definition + const dirMap: Record = { + north: { x: 0, z: -1 }, + south: { x: 0, z: 1 }, + east: { x: 1, z: 0 }, + west: { x: -1, z: 0 }, }; + const waterwardDir = dirMap[def.direction]; + const landwardDir = { x: -waterwardDir.x, z: -waterwardDir.z }; - // Use per-body water level for shoreline detection - const shorelinePoints = findShorelinePoints( - waterBody, - getTerrainHeight, - SHORELINE_SAMPLES, - waterLevel, - ); - - if (shorelinePoints.length === 0) return null; - - const estimatedLength = - (DEFAULT_DOCK_PARAMS.lengthRange[0] + - DEFAULT_DOCK_PARAMS.lengthRange[1]) / - 2; - - const candidates = shorelinePoints.map((point) => - scorePlacementPoint( - point, - getTerrainHeight, - checkObstacle, - estimatedLength, - waterLevel, - ), - ); - - const seedNum = this.hashString(seed); - const selected = selectBestPlacement(candidates, seedNum); - if (!selected) return null; - - // ── Tile-alignment: snap direction to nearest cardinal ── - // This ensures the dock is axis-aligned with the 1m tile grid. - const rawDir = selected.point.waterwardNormal; - const snappedDir = snapToCardinal(rawDir.x, rawDir.z); - const snappedLand = { x: -snappedDir.x, z: -snappedDir.z }; - - // ── Tile-alignment: snap position to tile grid ── - // Extend 3m landward from shoreline, then snap: - // - Along dock direction: start at tile boundary (integer) - // - Perpendicular: centerline at tile center (integer + 0.5) - const landwardOffset = 3; - const rawX = selected.point.position.x + snappedLand.x * landwardOffset; - const rawZ = selected.point.position.z + snappedLand.z * landwardOffset; - - // Dock runs along direction axis. Snap start to tile edge (integer) - // along that axis, and center to tile center (N+0.5) across it. + // Snap anchor to tile grid let snappedX: number; let snappedZ: number; - if (Math.abs(snappedDir.x) > Math.abs(snappedDir.z)) { - // Dock runs along X axis → snap X to integer, Z to N+0.5 - snappedX = Math.round(rawX); - snappedZ = Math.floor(rawZ) + 0.5; + if (Math.abs(waterwardDir.x) > Math.abs(waterwardDir.z)) { + snappedX = Math.round(def.x); + snappedZ = Math.floor(def.z) + 0.5; } else { - // Dock runs along Z axis → snap Z to integer, X to N+0.5 - snappedX = Math.floor(rawX) + 0.5; - snappedZ = Math.round(rawZ); + snappedX = Math.floor(def.x) + 0.5; + snappedZ = Math.round(def.z); } + const anchorY = this.terrainSystem.getHeightAt(def.x, def.z); + const adjustedPoint: ShorelinePoint = { - ...selected.point, - position: { - x: snappedX, - y: selected.point.position.y, - z: snappedZ, - }, - waterwardNormal: snappedDir, - landwardNormal: snappedLand, + position: { x: snappedX, y: anchorY, z: snappedZ }, + waterwardNormal: waterwardDir, + landwardNormal: landwardDir, + height: anchorY, + slope: 0, + distanceFromCenter: 0, }; - // ── Tile-alignment: force width = 3 tiles, integer length ── - const dockWidth = 3.0; - const dockLength = Math.round( - DEFAULT_DOCK_PARAMS.lengthRange[0] + landwardOffset + 4 + (seedNum % 5), // deterministic variation 0-4 extra tiles - ); + const dockWidth = def.width ?? 3.0; + const dockLength = def.length ?? 12; const recipe: DockRecipe = { ...DEFAULT_DOCK_PARAMS, - label: "Dock", + label: def.label ?? "Dock", widthRange: [dockWidth, dockWidth], lengthRange: [dockLength, dockLength], }; @@ -551,18 +289,14 @@ export class ProceduralDocks extends System { const waterFloorDepth = 3.0; const waterFloorY = waterLevel - waterFloorDepth; - // Skip geometry+material creation when there's no scene (server). - // On server we only need layout + collision data from DockGenerator. const isServer = !this.scene; const dock = this.generator.generate(recipe, adjustedPoint, { - seed, + seed: def.id, waterLevel, waterFloorDepth, skipMesh: isServer, }); - // Dispose the DockGenerator's local-space mesh (we build world-space mesh). - // On server with skipMesh, the mesh is an empty Group — dispose is a no-op. dock.mesh.traverse((child) => { if (child instanceof THREE.Mesh) { child.geometry.dispose(); @@ -572,7 +306,6 @@ export class ProceduralDocks extends System { } }); - // Build world-space mesh (client only — server has no scene) let worldMesh: THREE.Mesh | null = null; if (this.scene) { worldMesh = this.buildDockMeshWorldSpace( @@ -587,13 +320,11 @@ export class ProceduralDocks extends System { } } - // Register collision flags and deck heights this.registerDockCollision(dock); - // Store instance const instance: DockInstance = { - id: `dock-${waterBody.id}`, - waterBodyId: waterBody.id, + id: `dock-${def.id}`, + waterBodyId: def.id, dock, mesh: worldMesh, }; @@ -1479,16 +1210,6 @@ export class ProceduralDocks extends System { this.dockDeckHeights.clear(); } - private hashString(str: string): number { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash = hash & hash; - } - return Math.abs(hash); - } - update(_deltaTime: number): void { if (this.docksGenerated) return; @@ -1503,13 +1224,9 @@ export class ProceduralDocks extends System { // Process one dock per tick to avoid synchronous spikes if (this.pendingDockQueue.length > 0) { - const item = this.pendingDockQueue.shift()!; + const def = this.pendingDockQueue.shift()!; try { - this.generateDockForWaterBody( - item.waterBody, - item.seed, - item.waterSurfaceY, - ); + this.generateDockFromDefinition(def); } catch (err) { console.error("[ProceduralDocks] Error generating dock:", err); } diff --git a/packages/shared/src/systems/shared/world/TerrainShader.ts b/packages/shared/src/systems/shared/world/TerrainShader.ts index b0b576629..a02e52930 100644 --- a/packages/shared/src/systems/shared/world/TerrainShader.ts +++ b/packages/shared/src/systems/shared/world/TerrainShader.ts @@ -48,6 +48,7 @@ import { } from "../../../extras/three/three"; import { getRoadInfluenceTextureState } from "./RoadInfluenceMask"; import { getLamppostLightTextureState } from "./LamppostLightMask"; +import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants"; import { FOG_NEAR_SQ, FOG_FAR_SQ, fogRenderTarget } from "./FogConfig"; import { SUN_LIGHT, SUN_SHADE } from "./LightingConfig"; @@ -58,7 +59,7 @@ export const TERRAIN_SHADER_CONSTANTS = { DIRT_THRESHOLD: 0.5, LOD_FULL_DETAIL: 100.0, LOD_MEDIUM_DETAIL: 200.0, - WATER_LEVEL: 5.0, + WATER_LEVEL: TERRAIN_CONSTANTS.WATER_THRESHOLD, DISTORT_NOISE_SCALE: 0.067, VARIATION_NOISE_SCALE: 0.0015, ROCK_DISTORT_STRENGTH: 0.5, diff --git a/packages/shared/src/systems/shared/world/WaterSystem.ts b/packages/shared/src/systems/shared/world/WaterSystem.ts index f42e70d6a..3060709ea 100644 --- a/packages/shared/src/systems/shared/world/WaterSystem.ts +++ b/packages/shared/src/systems/shared/world/WaterSystem.ts @@ -48,6 +48,7 @@ import type { TerrainTile } from "../../../types/world/terrain"; import type { Wind } from "./Wind"; import { FOG_NEAR_SQ, FOG_FAR_SQ, fogRenderTarget } from "./FogConfig"; import { SUN_SHADE, NIGHT, applySunShade } from "./LightingConfig"; +import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants"; // ============================================================================ // CONFIGURATION @@ -184,7 +185,7 @@ export class WaterSystem { // TSL planar reflection (Three.js ReflectorNode handles camera, RT, clipping) private reflection?: ReturnType; - private waterLevel = 5; + private waterLevel: number = TERRAIN_CONSTANTS.WATER_THRESHOLD; private waterMeshes: THREE.Mesh[] = []; private reflectionActive = false; From 1d9589b8a478747d74db197c6cbe985aceeb8c48 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Wed, 25 Mar 2026 01:53:23 +0800 Subject: [PATCH 43/71] feat: use rotation angle for docks, add test dock and bridge placements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace cardinal direction with compass bearing (degrees) for dock rotation - Add test dock at (1075.5, 1172.5) facing south - Replace stale bridge coordinates with river crossing at (877.5, 512.5) → (1057.5, 603.5) Made-with: Cursor --- .../systems/shared/world/BridgeDefinition.ts | 47 +++---------------- .../systems/shared/world/DockDefinition.ts | 31 ++++++------ .../systems/shared/world/ProceduralDocks.ts | 24 +++------- 3 files changed, 27 insertions(+), 75 deletions(-) diff --git a/packages/shared/src/systems/shared/world/BridgeDefinition.ts b/packages/shared/src/systems/shared/world/BridgeDefinition.ts index e6ff6399f..50a844293 100644 --- a/packages/shared/src/systems/shared/world/BridgeDefinition.ts +++ b/packages/shared/src/systems/shared/world/BridgeDefinition.ts @@ -33,47 +33,14 @@ export interface BridgeDefinition { */ export const ISLAND_BRIDGES: BridgeDefinition[] = [ { - id: "bridge_west", - startX: -330, - startZ: -100, - endX: -330, - endZ: -60, - width: 4, + id: "bridge_river_crossing", + startX: 877.5, + startZ: 512.5, + endX: 1057.5, + endZ: 603.5, + width: 8, railingHeight: 1.2, - archHeight: 1.0, - style: "wood", - }, - { - id: "bridge_central", - startX: -60, - startZ: -150, - endX: -60, - endZ: -110, - width: 4.5, - railingHeight: 1.2, - archHeight: 1.2, - style: "wood", - }, - { - id: "bridge_east", - startX: 230, - startZ: -150, - endX: 230, - endZ: -110, - width: 4, - railingHeight: 1.2, - archHeight: 1.0, - style: "wood", - }, - { - id: "bridge_coastal", - startX: 440, - startZ: -70, - endX: 440, - endZ: -20, - width: 4, - railingHeight: 1.2, - archHeight: 0.8, + archHeight: 2.0, style: "wood", }, ]; diff --git a/packages/shared/src/systems/shared/world/DockDefinition.ts b/packages/shared/src/systems/shared/world/DockDefinition.ts index 4b9dcf87c..669634f1e 100644 --- a/packages/shared/src/systems/shared/world/DockDefinition.ts +++ b/packages/shared/src/systems/shared/world/DockDefinition.ts @@ -1,23 +1,21 @@ /** * DockDefinition — data for dock placements. * - * Each dock specifies a position, direction, and dimensions. + * Each dock specifies a position, rotation, and dimensions. * Devs assign exact positions — no automatic shoreline detection. * - * The direction is a cardinal: "north" | "south" | "east" | "west", - * indicating which way the dock extends out over water. + * `rotation` is degrees (compass bearing) for the direction the dock + * extends over water: 0° = north (−Z), 90° = east (+X), 180° = south (+Z), 270° = west (−X). */ -export type DockDirection = "north" | "south" | "east" | "west"; - export interface DockDefinition { id: string; /** Shore-side anchor X (where the dock meets land) */ x: number; /** Shore-side anchor Z */ z: number; - /** Cardinal direction the dock extends over water */ - direction: DockDirection; + /** Compass bearing in degrees — direction the dock extends over water */ + rotation: number; /** Deck width in meters (tiles across, default 3) */ width?: number; /** Deck length in meters (tiles into water, default 12) */ @@ -31,14 +29,13 @@ export interface DockDefinition { * Add entries here to place docks anywhere on the map. */ export const ISLAND_DOCKS: DockDefinition[] = [ - // Example placements — update coordinates to match your terrain: - // { - // id: "dock_harbor", - // x: 100, - // z: -200, - // direction: "south", - // width: 3, - // length: 14, - // label: "Harbor Dock", - // }, + { + id: "dock_test", + x: 1075.5, + z: 1172.5, + rotation: 180, // south + width: 3, + length: 12, + label: "Dock", + }, ]; diff --git a/packages/shared/src/systems/shared/world/ProceduralDocks.ts b/packages/shared/src/systems/shared/world/ProceduralDocks.ts index 09d023b70..086a5ece6 100644 --- a/packages/shared/src/systems/shared/world/ProceduralDocks.ts +++ b/packages/shared/src/systems/shared/world/ProceduralDocks.ts @@ -244,26 +244,14 @@ export class ProceduralDocks extends System { const waterLevel = WATER_LEVEL; - // Direction vector from definition - const dirMap: Record = { - north: { x: 0, z: -1 }, - south: { x: 0, z: 1 }, - east: { x: 1, z: 0 }, - west: { x: -1, z: 0 }, - }; - const waterwardDir = dirMap[def.direction]; + // Convert compass bearing (degrees) to direction vector + // 0° = north (−Z), 90° = east (+X), 180° = south (+Z), 270° = west (−X) + const rad = (def.rotation * Math.PI) / 180; + const waterwardDir = { x: Math.sin(rad), z: Math.cos(rad) }; const landwardDir = { x: -waterwardDir.x, z: -waterwardDir.z }; - // Snap anchor to tile grid - let snappedX: number; - let snappedZ: number; - if (Math.abs(waterwardDir.x) > Math.abs(waterwardDir.z)) { - snappedX = Math.round(def.x); - snappedZ = Math.floor(def.z) + 0.5; - } else { - snappedX = Math.floor(def.x) + 0.5; - snappedZ = Math.round(def.z); - } + const snappedX = def.x; + const snappedZ = def.z; const anchorY = this.terrainSystem.getHeightAt(def.x, def.z); From 906707f8f84742d4dec946150c9e4daee254c865 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Wed, 25 Mar 2026 12:41:32 +0800 Subject: [PATCH 44/71] night brightness --- packages/shared/src/systems/shared/world/LightingConfig.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/systems/shared/world/LightingConfig.ts b/packages/shared/src/systems/shared/world/LightingConfig.ts index 0dde6126c..09dfac63c 100644 --- a/packages/shared/src/systems/shared/world/LightingConfig.ts +++ b/packages/shared/src/systems/shared/world/LightingConfig.ts @@ -87,7 +87,7 @@ export const SUN_SHADE = { export const NIGHT = { /** Master night brightness (0 = pitch black, 1 = full day brightness). * Controls both the tree shader nightDim floor and scene light intensity bases. */ - BRIGHTNESS: 0.5, + BRIGHTNESS: 0.8, } as const; // ============================================================================ From 09a3f66fa36e49ef74f136e6efd68453ff2fb243 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Wed, 25 Mar 2026 14:18:12 +0800 Subject: [PATCH 45/71] fix: improve tree vertex-color AO with bark/leaf channel support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Robust vertex-color detection (check geometry fallback) - Read R channel as bark/leaf mask for differentiated AO floor - Soften AO power curve (1.8→1.4) and raise dark floor (0.35→0.45) to prevent overly dark canopy interiors Made-with: Cursor --- .../src/systems/shared/world/GPUMaterials.ts | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index f3ae1acb6..0f0d29088 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -974,7 +974,13 @@ export function createTreeDissolveMaterial( const material = baseDm as unknown as THREE.MeshStandardNodeMaterial; const isLeaf = options.isLeafMaterial ?? false; - const hasVertexColors = !!(source as any).vertexColors; + // Vertex-color detection: check material flag AND geometry attribute. + // GLTFLoader sets material.vertexColors=true when COLOR_0 is present, + // but we also check geometry as a fallback for manual mesh construction. + const srcMat = source as any; + const hasVertexColors = + !!srcMat.vertexColors || + !!(srcMat._geometry ?? srcMat.geometry)?.attributes?.color; material.vertexColors = false; // --- Uniforms --- @@ -988,8 +994,10 @@ export function createTreeDissolveMaterial( const uWindDir = uniform(new THREE.Vector2(1, 0)); // --- Tuning --- - const AO_POWER = 1.8; - const AO_DARK = 0.35; + // Vertex color channels: R = bark/leaf mask (1=bark, 0=leaf), G = AO, B = unused + const AO_POWER = 1.4; + const AO_DARK = 0.45; + const AO_BARK_DARK = 0.55; const SAT_BOOST = 1.15; const HL_BRIGHTEN = 0.08; const HL_RIM_POWER = 2.5; @@ -1061,10 +1069,16 @@ export function createTreeDissolveMaterial( let baseAlbedo: any = mul(albedoSample.rgb, matColor); // ---- Vertex-color AO ---- + // R = bark/leaf mask (1 = bark, 0 = leaf), G = ambient occlusion (0 = occluded, 1 = exposed) + // Bark gets a lighter AO floor (AO_BARK_DARK) because deep crevice darkening + // looks wrong on solid trunk geometry; leaves use the stronger AO_DARK. if (hasVertexColors) { - const aoRaw = attribute("color", "vec3").y; + const vtxColor = attribute("color", "vec3"); + const aoRaw = vtxColor.y; + const barkMask = vtxColor.x; const aoFactor = pow(aoRaw, float(AO_POWER)); - const aoMul = mix(float(AO_DARK), float(1.0), aoFactor); + const aoDarkFloor = mix(float(AO_DARK), float(AO_BARK_DARK), barkMask); + const aoMul = mix(aoDarkFloor, float(1.0), aoFactor); baseAlbedo = mul(baseAlbedo, aoMul); } From 88a857931663e7e1d3af79cbb6175296222401ca Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Wed, 25 Mar 2026 17:40:30 +0800 Subject: [PATCH 46/71] feat: add dead pine tree type and increase slope limits - Replace WindPine with PineDead tree type (snowCapable property) - Update tundra/forest/canyon biome tree configs and densities - Add client-side resource entity spawning in ResourceSystem - Increase MAX_WALKABLE_SLOPE to 2.5 and slopeLimit to 60 degrees - Update test expectations for new slope values Made-with: Cursor --- .../shared/src/constants/GameConstants.ts | 4 +- packages/shared/src/constants/TreeTypes.ts | 6 +- .../systems/shared/entities/ResourceSystem.ts | 94 ++++++++++++++++++- .../systems/shared/world/TerrainBiomeTypes.ts | 32 ++----- .../BuildingTerrainInteraction.test.ts | 4 +- .../shared/src/utils/physics/MovementUtils.ts | 2 +- 6 files changed, 111 insertions(+), 31 deletions(-) diff --git a/packages/shared/src/constants/GameConstants.ts b/packages/shared/src/constants/GameConstants.ts index 91078c98f..20f1cef27 100644 --- a/packages/shared/src/constants/GameConstants.ts +++ b/packages/shared/src/constants/GameConstants.ts @@ -102,9 +102,9 @@ export const TERRAIN_CONSTANTS = { /** * Maximum slope for walkable terrain (tan of angle). * Slopes steeper than this block movement. - * 1.5 ≈ 56 degree angle. + * 2.5 ≈ 68 degree angle. */ - MAX_WALKABLE_SLOPE: 1.5, + MAX_WALKABLE_SLOPE: 2.5, /** * Distance to sample for slope calculation (in meters). diff --git a/packages/shared/src/constants/TreeTypes.ts b/packages/shared/src/constants/TreeTypes.ts index 30e59362d..3232f8b5e 100644 --- a/packages/shared/src/constants/TreeTypes.ts +++ b/packages/shared/src/constants/TreeTypes.ts @@ -29,7 +29,7 @@ export enum TreeId { Banana = "tree_banana", Dead = "tree_dead", Knotwood = "tree_knotwood", - WindPine = "tree_windPine", + PineDead = "tree_pineDead", } /** Extract the subtype key from a TreeId (e.g. TreeId.Oak → "oak") */ @@ -71,6 +71,8 @@ export interface TreeTypeDefinition { name: string; /** Woodcutting level required to chop */ levelRequired: number; + /** Model vertex-color R channel encodes snow mask (used by tundra biome shader) */ + snowCapable?: boolean; } /** @@ -89,7 +91,7 @@ export const TREE_TYPES = { banana: { name: "Banana Tree", levelRequired: 1 }, dead: { name: "Dead Tree", levelRequired: 1 }, knotwood: { name: "Knotwood Tree", levelRequired: 1 }, - windPine: { name: "Wind Pine", levelRequired: 1 }, + pineDead: { name: "Dead Pine", levelRequired: 1, snowCapable: true }, } as const satisfies Record; /** All valid tree subtype keys (e.g., "oak", "willow") */ diff --git a/packages/shared/src/systems/shared/entities/ResourceSystem.ts b/packages/shared/src/systems/shared/entities/ResourceSystem.ts index 1cce1d696..e861a949b 100644 --- a/packages/shared/src/systems/shared/entities/ResourceSystem.ts +++ b/packages/shared/src/systems/shared/entities/ResourceSystem.ts @@ -861,8 +861,100 @@ export class ResourceSystem extends SystemBase { if (spawnPoints.length === 0) return; - // Only spawn actual entities on the server (authoritative) if (!this.world.isServer) { + for (const spawnPoint of spawnPoints) { + const resource = this.createResourceFromSpawnPoint(spawnPoint); + if (!resource) continue; + + const rid = createResourceID(resource.id); + this.resources.set(rid, resource); + if (isManifest) this.manifestResourceIds.add(rid); + + const yRotation = spawnPoint.rotation ?? Math.random() * Math.PI * 2; + const footprint: ResourceFootprint = resource.footprint || "standard"; + const anchorTile = worldToTile( + resource.position.x, + resource.position.z, + ); + const occupiedTiles = this.getOccupiedTiles(anchorTile, footprint); + const baseScale = this.getScaleForResource( + resource.type, + spawnPoint.subType, + ); + const scaleVariation = spawnPoint.scale ?? 1.0; + const finalScale = baseScale * scaleVariation; + const baseDepletedScale = this.getDepletedScaleForResource( + resource.type, + spawnPoint.subType, + ); + const finalDepletedScale = baseDepletedScale * scaleVariation; + + const entityData = { + id: resource.id, + type: "resource" as const, + name: resource.name, + position: [ + resource.position.x, + resource.position.y, + resource.position.z, + ] as [number, number, number], + quaternion: [ + 0, + Math.sin(yRotation / 2), + 0, + Math.cos(yRotation / 2), + ] as [number, number, number, number], + scale: { x: 1, y: 1, z: 1 }, + visible: true, + interactable: true, + interactionType: "harvest", + interactionDistance: 3, + description: `${resource.name} - Requires level ${resource.levelRequired} ${resource.skillRequired}`, + model: this.getModelPathForResource( + resource.type, + spawnPoint.subType, + ), + properties: {}, + resourceType: resource.type === "ore" ? "mining_rock" : resource.type, + resourceId: spawnPoint.subType + ? `${resource.type}_${spawnPoint.subType}` + : `${resource.type}_normal`, + harvestSkill: resource.skillRequired, + requiredLevel: resource.levelRequired, + harvestTime: 3000, + harvestYield: resource.drops.map((drop) => ({ + itemId: drop.itemId, + quantity: drop.quantity, + chance: drop.chance, + })), + respawnTime: resource.respawnTime, + depleted: false, + depletedModelPath: this.getDepletedModelPathForResource( + resource.type, + spawnPoint.subType, + ), + modelScale: finalScale, + depletedModelScale: finalDepletedScale, + lod1Model: this.getLod1ModelPathForResource( + resource.type, + spawnPoint.subType, + ), + lod1ModelScale: finalScale, + procgenPreset: this.getProcgenPresetForResource( + resource.type, + spawnPoint.subType, + ), + modelVariants: this.getModelVariantsForResource( + resource.type, + spawnPoint.subType, + ), + footprint, + anchorTile, + occupiedTiles, + }; + + this.world.entities.add(entityData); + } return; } diff --git a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts index 38d73b65c..f43a2ca63 100644 --- a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts +++ b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts @@ -42,23 +42,11 @@ const FOREST_TREE_CONFIG: BiomeTreeConfig = { [TreeId.Oak]: { weight: 30, minHeight: 33.6, maxHeight: 60 }, [TreeId.Birch]: { weight: 25, minHeight: 33.6, maxHeight: 60 }, [TreeId.Pine]: { weight: 20, minHeight: 60 }, - [TreeId.Knotwood]: { weight: 5, minHeight: 35, maxHeight: 60 }, - [TreeId.Maple]: { weight: 5, minHeight: 35, maxHeight: 60 }, - [TreeId.Palm]: { - weight: 25, - waterAffinity: 0.8, - waterSearchRadius: 100, - waterMaxDistance: 80, - }, - [TreeId.Banana]: { - weight: 25, - waterAffinity: 0.7, - waterSearchRadius: 100, - waterMaxDistance: 80, - }, + [TreeId.Knotwood]: { weight: 1, minHeight: 35, maxHeight: 60 }, + [TreeId.Maple]: { weight: 1, minHeight: 35, maxHeight: 60 }, }, - density: 10, - minSpacing: 50, + density: 3, + minSpacing: 150, clustering: false, scaleVariation: [0.8, 1.2], maxSlope: 1.5, @@ -82,8 +70,8 @@ const CANYON_TREE_CONFIG: BiomeTreeConfig = { waterMaxDistance: 80, }, }, - density: 6, - minSpacing: 50, + density: 1, + minSpacing: 1050, clustering: false, scaleVariation: [0.7, 1.3], maxSlope: 0.1, @@ -92,13 +80,11 @@ const CANYON_TREE_CONFIG: BiomeTreeConfig = { const TUNDRA_TREE_CONFIG: BiomeTreeConfig = { enabled: true, trees: { - [TreeId.WindPine]: { weight: 40, minHeight: 38 }, - [TreeId.Fir]: { weight: 30, minHeight: 35 }, + [TreeId.PineDead]: { weight: 40, minHeight: 38 }, [TreeId.Pine]: { weight: 20, minHeight: 35 }, - [TreeId.Birch]: { weight: 10, maxHeight: 55 }, }, - density: 10, - minSpacing: 50, + density: 3, + minSpacing: 150, clustering: false, scaleVariation: [0.6, 1.0], maxSlope: 1.5, diff --git a/packages/shared/src/systems/shared/world/__tests__/BuildingTerrainInteraction.test.ts b/packages/shared/src/systems/shared/world/__tests__/BuildingTerrainInteraction.test.ts index 38685d6fb..b1d4807ec 100644 --- a/packages/shared/src/systems/shared/world/__tests__/BuildingTerrainInteraction.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/BuildingTerrainInteraction.test.ts @@ -932,7 +932,7 @@ describe("TERRAIN_CONSTANTS Centralization", () => { it("should have consistent MAX_WALKABLE_SLOPE value", async () => { const { TERRAIN_CONSTANTS } = await import("@hyperscape/shared"); - // MAX_WALKABLE_SLOPE should be 1.5 - expect(TERRAIN_CONSTANTS.MAX_WALKABLE_SLOPE).toBe(1.5); + // MAX_WALKABLE_SLOPE should be 2.5 + expect(TERRAIN_CONSTANTS.MAX_WALKABLE_SLOPE).toBe(2.5); }); }); diff --git a/packages/shared/src/utils/physics/MovementUtils.ts b/packages/shared/src/utils/physics/MovementUtils.ts index a7df021b3..dce497f86 100644 --- a/packages/shared/src/utils/physics/MovementUtils.ts +++ b/packages/shared/src/utils/physics/MovementUtils.ts @@ -48,7 +48,7 @@ export const MovementConfig: IMovementConfig = { stepHeight: 0.3, /** Maximum slope angle player can walk up (degrees) */ - slopeLimit: 45, + slopeLimit: 60, // ============================================ // Networking Configuration From e5bfcec367e69a4d8d04158a751ab6d7e8bea2af Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Wed, 25 Mar 2026 18:52:16 +0800 Subject: [PATCH 47/71] feat: add snow pine tree type with biome-driven snow shader - Add PineSnow tree type with snowVertexData flag for R-channel snow masks - Make pine and pineDead snowCapable with normal-based fallback shader - Split snow shader into two paths: explicit R-channel (pineSnow) vs normal-based fallback (pine, pineDead) to avoid cross-contamination - Encode tundra biome weight into instance color blue channel for per-tree snow strength - Hard-edge smoothstep on final snow blend for solid coverage - Update tundra biome tree config with snow pine distribution Made-with: Cursor --- packages/shared/src/constants/TreeTypes.ts | 13 ++- .../shared/world/GLBTreeBatchedInstancer.ts | 69 +++++++++++++-- .../src/systems/shared/world/GPUMaterials.ts | 83 +++++++++++++++---- .../systems/shared/world/TerrainBiomeTypes.ts | 5 +- 4 files changed, 145 insertions(+), 25 deletions(-) diff --git a/packages/shared/src/constants/TreeTypes.ts b/packages/shared/src/constants/TreeTypes.ts index 3232f8b5e..d54cc94b1 100644 --- a/packages/shared/src/constants/TreeTypes.ts +++ b/packages/shared/src/constants/TreeTypes.ts @@ -30,6 +30,7 @@ export enum TreeId { Dead = "tree_dead", Knotwood = "tree_knotwood", PineDead = "tree_pineDead", + PineSnow = "tree_pineSnow", } /** Extract the subtype key from a TreeId (e.g. TreeId.Oak → "oak") */ @@ -71,8 +72,10 @@ export interface TreeTypeDefinition { name: string; /** Woodcutting level required to chop */ levelRequired: number; - /** Model vertex-color R channel encodes snow mask (used by tundra biome shader) */ + /** Tree can receive snow in tundra biome (via R-channel or normal fallback) */ snowCapable?: boolean; + /** Model vertex-color R channel contains explicit snow mask data */ + snowVertexData?: boolean; } /** @@ -83,7 +86,7 @@ export interface TreeTypeDefinition { */ export const TREE_TYPES = { fir: { name: "Fir Tree", levelRequired: 1 }, - pine: { name: "Pine Tree", levelRequired: 1 }, + pine: { name: "Pine Tree", levelRequired: 1, snowCapable: true }, oak: { name: "Oak Tree", levelRequired: 15 }, birch: { name: "Birch Tree", levelRequired: 1 }, maple: { name: "Maple Tree", levelRequired: 45 }, @@ -92,6 +95,12 @@ export const TREE_TYPES = { dead: { name: "Dead Tree", levelRequired: 1 }, knotwood: { name: "Knotwood Tree", levelRequired: 1 }, pineDead: { name: "Dead Pine", levelRequired: 1, snowCapable: true }, + pineSnow: { + name: "Snow Pine", + levelRequired: 1, + snowCapable: true, + snowVertexData: true, + }, } as const satisfies Record; /** All valid tree subtype keys (e.g., "oak", "willow") */ diff --git a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts index 9306bc910..8fd325f20 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts @@ -16,6 +16,8 @@ import THREE from "../../../extras/three/three"; import type { World } from "../../../core/World"; +import { TREE_TYPES, treeIdToSubType } from "../../../constants/TreeTypes"; +import type { TreeTypeDefinition } from "../../../constants/TreeTypes"; import { modelCache } from "../../../utils/rendering/ModelCache"; import { createTreeDissolveMaterial, @@ -36,6 +38,8 @@ const _scale = new THREE.Vector3(); const _defaultColor = new THREE.Color(1, 1, 1); const _hlColor = new THREE.Color(1.15, 1.15, 1.15); +const _snowColor = new THREE.Color(); +const _snowHlColor = new THREE.Color(); interface TreeSlot { entityId: string; @@ -47,6 +51,7 @@ interface TreeSlot { currentLOD: 0 | 1 | 2; depleted: boolean; variantIndex: number; + snowWeight: number; } interface BatchedLODPool { @@ -74,6 +79,7 @@ interface TreeTypePool { depletedYOffset: number; modelHeight: number; modelRadius: number; + snowCapable: boolean; } const resourceLOD = getLODDistances("resource"); @@ -326,6 +332,12 @@ async function ensureTreeTypePool( if (pending) return pending; const promise = (async (): Promise => { + const treeDef = (TREE_TYPES as Record)[ + treeType + ]; + const isSnowCapable = !!treeDef?.snowCapable; + const hasSnowVertexData = !!treeDef?.snowVertexData; + const dissolveOpts = { fadeStart: GPU_VEG_CONFIG.FADE_START, fadeEnd: GPU_VEG_CONFIG.FADE_END, @@ -343,6 +355,8 @@ async function ensureTreeTypePool( ...dissolveOpts, batched: true, isLeafMaterial: isLeaf, + enableSnow: isSnowCapable, + snowVertexData: hasSnowVertexData, } as TreeMaterialOptions); dm.side = THREE.DoubleSide; enableTextureRepeat(dm); @@ -469,6 +483,7 @@ async function ensureTreeTypePool( depletedYOffset: 0, modelHeight: bounds.height, modelRadius: bounds.radius, + snowCapable: isSnowCapable, }; pools.set(treeType, pool); @@ -561,7 +576,10 @@ function addToPool( entityId: string, mat: THREE.Matrix4, variantIndex: number, + snowWeight = 0, ): void { + const color = + snowWeight > 0 ? _snowColor.setRGB(1, 1, snowWeight) : _defaultColor; const ids: number[] = []; for (let i = 0; i < pool.batches.length; i++) { const numVariants = pool.geometryIds[i].length; @@ -575,7 +593,7 @@ function addToPool( } const instId = pool.batches[i].addInstance(geoId); pool.batches[i].setMatrixAt(instId, mat); - pool.batches[i].setColorAt(instId, _defaultColor); + pool.batches[i].setColorAt(instId, color); ids.push(instId); } pool.instanceIds.set(entityId, ids); @@ -603,10 +621,18 @@ function applyHighlightColor( pool: BatchedLODPool, entityId: string, on: boolean, + snowWeight = 0, ): void { const ids = pool.instanceIds.get(entityId); if (!ids) return; - const color = on ? _hlColor : _defaultColor; + let color: THREE.Color; + if (snowWeight > 0) { + color = on + ? _snowHlColor.setRGB(1.15, 1.15, snowWeight) + : _snowColor.setRGB(1, 1, snowWeight); + } else { + color = on ? _hlColor : _defaultColor; + } for (let i = 0; i < pool.batches.length; i++) { pool.batches[i].setColorAt(ids[i], color); } @@ -671,6 +697,25 @@ export async function addInstance( } } + let snowWeight = 0; + if (pool.snowCapable) { + const terrain = world!.getSystem("terrain"); + if (terrain?.computeBiomeWeightsByPosition) { + const weights = terrain.computeBiomeWeightsByPosition( + position.x, + position.z, + ) as Record; + const totalWeight = Object.values(weights).reduce( + (a: number, b: number) => a + b, + 0, + ); + snowWeight = + totalWeight > 0 ? (weights["tundra"] ?? 0) / totalWeight : 0; + } else { + snowWeight = 1.0; + } + } + const slot: TreeSlot = { entityId, position: position.clone(), @@ -681,6 +726,7 @@ export async function addInstance( currentLOD: initialLOD, depleted: false, variantIndex, + snowWeight, }; pool.instances.set(entityId, slot); @@ -689,7 +735,8 @@ export async function addInstance( const mat = composeInstanceMatrix(position, rotation, scale, pool.yOffset); const initialPool = initialLOD === 0 ? pool.lod0 : initialLOD === 1 ? pool.lod1 : pool.lod2; - if (initialPool) addToPool(initialPool, entityId, mat, variantIndex); + if (initialPool) + addToPool(initialPool, entityId, mat, variantIndex, snowWeight); return true; } catch (error) { @@ -769,7 +816,8 @@ export function setDepleted(entityId: string, depleted: boolean): void { : slot.currentLOD === 1 ? pool.lod1 : pool.lod2; - if (lodPool) addToPool(lodPool, entityId, mat, slot.variantIndex); + if (lodPool) + addToPool(lodPool, entityId, mat, slot.variantIndex, slot.snowWeight); } } @@ -813,7 +861,7 @@ export function setHighlight(entityId: string, on: boolean): void { const lodPool = getLodPool(pool, slot); if (!lodPool) return; - applyHighlightColor(lodPool, entityId, on); + applyHighlightColor(lodPool, entityId, on, slot.snowWeight); highlightedEntityId = on ? entityId : null; } @@ -879,8 +927,15 @@ export function updateGLBTreeBatchedInstancer(): void { slot.scale, slot.yOffset, ); - addToPool(newPool, slot.entityId, mat, slot.variantIndex); - if (wasHl) applyHighlightColor(newPool, slot.entityId, true); + addToPool( + newPool, + slot.entityId, + mat, + slot.variantIndex, + slot.snowWeight, + ); + if (wasHl) + applyHighlightColor(newPool, slot.entityId, true, slot.snowWeight); } } } diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index 0f0d29088..9892b8b01 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -924,6 +924,10 @@ export function applyRimHighlight( export type TreeMaterialOptions = DissolveMaterialOptions & { /** Whether this material covers leaf geometry (enables wind + SSS) */ isLeafMaterial?: boolean; + /** Enable snow blending driven by per-instance biome weight */ + enableSnow?: boolean; + /** Model has explicit snow mask in vertex-color R channel (skip normal fallback) */ + snowVertexData?: boolean; }; /** @@ -981,7 +985,7 @@ export function createTreeDissolveMaterial( const hasVertexColors = !!srcMat.vertexColors || !!(srcMat._geometry ?? srcMat.geometry)?.attributes?.color; - material.vertexColors = false; + material.vertexColors = hasVertexColors; // --- Uniforms --- const uSunDir = uniform(new THREE.Vector3(...SUN_LIGHT.DEFAULT_DIRECTION)); @@ -993,11 +997,25 @@ export function createTreeDissolveMaterial( const uWindStrength = uniform(0.3); const uWindDir = uniform(new THREE.Vector2(1, 0)); + const enableSnow = options.enableSnow ?? false; + const snowVertexData = options.snowVertexData ?? false; + // --- Tuning --- - // Vertex color channels: R = bark/leaf mask (1=bark, 0=leaf), G = AO, B = unused - const AO_POWER = 1.4; - const AO_DARK = 0.45; - const AO_BARK_DARK = 0.55; + // Vertex color channels (non-snow): R = bark/leaf mask (1=bark, 0=leaf), G = AO, B = unused + // Vertex color channels (snow vtx): R = snow mask (0=no snow, 1=full snow), G = AO, B = unused + const AO_POWER = 1.6; + const AO_DARK = 0.35; + const AO_BARK_DARK = 0.45; + + // Snow tuning — R-channel path (models with explicit snow vertex data) + const SNOW_COLOR: [number, number, number] = [0.92, 0.95, 0.98]; + const SNOW_AO_TINT: [number, number, number] = [0.55, 0.6, 0.72]; + const SNOW_SMOOTH_LO = 0.05; + const SNOW_SMOOTH_HI = 0.15; + // Normal-based fallback (models WITHOUT R-channel snow data) + const SNOW_NORMAL_LO = 0.05; + const SNOW_NORMAL_HI = 0.35; + const SNOW_NORMAL_STRENGTH = 3.5; const SAT_BOOST = 1.15; const HL_BRIGHTEN = 0.08; const HL_RIM_POWER = 2.5; @@ -1068,18 +1086,55 @@ export function createTreeDissolveMaterial( : vec4(1, 1, 1, 1); let baseAlbedo: any = mul(albedoSample.rgb, matColor); - // ---- Vertex-color AO ---- - // R = bark/leaf mask (1 = bark, 0 = leaf), G = ambient occlusion (0 = occluded, 1 = exposed) - // Bark gets a lighter AO floor (AO_BARK_DARK) because deep crevice darkening - // looks wrong on solid trunk geometry; leaves use the stronger AO_DARK. + // ---- Vertex-color AO (+ optional snow) ---- if (hasVertexColors) { const vtxColor = attribute("color", "vec3"); const aoRaw = vtxColor.y; - const barkMask = vtxColor.x; - const aoFactor = pow(aoRaw, float(AO_POWER)); - const aoDarkFloor = mix(float(AO_DARK), float(AO_BARK_DARK), barkMask); - const aoMul = mix(aoDarkFloor, float(1.0), aoFactor); - baseAlbedo = mul(baseAlbedo, aoMul); + + if (enableSnow) { + const aoFactor = pow(aoRaw, float(AO_POWER)); + const aoMul = mix(float(AO_BARK_DARK), float(1.0), aoFactor); + baseAlbedo = mul(baseAlbedo, aoMul); + + let snowMask: any; + if (snowVertexData) { + // Explicit R-channel snow mask (pineSnow models) + const rawSnowMask = vtxColor.x; + snowMask = smoothstep( + float(SNOW_SMOOTH_LO), + float(SNOW_SMOOTH_HI), + rawSnowMask, + ); + } else { + // Normal-based fallback (pine, pineDead — no R-channel snow data) + const upFacing = smoothstep( + float(SNOW_NORMAL_LO), + float(SNOW_NORMAL_HI), + normalWorldGeometry.y, + ); + snowMask = clamp( + mul(mul(upFacing, aoRaw), float(SNOW_NORMAL_STRENGTH)), + float(0.0), + float(1.0), + ); + } + + const batchColor = varyingProperty("vec3", "vBatchColor"); + const biomeSnowStrength = clamp(batchColor.z, float(0.0), float(1.0)); + const snowBase = vec3(...SNOW_COLOR); + const snowAO = vec3(...SNOW_AO_TINT); + const snowCol = mix(snowAO, snowBase, aoFactor); + const rawWeight = mul(snowMask, biomeSnowStrength); + const snowWeight = smoothstep(float(0.15), float(0.35), rawWeight); + baseAlbedo = mix(baseAlbedo, snowCol, snowWeight); + } else { + // Standard path: R = bark/leaf mask, G = AO + const barkMask = vtxColor.x; + const aoFactor = pow(aoRaw, float(AO_POWER)); + const aoDarkFloor = mix(float(AO_DARK), float(AO_BARK_DARK), barkMask); + const aoMul = mix(aoDarkFloor, float(1.0), aoFactor); + baseAlbedo = mul(baseAlbedo, aoMul); + } } // ---- dayFactor (used by shade, toon, SSS, saturation) ---- diff --git a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts index f43a2ca63..5e5d3fe23 100644 --- a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts +++ b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts @@ -80,8 +80,9 @@ const CANYON_TREE_CONFIG: BiomeTreeConfig = { const TUNDRA_TREE_CONFIG: BiomeTreeConfig = { enabled: true, trees: { - [TreeId.PineDead]: { weight: 40, minHeight: 38 }, - [TreeId.Pine]: { weight: 20, minHeight: 35 }, + [TreeId.PineSnow]: { weight: 40, minHeight: 38 }, + [TreeId.PineDead]: { weight: 20, minHeight: 38 }, + [TreeId.Pine]: { weight: 15, minHeight: 35 }, }, density: 3, minSpacing: 150, From 1b6a97e077858a90c911493b6bf34ba66a7f3bc9 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Wed, 25 Mar 2026 18:56:48 +0800 Subject: [PATCH 48/71] refactor: derive snow from biome config instead of hardcoded tundra check Add enableSnow flag to BiomeTreeConfig and set it on tundra. Snow weight is now computed by summing all snow-enabled biome weights at each tree position, removing the hardcoded "tundra" string dependency. Made-with: Cursor --- .../systems/shared/world/GLBTreeBatchedInstancer.ts | 10 ++++++++-- .../src/systems/shared/world/TerrainBiomeTypes.ts | 8 ++++++++ packages/shared/src/types/world/world-types.ts | 2 ++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts index 8fd325f20..a5e1d035c 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts @@ -18,6 +18,7 @@ import THREE from "../../../extras/three/three"; import type { World } from "../../../core/World"; import { TREE_TYPES, treeIdToSubType } from "../../../constants/TreeTypes"; import type { TreeTypeDefinition } from "../../../constants/TreeTypes"; +import { SNOW_BIOMES } from "./TerrainBiomeTypes"; import { modelCache } from "../../../utils/rendering/ModelCache"; import { createTreeDissolveMaterial, @@ -709,8 +710,13 @@ export async function addInstance( (a: number, b: number) => a + b, 0, ); - snowWeight = - totalWeight > 0 ? (weights["tundra"] ?? 0) / totalWeight : 0; + if (totalWeight > 0) { + let snowSum = 0; + for (const [biome, w] of Object.entries(weights)) { + if (SNOW_BIOMES.has(biome)) snowSum += w; + } + snowWeight = snowSum / totalWeight; + } } else { snowWeight = 1.0; } diff --git a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts index 5e5d3fe23..a97b57ca4 100644 --- a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts +++ b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts @@ -79,6 +79,7 @@ const CANYON_TREE_CONFIG: BiomeTreeConfig = { const TUNDRA_TREE_CONFIG: BiomeTreeConfig = { enabled: true, + enableSnow: true, trees: { [TreeId.PineSnow]: { weight: 40, minHeight: 38 }, [TreeId.PineDead]: { weight: 20, minHeight: 38 }, @@ -104,3 +105,10 @@ const BIOME_TREE_CONFIGS: Record = { export function getTreeConfigForBiome(biomeId: string): BiomeTreeConfig { return BIOME_TREE_CONFIGS[biomeId as BiomeType] ?? FOREST_TREE_CONFIG; } + +/** Biome IDs whose tree config has enableSnow set to true. */ +export const SNOW_BIOMES: ReadonlySet = new Set( + Object.entries(BIOME_TREE_CONFIGS) + .filter(([, cfg]) => cfg.enableSnow) + .map(([id]) => id), +); diff --git a/packages/shared/src/types/world/world-types.ts b/packages/shared/src/types/world/world-types.ts index 70b807b65..35b80cd87 100644 --- a/packages/shared/src/types/world/world-types.ts +++ b/packages/shared/src/types/world/world-types.ts @@ -261,6 +261,8 @@ export interface BiomeTreeConfig { minSpacing: number; /** Whether trees should cluster together */ clustering: boolean; + /** Whether snow-capable trees in this biome receive snow coverage */ + enableSnow?: boolean; /** Cluster size if clustering is enabled */ clusterSize?: number; /** Scale variation range [min, max] multiplier (default: [0.8, 1.2]) */ From 502a3ef159c124cbf5ef10c5f4291c6b0ea31a54 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Thu, 26 Mar 2026 00:28:48 +0800 Subject: [PATCH 49/71] feat: improve tree placement, LOD, and leaf rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactor tree placement to use Poisson disk sampling for natural spacing - Add clustering support with configurable radius and inter-cluster spacing - Make species zone allocation weight-proportional (reduce zone boost 10x→3x) - Fix tree LOD using "resource" distances (380m) instead of "tree" (800m) - Fix snow shader LOD issue where distant trees appeared all white - Lower vegetation alphaTest 0.5→0.1 to preserve sparse leaf texture edges - Add clusterRadius/clusterSpacing config options to BiomeTreeConfig - Update biome configs with clustering and corrected spacing values Made-with: Cursor --- .../shared/world/BiomeResourceGenerator.ts | 304 +++++++++++------- .../shared/world/GLBTreeBatchedInstancer.ts | 2 +- .../src/systems/shared/world/GPUMaterials.ts | 27 +- .../systems/shared/world/TerrainBiomeTypes.ts | 32 +- .../__tests__/BiomeResourceSpawning.test.ts | 27 +- .../shared/src/types/world/world-types.ts | 8 +- 6 files changed, 259 insertions(+), 141 deletions(-) diff --git a/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts b/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts index 70ff61633..23d07a93f 100644 --- a/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts +++ b/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts @@ -29,12 +29,122 @@ import { import type { TreePlacementRules } from "../../../constants/TreeTypes"; import { getTreeConfigForBiome } from "./TerrainBiomeTypes"; +// --------------------------------------------------------------------------- +// Bridson's Poisson Disk Sampling — O(n) blue-noise point generation +// --------------------------------------------------------------------------- + +/** + * Generate well-spaced 2D points using Bridson's algorithm. + * Points are guaranteed to be at least `minDistance` apart while packing + * as densely as possible (blue noise distribution). + * + * An optional `padding` expands the sampling area beyond the output bounds + * so that points near tile edges respect spacing against hypothetical + * neighbors, reducing cross-tile clumping. + */ +function poissonDiskSample2D( + width: number, + height: number, + minDistance: number, + rng: () => number, + padding = 0, + k = 30, +): Array<{ x: number; z: number }> { + if (width <= 0 || height <= 0 || minDistance <= 0) return []; + + const r2 = minDistance * minDistance; + const cellSize = minDistance / Math.SQRT2; + + const xMin = -padding; + const zMin = -padding; + const xMax = width + padding; + const zMax = height + padding; + const sampleW = xMax - xMin; + const sampleH = zMax - zMin; + + const cols = Math.ceil(sampleW / cellSize); + const rows = Math.ceil(sampleH / cellSize); + const grid = new Int32Array(cols * rows).fill(-1); + + const samplesX: number[] = []; + const samplesZ: number[] = []; + const active: number[] = []; + + const addSample = (x: number, z: number): void => { + const idx = samplesX.length; + samplesX.push(x); + samplesZ.push(z); + active.push(idx); + const ix = Math.floor((x - xMin) / cellSize); + const iz = Math.floor((z - zMin) / cellSize); + grid[iz * cols + ix] = idx; + }; + + const isFarEnough = (x: number, z: number): boolean => { + const ix = Math.floor((x - xMin) / cellSize); + const iz = Math.floor((z - zMin) / cellSize); + for (let dz = -2; dz <= 2; dz++) { + for (let dx = -2; dx <= 2; dx++) { + const nx = ix + dx; + const nz = iz + dz; + if (nx < 0 || nz < 0 || nx >= cols || nz >= rows) continue; + const si = grid[nz * cols + nx]; + if (si === -1) continue; + const ddx = x - samplesX[si]; + const ddz = z - samplesZ[si]; + if (ddx * ddx + ddz * ddz < r2) return false; + } + } + return true; + }; + + addSample(xMin + rng() * sampleW, zMin + rng() * sampleH); + + const twoPi = Math.PI * 2; + while (active.length > 0) { + const ai = Math.floor(rng() * active.length); + const pi = active[ai]; + const px = samplesX[pi]; + const pz = samplesZ[pi]; + + let found = false; + for (let i = 0; i < k; i++) { + const radius = minDistance * Math.sqrt(1 + 3 * rng()); + const theta = rng() * twoPi; + const x = px + radius * Math.cos(theta); + const z = pz + radius * Math.sin(theta); + + if (x < xMin || x >= xMax || z < zMin || z >= zMax) continue; + if (!isFarEnough(x, z)) continue; + + addSample(x, z); + found = true; + break; + } + + if (!found) { + active[ai] = active[active.length - 1]; + active.pop(); + } + } + + const result: Array<{ x: number; z: number }> = []; + for (let i = 0; i < samplesX.length; i++) { + const x = samplesX[i]; + const z = samplesZ[i]; + if (x >= 0 && x < width && z >= 0 && z < height) { + result.push({ x, z }); + } + } + return result; +} + // --------------------------------------------------------------------------- // Species zoning — smooth 2D value noise so nearby trees tend to be the same type // --------------------------------------------------------------------------- const SPECIES_ZONE_SCALE = 0.012; // ~80m species zones -const SPECIES_ZONE_BOOST = 10.0; // preferred species gets 10x weight +const SPECIES_ZONE_BOOST = 3.0; // preferred species gets 3x weight const WATER_PROXIMITY_BOOST = 20.0; // water-affinity trees get 20x weight near water const WATER_HEIGHT_PRECHECK = 35; // only run expensive water search within this height above water @@ -223,116 +333,97 @@ export function generateTrees( return []; } - const resources: ResourceNode[] = []; - - // Calculate tree count based on density and tile size const tileArea = (ctx.tileSize / 100) * (ctx.tileSize / 100); const baseCount = Math.floor(treeConfig.density * tileArea); - - if (baseCount === 0) { - return []; - } + if (baseCount === 0) return []; const maxSlope = treeConfig.maxSlope ?? Infinity; - - // Use deterministic RNG for reproducible placement const rng = ctx.createRng("trees"); - // Pre-compute tile-level distribution from the merged trees map const tileTreeMap = treeConfig.trees; const tileTreeTypes = Object.keys(tileTreeMap); - if (tileTreeTypes.length === 0) { - return []; - } + if (tileTreeTypes.length === 0) return []; const tileTotalWeight = Object.values(tileTreeMap).reduce( (sum, cfg) => sum + cfg.weight, 0, ); - if (tileTotalWeight === 0) { - return []; - } + if (tileTotalWeight === 0) return []; - // Generate tree positions - const placedPositions: Array<{ x: number; z: number }> = []; + // --- Phase 1: Generate well-spaced candidate positions via Poisson disk --- const minSpacing = treeConfig.minSpacing; - const minSpacingSq = minSpacing * minSpacing; + let candidates = poissonDiskSample2D( + ctx.tileSize, + ctx.tileSize, + minSpacing, + rng, + minSpacing, + ); - // If clustering is enabled, generate cluster centers first - const clusterCenters: Array<{ x: number; z: number }> = []; - if (treeConfig.clustering && treeConfig.clusterSize) { - const numClusters = Math.max( - 1, - Math.ceil(baseCount / treeConfig.clusterSize), + // Clustering: place well-spaced cluster centers via Poisson disk, then keep + // only candidates within a cluster radius. Creates natural groves with clearings. + if (treeConfig.clustering) { + const treesPerCluster = treeConfig.clusterSize ?? 4; + const numClusters = Math.max(1, Math.ceil(baseCount / treesPerCluster)); + const clusterRadius = + treeConfig.clusterRadius ?? treesPerCluster * minSpacing; + const clusterSpacing = treeConfig.clusterSpacing ?? clusterRadius * 2; + const clusterRadiusSq = clusterRadius * clusterRadius; + + const centers = poissonDiskSample2D( + ctx.tileSize, + ctx.tileSize, + clusterSpacing, + rng, + clusterSpacing, ); - for (let i = 0; i < numClusters; i++) { - clusterCenters.push({ - x: rng() * ctx.tileSize, - z: rng() * ctx.tileSize, - }); + // Shuffle and trim to desired cluster count + for (let i = centers.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + const tmp = centers[i]; + centers[i] = centers[j]; + centers[j] = tmp; } - } - - let treesPlaced = 0; - const maxAttempts = baseCount * 10; - let attempts = 0; + centers.length = Math.min(centers.length, numClusters); - while (treesPlaced < baseCount && attempts < maxAttempts) { - attempts++; + candidates = candidates.filter((p) => { + for (const c of centers) { + const dx = p.x - c.x; + const dz = p.z - c.z; + if (dx * dx + dz * dz < clusterRadiusSq) return true; + } + return false; + }); + } - // Generate position (clustered or uniform) - let localX: number; - let localZ: number; + // Shuffle candidates so terrain-filter order doesn't create spatial bias + for (let i = candidates.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + const tmp = candidates[i]; + candidates[i] = candidates[j]; + candidates[j] = tmp; + } - if (treeConfig.clustering && clusterCenters.length > 0) { - // Pick a random cluster center and scatter around it - const cluster = clusterCenters[Math.floor(rng() * clusterCenters.length)]; - const scatterRadius = treeConfig.clusterSize! * 3; - const angle = rng() * Math.PI * 2; - const distance = rng() * scatterRadius; - localX = cluster.x + Math.cos(angle) * distance; - localZ = cluster.z + Math.sin(angle) * distance; - } else { - // Uniform random placement - localX = rng() * ctx.tileSize; - localZ = rng() * ctx.tileSize; - } + // --- Phase 2: Filter candidates through terrain + biome rules --- + const resources: ResourceNode[] = []; + const [minScale, maxScale] = treeConfig.scaleVariation ?? [0.8, 1.2]; - // Clamp to tile bounds - localX = Math.max(0, Math.min(ctx.tileSize, localX)); - localZ = Math.max(0, Math.min(ctx.tileSize, localZ)); + for (const pos of candidates) { + if (resources.length >= baseCount) break; - // Convert to world coordinates for height lookup + const localX = pos.x; + const localZ = pos.z; const worldX = ctx.tileX * ctx.tileSize + localX; const worldZ = ctx.tileZ * ctx.tileSize + localZ; const height = ctx.getHeightAt(worldX, worldZ); - // Skip if underwater (ocean or river/pond) - if (height < ctx.waterThreshold) { - continue; - } + if (height < ctx.waterThreshold) continue; if (ctx.getWaterSurfaceAt) { const waterY = ctx.getWaterSurfaceAt(worldX, worldZ); - if (height <= waterY + 1.0) continue; // 1m buffer above water surface + if (height <= waterY + 1.0) continue; } - // Check minimum spacing - let tooClose = false; - for (const pos of placedPositions) { - const dx = localX - pos.x; - const dz = localZ - pos.z; - if (dx * dx + dz * dz < minSpacingSq) { - tooClose = true; - break; - } - } - if (tooClose) continue; - - // Check if on road - if (ctx.isOnRoad?.(worldX, worldZ)) { - continue; - } + if (ctx.isOnRoad?.(worldX, worldZ)) continue; - // Reject steep slopes — sample 4 neighbors to estimate gradient magnitude if (maxSlope < Infinity) { const sd = 1.0; const dhdx = @@ -346,8 +437,7 @@ export function generateTrees( if (dhdx * dhdx + dhdz * dhdz > maxSlope * maxSlope) continue; } - // Density noise — creates natural dense groves and sparse clearings. - // Uses a different noise offset (+500) to decouple from species zoning. + // Density noise — natural dense groves and sparse clearings const rawDensity = speciesNoise2D( (worldX + 500) * DENSITY_NOISE_SCALE, (worldZ + 500) * DENSITY_NOISE_SCALE, @@ -357,12 +447,9 @@ export function generateTrees( (1 - DENSITY_NOISE_MIN) * Math.pow(rawDensity, DENSITY_NOISE_POWER); if (rng() > densityChance) continue; - // Resolve the tree map for THIS position. If we have a per-position - // biome callback, use it to get the actual biome here instead of - // relying on the single tile-center biome. + // Per-position biome override at biome boundaries let activeTreeMap = tileTreeMap; let treeTypes = tileTreeTypes; - let totalWeight = tileTotalWeight; if (ctx.getDominantBiome) { const positionBiome = ctx.getDominantBiome(worldX, worldZ); @@ -370,22 +457,20 @@ export function generateTrees( if (posConfig && posConfig.trees !== tileTreeMap) { activeTreeMap = posConfig.trees; treeTypes = Object.keys(activeTreeMap); - totalWeight = Object.values(activeTreeMap).reduce( + const tw = Object.values(activeTreeMap).reduce( (sum, cfg) => sum + cfg.weight, 0, ); - if (totalWeight === 0 || treeTypes.length === 0) continue; + if (tw === 0 || treeTypes.length === 0) continue; } } - // Water proximity check — cheap height pre-check then expensive radial scan. - // Cached per-position so the post-selection water rejection can reuse it. + // Water proximity (cached per-position) const heightAboveWater = height - ctx.waterThreshold; let posDistToWater = Infinity; let waterChecked = false; if (heightAboveWater <= WATER_HEIGHT_PRECHECK) { - // Find the max search radius among all water-affinity trees in this map let maxSearch = 0; for (const treeType of treeTypes) { const cfg = activeTreeMap[treeType]; @@ -407,14 +492,23 @@ export function generateTrees( const nearWater = waterChecked && posDistToWater < Infinity; - // Select tree type — species zoning + water proximity boost + // Species selection — weight-proportional zoning + water proximity boost const zoneVal = speciesNoise2D( worldX * SPECIES_ZONE_SCALE, worldZ * SPECIES_ZONE_SCALE, ); - const preferredIdx = - Math.floor(zoneVal * treeTypes.length) % treeTypes.length; - const preferredSpecies = treeTypes[preferredIdx]; + let activeTotalWeight = 0; + for (const tt of treeTypes) activeTotalWeight += activeTreeMap[tt].weight; + const scaledZone = zoneVal * activeTotalWeight; + let zoneAccum = 0; + let preferredSpecies = treeTypes[0]; + for (const tt of treeTypes) { + zoneAccum += activeTreeMap[tt].weight; + if (scaledZone < zoneAccum) { + preferredSpecies = tt; + break; + } + } let boostedTotal = 0; for (const treeType of treeTypes) { @@ -443,7 +537,7 @@ export function generateTrees( } const selectedType = treeIdToSubType(selectedTreeId); - // Apply per-tree placement rules from the merged config + // Per-tree placement rules (height, water affinity, etc.) const rules: TreePlacementRules | undefined = activeTreeMap[selectedTreeId]; if (rules) { if (rules.minHeight !== undefined && height < rules.minHeight) continue; @@ -456,7 +550,6 @@ export function generateTrees( continue; if (rules.waterAffinity && rules.waterAffinity > 0) { - // Reuse cached distance if available, otherwise compute fresh const maxDist = rules.waterMaxDistance ?? 30; let dist = posDistToWater; if (!waterChecked) { @@ -477,32 +570,23 @@ export function generateTrees( } } - // Generate random scale within variation range - const [minScale, maxScale] = treeConfig.scaleVariation ?? [0.8, 1.2]; const scale = minScale + rng() * (maxScale - minScale); - - // Generate random Y-axis rotation const rotation = rng() * Math.PI * 2; - // Create resource node - const resource: ResourceNode = { - id: `${ctx.tileKey}_tree_${treesPlaced}`, + resources.push({ + id: `${ctx.tileKey}_tree_${resources.length}`, type: "tree", subType: selectedType as ResourceSubType, position: { x: localX, y: height, z: localZ }, mesh: null, health: 100, maxHealth: 100, - respawnTime: 300000, // 5 minutes + respawnTime: 300000, harvestable: true, requiredLevel: getTreeLevelRequirement(selectedType), scale, rotation, - }; - - resources.push(resource); - placedPositions.push({ x: localX, z: localZ }); - treesPlaced++; + }); } return resources; diff --git a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts index a5e1d035c..b534c624c 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts @@ -83,7 +83,7 @@ interface TreeTypePool { snowCapable: boolean; } -const resourceLOD = getLODDistances("resource"); +const resourceLOD = getLODDistances("tree"); // ---- Module state ---- let scene: THREE.Scene | null = null; diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index 9892b8b01..8111e86cc 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -414,7 +414,7 @@ export function createGPUVegetationMaterial( material.transparent = false; material.opacity = 1.0; - material.alphaTest = 0.5; + material.alphaTest = 0.1; material.side = THREE.DoubleSide; material.depthWrite = true; @@ -1092,28 +1092,43 @@ export function createTreeDissolveMaterial( const aoRaw = vtxColor.y; if (enableSnow) { - const aoFactor = pow(aoRaw, float(AO_POWER)); + // Detect default/unset vertex colors (all channels ~1.0). + // Real AO data always has variation; LOD models often have flat white. + // When detected, zero out snow to avoid all-white LOD trees. + const isDefaultVtx = step(float(0.98), mul(vtxColor.x, vtxColor.y)); + const effectiveAO = mix(aoRaw, float(0.5), isDefaultVtx); + + const aoFactor = pow(effectiveAO, float(AO_POWER)); const aoMul = mix(float(AO_BARK_DARK), float(1.0), aoFactor); baseAlbedo = mul(baseAlbedo, aoMul); let snowMask: any; if (snowVertexData) { - // Explicit R-channel snow mask (pineSnow models) const rawSnowMask = vtxColor.x; - snowMask = smoothstep( + const rMask = smoothstep( float(SNOW_SMOOTH_LO), float(SNOW_SMOOTH_HI), rawSnowMask, ); + const upFacing = smoothstep( + float(SNOW_NORMAL_LO), + float(SNOW_NORMAL_HI), + normalWorldGeometry.y, + ); + const fallback = clamp( + mul(mul(upFacing, effectiveAO), float(SNOW_NORMAL_STRENGTH)), + float(0.0), + float(1.0), + ); + snowMask = mix(rMask, fallback, isDefaultVtx); } else { - // Normal-based fallback (pine, pineDead — no R-channel snow data) const upFacing = smoothstep( float(SNOW_NORMAL_LO), float(SNOW_NORMAL_HI), normalWorldGeometry.y, ); snowMask = clamp( - mul(mul(upFacing, aoRaw), float(SNOW_NORMAL_STRENGTH)), + mul(mul(upFacing, effectiveAO), float(SNOW_NORMAL_STRENGTH)), float(0.0), float(1.0), ); diff --git a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts index a97b57ca4..8b413e648 100644 --- a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts +++ b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts @@ -39,15 +39,18 @@ export function buildBiomeConstantsJS(): string { const FOREST_TREE_CONFIG: BiomeTreeConfig = { enabled: true, trees: { - [TreeId.Oak]: { weight: 30, minHeight: 33.6, maxHeight: 60 }, - [TreeId.Birch]: { weight: 25, minHeight: 33.6, maxHeight: 60 }, + [TreeId.Oak]: { weight: 30, maxHeight: 60 }, + [TreeId.Birch]: { weight: 20, maxHeight: 60 }, [TreeId.Pine]: { weight: 20, minHeight: 60 }, - [TreeId.Knotwood]: { weight: 1, minHeight: 35, maxHeight: 60 }, - [TreeId.Maple]: { weight: 1, minHeight: 35, maxHeight: 60 }, + [TreeId.Knotwood]: { weight: 5, maxHeight: 60 }, + [TreeId.Maple]: { weight: 5, maxHeight: 60 }, }, - density: 3, - minSpacing: 150, - clustering: false, + density: 5, + minSpacing: 5, + clustering: true, + clusterSize: 30, + clusterRadius: 100, + clusterSpacing: 200, scaleVariation: [0.8, 1.2], maxSlope: 1.5, }; @@ -56,7 +59,7 @@ const CANYON_TREE_CONFIG: BiomeTreeConfig = { enabled: true, trees: { [TreeId.Oak]: { weight: 20 }, - [TreeId.Dead]: { weight: 25, minHeight: 60 }, + [TreeId.Dead]: { weight: 25 }, [TreeId.Palm]: { weight: 25, waterAffinity: 0.8, @@ -70,8 +73,8 @@ const CANYON_TREE_CONFIG: BiomeTreeConfig = { waterMaxDistance: 80, }, }, - density: 1, - minSpacing: 1050, + density: 2, + minSpacing: 60, clustering: false, scaleVariation: [0.7, 1.3], maxSlope: 0.1, @@ -85,9 +88,12 @@ const TUNDRA_TREE_CONFIG: BiomeTreeConfig = { [TreeId.PineDead]: { weight: 20, minHeight: 38 }, [TreeId.Pine]: { weight: 15, minHeight: 35 }, }, - density: 3, - minSpacing: 150, - clustering: false, + density: 5, + minSpacing: 5, + clustering: true, + clusterSize: 30, + clusterRadius: 100, + clusterSpacing: 200, scaleVariation: [0.6, 1.0], maxSlope: 1.5, }; diff --git a/packages/shared/src/systems/shared/world/__tests__/BiomeResourceSpawning.test.ts b/packages/shared/src/systems/shared/world/__tests__/BiomeResourceSpawning.test.ts index f9fc0d93a..03d562573 100644 --- a/packages/shared/src/systems/shared/world/__tests__/BiomeResourceSpawning.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/BiomeResourceSpawning.test.ts @@ -119,23 +119,32 @@ describe("BiomeResourceGenerator", () => { }); it("respects weighted distribution", () => { - // Generate trees across many tiles for statistical significance + // Use a 2-species config with a large weight gap so the dominant + // species stays dominant even after the 10x species-zoning boost. + const weightedConfig: BiomeTreeConfig = { + enabled: true, + trees: { + tree_normal: { weight: 0.85 }, + tree_willow: { weight: 0.15 }, + }, + density: 8, + minSpacing: 8, + clustering: false, + }; + const allTrees: ReturnType = []; - for (let i = 0; i < 10; i++) { - for (let j = 0; j < 10; j++) { + for (let i = 0; i < 30; i++) { + for (let j = 0; j < 30; j++) { const ctx = createTestContext(i, j); - allTrees.push(...generateTrees(ctx, forestTreeConfig)); + allTrees.push(...generateTrees(ctx, weightedConfig)); } } const normalCount = allTrees.filter((r) => r.subType === "normal").length; - const oakCount = allTrees.filter((r) => r.subType === "oak").length; const willowCount = allTrees.filter((r) => r.subType === "willow").length; - // Normal (50%) should be most common - expect(normalCount).toBeGreaterThan(oakCount); - // Oak (35%) should be more common than willow (15%) - expect(oakCount).toBeGreaterThan(willowCount); + // Normal (85%) should be significantly more common than willow (15%) + expect(normalCount).toBeGreaterThan(willowCount); }); it("assigns correct level requirements from shared constants", () => { diff --git a/packages/shared/src/types/world/world-types.ts b/packages/shared/src/types/world/world-types.ts index 35b80cd87..4b3133926 100644 --- a/packages/shared/src/types/world/world-types.ts +++ b/packages/shared/src/types/world/world-types.ts @@ -255,7 +255,7 @@ export interface BiomeTreeConfig { enabled: boolean; /** Per-tree spawn weight + placement rules, keyed by TreeId */ trees: Record; - /** Trees per 64m tile (base density, modified by resourceDensity) */ + /** Target tree count per terrain tile (100m). Actual count depends on terrain filters and Poisson disk packing. */ density: number; /** Minimum spacing between trees in meters */ minSpacing: number; @@ -263,8 +263,12 @@ export interface BiomeTreeConfig { clustering: boolean; /** Whether snow-capable trees in this biome receive snow coverage */ enableSnow?: boolean; - /** Cluster size if clustering is enabled */ + /** Average number of trees per cluster (default: 4) */ clusterSize?: number; + /** Radius of each cluster in meters (default: clusterSize * minSpacing) */ + clusterRadius?: number; + /** Minimum distance between cluster centers in meters (default: clusterRadius * 2) */ + clusterSpacing?: number; /** Scale variation range [min, max] multiplier (default: [0.8, 1.2]) */ scaleVariation?: [number, number]; /** Maximum terrain slope for tree placement (gradient magnitude, default: 1.5) */ From 77808ff8d2014afa65de0d207875ddcc27abf686 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Thu, 26 Mar 2026 00:47:26 +0800 Subject: [PATCH 50/71] fix: remove duplicate starter_area causing two home icons The hardcoded starter_area was superseded by central_haven from the world-areas.json manifest. Both were safe zones, producing two floating home icons. Remove the stale default so only the JSON-loaded area remains. Made-with: Cursor --- packages/shared/src/data/world-areas.ts | 34 +------------------------ 1 file changed, 1 insertion(+), 33 deletions(-) diff --git a/packages/shared/src/data/world-areas.ts b/packages/shared/src/data/world-areas.ts index 9242b7bac..710193310 100644 --- a/packages/shared/src/data/world-areas.ts +++ b/packages/shared/src/data/world-areas.ts @@ -44,36 +44,6 @@ export type { * DEFAULT: If JSON is empty, use this hardcoded starter area */ export const ALL_WORLD_AREAS: Record = { - starter_area: { - id: "starter_area", - name: "Starter Area", - description: "A peaceful area for new adventurers", - difficultyLevel: 0, - bounds: { - minX: 294, - maxX: 594, - minZ: 180, - maxZ: 480, - }, - biomeType: "plains", - safeZone: true, - npcs: [ - { - id: "bank_clerk", - type: "bank", - position: { x: 5, y: 0, z: -5 }, - // All other NPC data (name, services, model, description) comes from npcs.json - }, - ], - resources: [ - // Resources are now defined in world-areas.json manifest only - // Do not add hardcoded resources here - ], - mobSpawns: [ - // Starter area is a safe zone - no mob spawns - // The default test goblin is spawned by MobNPCSpawnerSystem near origin - ], - }, // Wilderness test zone - PvP enabled area for testing player vs player combat wilderness_test: { id: "wilderness_test", @@ -123,9 +93,7 @@ export const ALL_WORLD_AREAS: Record = { /** * Starter Towns - Populated by DataManager from world-areas.json */ -export const STARTER_TOWNS: Record = { - starter_area: ALL_WORLD_AREAS["starter_area"], -}; +export const STARTER_TOWNS: Record = {}; /** * Helper Functions From e3c8699f0deedf4e8d7f5d2d5f0126c369f71fd4 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Fri, 27 Mar 2026 00:21:47 +0800 Subject: [PATCH 51/71] feat: add quad-tree chunk-based grass system with CPU frustum culling Introduces GrassVisualManager, a QuadTreeListener that creates instanced grass meshes (GLB model) on max-depth terrain leaves. Includes per-chunk CPU-side frustum culling using the terrain chunk bounding box (same approach as portfolio project) to toggle mesh.visible, avoiding the InstancedMesh bounding-sphere bug with Three.js frustumCulled. Made-with: Cursor --- .../shared/world/GrassVisualManager.ts | 438 ++++++++++++++++++ .../src/systems/shared/world/TerrainSystem.ts | 30 +- 2 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 packages/shared/src/systems/shared/world/GrassVisualManager.ts diff --git a/packages/shared/src/systems/shared/world/GrassVisualManager.ts b/packages/shared/src/systems/shared/world/GrassVisualManager.ts new file mode 100644 index 000000000..1975b91cf --- /dev/null +++ b/packages/shared/src/systems/shared/world/GrassVisualManager.ts @@ -0,0 +1,438 @@ +// @ts-nocheck -- TSL type definitions are incomplete for Fn() callbacks and node reassignment +/** + * GrassVisualManager — Generates instanced grass meshes aligned with the + * terrain quad-tree. Each max-depth leaf node gets an InstancedMesh using + * a GLB grass blade model, with CPU-baked terrain heights and TSL distance fade. + * + * Follows the same QuadTreeListener pattern as WaterVisualManager: + * the terrain quad-tree drives split/merge; this manager only reacts to + * onNodeNeedsGeometry / onNodeDestroyGeometry events. + * + * CLIENT-ONLY: Only used when USE_QUADTREE_LOD is true. + */ + +import THREE, { + uniform, + Fn, + float, + sin, + mix, + time, + positionLocal, + attribute, + hash, + cos, +} from "../../../extras/three/three"; +import { MeshStandardNodeMaterial } from "three/webgpu"; +import type { TerrainQuadNode, QuadTreeListener } from "./TerrainQuadTree"; +import { modelCache } from "../../../utils/rendering/ModelCache"; +import type { World } from "../../../core/World"; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +const GRASS_CONFIG = { + INSTANCES_PER_CHUNK: 4000, + SCALE_MIN: 0.6, + SCALE_MAX: 1.4, + FADE_START: 30, + FADE_END: 45, + WIND_SPEED: 1.2, + WIND_STRENGTH: 0.08, + BASE_COLOR: new THREE.Color(0x2d5a1e), + TIP_COLOR: new THREE.Color(0x7bc950), + MAX_SLOPE: 0.7, + SEED: 73856093, + MODEL_PATH: "grass/grassBlade1.glb", +}; + +// --------------------------------------------------------------------------- +// Seeded PRNG (deterministic blade placement per chunk) +// --------------------------------------------------------------------------- + +function mulberry32(seed: number): () => number { + let s = seed | 0; + return () => { + s = (s + 0x6d2b79f5) | 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface GrassChunk { + nodeId: number; + mesh: THREE.InstancedMesh; + /** World-space bounding box covering the full terrain chunk extent */ + box: THREE.Box3; +} + +// --------------------------------------------------------------------------- +// GrassVisualManager +// --------------------------------------------------------------------------- + +export class GrassVisualManager implements QuadTreeListener { + private container: THREE.Group; + private getHeightAt: (x: number, z: number) => number; + private getRoadInfluence: (wx: number, wz: number) => number; + private waterThreshold: number; + private world: World; + private chunks = new Map(); + private material: MeshStandardNodeMaterial | null = null; + + private uPlayerPos = uniform(new THREE.Vector2(0, 0)); + + /** Reusable frustum + matrix for CPU-side per-chunk visibility testing */ + private frustum = new THREE.Frustum(); + private projScreenMatrix = new THREE.Matrix4(); + + /** Loaded GLB blade geometry (null until async load completes) */ + private bladeGeometry: THREE.BufferGeometry | null = null; + /** Height of the loaded blade model (for normalizing tipness) */ + private bladeModelHeight = 1; + /** Nodes that arrived before the model finished loading */ + private pendingNodes: TerrainQuadNode[] = []; + private modelLoaded = false; + + constructor( + container: THREE.Group, + world: World, + getHeightAt: (x: number, z: number) => number, + waterThreshold: number, + getRoadInfluence: (wx: number, wz: number) => number, + ) { + this.container = container; + this.world = world; + this.getHeightAt = getHeightAt; + this.waterThreshold = waterThreshold; + this.getRoadInfluence = getRoadInfluence; + + this.loadGrassModel(); + } + + // -- Model loading -------------------------------------------------------- + + private async loadGrassModel(): Promise { + try { + const baseUrl = (this.world.assetsUrl || "").replace(/\/$/, ""); + const fullPath = `${baseUrl}/${GRASS_CONFIG.MODEL_PATH}`; + const { scene } = await modelCache.loadModel(fullPath, this.world); + + let geometry: THREE.BufferGeometry | null = null; + scene.traverse((child: THREE.Object3D) => { + if (!geometry && child instanceof THREE.Mesh && child.geometry) { + geometry = child.geometry; + } + }); + + if (!geometry) { + console.warn("[GrassVisualManager] No geometry found in grass GLB"); + return; + } + + this.bladeGeometry = geometry; + + // Compute model height for tipness normalization + geometry.computeBoundingBox(); + if (geometry.boundingBox) { + this.bladeModelHeight = Math.max( + 0.01, + geometry.boundingBox.max.y - geometry.boundingBox.min.y, + ); + } + + this.material = this.createMaterial(); + this.modelLoaded = true; + + console.log( + `[GrassVisualManager] Grass model loaded (${geometry.attributes.position.count} verts, h=${this.bladeModelHeight.toFixed(2)}m)`, + ); + + // Process any nodes that arrived before the model was ready + for (const node of this.pendingNodes) { + this.createChunkMesh(node); + } + this.pendingNodes.length = 0; + } catch (err) { + console.error("[GrassVisualManager] Failed to load grass model:", err); + } + } + + // -- Public API ----------------------------------------------------------- + + update(playerX: number, playerZ: number, camera?: THREE.Camera): void { + this.uPlayerPos.value.set(playerX, playerZ); + + if (!camera || this.chunks.size === 0) return; + + // Build frustum from camera (same approach as portfolio project) + this.projScreenMatrix.multiplyMatrices( + camera.projectionMatrix, + camera.matrixWorldInverse, + ); + this.frustum.setFromProjectionMatrix(this.projScreenMatrix); + + for (const [, chunk] of this.chunks) { + chunk.mesh.visible = this.frustum.intersectsBox(chunk.box); + } + } + + // -- QuadTreeListener ----------------------------------------------------- + + onNodeNeedsGeometry(node: TerrainQuadNode): void { + if (!node.isMaxDepth) return; + + const key = this.chunkKey(node); + if (this.chunks.has(key)) return; + + if (!this.modelLoaded) { + this.pendingNodes.push(node); + return; + } + + this.createChunkMesh(node); + } + + onNodeDestroyGeometry(node: TerrainQuadNode): void { + const key = this.chunkKey(node); + const chunk = this.chunks.get(key); + if (!chunk) return; + + if (chunk.mesh.parent) chunk.mesh.parent.remove(chunk.mesh); + chunk.mesh.geometry.dispose(); + this.chunks.delete(key); + + // Also remove from pending queue if present + const pi = this.pendingNodes.indexOf(node); + if (pi >= 0) this.pendingNodes.splice(pi, 1); + } + + destroy(): void { + for (const [, chunk] of this.chunks) { + if (chunk.mesh.parent) chunk.mesh.parent.remove(chunk.mesh); + chunk.mesh.geometry.dispose(); + } + this.chunks.clear(); + this.pendingNodes.length = 0; + if (this.material) this.material.dispose(); + if (this.container.parent) this.container.parent.remove(this.container); + } + + // -- Chunk mesh creation -------------------------------------------------- + + private createChunkMesh(node: TerrainQuadNode): void { + if (!this.bladeGeometry || !this.material) return; + + const key = this.chunkKey(node); + if (this.chunks.has(key)) return; + + const instanceData = this.generateInstanceData(node); + if (!instanceData || instanceData.count === 0) return; + + // Clone geometry and attach instanced attributes + const geo = this.bladeGeometry.clone(); + geo.setAttribute( + "instanceOffset", + new THREE.InstancedBufferAttribute(instanceData.offsets, 3), + ); + geo.setAttribute( + "instanceRotScale", + new THREE.InstancedBufferAttribute(instanceData.rotScales, 2), + ); + geo.setAttribute( + "instanceHash", + new THREE.InstancedBufferAttribute(instanceData.hashes, 1), + ); + + const mesh = new THREE.InstancedMesh( + geo, + this.material, + instanceData.count, + ); + mesh.position.set(node.centerX, 0, node.centerZ); + mesh.name = `GrassQT_${key}`; + mesh.frustumCulled = false; + mesh.receiveShadow = false; + mesh.castShadow = false; + mesh.userData = { type: "grass", walkable: false, clickable: false }; + + // Instance matrices set to identity — positioning is handled in positionNode + const identity = new THREE.Matrix4(); + for (let i = 0; i < instanceData.count; i++) { + mesh.setMatrixAt(i, identity); + } + mesh.instanceMatrix.needsUpdate = true; + + // Bounding box covering the full terrain chunk (for CPU frustum culling) + const half = node.halfSize; + const box = new THREE.Box3( + new THREE.Vector3(node.centerX - half, -50, node.centerZ - half), + new THREE.Vector3(node.centerX + half, 200, node.centerZ + half), + ); + + this.container.add(mesh); + this.chunks.set(key, { nodeId: node.id, mesh, box }); + } + + // -- Instance data generation --------------------------------------------- + + private generateInstanceData(node: TerrainQuadNode): { + offsets: Float32Array; + rotScales: Float32Array; + hashes: Float32Array; + count: number; + } | null { + const maxCount = GRASS_CONFIG.INSTANCES_PER_CHUNK; + const rng = mulberry32( + GRASS_CONFIG.SEED ^ + ((node.centerX * 374761393 + node.centerZ * 668265263) | 0), + ); + + const offsets = new Float32Array(maxCount * 3); + const rotScales = new Float32Array(maxCount * 2); + const hashes = new Float32Array(maxCount); + + let count = 0; + + for (let i = 0; i < maxCount; i++) { + const lx = (rng() - 0.5) * node.size; + const lz = (rng() - 0.5) * node.size; + const bladeRng = rng(); + + const wx = node.centerX + lx; + const wz = node.centerZ + lz; + const ty = this.getHeightAt(wx, wz); + + if (ty < this.waterThreshold + 0.1) continue; + if (this.getRoadInfluence(wx, wz) > 0.3) continue; + + // Slope check + const sd = 0.5; + const hL = this.getHeightAt(wx - sd, wz); + const hR = this.getHeightAt(wx + sd, wz); + const hD = this.getHeightAt(wx, wz - sd); + const hU = this.getHeightAt(wx, wz + sd); + const dx = hR - hL; + const dz = hU - hD; + if ( + dx * dx + dz * dz > + GRASS_CONFIG.MAX_SLOPE * GRASS_CONFIG.MAX_SLOPE * 4 * sd * sd + ) + continue; + + offsets[count * 3] = lx; + offsets[count * 3 + 1] = ty; + offsets[count * 3 + 2] = lz; + + const rotation = rng() * Math.PI * 2; + const scale = + GRASS_CONFIG.SCALE_MIN + + bladeRng * (GRASS_CONFIG.SCALE_MAX - GRASS_CONFIG.SCALE_MIN); + rotScales[count * 2] = rotation; + rotScales[count * 2 + 1] = scale; + + hashes[count] = bladeRng; + count++; + } + + if (count === 0) return null; + + return { + offsets: offsets.slice(0, count * 3), + rotScales: rotScales.slice(0, count * 2), + hashes: hashes.slice(0, count), + count, + }; + } + + // -- TSL Material --------------------------------------------------------- + + private createMaterial(): MeshStandardNodeMaterial { + const mat = new MeshStandardNodeMaterial(); + mat.side = THREE.DoubleSide; + mat.transparent = false; + mat.depthWrite = true; + + const uBaseColor = uniform(GRASS_CONFIG.BASE_COLOR); + const uTipColor = uniform(GRASS_CONFIG.TIP_COLOR); + const uWindSpeed = uniform(GRASS_CONFIG.WIND_SPEED); + const uWindStrength = uniform(GRASS_CONFIG.WIND_STRENGTH); + const uModelHeight = uniform(this.bladeModelHeight); + + // --- positionNode: instance placement + Y rotation + wind + distance fade --- + mat.positionNode = Fn(() => { + const localPos = positionLocal.toVar("gp"); + + // Per-instance data + const offset = attribute("instanceOffset", "vec3"); + const rotScale = attribute("instanceRotScale", "vec2"); + const rot = rotScale.x; + const scale = rotScale.y; + + // Tipness: normalized Y position within the blade model (0=base, 1=tip) + const tipness = localPos.y.div(uModelHeight).clamp(0.0, 1.0); + + // Scale the blade + localPos.x.assign(localPos.x.mul(scale)); + localPos.y.assign(localPos.y.mul(scale)); + localPos.z.assign(localPos.z.mul(scale)); + + // Rotate around Y axis + const cosR = cos(rot); + const sinR = sin(rot); + const rx = localPos.x.mul(cosR).sub(localPos.z.mul(sinR)); + const rz = localPos.x.mul(sinR).add(localPos.z.mul(cosR)); + localPos.x.assign(rx); + localPos.z.assign(rz); + + // Translate to instance position (offset is chunk-local XZ + baked terrainY) + localPos.x.addAssign(offset.x); + localPos.y.addAssign(offset.y); + localPos.z.addAssign(offset.z); + + // Wind: displace tips in XZ using sine waves + const wt = time.mul(uWindSpeed); + localPos.x.addAssign( + sin(wt.add(offset.x.mul(0.8)).add(offset.z.mul(0.6))) + .mul(uWindStrength) + .mul(tipness), + ); + localPos.z.addAssign( + sin(wt.mul(0.7).add(offset.x.mul(0.5)).add(offset.z.mul(0.9)).add(2.0)) + .mul(uWindStrength) + .mul(tipness) + .mul(0.7), + ); + + return localPos; + })(); + + // --- colorNode: base-to-tip gradient with per-instance variation --- + mat.colorNode = Fn(() => { + const instHash = attribute("instanceHash", "float"); + const tipness = positionLocal.y.div(uModelHeight).clamp(0.0, 1.0); + + const col = mix(uBaseColor, uTipColor, tipness).toVar("gc"); + + const v = hash(instHash.mul(1234.5)).mul(0.12).sub(0.06); + col.r.addAssign(v); + col.g.addAssign(v.mul(0.6)); + col.b.addAssign(v.mul(-0.3)); + + return col; + })(); + + return mat; + } + + // -- Helpers -------------------------------------------------------------- + + private chunkKey(node: TerrainQuadNode): string { + return `gq_${node.id}_d${node.depth}_${node.centerX}_${node.centerZ}`; + } +} diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts index c94a52caf..2a97e29f6 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -115,6 +115,7 @@ import { type VisualManagerTerrainProvider, } from "./TerrainVisualManager"; import { WaterVisualManager } from "./WaterVisualManager"; +import { GrassVisualManager } from "./GrassVisualManager"; import { CompositeQuadTreeListener } from "./TerrainQuadTree"; import type { QuadChunkWorkerConfig } from "../../../utils/workers/QuadChunkWorker"; import { terminateQuadChunkWorkerPool } from "../../../utils/workers/QuadChunkWorker"; @@ -261,6 +262,7 @@ export class TerrainSystem extends System { // Quad-tree LOD visual manager (client-only, when CONFIG.USE_QUADTREE_LOD is true) private quadTreeVisualManager: TerrainVisualManager | null = null; private waterVisualManager: WaterVisualManager | null = null; + private grassVisualManager: GrassVisualManager | null = null; // Unified terrain generator from @hyperscape/procgen // Provides deterministic height/biome calculation independent of rendering @@ -1953,10 +1955,27 @@ export class TerrainSystem extends System { this.CONFIG.WATER_THRESHOLD, ); - // Wire both terrain and water managers to the same quad-tree via composite + // Grass quad-tree visual manager — instanced grass blades on finest terrain leaves + const grassContainer = new THREE.Group(); + grassContainer.name = "QuadTreeGrassContainer"; + if (this.terrainContainer) { + this.terrainContainer.parent?.add(grassContainer); + } + + this.grassVisualManager = new GrassVisualManager( + grassContainer, + this.world, + (x: number, z: number) => this.getHeightAt(x, z), + this.CONFIG.WATER_THRESHOLD, + (wx: number, wz: number) => + this.calculateRoadInfluenceAtVertex(wx, wz, 0, 0), + ); + + // Wire terrain, water, and grass managers to the same quad-tree via composite const composite = new CompositeQuadTreeListener(); composite.add(this.quadTreeVisualManager); composite.add(this.waterVisualManager); + composite.add(this.grassVisualManager); this.quadTreeVisualManager.getQuadTree().setListener(composite); } @@ -5154,6 +5173,10 @@ export class TerrainSystem extends System { if (centers.length > 0) { const pos = centers[0].position; this.quadTreeVisualManager.update(pos.x, pos.z); + + if (this.grassVisualManager) { + this.grassVisualManager.update(pos.x, pos.z, this.world.camera); + } } } @@ -6742,6 +6765,11 @@ export class TerrainSystem extends System { this.waterVisualManager = null; } + if (this.grassVisualManager) { + this.grassVisualManager.destroy(); + this.grassVisualManager = null; + } + // Terminate worker pools to free resources terminateTerrainWorkerPool(); terminateQuadChunkWorkerPool(); From dee9bcdbfe9429b9cbe5c0520cfa5e141197b937 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Fri, 27 Mar 2026 05:22:22 +0800 Subject: [PATCH 52/71] =?UTF-8?q?fix:=20grass=20color=20blending=20with=20?= =?UTF-8?q?terrain=20via=20sRGB=E2=86=92linear=20conversion=20and=20anime?= =?UTF-8?q?=20shading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert CPU terrain color output from sRGB to linear before passing to grass shader (float vertex attributes have no auto color space conversion) - Add half-lambert anime shade to grass material matching terrain shader so shadow tint is consistent at the grass/terrain boundary - Update CPU fallback color constants to match actual texture averages - Use darken() helper (×0.65) for dark variants matching GPU TEX_DARKEN - Sync sun direction uniform to grass from environment system - Remove COLOR_VARIATION, derive gradient purely from root ground color - Flip gradient: root is brighter (×1.5) to match terrain, tip fades darker - Add rebuildAllChunks/invalidateRegion for flat zone and road updates - Fix height data storage order (store after tile added to terrainTiles) - Invalidate quad-tree chunks when flat zones register/unregister Made-with: Cursor --- .../shared/world/GrassVisualManager.ts | 657 +++++-- .../systems/shared/world/ProceduralGrass.ts | 1572 ++++------------- .../src/systems/shared/world/TerrainShader.ts | 315 +++- .../src/systems/shared/world/TerrainSystem.ts | 225 +-- 4 files changed, 1210 insertions(+), 1559 deletions(-) diff --git a/packages/shared/src/systems/shared/world/GrassVisualManager.ts b/packages/shared/src/systems/shared/world/GrassVisualManager.ts index 1975b91cf..caa27dd50 100644 --- a/packages/shared/src/systems/shared/world/GrassVisualManager.ts +++ b/packages/shared/src/systems/shared/world/GrassVisualManager.ts @@ -1,13 +1,11 @@ // @ts-nocheck -- TSL type definitions are incomplete for Fn() callbacks and node reassignment /** - * GrassVisualManager — Generates instanced grass meshes aligned with the - * terrain quad-tree. Each max-depth leaf node gets an InstancedMesh using - * a GLB grass blade model, with CPU-baked terrain heights and TSL distance fade. - * - * Follows the same QuadTreeListener pattern as WaterVisualManager: - * the terrain quad-tree drives split/merge; this manager only reacts to - * onNodeNeedsGeometry / onNodeDestroyGeometry events. + * GrassVisualManager — Procedural instanced grass clumps aligned with the + * terrain quad-tree. Each clump is a single geometry containing multiple + * blades with pre-baked local offsets, rotations and height variation. + * Each max-depth quad-tree leaf gets one InstancedMesh of clumps. * + * Follows the same QuadTreeListener pattern as WaterVisualManager. * CLIENT-ONLY: Only used when USE_QUADTREE_LOD is true. */ @@ -17,38 +15,102 @@ import THREE, { float, sin, mix, + smoothstep, time, positionLocal, attribute, - hash, cos, + uv, + pow, + vec3, + dot, + normalize, + add, + mul, + sub, } from "../../../extras/three/three"; +import { SUN_LIGHT } from "./LightingConfig"; +import { TERRAIN_SHADE } from "./TerrainShader"; import { MeshStandardNodeMaterial } from "three/webgpu"; import type { TerrainQuadNode, QuadTreeListener } from "./TerrainQuadTree"; -import { modelCache } from "../../../utils/rendering/ModelCache"; -import type { World } from "../../../core/World"; + +// sRGB → linear conversion. computeTerrainColorCPU returns sRGB-space values +// (its constants match the textures' sRGB averages). The GPU terrain shader +// auto-converts sRGB textures to linear before blending. Float vertex +// attributes have NO automatic conversion, so we must do it on the CPU side +// before writing to instanceGroundColor. +function srgbToLinear(c: number): number { + return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); +} // --------------------------------------------------------------------------- -// Configuration +// Configuration — tweak these to control grass appearance & performance // --------------------------------------------------------------------------- -const GRASS_CONFIG = { - INSTANCES_PER_CHUNK: 4000, - SCALE_MIN: 0.6, - SCALE_MAX: 1.4, - FADE_START: 30, - FADE_END: 45, - WIND_SPEED: 1.2, - WIND_STRENGTH: 0.08, - BASE_COLOR: new THREE.Color(0x2d5a1e), - TIP_COLOR: new THREE.Color(0x7bc950), +export const GRASS_CONFIG = { + // -- Density & distribution ----------------------------------------------- + /** Average distance between clump centers in meters (lower = denser) */ + CLUMP_SPACING: 0.7, + + // -- Clump composition ---------------------------------------------------- + /** Number of blades baked into each clump geometry */ + BLADES_PER_CLUMP: 24, + /** Outer radius of the clump ring in meters */ + CLUMP_RADIUS: 0.7, + /** Inner radius ratio (0-1, blades placed between inner*radius and radius) */ + CLUMP_INNER_RATIO: 0.05, + + // -- Blade shape ---------------------------------------------------------- + /** Segments per blade (4 rows of 2 verts + 1 tip = 9 verts) */ + BLADE_SEGMENTS: 3, + /** Blade width scales with height: width = height * WIDTH_RATIO */ + BLADE_WIDTH_RATIO: 0.04, + /** Blade min height in world units */ + BLADE_HEIGHT_MIN: 0.45, + /** Blade max height in world units */ + BLADE_HEIGHT_MAX: 1.15, + /** Arc curvature ratio (arc distance ≈ height * this) */ + BLADE_ARC_RATIO: 0.18, + /** Tip taper (0 = rectangle, 1 = full point) */ + BLADE_TAPER: 0.85, + + // -- Per-instance variation ----------------------------------------------- + /** Clump-level scale range */ + SCALE_MIN: 0.7, + SCALE_MAX: 1.3, + + // -- Wind ----------------------------------------------------------------- + WIND_SPEED: 1.8, + WIND_STRENGTH: 0.15, + + // -- Color ---------------------------------------------------------------- + /** Gradient power curve (higher = root color persists longer up the blade) */ + GRADIENT_FALLOFF: 1.7, + + // -- Terrain filters ------------------------------------------------------ MAX_SLOPE: 0.7, + /** Minimum grassWeight from terrain color function to place a clump (0-1) */ + MIN_GRASS_WEIGHT: 0.3, + + // -- LOD tiers (by distance from camera) ------------------------------------ + LOD_TIERS: [ + { maxDistance: 200, bladesPerClump: 24, bladeSegments: 3, spacingMul: 1.0 }, + { maxDistance: 400, bladesPerClump: 12, bladeSegments: 2, spacingMul: 2.0 }, + { + maxDistance: Infinity, + bladesPerClump: 6, + bladeSegments: 1, + spacingMul: 4.0, + }, + ], + LOD_HYSTERESIS: 0.1, + + /** Deterministic seed */ SEED: 73856093, - MODEL_PATH: "grass/grassBlade1.glb", }; // --------------------------------------------------------------------------- -// Seeded PRNG (deterministic blade placement per chunk) +// Seeded PRNG // --------------------------------------------------------------------------- function mulberry32(seed: number): () => number { @@ -61,6 +123,127 @@ function mulberry32(seed: number): () => number { }; } +// --------------------------------------------------------------------------- +// Procedural clump geometry — sunflower-spiral blade arrangement with baked +// arc curvature, per-blade rotation/height/width variation. +// --------------------------------------------------------------------------- + +function createClumpGeometry( + bladesPerClump = GRASS_CONFIG.BLADES_PER_CLUMP, + bladeSegments = GRASS_CONFIG.BLADE_SEGMENTS, +): THREE.BufferGeometry { + const N = bladesPerClump; + const segs = bladeSegments; + const { + CLUMP_RADIUS, + CLUMP_INNER_RATIO, + BLADE_WIDTH_RATIO, + BLADE_HEIGHT_MIN: hMin, + BLADE_HEIGHT_MAX: hMax, + BLADE_ARC_RATIO, + BLADE_TAPER: taper, + } = GRASS_CONFIG; + + const vertsPerBlade = segs * 2 + 1; + const trisPerBlade = (segs - 1) * 2 + 1; + const totalVerts = vertsPerBlade * N; + const totalIdx = trisPerBlade * 3 * N; + + const positions = new Float32Array(totalVerts * 3); + const normals = new Float32Array(totalVerts * 3); + const uvs = new Float32Array(totalVerts * 2); + const indices = new Uint16Array(totalIdx); + const bladeHashAttr = new Float32Array(totalVerts); + + const rng = mulberry32(91827364); + const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); + + let vi = 0; + let ii = 0; + + for (let b = 0; b < N; b++) { + const t01 = b / N; + const angle = b * GOLDEN_ANGLE; + const rNorm = CLUMP_INNER_RATIO + (1 - CLUMP_INNER_RATIO) * Math.sqrt(t01); + const r = rNorm * CLUMP_RADIUS; + const jitter = (rng() - 0.5) * 0.15 * CLUMP_RADIUS; + const ox = Math.cos(angle) * r + Math.cos(angle + 1.3) * jitter; + const oz = Math.sin(angle) * r + Math.sin(angle + 1.3) * jitter; + + const facingAngle = angle + Math.PI * 0.5 + (rng() - 0.5) * Math.PI; + const cr = Math.cos(facingAngle); + const sr = Math.sin(facingAngle); + + const h = hMin + (hMax - hMin) * (0.3 + 0.7 * t01 + (rng() - 0.5) * 0.4); + const w = h * BLADE_WIDTH_RATIO; + + const curveAngle = angle + (rng() - 0.5) * Math.PI * 0.6; + const arcDist = h * BLADE_ARC_RATIO * (0.8 + rng() * 0.4); + const curveDirX = Math.cos(curveAngle) * arcDist; + const curveDirZ = Math.sin(curveAngle) * arcDist; + + const bHash = rng(); + const baseVert = vi; + + for (let i = 0; i < segs; i++) { + const t = i / segs; + const y = t * h; + const hw = w * 0.5 * (1.0 - t * taper); + const arc = t * t; + const arcX = curveDirX * arc; + const arcZ = curveDirZ * arc; + + for (let side = 0; side < 2; side++) { + const lx = side === 0 ? -hw : hw; + positions[vi * 3] = lx * cr + arcX + ox; + positions[vi * 3 + 1] = y; + positions[vi * 3 + 2] = lx * sr + arcZ + oz; + normals[vi * 3] = -sr; + normals[vi * 3 + 1] = 0; + normals[vi * 3 + 2] = cr; + uvs[vi * 2] = side; + uvs[vi * 2 + 1] = t; + bladeHashAttr[vi] = bHash; + vi++; + } + } + + positions[vi * 3] = curveDirX + ox; + positions[vi * 3 + 1] = h; + positions[vi * 3 + 2] = curveDirZ + oz; + normals[vi * 3] = -sr; + normals[vi * 3 + 1] = 0; + normals[vi * 3 + 2] = cr; + uvs[vi * 2] = 0.5; + uvs[vi * 2 + 1] = 1.0; + bladeHashAttr[vi] = bHash; + vi++; + + for (let i = 0; i < segs - 1; i++) { + const bv = baseVert + i * 2; + indices[ii++] = bv; + indices[ii++] = bv + 1; + indices[ii++] = bv + 2; + indices[ii++] = bv + 1; + indices[ii++] = bv + 3; + indices[ii++] = bv + 2; + } + const lastRow = baseVert + (segs - 1) * 2; + const tipIdx = baseVert + segs * 2; + indices[ii++] = lastRow; + indices[ii++] = lastRow + 1; + indices[ii++] = tipIdx; + } + + const geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geo.setAttribute("normal", new THREE.BufferAttribute(normals, 3)); + geo.setAttribute("uv", new THREE.BufferAttribute(uvs, 2)); + geo.setAttribute("bladeHash", new THREE.BufferAttribute(bladeHashAttr, 1)); + geo.setIndex(new THREE.BufferAttribute(indices, 1)); + return geo; +} + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -68,8 +251,9 @@ function mulberry32(seed: number): () => number { interface GrassChunk { nodeId: number; mesh: THREE.InstancedMesh; - /** World-space bounding box covering the full terrain chunk extent */ box: THREE.Box3; + lodLevel: number; + node: TerrainQuadNode; } // --------------------------------------------------------------------------- @@ -80,105 +264,127 @@ export class GrassVisualManager implements QuadTreeListener { private container: THREE.Group; private getHeightAt: (x: number, z: number) => number; private getRoadInfluence: (wx: number, wz: number) => number; + private sunDirUniform: ReturnType>; + private getTerrainColor: ( + wx: number, + wz: number, + h: number, + slope: number, + fW: number, + cW: number, + ) => { r: number; g: number; b: number; grassWeight: number }; + private getBiomeWeights: ( + wx: number, + wz: number, + ) => { biomeWeightMap: Map; totalWeight: number }; private waterThreshold: number; - private world: World; private chunks = new Map(); - private material: MeshStandardNodeMaterial | null = null; + private material: MeshStandardNodeMaterial; + private lodGeometries: THREE.BufferGeometry[]; - private uPlayerPos = uniform(new THREE.Vector2(0, 0)); - - /** Reusable frustum + matrix for CPU-side per-chunk visibility testing */ private frustum = new THREE.Frustum(); private projScreenMatrix = new THREE.Matrix4(); - - /** Loaded GLB blade geometry (null until async load completes) */ - private bladeGeometry: THREE.BufferGeometry | null = null; - /** Height of the loaded blade model (for normalizing tipness) */ - private bladeModelHeight = 1; - /** Nodes that arrived before the model finished loading */ - private pendingNodes: TerrainQuadNode[] = []; - private modelLoaded = false; + private playerX = 0; + private playerZ = 0; constructor( container: THREE.Group, - world: World, + _world: unknown, getHeightAt: (x: number, z: number) => number, waterThreshold: number, getRoadInfluence: (wx: number, wz: number) => number, + getTerrainColor: ( + wx: number, + wz: number, + h: number, + slope: number, + fW: number, + cW: number, + ) => { r: number; g: number; b: number; grassWeight: number }, + getBiomeWeights: ( + wx: number, + wz: number, + ) => { biomeWeightMap: Map; totalWeight: number }, ) { this.container = container; - this.world = world; this.getHeightAt = getHeightAt; this.waterThreshold = waterThreshold; this.getRoadInfluence = getRoadInfluence; + this.getTerrainColor = getTerrainColor; + this.getBiomeWeights = getBiomeWeights; - this.loadGrassModel(); - } - - // -- Model loading -------------------------------------------------------- - - private async loadGrassModel(): Promise { - try { - const baseUrl = (this.world.assetsUrl || "").replace(/\/$/, ""); - const fullPath = `${baseUrl}/${GRASS_CONFIG.MODEL_PATH}`; - const { scene } = await modelCache.loadModel(fullPath, this.world); - - let geometry: THREE.BufferGeometry | null = null; - scene.traverse((child: THREE.Object3D) => { - if (!geometry && child instanceof THREE.Mesh && child.geometry) { - geometry = child.geometry; - } - }); - - if (!geometry) { - console.warn("[GrassVisualManager] No geometry found in grass GLB"); - return; - } - - this.bladeGeometry = geometry; - - // Compute model height for tipness normalization - geometry.computeBoundingBox(); - if (geometry.boundingBox) { - this.bladeModelHeight = Math.max( - 0.01, - geometry.boundingBox.max.y - geometry.boundingBox.min.y, - ); - } - - this.material = this.createMaterial(); - this.modelLoaded = true; - - console.log( - `[GrassVisualManager] Grass model loaded (${geometry.attributes.position.count} verts, h=${this.bladeModelHeight.toFixed(2)}m)`, + this.lodGeometries = GRASS_CONFIG.LOD_TIERS.map((tier) => + createClumpGeometry(tier.bladesPerClump, tier.bladeSegments), + ); + this.material = this.createMaterial(); + + const tierDescs = GRASS_CONFIG.LOD_TIERS.map((t, i) => { + const g = this.lodGeometries[i]; + return ( + `LOD${i}(${t.bladesPerClump}b/${t.bladeSegments}s, ` + + `${g.attributes.position.count}v, ${g.index!.count / 3}t, ` + + `<${t.maxDistance === Infinity ? "inf" : t.maxDistance}m, ` + + `×${t.spacingMul})` ); - - // Process any nodes that arrived before the model was ready - for (const node of this.pendingNodes) { - this.createChunkMesh(node); - } - this.pendingNodes.length = 0; - } catch (err) { - console.error("[GrassVisualManager] Failed to load grass model:", err); - } + }); + console.log( + `[GrassVisualManager] ${tierDescs.length} LOD tiers | ` + + `spacing ${GRASS_CONFIG.CLUMP_SPACING}m | ${tierDescs.join(" | ")}`, + ); } // -- Public API ----------------------------------------------------------- - update(playerX: number, playerZ: number, camera?: THREE.Camera): void { - this.uPlayerPos.value.set(playerX, playerZ); + updateLighting(sunDir: THREE.Vector3): void { + this.sunDirUniform.value.copy(sunDir); + } + update(playerX: number, playerZ: number, camera?: THREE.Camera): void { + this.playerX = playerX; + this.playerZ = playerZ; if (!camera || this.chunks.size === 0) return; - // Build frustum from camera (same approach as portfolio project) this.projScreenMatrix.multiplyMatrices( camera.projectionMatrix, camera.matrixWorldInverse, ); this.frustum.setFromProjectionMatrix(this.projScreenMatrix); - for (const [, chunk] of this.chunks) { + const tiers = GRASS_CONFIG.LOD_TIERS; + const hysteresis = GRASS_CONFIG.LOD_HYSTERESIS; + let rebuilds = 0; + const MAX_REBUILDS_PER_FRAME = 2; + + for (const [key, chunk] of this.chunks) { chunk.mesh.visible = this.frustum.intersectsBox(chunk.box); + if (!chunk.mesh.visible || rebuilds >= MAX_REBUILDS_PER_FRAME) continue; + + // Check if LOD tier should change + const dx = chunk.node.centerX - playerX; + const dz = chunk.node.centerZ - playerZ; + const dist = Math.sqrt(dx * dx + dz * dz); + const currentTier = tiers[chunk.lodLevel]; + const desiredLod = this.getLodLevel(chunk.node); + + if (desiredLod !== chunk.lodLevel) { + const boundary = + desiredLod < chunk.lodLevel + ? currentTier.maxDistance // moving closer: use current tier boundary + : (tiers[desiredLod - 1]?.maxDistance ?? 0); // moving farther: use target-1 boundary + const threshold = + boundary * + (1 + (desiredLod < chunk.lodLevel ? -hysteresis : hysteresis)); + const shouldSwitch = + desiredLod < chunk.lodLevel ? dist < threshold : dist > threshold; + + if (shouldSwitch) { + if (chunk.mesh.parent) chunk.mesh.parent.remove(chunk.mesh); + chunk.mesh.geometry.dispose(); + this.chunks.delete(key); + this.createChunkMesh(chunk.node, desiredLod); + rebuilds++; + } + } } } @@ -186,15 +392,8 @@ export class GrassVisualManager implements QuadTreeListener { onNodeNeedsGeometry(node: TerrainQuadNode): void { if (!node.isMaxDepth) return; - const key = this.chunkKey(node); if (this.chunks.has(key)) return; - - if (!this.modelLoaded) { - this.pendingNodes.push(node); - return; - } - this.createChunkMesh(node); } @@ -202,14 +401,9 @@ export class GrassVisualManager implements QuadTreeListener { const key = this.chunkKey(node); const chunk = this.chunks.get(key); if (!chunk) return; - if (chunk.mesh.parent) chunk.mesh.parent.remove(chunk.mesh); chunk.mesh.geometry.dispose(); this.chunks.delete(key); - - // Also remove from pending queue if present - const pi = this.pendingNodes.indexOf(node); - if (pi >= 0) this.pendingNodes.splice(pi, 1); } destroy(): void { @@ -218,35 +412,96 @@ export class GrassVisualManager implements QuadTreeListener { chunk.mesh.geometry.dispose(); } this.chunks.clear(); - this.pendingNodes.length = 0; if (this.material) this.material.dispose(); if (this.container.parent) this.container.parent.remove(this.container); } - // -- Chunk mesh creation -------------------------------------------------- + /** + * Destroy and recreate all grass chunks (e.g. after road data loads). + */ + rebuildAllChunks(): void { + const nodes: TerrainQuadNode[] = []; + for (const chunk of this.chunks.values()) { + nodes.push(chunk.node); + if (chunk.mesh.parent) chunk.mesh.parent.remove(chunk.mesh); + chunk.mesh.geometry.dispose(); + } + this.chunks.clear(); + for (const node of nodes) { + node.visualChunkKey = null; + this.createChunkMesh(node); + } + } + + /** + * Invalidate grass chunks that overlap a world-space bounding box. + * Used when flat zones are registered/unregistered so grass regenerates + * with correct heights and placement. + */ + invalidateRegion( + minX: number, + minZ: number, + maxX: number, + maxZ: number, + ): void { + const toRebuild: TerrainQuadNode[] = []; + for (const [key, chunk] of this.chunks) { + const half = chunk.node.halfSize; + const cx = chunk.node.centerX; + const cz = chunk.node.centerZ; + if ( + cx + half < minX || + cx - half > maxX || + cz + half < minZ || + cz - half > maxZ + ) + continue; + toRebuild.push(chunk.node); + if (chunk.mesh.parent) chunk.mesh.parent.remove(chunk.mesh); + chunk.mesh.geometry.dispose(); + this.chunks.delete(key); + } + for (const node of toRebuild) { + this.createChunkMesh(node); + } + } + + // -- LOD helpers ----------------------------------------------------------- - private createChunkMesh(node: TerrainQuadNode): void { - if (!this.bladeGeometry || !this.material) return; + private getLodLevel(node: TerrainQuadNode): number { + const dx = node.centerX - this.playerX; + const dz = node.centerZ - this.playerZ; + const dist = Math.sqrt(dx * dx + dz * dz); + const tiers = GRASS_CONFIG.LOD_TIERS; + for (let i = 0; i < tiers.length; i++) { + if (dist < tiers[i].maxDistance) return i; + } + return tiers.length - 1; + } + + // -- Chunk mesh creation -------------------------------------------------- + private createChunkMesh(node: TerrainQuadNode, lodLevel?: number): void { const key = this.chunkKey(node); if (this.chunks.has(key)) return; - const instanceData = this.generateInstanceData(node); + const lod = lodLevel ?? this.getLodLevel(node); + const tier = GRASS_CONFIG.LOD_TIERS[lod]; + const instanceData = this.generateInstanceData(node, tier.spacingMul); if (!instanceData || instanceData.count === 0) return; - // Clone geometry and attach instanced attributes - const geo = this.bladeGeometry.clone(); + const geo = this.lodGeometries[lod].clone(); geo.setAttribute( "instanceOffset", new THREE.InstancedBufferAttribute(instanceData.offsets, 3), ); geo.setAttribute( - "instanceRotScale", - new THREE.InstancedBufferAttribute(instanceData.rotScales, 2), + "instanceRotScaleHash", + new THREE.InstancedBufferAttribute(instanceData.rotScaleHash, 3), ); geo.setAttribute( - "instanceHash", - new THREE.InstancedBufferAttribute(instanceData.hashes, 1), + "instanceGroundColor", + new THREE.InstancedBufferAttribute(instanceData.groundColors, 3), ); const mesh = new THREE.InstancedMesh( @@ -261,14 +516,12 @@ export class GrassVisualManager implements QuadTreeListener { mesh.castShadow = false; mesh.userData = { type: "grass", walkable: false, clickable: false }; - // Instance matrices set to identity — positioning is handled in positionNode const identity = new THREE.Matrix4(); for (let i = 0; i < instanceData.count; i++) { mesh.setMatrixAt(i, identity); } mesh.instanceMatrix.needsUpdate = true; - // Bounding box covering the full terrain chunk (for CPU frustum culling) const half = node.halfSize; const box = new THREE.Box3( new THREE.Vector3(node.centerX - half, -50, node.centerZ - half), @@ -276,54 +529,78 @@ export class GrassVisualManager implements QuadTreeListener { ); this.container.add(mesh); - this.chunks.set(key, { nodeId: node.id, mesh, box }); + this.chunks.set(key, { nodeId: node.id, mesh, box, lodLevel: lod, node }); } // -- Instance data generation --------------------------------------------- - private generateInstanceData(node: TerrainQuadNode): { + private generateInstanceData( + node: TerrainQuadNode, + spacingMul = 1, + ): { offsets: Float32Array; - rotScales: Float32Array; - hashes: Float32Array; + rotScaleHash: Float32Array; + groundColors: Float32Array; count: number; } | null { - const maxCount = GRASS_CONFIG.INSTANCES_PER_CHUNK; + const spacing = GRASS_CONFIG.CLUMP_SPACING * spacingMul; + const maxCount = Math.ceil((node.size * node.size) / (spacing * spacing)); const rng = mulberry32( GRASS_CONFIG.SEED ^ ((node.centerX * 374761393 + node.centerZ * 668265263) | 0), ); const offsets = new Float32Array(maxCount * 3); - const rotScales = new Float32Array(maxCount * 2); - const hashes = new Float32Array(maxCount); + const rotScaleHash = new Float32Array(maxCount * 3); + const groundColors = new Float32Array(maxCount * 3); let count = 0; for (let i = 0; i < maxCount; i++) { const lx = (rng() - 0.5) * node.size; const lz = (rng() - 0.5) * node.size; - const bladeRng = rng(); + const clumpRng = rng(); const wx = node.centerX + lx; const wz = node.centerZ + lz; const ty = this.getHeightAt(wx, wz); if (ty < this.waterThreshold + 0.1) continue; - if (this.getRoadInfluence(wx, wz) > 0.3) continue; - // Slope check + const roadInf = this.getRoadInfluence(wx, wz); + if (roadInf > 0.8) continue; + + // Slope via finite differences const sd = 0.5; const hL = this.getHeightAt(wx - sd, wz); const hR = this.getHeightAt(wx + sd, wz); const hD = this.getHeightAt(wx, wz - sd); const hU = this.getHeightAt(wx, wz + sd); - const dx = hR - hL; - const dz = hU - hD; - if ( - dx * dx + dz * dz > - GRASS_CONFIG.MAX_SLOPE * GRASS_CONFIG.MAX_SLOPE * 4 * sd * sd - ) - continue; + const dhdx = (hR - hL) / (2 * sd); + const dhdz = (hU - hD) / (2 * sd); + const gradMag = Math.sqrt(dhdx * dhdx + dhdz * dhdz); + const normalY = 1 / Math.sqrt(1 + gradMag * gradMag); + const slope = 1 - normalY; + + if (slope > GRASS_CONFIG.MAX_SLOPE) continue; + + // Biome weights at this position + const { biomeWeightMap, totalWeight } = this.getBiomeWeights(wx, wz); + const invW = totalWeight > 0 ? 1 / totalWeight : 1; + const forestW = (biomeWeightMap.get("forest") || 0) * invW; + const canyonW = (biomeWeightMap.get("canyon") || 0) * invW; + + // Terrain color + grass weight, reduced by road influence + const { + r, + g, + b, + grassWeight: rawGW, + } = this.getTerrainColor(wx, wz, ty, slope, forestW, canyonW); + const grassWeight = Math.max(0, rawGW - roadInf); + + if (grassWeight < GRASS_CONFIG.MIN_GRASS_WEIGHT) continue; + if (clumpRng > grassWeight) continue; offsets[count * 3] = lx; offsets[count * 3 + 1] = ty; @@ -332,11 +609,15 @@ export class GrassVisualManager implements QuadTreeListener { const rotation = rng() * Math.PI * 2; const scale = GRASS_CONFIG.SCALE_MIN + - bladeRng * (GRASS_CONFIG.SCALE_MAX - GRASS_CONFIG.SCALE_MIN); - rotScales[count * 2] = rotation; - rotScales[count * 2 + 1] = scale; + clumpRng * (GRASS_CONFIG.SCALE_MAX - GRASS_CONFIG.SCALE_MIN); + rotScaleHash[count * 3] = rotation; + rotScaleHash[count * 3 + 1] = scale; + rotScaleHash[count * 3 + 2] = clumpRng; + + groundColors[count * 3] = srgbToLinear(r); + groundColors[count * 3 + 1] = srgbToLinear(g); + groundColors[count * 3 + 2] = srgbToLinear(b); - hashes[count] = bladeRng; count++; } @@ -344,8 +625,8 @@ export class GrassVisualManager implements QuadTreeListener { return { offsets: offsets.slice(0, count * 3), - rotScales: rotScales.slice(0, count * 2), - hashes: hashes.slice(0, count), + rotScaleHash: rotScaleHash.slice(0, count * 3), + groundColors: groundColors.slice(0, count * 3), count, }; } @@ -357,74 +638,98 @@ export class GrassVisualManager implements QuadTreeListener { mat.side = THREE.DoubleSide; mat.transparent = false; mat.depthWrite = true; + mat.roughness = 1.0; + mat.metalness = 0.0; + mat.fog = false; - const uBaseColor = uniform(GRASS_CONFIG.BASE_COLOR); - const uTipColor = uniform(GRASS_CONFIG.TIP_COLOR); const uWindSpeed = uniform(GRASS_CONFIG.WIND_SPEED); const uWindStrength = uniform(GRASS_CONFIG.WIND_STRENGTH); - const uModelHeight = uniform(this.bladeModelHeight); + const uBladeHeight = uniform(GRASS_CONFIG.BLADE_HEIGHT_MAX); + this.sunDirUniform = uniform( + new THREE.Vector3(...SUN_LIGHT.DEFAULT_DIRECTION), + ); - // --- positionNode: instance placement + Y rotation + wind + distance fade --- mat.positionNode = Fn(() => { const localPos = positionLocal.toVar("gp"); - // Per-instance data const offset = attribute("instanceOffset", "vec3"); - const rotScale = attribute("instanceRotScale", "vec2"); - const rot = rotScale.x; - const scale = rotScale.y; + const rsh = attribute("instanceRotScaleHash", "vec3"); + const rot = rsh.x; + const scale = rsh.y; - // Tipness: normalized Y position within the blade model (0=base, 1=tip) - const tipness = localPos.y.div(uModelHeight).clamp(0.0, 1.0); + const t = uv().y; - // Scale the blade + // Scale entire clump localPos.x.assign(localPos.x.mul(scale)); localPos.y.assign(localPos.y.mul(scale)); localPos.z.assign(localPos.z.mul(scale)); - // Rotate around Y axis + // Rotate entire clump around Y — snapshot x/z first because TSL assign + // is sequential in WGSL (second assign would read the modified first). const cosR = cos(rot); const sinR = sin(rot); - const rx = localPos.x.mul(cosR).sub(localPos.z.mul(sinR)); - const rz = localPos.x.mul(sinR).add(localPos.z.mul(cosR)); - localPos.x.assign(rx); - localPos.z.assign(rz); + const preRotX = localPos.x.toVar("preRotX"); + const preRotZ = localPos.z.toVar("preRotZ"); + localPos.x.assign(preRotX.mul(cosR).sub(preRotZ.mul(sinR))); + localPos.z.assign(preRotX.mul(sinR).add(preRotZ.mul(cosR))); - // Translate to instance position (offset is chunk-local XZ + baked terrainY) - localPos.x.addAssign(offset.x); - localPos.y.addAssign(offset.y); - localPos.z.addAssign(offset.z); - - // Wind: displace tips in XZ using sine waves + // Wind: displace tips via sine waves keyed to world-space offset const wt = time.mul(uWindSpeed); + const bendFactor = pow(t, float(1.8)); localPos.x.addAssign( - sin(wt.add(offset.x.mul(0.8)).add(offset.z.mul(0.6))) + sin(wt.add(offset.x.mul(0.35)).add(offset.z.mul(0.12))) .mul(uWindStrength) - .mul(tipness), + .mul(bendFactor) + .mul(uBladeHeight), ); localPos.z.addAssign( - sin(wt.mul(0.7).add(offset.x.mul(0.5)).add(offset.z.mul(0.9)).add(2.0)) + sin( + wt.mul(0.67).add(offset.x.mul(0.18)).add(offset.z.mul(0.28)).add(2.0), + ) .mul(uWindStrength) - .mul(tipness) - .mul(0.7), + .mul(0.55) + .mul(bendFactor) + .mul(uBladeHeight), ); + // Translate to instance world position (chunk-local XZ + baked terrainY) + localPos.x.addAssign(offset.x); + localPos.y.addAssign(offset.y); + localPos.z.addAssign(offset.z); + return localPos; })(); - // --- colorNode: base-to-tip gradient with per-instance variation --- - mat.colorNode = Fn(() => { - const instHash = attribute("instanceHash", "float"); - const tipness = positionLocal.y.div(uModelHeight).clamp(0.0, 1.0); + // All normals point up so PBR lighting is uniform across all blades + mat.normalNode = vec3(0, 1, 0); - const col = mix(uBaseColor, uTipColor, tipness).toVar("gc"); + const uSunDir = this.sunDirUniform; - const v = hash(instHash.mul(1234.5)).mul(0.12).sub(0.06); - col.r.addAssign(v); - col.g.addAssign(v.mul(0.6)); - col.b.addAssign(v.mul(-0.3)); + mat.colorNode = Fn(() => { + const groundCol = attribute("instanceGroundColor", "vec3"); + const t = uv().y; // 0 = root, 1 = tip + + const colorLerp = smoothstep(float(0.0), float(0.8), t); + const rootCol = groundCol.mul(float(1.5)); + const baseCol = mix(rootCol, groundCol, colorLerp).toVar("grassBaseCol"); + + // Half-lambert anime shade — same as terrain shader so grass + // blends visually at the root boundary. + const sunDir = normalize(vec3(uSunDir)); + const NdotL = dot(vec3(0, 1, 0), sunDir); + const halfLambert = add(mul(NdotL, float(0.5)), float(0.5)); + const shadeFactor = sub(float(1.0), halfLambert); + const coolTint = vec3(...TERRAIN_SHADE.TINT_COLOR); + const tintedBase = mul(baseCol, coolTint); + baseCol.assign( + mix( + baseCol, + tintedBase, + mul(shadeFactor, float(TERRAIN_SHADE.STRENGTH)), + ), + ); - return col; + return baseCol; })(); return mat; diff --git a/packages/shared/src/systems/shared/world/ProceduralGrass.ts b/packages/shared/src/systems/shared/world/ProceduralGrass.ts index ba013a1b3..ae07bb6b6 100644 --- a/packages/shared/src/systems/shared/world/ProceduralGrass.ts +++ b/packages/shared/src/systems/shared/world/ProceduralGrass.ts @@ -26,21 +26,15 @@ /** * ProceduralGrass.ts - GPU Grass System * - * Key architecture: - * - SpriteNodeMaterial for billboard grass - * - Two-buffer SSBO (vec4 + float) with bit-packed data - * - Visibility NOT used for opacity/scale (only for offscreen culling) - * - Wind stored as vec2 displacement - * - Heightmap Y offset integration with terrain system + * LOD0 architecture (near-player blades): + * - Single THREE.Mesh with ~78K triangles (one per blade) + * - MeshBasicNodeMaterial with TSL positionNode/colorNode + * - Toroidal mod() wrapping centered on player position + * - Heightmap sampling, billboard rotation, wind, distance fade all in vertex shader + * - Zero compute shaders, zero SSBO — one draw call * - * **PERFORMANCE OPTIMIZATIONS:** - * - Heightmap generation uses chunked async processing (no main thread blocking) - * - Compute shaders pre-initialized during load (no first-frame spike) - * - All heavy work done before gameplay starts - * - * **RELATED MODULES:** - * - For standalone grass generation (Asset Forge, viewers), see @hyperscape/procgen GrassGen module - * - GrassGen provides simpler grass geometry/materials without SSBO compute integration + * LOD1 architecture (far-field tiles): + * - InstancedMesh with GPU-driven tile positioning/culling in vertex shader * * @module ProceduralGrass */ @@ -55,6 +49,7 @@ import THREE, { vertexColor, sin, cos, + atan, mix, uv, floor, @@ -84,8 +79,9 @@ import THREE, { length, positionLocal, Loop, + attribute, } from "../../../extras/three/three"; -import { MeshBasicNodeMaterial, SpriteNodeMaterial } from "three/webgpu"; +import { MeshBasicNodeMaterial } from "three/webgpu"; import { System } from "../infrastructure/System"; import type { World } from "../../../types"; import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants"; @@ -150,11 +146,13 @@ function shouldYield(startTime: number, budgetMs = 8): boolean { // ============================================================================ // LOD0: Individual grass blades (near player) +// Default uses MEDIUM preset (~78K blades) for reasonable performance. +// HIGH (~166K) was causing GPU bottleneck with per-frame compute shaders. const getConfig = () => { - const BLADE_WIDTH = 0.12; // Thicker for stylized look (was 0.08) - const BLADE_HEIGHT = 0.6; // Taller for Zelda feel (was 0.25) - const TILE_SIZE = 16; // 8m radius - const BLADES_PER_SIDE = 408; // ~166k blades (2x thicker near grass) + const BLADE_WIDTH = 0.12; + const BLADE_HEIGHT = 0.6; + const TILE_SIZE = 60; // 30m radius — large enough for 3rd-person camera offset + const BLADES_PER_SIDE = 350; // ~122K blades, spacing=0.17m (~34 blades/m²) return { BLADE_WIDTH, @@ -166,7 +164,7 @@ const getConfig = () => { COUNT: BLADES_PER_SIDE * BLADES_PER_SIDE, SPACING: TILE_SIZE / BLADES_PER_SIDE, WORKGROUP_SIZE: 256, - SEGMENTS: 4, + SEGMENTS: 3, // MEDIUM: 3 segments (was 4 for HIGH) }; }; @@ -174,31 +172,30 @@ const config = getConfig(); /** Quality presets for different devices */ export enum GrassQuality { - LOW = "low", // Mobile: ~40K blades, 20m range - MEDIUM = "medium", // Tablet: ~78K blades, 25m range - HIGH = "high", // Desktop: ~166K blades, full range + LOW = "low", // Mobile: ~40K blades, 40m field + MEDIUM = "medium", // Default: ~122K blades, 60m field + HIGH = "high", // Desktop: ~202K blades, 80m field } const QUALITY_PRESETS: Record< GrassQuality, { bladesPerSide: number; tileSize: number; segments: number } > = { - [GrassQuality.LOW]: { bladesPerSide: 200, tileSize: 10, segments: 2 }, - [GrassQuality.MEDIUM]: { bladesPerSide: 280, tileSize: 12, segments: 3 }, - [GrassQuality.HIGH]: { bladesPerSide: 408, tileSize: 16, segments: 4 }, + [GrassQuality.LOW]: { bladesPerSide: 200, tileSize: 40, segments: 2 }, + [GrassQuality.MEDIUM]: { bladesPerSide: 350, tileSize: 60, segments: 3 }, + [GrassQuality.HIGH]: { bladesPerSide: 450, tileSize: 80, segments: 4 }, }; // Grass tile settings (matches procgen viewer defaults) -// LOD TRANSITION DIAGRAM (complementary dither crossfade): +// LOD TRANSITION DIAGRAM (LOD0 uses player distance, LOD1 uses camera distance): // ┌─────────────────────────────────────────────────────────────────────────┐ -// │ LOD0 (blades) ████████████████████░░░░░░ │ -// │ 0m 10m 13m │ +// │ LOD0 (blades) ████████████████████████████░░░░░░ │ +// │ (from player) 0m 25m 28m │ // │ │ -// │ LOD1 (tiles) ░░░░░░░░████████████████████████████████░░░░░░ │ -// │ 8m 13m 40m 50m │ +// │ LOD1 (tiles) ░░░░░░░████████████████████████████░░░░░░ │ +// │ (from camera) 20m 27m 40m 50m │ // └─────────────────────────────────────────────────────────────────────────┘ -// ░ = heavy dither fade zone, █ = full visibility -// LOD1 fade-in (8-13m) overlaps LOD0 fade-out (10-13m) for seamless crossfade +// ░ = fade zone, █ = full visibility const GRASS_TILE_SETTINGS = { TILE_SIZE: 1.0, DENSITY: 64, // Reduced from 128 for perf @@ -212,13 +209,13 @@ const GRASS_TILE_SETTINGS = { CLUMP_SPREAD: 0.04, CLUMP_HEIGHT_VARIATION: 0.4, CLUMP_WIDTH_VARIATION: 0.2, - // LOD0 (blade grass): 0-13m, fade 10-13m - LOD0_FADE_START: 10, - LOD0_FADE_END: 13, - // LOD1 (instanced tiles): 8-50m - // Fade in 8-13m overlaps with LOD0 fade-out for seamless crossfade - LOD1_FADE_IN_START: 8, - LOD1_FADE_IN_END: 13, + // LOD0 (blade grass): 0-28m from player, fade 25-28m + LOD0_FADE_START: 25, + LOD0_FADE_END: 28, + // LOD1 (instanced tiles): 20-50m + // Fade in 20-27m overlaps with LOD0 fade-out for seamless crossfade + LOD1_FADE_IN_START: 20, + LOD1_FADE_IN_END: 27, LOD1_FADE_OUT_START: 40, LOD1_FADE_OUT_END: 50, // Grid sizes @@ -298,17 +295,15 @@ const uniforms = { uWindColorStrength: uniform(0.7), // Strong lighter crests in wind uBaseWindShade: uniform(0.5), // Wind darkening uBaseShadeHeight: uniform(1.0), - // Stochastic distance culling - extends grass rendering distance - // Works WITH fade: grass gradually thins AND fades at the same time - uR0: uniform(10), // Full density within 10m - uR1: uniform(13), // Thin to minimum by 13m (3m transition zone) - uPMin: uniform(0.2), // Heavy dither at edge for visible effect - // Distance fade - overlaps with stochastic culling for natural falloff - // Matches LOD1 fade-in (8-13m) for seamless crossfade - uFadeStart: uniform(10), // Start fading at 10m - uFadeEnd: uniform(13), // Fully faded by 13m - // Camera forward bias - extends grass in view direction - uForwardBias: uniform(15), // More grass in front of camera + // Stochastic distance culling - thins grass at tile edges + // Field is 60m (±30m). Fade uses PLAYER distance, not camera. + uR0: uniform(22), // Full density within 22m of player + uR1: uniform(27), // Thin to minimum by 27m from player + uPMin: uniform(0.2), + // Distance fade from player (not camera) — collapse at wrapping edge + uFadeStart: uniform(25), // Start fading at 25m from player + uFadeEnd: uniform(28), // Fully faded by 28m (before 30m wrap edge) + uForwardBias: uniform(10), // Rotation uBaseBending: uniform(3.0), // More bending (was 2.0) // Bottom fade - dither grass base into ground (0.3 UV = 0.15m on 0.5m blade) @@ -359,21 +354,8 @@ const HEIGHTMAP_CONFIG = { } as const; // ============================================================================ -// GRASS SSBO - Three-buffer structure +// HEIGHTMAP TEXTURE NODE // ============================================================================ -// Buffer1 (vec4): -// x -> offsetX (local to player) -// y -> offsetZ (local to player) -// z -> 0/12 windX - 12/12 windZ -// w -> 0/8 current scale - 8/8 original scale - 16/1 shadow - 17/1 visibility - 18/6 wind noise factor -// -// Buffer2 (float): -// 0/4 position based noise -// -// Buffer3 (float): HYPERSCAPE ADDITION -// heightmapY - terrain height at this blade's world position - -type InstancedArrayBuffer = ReturnType; // Heightmap texture node for compute shader let heightmapTextureNode: ReturnType | null = null; @@ -664,970 +646,305 @@ bayerTexture.magFilter = THREE.NearestFilter; bayerTexture.needsUpdate = true; const bayerTextureNode = texture(bayerTexture); -class GrassSsbo { - private buffer1: InstancedArrayBuffer; - private buffer2: InstancedArrayBuffer; - private buffer3: InstancedArrayBuffer; // heightmapY - private buffer4: InstancedArrayBuffer; // static visibility (0=culled, 1=visible) - - // Compute shaders - initialized in constructor after buffers - computeInit: any; - computeStatic: any; - computeUpdate: any; - - // Track initialization state - private _initialized = false; - - constructor() { - // Create buffers FIRST - this.buffer1 = instancedArray(config.COUNT, "vec4"); - this.buffer2 = instancedArray(config.COUNT, "float"); - this.buffer3 = instancedArray(config.COUNT, "float"); - this.buffer4 = instancedArray(config.COUNT, "float"); // Static visibility cache - - // Then create compute shaders that reference the buffers - this.computeInit = this.createComputeInit(); - this.computeStatic = this.createComputeStatic(); - this.computeUpdate = this.createComputeUpdate(); - - // NOTE: We DO NOT use onInit callback here anymore! - // This caused first-frame spikes because computeInit ran during gameplay. - // Instead, call runInitialization() explicitly during loading. - } - - /** - * Run compute initialization explicitly during loading phase. - * This avoids first-frame spikes by pre-running heavy compute work. - * @returns Promise that resolves when initialization is complete - */ - async runInitialization(renderer: THREE.WebGPURenderer): Promise { - if (this._initialized) return; - - console.log("[ProceduralGrass] Running SSBO compute initialization..."); - const startTime = performance.now(); - - // Run compute init - this populates all grass blade positions - await renderer.computeAsync(this.computeInit); - - this._initialized = true; - console.log( - `[ProceduralGrass] SSBO init complete: ${(performance.now() - startTime).toFixed(1)}ms`, - ); - } - - get isInitialized(): boolean { - return this._initialized; - } - - get computeBuffer1(): InstancedArrayBuffer { - return this.buffer1; - } - - get computeBuffer2(): InstancedArrayBuffer { - return this.buffer2; - } - - get computeBuffer3(): InstancedArrayBuffer { - return this.buffer3; - } - - get computeBuffer4(): InstancedArrayBuffer { - return this.buffer4; - } - - // Unpacking functions - getWind = Fn(([data = vec4(0)]) => { - const x = tslUtils.unpackUnits(data.z, 0, 12, -2, 2); - const z = tslUtils.unpackUnits(data.z, 12, 12, -2, 2); - return vec2(x, z); - }); - - getScale = Fn(([data = vec4(0)]) => { - return tslUtils.unpackUnits( - data.w, - 0, - 8, - uniforms.uTrailMinScale, - uniforms.uBladeMaxScale, - ); - }); - - getOriginalScale = Fn(([data = vec4(0)]) => { - return tslUtils.unpackUnits( - data.w, - 8, - 8, - uniforms.uBladeMinScale, - uniforms.uBladeMaxScale, - ); - }); - - getVisibility = Fn(([data = vec4(0)]) => { - return tslUtils.unpackFlag(data.w, 17); - }); - - getWindNoise = Fn(([data = vec4(0)]) => { - return tslUtils.unpackUnit(data.w, 18, 6); - }); - - getPositionNoise = Fn(([data = float(0)]) => { - return tslUtils.unpackUnit(data, 0, 4); - }); - - // Packing functions - private setWind = Fn(([data = vec4(0), value = vec2(0)]) => { - data.z = tslUtils.packUnits(data.z, 0, 12, value.x, -2, 2); - data.z = tslUtils.packUnits(data.z, 12, 12, value.y, -2, 2); - return data; - }); - - private setScale = Fn(([data = vec4(0), value = float(0)]) => { - data.w = tslUtils.packUnits( - data.w, - 0, - 8, - value, - uniforms.uTrailMinScale, - uniforms.uBladeMaxScale, - ); - return data; - }); - - private setOriginalScale = Fn(([data = vec4(0), value = float(0)]) => { - data.w = tslUtils.packUnits( - data.w, - 8, - 8, - value, - uniforms.uBladeMinScale, - uniforms.uBladeMaxScale, - ); - return data; - }); - - private setVisibility = Fn(([data = vec4(0), value = float(0)]) => { - data.w = tslUtils.packFlag(data.w, 17, value); - return data; - }); - - private setWindNoise = Fn(([data = vec4(0), value = float(0)]) => { - data.w = tslUtils.packUnit(data.w, 18, 6, value); - return data; - }); - - private setPositionNoise = Fn(([data = float(0), value = float(0)]) => { - return tslUtils.packUnit(data, 0, 4, value); - }); - - // Compute Init - private createComputeInit() { - return Fn(() => { - const data1 = this.buffer1.element(instanceIndex); - const data2 = this.buffer2.element(instanceIndex); - const data3 = this.buffer3.element(instanceIndex); - - // Position XZ in grid - const row = floor(float(instanceIndex).div(config.BLADES_PER_SIDE)); - const col = float(instanceIndex).mod(config.BLADES_PER_SIDE); - const randX = hash(instanceIndex.add(4321)); - const randZ = hash(instanceIndex.add(1234)); - const offsetX = col - .mul(config.SPACING) - .sub(config.TILE_HALF_SIZE) - .add(randX.mul(config.SPACING * 0.5)); - const offsetZ = row - .mul(config.SPACING) - .sub(config.TILE_HALF_SIZE) - .add(randZ.mul(config.SPACING * 0.5)); - - // ========== MEGA SIMPLIFIED - use per-instance hash instead of tile-scale noise ========== - // Use instanceIndex-based hash for jitter to avoid 80m tile patterns - const noiseR = hash(instanceIndex.mul(0.73)); - const noiseB = hash(instanceIndex.mul(0.91)); - - // Small per-instance jitter to break up grid pattern - const jitterX = noiseR.sub(0.5).mul(config.SPACING * 0.8); - const jitterZ = noiseB.sub(0.5).mul(config.SPACING * 0.8); - data1.x = offsetX.add(jitterX); - data1.y = offsetZ.add(jitterZ); - - data2.assign(this.setPositionNoise(data2, noiseR)); - - // Scale - random within range (shaped distribution) - const n = noiseB; - const shaped = n.mul(n); - const randomScale = remap( - shaped, - 0, - 1, - uniforms.uBladeMinScale, - uniforms.uBladeMaxScale, - ); - data1.assign(this.setScale(data1, randomScale)); - data1.assign(this.setOriginalScale(data1, randomScale)); +// ============================================================================ +// SIMPLE GRASS LOD0 - Single mesh with mod() wrapping in vertex shader +// Replaces GrassSsbo (3 compute shaders) + GrassMaterial (SpriteNodeMaterial) +// Inspired by infinite-world-master and folio-2025-main +// ============================================================================ - // Set visibility to 1 initially (visible) - data1.assign(this.setVisibility(data1, float(1))); +/** + * Build a non-indexed triangle mesh: one triangle per blade, gridSize^2 blades. + * Attributes: + * position (vec3) - blade vertex offsets (-halfW,0,0), (0,1,0), (+halfW,0,0) normalised + * center (vec2) - grid center XZ (same for all 3 verts of a blade) + * aHeightRandom (float) - per-blade random 0.6-1.0 (same for all 3 verts) + */ +function createSimpleGrassGeometry(): THREE.BufferGeometry { + const gridSize = config.BLADES_PER_SIDE; + const fieldSize = config.TILE_SIZE; + const bladeCount = gridSize * gridSize; + const vertexCount = bladeCount * 3; + const cellSize = fieldSize / gridSize; + + const positions = new Float32Array(vertexCount * 3); + const centers = new Float32Array(vertexCount * 2); + const heightRandoms = new Float32Array(vertexCount); + + let seed = 12345; + const rng = () => { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + return seed / 0x7fffffff; + }; - // Initialize heightmapY to 0 (will be updated in computeUpdate) - data3.assign(float(0)); - })().compute(config.COUNT, [config.WORKGROUP_SIZE]); + const halfField = fieldSize * 0.5; + let vi = 0; + for (let row = 0; row < gridSize; row++) { + for (let col = 0; col < gridSize; col++) { + const cx = + (col + 0.5) * cellSize - halfField + (rng() - 0.5) * cellSize * 0.8; + const cz = + (row + 0.5) * cellSize - halfField + (rng() - 0.5) * cellSize * 0.8; + const hRand = 0.6 + rng() * 0.4; + + // Vertex 0 : base-left (-1, 0, 0) + positions[vi * 3] = -1; + positions[vi * 3 + 1] = 0; + positions[vi * 3 + 2] = 0; + centers[vi * 2] = cx; + centers[vi * 2 + 1] = cz; + heightRandoms[vi] = hRand; + vi++; + + // Vertex 1 : tip ( 0, 1, 0) + positions[vi * 3] = 0; + positions[vi * 3 + 1] = 1; + positions[vi * 3 + 2] = 0; + centers[vi * 2] = cx; + centers[vi * 2 + 1] = cz; + heightRandoms[vi] = hRand; + vi++; + + // Vertex 2 : base-right (+1, 0, 0) + positions[vi * 3] = 1; + positions[vi * 3 + 1] = 0; + positions[vi * 3 + 2] = 0; + centers[vi * 2] = cx; + centers[vi * 2 + 1] = cz; + heightRandoms[vi] = hRand; + vi++; + } } - // Compute Wind - Enhanced with turbulence and gust patches (inspired by Grass_Journey) - private computeWind = Fn( - ([prevWindXZ = vec2(0), worldPos = vec3(0), positionNoise = float(0)]) => { - const intensity = smoothstep(0.2, 0.5, windManager.uIntensity); - const dir = windManager.uDirection.negate(); - const strength = uniforms.uWindStrength.add(intensity); - - // Gentle per-instance speed jitter (±10%) - const speed = uniforms.uWindSpeed.mul( - positionNoise.remap(0, 1, 0.95, 2.05), - ); - - // Base UV + scroll - const uvBase = worldPos.xz.mul(0.01).mul(uniforms.uvWindScale); - const scroll = dir.mul(speed).mul(time); - - // Sample noise textures for wind - const uvA = uvBase.add(scroll); - const uvB = uvBase.mul(1.37).add(scroll.mul(1.11)); - - // Use texture if available, otherwise hash fallback - const sampleNoise = (uvCoord: ReturnType) => { - if (noiseAtlasTexture) { - return texture(noiseAtlasTexture, uvCoord).mul(2.0).sub(1.0); - } - return vec3(hash(uvCoord.x.add(uvCoord.y.mul(100)))) - .mul(2.0) - .sub(1.0); - }; - - const nA = sampleNoise(uvA); - const nB = sampleNoise(uvB); - - // Mix noises - const mixRand = fract(sin(positionNoise.mul(12.9898)).mul(78.233)); - const mixTime = sin(time.mul(0.4).add(positionNoise.mul(0.1))).mul(0.25); - const w = clamp(mixRand.add(mixTime), 0.2, 0.8); - const n = mix(nA, nB, w); - - // ========== GUST PATCHES (like Grass_Journey) ========== - // Noise threshold creates areas of wind vs calm - like wind "clouds" passing - const gustNoiseThreshold = float(0.45); // Below this = calm, above = gusty - const gustMask = smoothstep( - gustNoiseThreshold, - gustNoiseThreshold.add(0.2), - n.x.add(0.5).mul(0.5), // Remap -1..1 to 0..1 - ); - - // ========== TURBULENCE LAYER (erratic per-blade movement) ========== - // Multiple fast sin waves at different frequencies for chaotic motion - const phase = positionNoise.mul(6.28); // Per-blade phase offset - const turbulenceTime = time.mul(20.0).add(phase.mul(100.0)); // Fast variation - - // 3 overlapping waves at different frequencies (Grass_Journey style) - const turb1 = sin(turbulenceTime).mul(0.15); - const turb2 = sin(turbulenceTime.mul(1.7).add(2.3)).mul(0.12); - const turb3 = cos(turbulenceTime.mul(0.8).add(phase.mul(50.0))).mul(0.1); - const turbulenceAmount = turb1.add(turb2).add(turb3); - - // Turbulence scales with global strength (visible everywhere, not just gusts) - const turbulence = turbulenceAmount.mul(strength).mul(0.6); - - // ========== COMBINE WIND COMPONENTS ========== - const baseMag = n.x.mul(strength); - const gustMag = n.y.mul(strength).mul(0.35).mul(gustMask); // Gusts only in gust patches - - // Total wind factor = base + gusts + turbulence - const windFactor = baseMag.add(gustMag).add(turbulence); - - const target = dir.mul(windFactor); - const k = mix(0.08, 0.25, n.z.abs()); - const newWind = prevWindXZ.add(target.sub(prevWindXZ).mul(k)); - - // Return wind XZ + gust mask (for potential cloud shadow effect) - return vec3(newWind, gustMask.mul(windFactor.abs())); - }, + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute("center", new THREE.BufferAttribute(centers, 2)); + geometry.setAttribute( + "aHeightRandom", + new THREE.BufferAttribute(heightRandoms, 1), ); - - // Compute Trail Scale - grass bends when player walks through - private computeTrailScale = Fn( - ([ - originalScale = float(0), - currentScale = float(0), - isStepped = float(0), - ]) => { - const up = currentScale.add( - originalScale.sub(currentScale).mul(uniforms.uTrailGrowthRate), - ); - const down = currentScale.add( - uniforms.uTrailMinScale.sub(currentScale).mul(uniforms.uKDown), - ); - const blended = mix(up, down, isStepped); - return clamp(blended, uniforms.uTrailMinScale, originalScale); - }, + geometry.boundingSphere = new THREE.Sphere( + new THREE.Vector3(), + fieldSize * 2, ); + return geometry; +} - // Compute Stochastic Keep - Distance-based dithered thinning - private computeStochasticKeep = Fn(([distSq = float(0)]) => { - const R0 = uniforms.uR0; - const R1 = uniforms.uR1; - const pMin = uniforms.uPMin; - const R0Sq = R0.mul(R0); - const R1Sq = R1.mul(R1); - - // t: 0 inside R0, 1 at/after R1 - const t = clamp( - distSq.sub(R0Sq).div(max(R1Sq.sub(R0Sq), float(0.00001))), - 0.0, - 1.0, - ); +const lod0Uniforms = { + uPlayerCenter: uniform(new THREE.Vector2(0, 0)), + uFieldSize: uniform(config.TILE_SIZE), + uBladeWidth: uniform(config.BLADE_WIDTH), + uBladeHeight: uniform(config.BLADE_HEIGHT), +}; - // Keep probability: 1 at center → pMin at edge - const p = mix(float(1.0), pMin, t); +/** + * MeshBasicNodeMaterial with TSL positionNode + colorNode. + * positionNode: mod-wrap around player, heightmap sample, billboard, wind, fade. + * colorNode: base/tip gradient, day/night tint. + */ +function createSimpleGrassMaterial(): MeshBasicNodeMaterial { + const material = new MeshBasicNodeMaterial(); + material.side = THREE.DoubleSide; + material.transparent = false; + material.depthWrite = true; + material.fog = false; - // Deterministic RNG per blade (stable across frames) - const rnd = hash(float(instanceIndex).mul(0.73)); + // --- positionNode --- + material.positionNode = Fn(() => { + const localVert = positionLocal.toVar("localVert"); // (-1|0|+1, 0|1, 0) + const centerAttr = attribute("center", "vec2"); + const hRand = attribute("aHeightRandom", "float"); - // Keep if random < probability - return step(rnd, p); - }); + const tipness = localVert.y; // 0 for base, 1 for tip - // Compute Distance Fade - OLD SCHOOL BAYER DITHER - // Creates retro ordered dithering pattern that fades grass into terrain - // @ts-expect-error TSL array destructuring - private computeDistanceFade: ReturnType = Fn( - ([distSq = float(0)]) => { - const fadeStartSq = uniforms.uFadeStart.mul(uniforms.uFadeStart); - const fadeEndSq = uniforms.uFadeEnd.mul(uniforms.uFadeEnd); - - // Linear fade: 1.0 at fadeStart, 0.0 at fadeEnd - const linearFade = float(1.0).sub( - clamp( - distSq - .sub(fadeStartSq) - .div(max(fadeEndSq.sub(fadeStartSq), float(0.001))), - 0.0, - 1.0, - ), - ); + // 1) Toroidal wrapping around player + const half = lod0Uniforms.uFieldSize.mul(0.5); + const rel = centerAttr.sub(lod0Uniforms.uPlayerCenter).toVar("rel"); + rel.x.assign(mod(rel.x.add(half), lod0Uniforms.uFieldSize).sub(half)); + rel.y.assign(mod(rel.y.add(half), lod0Uniforms.uFieldSize).sub(half)); - // OLD SCHOOL BAYER 8x8 ORDERED DITHERING - // Use instance index to create screen-like pattern (stable per-blade) - // This creates the classic retro dither look - const bayerX = float(instanceIndex).mod(8.0); - const bayerY = floor(float(instanceIndex).div(8.0)).mod(8.0); - - // Bayer 8x8 matrix threshold calculation (normalized 0-1) - // Uses bit interleaving pattern for proper Bayer sequence - const _x = bayerX.toInt(); - const _y = bayerY.toInt(); - - // Reconstruct Bayer pattern using bit operations - // bayer(x,y) = (bit_reverse(x XOR y) / 64) - // Simplified: use hash of position for deterministic per-pixel threshold - const bayerPattern = hash(bayerX.add(bayerY.mul(8.0)).add(0.123)); - - // Quantize to 8 levels for chunky retro look - const quantizedThreshold = floor(bayerPattern.mul(8.0)).div(8.0); - - // Compare fade against Bayer threshold - // This creates the ordered dither pattern - grass either fully visible or culled - return step(quantizedThreshold, linearFade); - }, - ); + const worldX = rel.x.add(lod0Uniforms.uPlayerCenter.x).toVar("worldX"); + const worldZ = rel.y.add(lod0Uniforms.uPlayerCenter.y).toVar("worldZ"); - // Compute Frustum Visibility for a single point - // Uses generous padding to prevent popping at frustum edges - private computeVisibility = Fn(([worldPos = vec3(0)]) => { - const clipPos = uniforms.uCameraMatrix.mul(vec4(worldPos, 1.0)); - const ndc = clipPos.xyz.div(clipPos.w); - // More generous padding - at least 0.15 NDC units + scaled radius - const baseRadius = float(config.BLADE_BOUNDING_SPHERE_RADIUS).div( - max(clipPos.w, float(0.1)), + // 2) Heightmap terrain Y + const hmWorldSize = max(uHeightmapWorldSize, float(0.001)); + const hmHalf = hmWorldSize.mul(0.5); + const hmU = worldX + .sub(uHeightmapCenterX) + .add(hmHalf) + .div(hmWorldSize) + .clamp(0.001, 0.999); + const hmV = worldZ + .sub(uHeightmapCenterZ) + .add(hmHalf) + .div(hmWorldSize) + .clamp(0.001, 0.999); + const hmUV = vec2(hmU, hmV); + + const hmSample = heightmapTextureNode + ? heightmapTextureNode.sample(hmUV) + : vec4(0); + const hmLoaded = step(float(0.001), hmSample.r); + const rawTerrainY = hmSample.r.mul(uHeightmapMax); + const terrainY = mix( + uniforms.uCameraPosition.y, + rawTerrainY, + hmLoaded, + ).toVar("terrainY"); + + // 3) Build blade vertex + const bladeH = lod0Uniforms.uBladeHeight.mul(hRand); + const bladeHW = lod0Uniforms.uBladeWidth.mul(0.5); + const vertexOffset = vec3( + localVert.x.mul(bladeHW), + localVert.y.mul(bladeH), + float(0), + ).toVar("vertexOffset"); + + // 4) Billboard: rotate blade to face camera + const dx = worldX.sub(uniforms.uCameraPosition.x); + const dz = worldZ.sub(uniforms.uCameraPosition.z); + const angleToCamera = atan(dx, dz); + const cosA = cos(angleToCamera); + const sinA = sin(angleToCamera); + const rotX = vertexOffset.x.mul(cosA).add(vertexOffset.z.mul(sinA)); + const rotZ = vertexOffset.z.mul(cosA).sub(vertexOffset.x.mul(sinA)); + vertexOffset.x.assign(rotX); + vertexOffset.z.assign(rotZ); + + // 5) World position + const worldPos = vec3(worldX, terrainY, worldZ) + .add(vertexOffset) + .toVar("worldPos"); + + // 6) Wind (displace tips only) + const windTime = time.mul(uniforms.uWindSpeed); + const windUV = vec2( + worldX.mul(0.02).add(windTime.mul(0.05)), + worldZ.mul(0.02).add(windTime.mul(0.03)), + ); + const windSample = noiseAtlasTexture + ? texture(noiseAtlasTexture).sample(windUV) + : vec4(0.5); + const windStrength = uniforms.uWindStrength; + worldPos.x.addAssign(windSample.x.sub(0.5).mul(tipness).mul(windStrength)); + worldPos.z.addAssign(windSample.y.sub(0.5).mul(tipness).mul(windStrength)); + + // 7) Distance fade: collapse toward center point + // Use PLAYER distance (not camera) so grass is always visible around the character + const distToPlayer = length( + vec2( + worldX.sub(lod0Uniforms.uPlayerCenter.x), + worldZ.sub(lod0Uniforms.uPlayerCenter.y), + ), ); - const paddingX = max(baseRadius, float(0.15)); // Generous horizontal padding - const paddingY = max(baseRadius, float(0.2)); // Extra vertical padding (camera tilt) - const one = float(1); - const visible = step(one.negate().sub(paddingX), ndc.x) - .mul(step(ndc.x, one.add(paddingX))) - .mul(step(one.negate().sub(paddingY), ndc.y)) - .mul(step(ndc.y, one.add(paddingY))) - .mul(step(float(-0.1), ndc.z)) // Allow slightly behind near plane - .mul(step(ndc.z, float(1.1))); // Allow slightly beyond far plane - return visible; - }); - - // ========== STATIC COMPUTE PASS ========== - // Runs periodically (not every frame) - handles terrain-dependent culling - // Stores static visibility in buffer4 and heightmapY in buffer3 - private createComputeStatic() { - return Fn(() => { - const data1 = this.buffer1.element(instanceIndex); - const data3 = this.buffer3.element(instanceIndex); - const data4 = this.buffer4.element(instanceIndex); - - // Read positions (wrapping is handled by the dynamic pass which runs every frame) - const offsetX = data1.x; - const offsetZ = data1.y; - const worldX = offsetX.add(uniforms.uCameraPosition.x); - const worldZ = offsetZ.add(uniforms.uCameraPosition.z); - - // Heightmap sampling - single sample, read pre-baked slope from G channel - const halfWorld = uHeightmapWorldSize.mul(0.5); - const hmUvX = worldX - .sub(uHeightmapCenterX) - .add(halfWorld) - .div(uHeightmapWorldSize); - const hmUvZ = worldZ - .sub(uHeightmapCenterZ) - .add(halfWorld) - .div(uHeightmapWorldSize); - const hmUV = vec2( - hmUvX.clamp(float(0.001), float(0.999)), - hmUvZ.clamp(float(0.001), float(0.999)), - ); - - let heightmapY: ReturnType; - let slope: ReturnType; - if (heightmapTextureNode) { - const hmSample = heightmapTextureNode.sample(hmUV); - heightmapY = hmSample.r.mul(uHeightmapMax); - slope = hmSample.g; // Pre-baked slope from heightmap G channel - } else { - heightmapY = uniforms.uCameraPosition.y; - slope = float(0); - } - data3.assign(heightmapY); - - // === CAMERA-BASED DISTANCE (for stochastic culling) === - const toGrassFromCam = vec2(offsetX, offsetZ); - const camForwardXZ = uniforms.uCameraForward.xz; - const forwardDot = toGrassFromCam.dot(camForwardXZ); - const forwardFactor = smoothstep(float(-0.5), float(1.0), forwardDot); - const biasAmount = uniforms.uForwardBias.mul(forwardFactor); - const camDist = toGrassFromCam.length(); - const effectiveDist = max(camDist.sub(biasAmount), float(0.0)); - const effectiveDistSq = effectiveDist.mul(effectiveDist); - - // 1. Stochastic distance culling (dithered thinning) - const _stochasticKeep = this.computeStochasticKeep(effectiveDistSq); - - // 2. Water/shoreline culling - gradual fade near water - const waterFade = smoothstep( - uWaterHardCutoff, - uWaterFadeStart, - heightmapY, - ); - const waterDitherRand = hash(float(instanceIndex).mul(3.71)); - const _aboveWater = step(waterDitherRand, waterFade); - - // 3. Dirt/terrain culling - matches TerrainShader.ts - const TERRAIN_NOISE_SCALE = 0.0008; - const DIRT_THRESHOLD = 0.5; - const noiseUV = vec2(worldX, worldZ).mul(TERRAIN_NOISE_SCALE); - let terrainNoiseValue: ReturnType; - if (noiseAtlasTexture) { - terrainNoiseValue = texture(noiseAtlasTexture, noiseUV).r; - } else { - terrainNoiseValue = hash( - worldX - .mul(TERRAIN_NOISE_SCALE * 1000) - .add(worldZ.mul(TERRAIN_NOISE_SCALE * 1000)), - ); - } - - const dirtPatchFactor = smoothstep( - float(DIRT_THRESHOLD - 0.05), - float(DIRT_THRESHOLD + 0.15), - terrainNoiseValue, - ); - - // Use pre-baked slope from heightmap G channel (no extra texture samples needed) - const flatnessFactor = smoothstep(float(0.3), float(0.05), slope); - const slopeFactor = smoothstep(float(0.25), float(0.6), slope); - - const _combinedDirtFactor = max( - dirtPatchFactor.mul(flatnessFactor).mul(0.7), - slopeFactor.mul(0.6), - ); - const dirtDitherRand = hash(float(instanceIndex).mul(5.67)); - const _notOnDirt = step(_combinedDirtFactor, dirtDitherRand.mul(0.5)); - - // 4. Sand zone (6-10m height on flat ground) - const sandZone = smoothstep(float(10.0), float(6.0), heightmapY).mul( - flatnessFactor, - ); - const sandDitherRand = hash(float(instanceIndex).mul(6.78)); - const _notOnSand = step(sandZone.mul(0.8), sandDitherRand.mul(0.4)); - - // 5. Road influence culling - let _notOnRoad2: ReturnType; - { - const hasRoadData = step(float(2), uRoadInfluenceWorldSize); - const roadHalfWorld = uRoadInfluenceWorldSize.mul(0.5); - const roadDx = abs(worldX.sub(uRoadInfluenceCenterX)); - const roadDz = abs(worldZ.sub(uRoadInfluenceCenterZ)); - const insideMask = step(roadDx, roadHalfWorld).mul( - step(roadDz, roadHalfWorld), - ); - const useMask = hasRoadData.mul(insideMask); - const roadUV = computeExclusionUV( - worldX, - worldZ, - uRoadInfluenceCenterX, - uRoadInfluenceCenterZ, - uRoadInfluenceWorldSize, - ); - const roadInfluence = roadInfluenceTextureNode.sample(roadUV).r; - const roadDitherRand = hash(float(instanceIndex).mul(9.12)); - _notOnRoad2 = mix( - float(1.0), - step( - roadInfluence, - uRoadInfluenceThreshold.add(roadDitherRand.mul(0.15)), - ), - useMask, - ); - } - - // 6. Exclusion zone culling - HYBRID: grid + legacy - let _notExcluded2: ReturnType; - { - const gridNotExcluded = float(1.0).toVar(); - if (useGridBasedExclusion && gridExclusionTextureNode) { - const gridUV = computeExclusionUV( - worldX, - worldZ, - uGridExclusionCenterX, - uGridExclusionCenterZ, - uGridExclusionWorldSize, - ); - const gridExclusionValue = gridExclusionTextureNode.sample(gridUV).r; - - const exclDitherRand = hash(float(instanceIndex).mul(10.34)); - const exclDitherRand2 = hash(float(instanceIndex).mul(17.89)); - const tileEdgeDist = min( - min(fract(worldX), float(1).sub(fract(worldX))), - min(fract(worldZ), float(1).sub(fract(worldZ))), - ); - const edgeSoftness = smoothstep(float(0), float(0.4), tileEdgeDist); - const baseNotExcluded = float(1).sub(gridExclusionValue); - const softEdgeBonus = gridExclusionValue - .mul(float(1).sub(edgeSoftness)) - .mul(exclDitherRand.lessThan(0.35).select(1, 0)) - .mul(exclDitherRand2.mul(0.5).add(0.5)); - gridNotExcluded.assign( - baseNotExcluded.add(softEdgeBonus).clamp(0, 1), - ); - } - - const legacyUV = computeExclusionUV( - worldX, - worldZ, - uExclusionCenterX, - uExclusionCenterZ, - uExclusionWorldSize, - ); - const legacyExclusionValue = exclusionTextureNode.sample(legacyUV).r; - const legacyDitherRand = hash(float(instanceIndex).mul(11.45)); - const legacyNotExcluded = step( - legacyExclusionValue, - float(0.6).add(legacyDitherRand.mul(0.2)), - ); - - _notExcluded2 = gridNotExcluded.mul(legacyNotExcluded); - } - - // Store combined static visibility in buffer4 (0=culled, 1=visible) - const staticVisible = _stochasticKeep - .mul(_aboveWater) - .mul(_notOnDirt) - .mul(_notOnSand) - .mul(_notOnRoad2) - .mul(_notExcluded2); - data4.assign(staticVisible); - })().compute(config.COUNT, [config.WORKGROUP_SIZE]); - } - - // ========== DYNAMIC COMPUTE PASS ========== - // Runs every frame - handles camera-dependent culling, wind, and trail - // Reads static visibility from buffer4, heightmapY from buffer3 - private createComputeUpdate() { - return Fn(() => { - const data1 = this.buffer1.element(instanceIndex); - const data2 = this.buffer2.element(instanceIndex); - const data3 = this.buffer3.element(instanceIndex); - const data4 = this.buffer4.element(instanceIndex); - - // Position wrapping - still needed for delta tracking each frame - const wrappedPos = VegetationSsboUtils.wrapPosition( - vec2(data1.x, data1.y), - uniforms.uPlayerDeltaXZ, - float(config.TILE_SIZE), - ); - data1.x = wrappedPos.x; - data1.y = wrappedPos.z; - - const offsetX = wrappedPos.x; - const offsetZ = wrappedPos.z; - const worldX = offsetX.add(uniforms.uCameraPosition.x); - const worldZ = offsetZ.add(uniforms.uCameraPosition.z); - - // Read heightmapY from buffer3 (pre-computed in static pass) - const heightmapY = data3; - const worldPos = vec3(worldX, heightmapY, worldZ); - - // Read static visibility from buffer4 - const staticVisible = data4; - - // === CAMERA-BASED DISTANCE CULLING (changes every frame) === - const toGrassFromCam = vec2(offsetX, offsetZ); - const camForwardXZ = uniforms.uCameraForward.xz; - const forwardDot = toGrassFromCam.dot(camForwardXZ); - const forwardFactor = smoothstep(float(-0.5), float(1.0), forwardDot); - const biasAmount = uniforms.uForwardBias.mul(forwardFactor); - const camDist = toGrassFromCam.length(); - const effectiveDist = max(camDist.sub(biasAmount), float(0.0)); - const effectiveDistSq = effectiveDist.mul(effectiveDist); - - // 1. Per-instance frustum culling (camera-dependent) - const _frustumVisible = this.computeVisibility(worldPos); - - // 2. Distance fade with Bayer dither (camera-dependent) - const distanceFade = this.computeDistanceFade(effectiveDistSq); - const ditherRand = hash(float(instanceIndex).mul(1.31)); - const _ditherVisible = step(ditherRand, distanceFade); - - // Combine: staticVisible * frustumVisible * ditherVisible = final visibility - const isVisible = staticVisible.mul(_frustumVisible).mul(_ditherVisible); - data1.assign(this.setVisibility(data1, isVisible)); - - // Skip expensive wind/trail for statically culled blades - // (roads, water, dirt, exclusions - these blades will never be visible) - const isStaticAlive = step(float(0.5), staticVisible); - - // === BENDING/TRAIL EFFECT === - // Either multi-character bending (texture-based) or legacy single-player trail - let contact: ReturnType; - - if (useMultiCharacterBending && characterBendingTextureNode) { - // MULTI-CHARACTER BENDING - Sample texture for each character - const maxContact = float(0).toVar(); - const texWidth = float(CHARACTER_TEXTURE_WIDTH); - const bendingTexture = characterBendingTextureNode; - - // Early skip: if blade is beyond maximum possible trail radius (3m), skip character loop entirely - const maxTrailRadius = float(3.0); // No character has radius > 3m - const bladeDistFromCam = toGrassFromCam.length(); - // Only process trail for blades that are reasonably close (within max view distance for trails) - const trailRelevant = step(bladeDistFromCam, float(15.0)); // Trails only visible within 15m - - Loop(uCharacterCount, ({ i }) => { - const charU = float(i).add(0.5).div(texWidth); - - const posUV = vec2(charU, float(0.25)); - const posData = bendingTexture.sample(posUV); - const charX = posData.x; - const charY = posData.y; - const charZ = posData.z; - const charRadius = posData.w; - - const velUV = vec2(charU, float(0.75)); - const velData = bendingTexture.sample(velUV); - const charSpeed = velData.w; - - const hasRadius = charRadius.greaterThan(0.01); - - const dx = worldX.sub(charX); - const dz = worldZ.sub(charZ); - const distSq = dx.mul(dx).add(dz.mul(dz)); - const radiusSq = charRadius.mul(charRadius); - - const charHeightDiff = charY.sub(heightmapY).abs(); - const isCharGrounded = smoothstep( - float(5.0), - float(0.0), - charHeightDiff, - ); - - const innerRadiusSq = radiusSq.mul(0.2); - const outerRadiusSq = radiusSq; - - const charContact = float(1.0) - .sub(smoothstep(innerRadiusSq, outerRadiusSq, distSq)) - .mul(isCharGrounded) - .mul(hasRadius.select(1, 0)); - - const speedFactor = charSpeed.mul(0.5).add(0.5).clamp(0.5, 2.0); - const boostedContact = charContact.mul(speedFactor); - - maxContact.assign(max(maxContact, boostedContact)); - }); - - contact = maxContact.clamp(0, 1).mul(trailRelevant).mul(isStaticAlive); - } else { - // LEGACY: SINGLE-PLAYER TRAIL EFFECT - const playerWorldX = offsetX.add(uniforms.uCameraPosition.x); - const playerWorldZ = offsetZ.add(uniforms.uCameraPosition.z); - const diff = vec2(playerWorldX, playerWorldZ).sub( - uniforms.uPlayerPosition.xz, - ); - const distSqPlayer = diff.dot(diff); - - const heightDiff = uniforms.uPlayerPosition.y.sub(heightmapY).abs(); - const isPlayerGrounded = smoothstep(float(5.0), float(0.0), heightDiff); - - const innerRadiusSq = uniforms.uTrailRadiusSquared.mul(0.2); - const outerRadiusSq = uniforms.uTrailRadiusSquared; - - contact = float(1.0) - .sub(smoothstep(innerRadiusSq, outerRadiusSq, distSqPlayer)) - .mul(isPlayerGrounded); - contact = contact.mul(isStaticAlive); - } - - // Trail scale - flatten grass based on contact - const currentTrailScale = this.getScale(data1); - const originalScale = this.getOriginalScale(data1); - const newScale = this.computeTrailScale( - originalScale, - currentTrailScale, - contact, - ); - data1.assign(this.setScale(data1, newScale)); - - // Wind - const positionNoise = this.getPositionNoise(data2); - const prevWind = this.getWind(data1); - const newWind = this.computeWind(prevWind, worldPos, positionNoise); - data1.assign( - this.setWind( - data1, - vec2(newWind.x.mul(isStaticAlive), newWind.y.mul(isStaticAlive)), - ), - ); - data1.assign(this.setWindNoise(data1, newWind.z.mul(isStaticAlive))); - })().compute(config.COUNT, [config.WORKGROUP_SIZE]); - } -} - -// ============================================================================ -// GRASS MATERIAL - SpriteNodeMaterial -// ============================================================================ - -class GrassMaterial extends SpriteNodeMaterial { - private ssbo: GrassSsbo; - - constructor(ssbo: GrassSsbo) { - super(); - this.ssbo = ssbo; - this.createGrassMaterial(); - } - - private createGrassMaterial(): void { - // Material settings - transparent for bottom dither dissolve - this.precision = "mediump"; - this.transparent = true; - this.alphaTest = 0.1; - - // Get data from SSBO - const data1 = this.ssbo.computeBuffer1.element(instanceIndex); - const data2 = this.ssbo.computeBuffer2.element(instanceIndex); - const data3 = this.ssbo.computeBuffer3.element(instanceIndex); - const offsetX = data1.x; - const offsetZ = data1.y; - const _windXZ = this.ssbo.getWind(data1); - const _scaleY = this.ssbo.getScale(data1); - const isVisible = this.ssbo.getVisibility(data1); - const _windNoiseFactor = this.ssbo.getWindNoise(data1); - const _positionNoise = this.ssbo.getPositionNoise(data2); - - // Get heightmapY from compute shader - const heightmapY = data3; - - // Height gradient for blade - const h = uv().y; - - // BOTTOM DITHER DISSOLVE - fade grass base into ground, 100% above fade zone - const bottomFade = smoothstep(float(0), uniforms.uBottomFadeHeight, h); - // Only apply dither noise within the fade zone, full opacity above - const inFadeZone = float(1.0).sub(step(uniforms.uBottomFadeHeight, h)); - const ditherNoise = hash(instanceIndex.add(h.mul(1000))) - .mul(0.4) - .mul(inFadeZone); - const bottomOpacity = clamp( - bottomFade.add(ditherNoise.sub(0.2).mul(inFadeZone)), - 0, - 1, + const distScale = smoothstep( + uniforms.uFadeEnd, + uniforms.uFadeStart, + distToPlayer, ); + const centerPt = vec3(worldX, terrainY, worldZ); + worldPos.assign(mix(centerPt, worldPos, distScale)); - // BAYER SCREEN-SPACE DITHERING - texture lookup (replaces 12 ALU bit ops) - const fragCoord = viewportCoordinate; - const bayerUV = vec2( - mod(floor(fragCoord.x), float(4.0)).add(0.5).div(4.0), - mod(floor(fragCoord.y), float(4.0)).add(0.5).div(4.0), - ); - const bayerThreshold = bayerTextureNode.sample(bayerUV).r; - - // Distance-based opacity fade with Bayer dithering - const distFromCamSq = data1.x.mul(data1.x).add(data1.y.mul(data1.y)); - const fadeStartSq = uniforms.uFadeStart.mul(uniforms.uFadeStart); - const fadeEndSq = uniforms.uFadeEnd.mul(uniforms.uFadeEnd); - const distFade = float(1.0).sub( - clamp( - distFromCamSq - .sub(fadeStartSq) - .div(max(fadeEndSq.sub(fadeStartSq), float(0.001))), - 0.0, - 1.0, - ), + // 8) Water culling: collapse blades below water + const waterFade = smoothstep( + uWaterHardCutoff.sub(1.0), + uWaterHardCutoff, + rawTerrainY, ); + const waterScale = max(waterFade, float(1).sub(hmLoaded)); + worldPos.assign(mix(centerPt, worldPos, waterScale)); - // Apply Bayer dither to distance fade - creates chunky retro dissolve - const bayerFadeVisible = step(bayerThreshold, distFade); - - this.opacityNode = isVisible.mul(bottomOpacity).mul(bayerFadeVisible); - - // SCALE - use computed scale from SSBO (but keep some height for flattened grass) - // When flattened, grass is rotated rather than shrunk - const originalScale = this.ssbo.getOriginalScale(data1); - const flattenAmount = float(1.0).sub( - _scaleY.div(max(originalScale, float(0.01))), - ); // 0 = upright, 1 = fully flattened - // Keep most of the height, just rotate it down - const adjustedScale = mix( - _scaleY, - originalScale.mul(0.9), - flattenAmount.mul(0.7), - ); - this.scaleNode = vec3(0.5, adjustedScale, 1); - - // ========== FLATTEN ROTATION (grass bends away from player) ========== - // Calculate world position of this blade - const worldX = uniforms.uCameraPosition.x.add(offsetX); - const worldZ = uniforms.uCameraPosition.z.add(offsetZ); - - // Direction from player to grass (grass bends AWAY from player) - const toGrassX = worldX.sub(uniforms.uPlayerPosition.x); - const toGrassZ = worldZ.sub(uniforms.uPlayerPosition.z); - const toGrassDist = sqrt( - toGrassX.mul(toGrassX).add(toGrassZ.mul(toGrassZ)), + // Exclusion checks are only meaningful for nearby blades (perf optimization). + // Far blades are already fading out, so we blend exclusion to 1.0 (always visible) + // beyond 15m to reduce the impact of texture sampling on distant vertices. + const nearPlayer = smoothstep(float(16.0), float(14.0), distToPlayer); + + // 9) Road exclusion: collapse blades on roads + const roadUV = computeExclusionUV( + worldX, + worldZ, + uRoadInfluenceCenterX, + uRoadInfluenceCenterZ, + uRoadInfluenceWorldSize, ); - const toGrassDirX = toGrassX.div(max(toGrassDist, float(0.01))); - const toGrassDirZ = toGrassZ.div(max(toGrassDist, float(0.01))); - - // Flatten angle - up to 80 degrees (1.4 radians) when fully flattened - const flattenAngle = flattenAmount.mul(1.4); - - // Apply flatten rotation in the direction away from player - // Rotation around X axis bends forward/back, around Z axis bends left/right - const flattenRotX = toGrassDirZ.mul(flattenAngle); // Bend in Z direction -> rotate around X - const flattenRotZ = toGrassDirX.negate().mul(flattenAngle); // Bend in X direction -> rotate around Z - - // ROTATION - combine wind bending + flatten rotation - // Wind causes grass to lean in wind direction, more at top - const windBendStrength = h.mul(h).mul(0.5); // Quadratic falloff from tip - const windRotX = _windXZ.x.mul(windBendStrength); - const windRotZ = _windXZ.y.mul(windBendStrength); - - // Flatten rotation affects the whole blade, wind affects tips more - const totalRotX = windRotZ.add(flattenRotX); - const totalRotZ = windRotX.negate().add(flattenRotZ); - this.rotationNode = vec3(totalRotX, 0, totalRotZ); - - // POSITION - heightmap Y + wind sway offset - const offsetY = heightmapY; - - // Wind sway - apply wind offset to position, more at blade top - const windSwayStrength = h.mul(0.25).mul(_windNoiseFactor.add(0.5)); - const windSwayX = _windXZ.x.mul(windSwayStrength); - const windSwayZ = _windXZ.y.mul(windSwayStrength); - - const bladePosition = vec3( - offsetX.add(windSwayX), - offsetY, - offsetZ.add(windSwayZ), + const roadSample = roadInfluenceTextureNode.sample(roadUV).r; + const roadRaw = smoothstep( + uRoadInfluenceThreshold.add(0.05), + uRoadInfluenceThreshold, + roadSample, ); + const roadVisible = mix(float(1.0), roadRaw, nearPlayer); + worldPos.assign(mix(centerPt, worldPos, roadVisible)); - this.positionNode = bladePosition; - - // COLOR - SAMPLE TERRAIN COLOR AT WORLD POSITION - // worldX and worldZ already calculated above for flatten rotation - - // ========== TERRAIN SLOPE FROM HEIGHTMAP G CHANNEL ========== - // Pre-baked slope avoids 3 heightmap samples per fragment - const halfWorld = uHeightmapWorldSize.mul(0.5); - const bladeHmUvX = worldX - .sub(uHeightmapCenterX) - .add(halfWorld) - .div(uHeightmapWorldSize); - const bladeHmUvZ = worldZ - .sub(uHeightmapCenterZ) - .add(halfWorld) - .div(uHeightmapWorldSize); - const bladeHmUV = vec2( - bladeHmUvX.clamp(0.001, 0.999), - bladeHmUvZ.clamp(0.001, 0.999), + // 10) Legacy exclusion: collapse blades on buildings/arenas + const exclUV = computeExclusionUV( + worldX, + worldZ, + uExclusionCenterX, + uExclusionCenterZ, + uExclusionWorldSize, ); - let slope: ReturnType; - if (heightmapTextureNode) { - slope = heightmapTextureNode.sample(bladeHmUV).g; // Pre-baked slope from G channel - } else { - slope = float(0); - } + const exclSample = exclusionTextureNode.sample(exclUV).r; + const exclRaw = smoothstep(float(0.5), float(0.3), exclSample); + const exclVisible = mix(float(1.0), exclRaw, nearPlayer); + worldPos.assign(mix(centerPt, worldPos, exclVisible)); - // ========== GRASS COLOR - Simplified (dirt/rock blades already culled by compute) ========== - const TERRAIN_NOISE_SCALE = 0.0008; - const noiseUV = vec2(worldX, worldZ).mul(TERRAIN_NOISE_SCALE); - let terrainNoiseValue: ReturnType; - if (noiseAtlasTexture) { - terrainNoiseValue = texture(noiseAtlasTexture, noiseUV).r; - } else { - terrainNoiseValue = hash(worldX.mul(0.8).add(worldZ.mul(1.3))); + // 11) Grid-based exclusion: collapse blades on collision-blocked tiles + if (gridExclusionTextureNode && useGridBasedExclusion) { + const gridExclUV = computeExclusionUV( + worldX, + worldZ, + uGridExclusionCenterX, + uGridExclusionCenterZ, + uGridExclusionWorldSize, + ); + const gridExclSample = gridExclusionTextureNode.sample(gridExclUV).r; + const gridExclRaw = smoothstep(float(0.5), float(0.3), gridExclSample); + const gridExclVisible = mix(float(1.0), gridExclRaw, nearPlayer); + worldPos.assign(mix(centerPt, worldPos, gridExclVisible)); } - // Grass green/dark variation (the only color variation that matters for visible grass) - const noiseValue2 = sin(terrainNoiseValue.mul(6.28)).mul(0.3).add(0.5); - const grassGreen = vec3(0.3, 0.55, 0.15); - const grassDark = vec3(0.22, 0.42, 0.1); - const grassVariation = smoothstep(float(0.4), float(0.6), noiseValue2); - let grassColor = mix(grassGreen, grassDark, grassVariation); - - // Simple height gradient: base to tip - const tipColor = grassColor.mul(1.1); - const colorProfile = h.mul(uniforms.uColorMixFactor).clamp(); - const baseToTip = mix(grassColor, tipColor, colorProfile); + return worldPos; + })(); - // Simple ambient occlusion near camera center - const r2 = offsetX.mul(offsetX).add(offsetZ.mul(offsetZ)); - const near = float(1).sub(smoothstep(0, uniforms.uAoRadiusSquared, r2)); - const hWeight = float(1).sub(smoothstep(0.1, 0.85, h)); - const ao = float(1).sub(uniforms.uAoScale.mul(0.15).mul(near.mul(hWeight))); + // --- colorNode --- + material.colorNode = Fn(() => { + const localVert = positionLocal; + const tipness = localVert.y; // 0 base, 1 tip + const centerAttr = attribute("center", "vec2"); + + const baseColor = uniforms.uBaseColor; + const tipColor = uniforms.uTipColor; + const grassColor = mix( + baseColor, + tipColor, + tipness.mul(uniforms.uColorMixFactor), + ).toVar("grassColor"); + + // Per-blade variation using hash of center position + const variation = hash(centerAttr.x.add(centerAttr.y.mul(1234.5))) + .mul(0.15) + .sub(0.075); + grassColor.r.addAssign(variation); + grassColor.g.addAssign(variation.mul(0.5)); // Day/night tinting - const dayNightTint = mix( - uniforms.uNightColor, - uniforms.uDayColor, - uniforms.uDayNightMix, - ); + const dayTint = uniforms.uDayColor; + const nightTint = uniforms.uNightColor; + const timeTint = mix(nightTint, dayTint, uniforms.uDayNightMix); + grassColor.assign(grassColor.mul(timeTint)); - // ========== WIND CLOUD SHADOW EFFECT (Grass_Journey style) ========== - // Grass in gusty areas is slightly darker (like clouds passing over) - // _windNoiseFactor contains the gust mask * wind intensity from computeWind - const cloudShadowStrength = float(0.15); // How dark the shadow gets (0-1) - const cloudShadow = float(1.0).sub( - _windNoiseFactor.mul(cloudShadowStrength), - ); + // Light intensity + grassColor.assign(grassColor.mul(uniforms.uLightIntensity)); - this.colorNode = baseToTip.mul(ao).mul(dayNightTint).mul(cloudShadow); - } + return grassColor; + })(); + + return material; } // ============================================================================ -// GPU-DRIVEN TILE MATERIAL - All culling in vertex shader +// GPU-DRIVEN TILE MATERIAL - All culling in vertex shader (LOD1) // ============================================================================ /** @@ -1690,14 +1007,22 @@ function createGpuDrivenTileMaterial( .div(uHeightmapWorldSize); const hmUV = vec2(hmUvX.clamp(0.001, 0.999), hmUvZ.clamp(0.001, 0.999)); - // Sample heightmap - use 0 if texture not available yet + // Sample heightmap - use camera Y as fallback for unloaded terrain let terrainY: ReturnType; let hmSampleFull: ReturnType | null = null; + let tileHeightmapLoaded: ReturnType; if (heightmapTextureNode) { hmSampleFull = heightmapTextureNode.sample(hmUV); - terrainY = hmSampleFull.r.mul(uHeightmapMax); + const rawHeight = hmSampleFull.r.mul(uHeightmapMax); + tileHeightmapLoaded = step(float(0.001), hmSampleFull.r); + terrainY = mix( + tileUniforms.uTileCameraPos.y, + rawHeight, + tileHeightmapLoaded, + ); } else { - terrainY = float(0); + terrainY = tileUniforms.uTileCameraPos.y; + tileHeightmapLoaded = float(0); } // ========== ROAD INFLUENCE EXCLUSION ========== @@ -1753,8 +1078,9 @@ function createGpuDrivenTileMaterial( ); // ========== WATER CULLING ========== - // Hide grass below water level - const waterVisible = select( + // Hide grass below water level. Skip for unloaded heightmap areas + // to prevent all tiles from being culled before terrain loads. + const waterVisibleRaw = select( terrainY.lessThan(uWaterHardCutoff), float(0), select( @@ -1763,6 +1089,10 @@ function createGpuDrivenTileMaterial( float(1), ), ); + const waterVisible = max( + waterVisibleRaw, + float(1).sub(tileHeightmapLoaded), + ); // Variation: rotation and mirror based on tile hash (moved up for dirt/sand dither) const tileHash = gridX.mul(374761393).add(gridZ.mul(668265263)); @@ -1989,9 +1319,8 @@ function createGpuDrivenTileMesh( // ============================================================================ export class ProceduralGrassSystem extends System { - // LOD0 - Individual blades - private mesh: THREE.InstancedMesh | null = null; - private ssbo: GrassSsbo | null = null; + // LOD0 - Simple single-mesh grass (no SSBO, no compute) + private mesh: THREE.Mesh | null = null; private useBladeGrass = true; private quality: GrassQuality = GrassQuality.HIGH; @@ -2006,16 +1335,6 @@ export class ProceduralGrassSystem extends System { private loggedHeightSampleError = false; private noiseTexture: THREE.Texture | null = null; - // Position tracking for world-stable grass - private lastCameraX = 0; - private lastCameraZ = 0; - - // Static compute tracking - only re-run when player moves significantly - private lastStaticComputeX = 0; - private lastStaticComputeZ = 0; - private static readonly STATIC_COMPUTE_THRESHOLD = 2; // Re-run static pass when player moves 2m - private staticComputeInitialized = false; - // ========== STREAMING HEIGHTMAP STATE ========== /** Center of the heightmap in world coordinates */ private heightmapCenterX = 0; @@ -3177,7 +2496,6 @@ export class ProceduralGrassSystem extends System { } } - // Mark texture for GPU upload if (heightmapTexture && updated > 0) { heightmapTexture.needsUpdate = true; } @@ -3284,7 +2602,6 @@ export class ProceduralGrassSystem extends System { } } - // Mark texture for GPU upload if (heightmapTexture) { heightmapTexture.needsUpdate = true; } @@ -3375,63 +2692,27 @@ export class ProceduralGrassSystem extends System { ); } - // STEP 2: Create SSBO and meshes + // STEP 2: Create simple grass mesh (no SSBO, no compute shaders) if (this.useBladeGrass) { - this.ssbo = new GrassSsbo(); - - // Create geometry - const geometry = this.createGeometry(config.SEGMENTS); + const geometry = createSimpleGrassGeometry(); + const material = createSimpleGrassMaterial(); - // Create material - const material = new GrassMaterial(this.ssbo); - - // Create instanced mesh - this.mesh = new THREE.InstancedMesh(geometry, material, config.COUNT); + this.mesh = new THREE.Mesh(geometry, material); this.mesh.frustumCulled = false; - this.mesh.name = "ProceduralGrass_GPU"; - - // CRITICAL: Render order 76 = after silhouette (50), before player (100) - // This prevents silhouette from showing through grass + this.mesh.name = "ProceduralGrass_Simple"; this.mesh.renderOrder = 76; - - // Layer 1 = main camera only (matches other vegetation) this.mesh.layers.set(1); stage.scene.add(this.mesh); } else { this.mesh = null; - this.ssbo = null; } - // STEP 3: Pre-run GPU compute initialization during loading - // This is the KEY OPTIMIZATION - run heavy compute BEFORE gameplay starts - // Previously this ran via onInit callback causing first-frame spikes - if (this.useBladeGrass && this.ssbo) { - console.log( - "[ProceduralGrass] Pre-running GPU compute initialization...", - ); - - // Run LOD0 SSBO initialization only (LOD1 disabled) - await this.ssbo.runInitialization(this.renderer); - - // Yield to ensure GPU work is flushed - await yieldToMain(); - - // STEP 3.5: Prime visibility with a compute pass (roads/exclusion/distance) - // This ensures the first rendered frame already respects roads and culling. - const camPos = this.world.camera?.position; - if (camPos) { - uniforms.uCameraPosition.value.copy(camPos); - uniforms.uPlayerPosition.value.copy(camPos); - uniforms.uPlayerDeltaXZ.value.set(0, 0); - } - // Run static pass first (terrain-dependent culling), then dynamic pass (frustum/wind) - await this.renderer.computeAsync(this.ssbo.computeStatic); - await this.renderer.computeAsync(this.ssbo.computeUpdate); - this.staticComputeInitialized = true; - this.lastStaticComputeX = camPos?.x ?? 0; - this.lastStaticComputeZ = camPos?.z ?? 0; - await yieldToMain(); + // Prime uniforms with initial camera/player position + const camPos = this.world.camera?.position; + if (camPos) { + uniforms.uCameraPosition.value.copy(camPos); + lod0Uniforms.uPlayerCenter.value.set(camPos.x, camPos.z); } this.grassInitialized = true; @@ -3442,7 +2723,7 @@ export class ProceduralGrassSystem extends System { const totalTime = performance.now() - initStartTime; console.log( `[ProceduralGrass] Initialization complete: ${totalTime.toFixed(1)}ms total\n` + - ` LOD0: ${this.useBladeGrass ? `${config.COUNT.toLocaleString()} blades with Bayer dither fade` : "DISABLED (tile instancing)"}\n` + + ` LOD0: ${this.useBladeGrass ? `${config.COUNT.toLocaleString()} blades (simple vertex shader)` : "DISABLED"}\n` + ` LOD1: Tile instances (async bake)`, ); } catch (error) { @@ -3770,88 +3051,7 @@ export class ProceduralGrassSystem extends System { return t * t * t * (t * (t * 6 - 15) + 10); } - private createGeometry(nSegments: number): THREE.BufferGeometry { - // Geometry creation - const segments = Math.max(1, Math.floor(nSegments)); - const height = config.BLADE_HEIGHT; - const halfWidthBase = config.BLADE_WIDTH * 0.5; - - const rowCount = segments; - const vertexCount = rowCount * 2 + 1; - const quadCount = Math.max(0, rowCount - 1); - const indexCount = quadCount * 6 + 3; - - const positions = new Float32Array(vertexCount * 3); - const uvs = new Float32Array(vertexCount * 2); - const indices = new Uint8Array(indexCount); - - const taper = (t: number) => halfWidthBase * (1.0 - 0.7 * t); - - let idx = 0; - for (let row = 0; row < rowCount; row++) { - const v = row / segments; - const y = v * height; - const halfWidth = taper(v); - - const left = row * 2; - const right = left + 1; - - positions[3 * left + 0] = -halfWidth; - positions[3 * left + 1] = y; - positions[3 * left + 2] = 0; - - positions[3 * right + 0] = halfWidth; - positions[3 * right + 1] = y; - positions[3 * right + 2] = 0; - - uvs[2 * left + 0] = 0.0; - uvs[2 * left + 1] = v; - uvs[2 * right + 0] = 1.0; - uvs[2 * right + 1] = v; - - if (row > 0) { - const prevLeft = (row - 1) * 2; - const prevRight = prevLeft + 1; - - indices[idx++] = prevLeft; - indices[idx++] = prevRight; - indices[idx++] = right; - - indices[idx++] = prevLeft; - indices[idx++] = right; - indices[idx++] = left; - } - } - - const tip = rowCount * 2; - positions[3 * tip + 0] = 0; - positions[3 * tip + 1] = height; - positions[3 * tip + 2] = 0; - uvs[2 * tip + 0] = 0.5; - uvs[2 * tip + 1] = 1.0; - - const lastLeft = (rowCount - 1) * 2; - const lastRight = lastLeft + 1; - indices[idx++] = lastLeft; - indices[idx++] = lastRight; - indices[idx++] = tip; - - const geom = new THREE.BufferGeometry(); - - const posAttribute = new THREE.BufferAttribute(positions, 3); - posAttribute.setUsage(THREE.StaticDrawUsage); - geom.setAttribute("position", posAttribute); - - const uvAttribute = new THREE.BufferAttribute(uvs, 2); - uvAttribute.setUsage(THREE.StaticDrawUsage); - geom.setAttribute("uv", uvAttribute); - - const indexAttribute = new THREE.BufferAttribute(indices, 1); - indexAttribute.setUsage(THREE.StaticDrawUsage); - geom.setIndex(indexAttribute); - - return geom; - } + // Old createGeometry removed - replaced by module-level createSimpleGrassGeometry() update(_deltaTime: number): void { if (!this.grassInitialized || !this.renderer) return; @@ -3861,49 +3061,42 @@ export class ProceduralGrassSystem extends System { const cameraPos = camera.position; - // Get actual player position for trail effect (may differ from camera in 3rd person) - // Use player.node.position (the 3D mesh position) not player.position (entity data) + // Player position for grass centering (3rd person: follow player, not camera) const players = this.world.getPlayers?.() as | { node?: { position?: THREE.Vector3 } }[] | undefined; const player = players?.[0]; const playerPos = player?.node?.position ?? cameraPos; - // Get camera forward direction for frustum culling const cameraForward = new THREE.Vector3(); camera.getWorldDirection(cameraForward); - // ========== GPU-DRIVEN TILE UPDATE ========== - // Update shared tile uniforms (used by vertex shader for all tile positioning/culling) - // This is the ONLY per-frame work needed - everything else happens in shaders + // ========== GPU-DRIVEN TILE UPDATE (LOD1) ========== tileUniforms.uTileCameraPos.value.copy(cameraPos); tileUniforms.uTileCameraForward.value.copy(cameraForward); - if (!this.useBladeGrass || !this.mesh || !this.ssbo) { + if (!this.useBladeGrass || !this.mesh) { return; } - // Calculate camera movement delta for position wrapping - const deltaX = cameraPos.x - this.lastCameraX; - const deltaZ = cameraPos.z - this.lastCameraZ; - this.lastCameraX = cameraPos.x; - this.lastCameraZ = cameraPos.z; + // ========== LOD0 UNIFORM UPDATES (no compute dispatches) ========== + lod0Uniforms.uPlayerCenter.value.set(playerPos.x, playerPos.z); + uniforms.uCameraPosition.value.copy(cameraPos); + + uHeightmapCenterX.value = this.heightmapCenterX; + uHeightmapCenterZ.value = this.heightmapCenterZ; // ========== STREAMING HEIGHTMAP UPDATE ========== - // Check if heightmap needs re-centering based on player movement this.checkHeightmapRecenter(cameraPos.x, cameraPos.z); // ========== GRID-BASED EXCLUSION UPDATE ========== - // Update grid-based exclusion (for collision-blocked tiles: rocks, trees) if (this.useGridExclusion && this.exclusionGrid) { this.exclusionGrid.update(cameraPos.x, cameraPos.z); } // ========== ROAD EXCLUSION POLLING ========== - // Poll for road data if not yet loaded (handles timing issues with road generation) if (!this.roadTextureLoaded) { this.roadPollCounter++; - // Check every 10 frames (~167ms at 60fps) for faster response if (this.roadPollCounter >= 10) { this.roadPollCounter = 0; this.checkExistingRoads(); @@ -3911,62 +3104,16 @@ export class ProceduralGrassSystem extends System { } // ========== LEGACY EXCLUSION TEXTURE UPDATE ========== - // ALWAYS update legacy texture (for buildings, duel arenas that don't set collision flags) - // This runs in ADDITION to grid-based, not instead of this.checkExclusionRecenter(cameraPos.x, cameraPos.z); // ========== MULTI-CHARACTER BENDING UPDATE ========== - // Update character influence manager for multi-character grass bending if (this.useMultiCharacterBending && this.characterInfluence) { this.characterInfluence.updateFromWorld(cameraPos); } - - // Update uniforms - uniforms.uCameraPosition.value.copy(cameraPos); // For distance culling - uniforms.uPlayerPosition.value.copy(playerPos); // For trail effect only - uniforms.uPlayerDeltaXZ.value.set(deltaX, deltaZ); // For position wrapping - - // Update heightmap center for GPU shader sampling - // The shader needs to know where the heightmap is centered to sample correctly - uHeightmapCenterX.value = this.heightmapCenterX; - uHeightmapCenterZ.value = this.heightmapCenterZ; - - // NOTE: Lighting is controlled by Environment.updateGrassLighting() - // via setLightIntensity(). DO NOT override here - let Environment - // be the authoritative source for day/night cycle. - - // Camera frustum data - const proj = camera.projectionMatrix; - uniforms.uCameraMatrix.value.copy(proj).multiply(camera.matrixWorldInverse); - camera.getWorldDirection(uniforms.uCameraForward.value); - - // Move grass meshes to follow CAMERA (density is camera-based) - this.mesh.position.set(cameraPos.x, 0, cameraPos.z); - - // ========== STATIC COMPUTE PASS ========== - // Run static pass (heightmap, exclusions, dirt, water) only when player moves significantly - // This avoids re-running expensive terrain checks every frame - const staticDx = cameraPos.x - this.lastStaticComputeX; - const staticDz = cameraPos.z - this.lastStaticComputeZ; - const staticDistSq = staticDx * staticDx + staticDz * staticDz; - const thresholdSq = - ProceduralGrassSystem.STATIC_COMPUTE_THRESHOLD * - ProceduralGrassSystem.STATIC_COMPUTE_THRESHOLD; - - if (!this.staticComputeInitialized || staticDistSq > thresholdSq) { - this.renderer.computeAsync(this.ssbo.computeStatic); - this.lastStaticComputeX = cameraPos.x; - this.lastStaticComputeZ = cameraPos.z; - this.staticComputeInitialized = true; - } - - // ========== DYNAMIC COMPUTE PASS ========== - // Run dynamic pass every frame (wind, trail, frustum, distance fade) - this.renderer.computeAsync(this.ssbo.computeUpdate); } // Public API - getMesh(): THREE.InstancedMesh | null { + getMesh(): THREE.Mesh | null { return this.mesh; } @@ -4016,14 +3163,6 @@ export class ProceduralGrassSystem extends System { // Mark road texture as loaded to stop polling this.roadTextureLoaded = true; - // Force SSBO to recalculate visibility on next frame - // This ensures grass is culled immediately after road texture is set - if (this.ssbo) { - // Touch the camera position to trigger a visibility update - uniforms.uCameraPosition.value.x += 0.001; - uniforms.uCameraPosition.value.x -= 0.001; - } - console.log( `[ProceduralGrass] ✅ Road influence texture updated: ${width}x${height}, ` + `${worldSize}m coverage, center (${centerX}, ${centerZ})`, @@ -4440,7 +3579,6 @@ export class ProceduralGrassSystem extends System { this.mesh?.geometry.dispose(); (this.mesh?.material as THREE.Material | undefined)?.dispose(); this.mesh = null; - this.ssbo = null; // GPU-driven LOD1 cleanup if (this.gpuLod1Mesh) { diff --git a/packages/shared/src/systems/shared/world/TerrainShader.ts b/packages/shared/src/systems/shared/world/TerrainShader.ts index a02e52930..fa42d4712 100644 --- a/packages/shared/src/systems/shared/world/TerrainShader.ts +++ b/packages/shared/src/systems/shared/world/TerrainShader.ts @@ -85,39 +85,39 @@ const TERRAIN_TEX_DIR = "textures/terrain-biomes"; const TERRAIN_BIOME_TEXTURES = { grass: { file: "grass.png", - fallback: [0.3, 0.58, 0.15] as [number, number, number], + fallback: [0.39, 0.63, 0.2] as [number, number, number], }, dirt: { file: "dirt.png", - fallback: [0.35, 0.24, 0.12] as [number, number, number], + fallback: [0.82, 0.64, 0.34] as [number, number, number], }, cliff: { file: "cliff.png", - fallback: [0.4, 0.38, 0.32] as [number, number, number], + fallback: [0.71, 0.67, 0.6] as [number, number, number], }, desertGrass: { file: "desertGrass.png", - fallback: [0.82, 0.52, 0.28] as [number, number, number], + fallback: [0.51, 0.41, 0.28] as [number, number, number], }, desertDirt: { file: "desertDirt.png", - fallback: [0.62, 0.28, 0.15] as [number, number, number], + fallback: [0.54, 0.42, 0.32] as [number, number, number], }, desertCliff: { file: "desertDirt.png", - fallback: [0.72, 0.38, 0.18] as [number, number, number], + fallback: [0.54, 0.42, 0.32] as [number, number, number], }, snowGrass: { file: "snowgrass.png", - fallback: [0.78, 0.82, 0.85] as [number, number, number], + fallback: [0.79, 0.8, 0.8] as [number, number, number], }, snowDirt: { file: "snowdirt.png", - fallback: [0.55, 0.55, 0.58] as [number, number, number], + fallback: [0.78, 0.82, 0.84] as [number, number, number], }, snowCliff: { file: "snowdirt.png", - fallback: [0.5, 0.52, 0.56] as [number, number, number], + fallback: [0.78, 0.82, 0.84] as [number, number, number], }, }; @@ -698,6 +698,256 @@ export function calculateSlope( return slope; } +// ============================================================================ +// CPU TERRAIN COLOR — mirrors computeTerrainBaseColor() for grass placement +// ============================================================================ + +type RGB = { r: number; g: number; b: number }; + +// GPU shader darkens textures by multiplying with TEX_DARKEN (0.65). +// CPU dark variants = base × 0.65 to match. +const TEX_DARKEN_CPU = 0.65; +const darken = (c: RGB): RGB => ({ + r: c.r * TEX_DARKEN_CPU, + g: c.g * TEX_DARKEN_CPU, + b: c.b * TEX_DARKEN_CPU, +}); + +// Colors matched to actual texture average colors (sampled from PNGs). +// Tundra/snow biome — snowgrass.png avg (0.79, 0.80, 0.80) +const _TUNDRA_GRASS: RGB = { r: 0.79, g: 0.8, b: 0.8 }; +const _TUNDRA_GRASS_DARK: RGB = darken(_TUNDRA_GRASS); +const _TUNDRA_GRASS_HIGH: RGB = { r: 0.68, g: 0.72, b: 0.78 }; +const _TUNDRA_VARIATION: RGB = { r: 0.6, g: 0.64, b: 0.7 }; +// snowdirt.png avg (0.78, 0.82, 0.84) +const _TUNDRA_DIRT: RGB = { r: 0.78, g: 0.82, b: 0.84 }; +const _TUNDRA_DIRT_DARK: RGB = darken(_TUNDRA_DIRT); +// snowdirt.png used for cliff too +const _TUNDRA_CLIFF: RGB = { r: 0.78, g: 0.82, b: 0.84 }; +const _TUNDRA_CLIFF_DARK: RGB = darken(_TUNDRA_CLIFF); + +// Forest biome — grass.png avg (0.39, 0.63, 0.20) +const _FOREST_GRASS: RGB = { r: 0.39, g: 0.63, b: 0.2 }; +const _FOREST_GRASS_DARK: RGB = darken(_FOREST_GRASS); +const _FOREST_GRASS_HIGH: RGB = { r: 0.24, g: 0.45, b: 0.18 }; +const _FOREST_VARIATION: RGB = { r: 0.15, g: 0.35, b: 0.1 }; +// dirt.png avg (0.82, 0.64, 0.34) +const _FOREST_DIRT: RGB = { r: 0.82, g: 0.64, b: 0.34 }; +const _FOREST_DIRT_DARK: RGB = darken(_FOREST_DIRT); +// cliff.png avg (0.71, 0.67, 0.60) +const _FOREST_CLIFF: RGB = { r: 0.71, g: 0.67, b: 0.6 }; +const _FOREST_CLIFF_DARK: RGB = darken(_FOREST_CLIFF); + +// Canyon/desert biome — desertGrass.png avg (0.51, 0.41, 0.28) +const _CANYON_SAND: RGB = { r: 0.51, g: 0.41, b: 0.28 }; +const _CANYON_SAND_DARK: RGB = darken(_CANYON_SAND); +const _CANYON_SAND_HIGH: RGB = { r: 0.42, g: 0.34, b: 0.22 }; +const _CANYON_VARIATION: RGB = { r: 0.45, g: 0.34, b: 0.2 }; +// desertDirt.png avg (0.54, 0.42, 0.32) +const _CANYON_ROCK: RGB = { r: 0.54, g: 0.42, b: 0.32 }; +const _CANYON_ROCK_DARK: RGB = darken(_CANYON_ROCK); +// desertDirt.png used for cliff too +const _CANYON_CLIFF: RGB = { r: 0.54, g: 0.42, b: 0.32 }; +const _CANYON_CLIFF_DARK: RGB = darken(_CANYON_CLIFF); + +const _CLIFF_TINT: RGB = { r: 0.28, g: 0.3, b: 0.36 }; +const _SAND_YELLOW: RGB = { r: 0.7, g: 0.6, b: 0.38 }; +const _DIRT_DARK_CPU: RGB = { r: 0.22, g: 0.15, b: 0.08 }; +const _MUD_BROWN: RGB = { r: 0.18, g: 0.12, b: 0.08 }; +const _WATER_EDGE: RGB = { r: 0.08, g: 0.06, b: 0.04 }; + +function mixRGB(a: RGB, b: RGB, t: number): RGB { + return { + r: a.r + (b.r - a.r) * t, + g: a.g + (b.g - a.g) * t, + b: a.b + (b.b - a.b) * t, + }; +} + +function blendBiome( + tundra: RGB, + forest: RGB, + canyon: RGB, + tW: number, + fW: number, + dW: number, +): RGB { + return { + r: tundra.r * tW + forest.r * fW + canyon.r * dW, + g: tundra.g * tW + forest.g * fW + canyon.g * dW, + b: tundra.b * tW + forest.b * fW + canyon.b * dW, + }; +} + +function sampleNoiseCPU(worldX: number, worldZ: number, scale: number): number { + if (!cachedPerm) cachedPerm = createPermutation(12345); + const u = worldX * scale; + const v = worldZ * scale; + const wu = u - Math.floor(u); + const wv = v - Math.floor(v); + return (seamlessFbm(wu, wv, cachedPerm, 4) + 1) * 0.5; +} + +/** + * CPU mirror of the GPU `computeTerrainBaseColor()`. + * Returns the procedural terrain color AND a grassWeight (0-1) indicating + * how much of the surface is "grass texture" vs dirt/cliff/sand/shoreline. + */ +export function computeTerrainColorCPU( + worldX: number, + worldZ: number, + height: number, + slope: number, + forestW: number, + canyonW: number, +): { r: number; g: number; b: number; grassWeight: number } { + const fW = forestW; + const dW = canyonW; + const tW = 1 - fW - dW; + + const noiseVal = sampleNoiseCPU( + worldX, + worldZ, + TERRAIN_SHADER_CONSTANTS.NOISE_SCALE, + ); + const noiseVal2 = Math.sin(noiseVal * 6.28) * 0.3 + 0.5; + const distortN = sampleNoiseCPU( + worldX, + worldZ, + TERRAIN_SHADER_CONSTANTS.DISTORT_NOISE_SCALE, + ); + const variationN = sampleNoiseCPU( + worldX, + worldZ, + TERRAIN_SHADER_CONSTANTS.VARIATION_NOISE_SCALE, + ); + + const distortedNY = + 1 - + slope + + (distortN - 0.5) * TERRAIN_SHADER_CONSTANTS.ROCK_DISTORT_STRENGTH; + const dSlope = 1 - distortedNY; + const dHeight = + height + + (distortN - 0.5) * TERRAIN_SHADER_CONSTANTS.HEIGHT_DISTORT_STRENGTH; + + // Biome-blended grass + const grassVar = smoothstepCPU(0.4, 0.6, noiseVal2); + const tundraGrass = mixRGB(_TUNDRA_GRASS, _TUNDRA_GRASS_DARK, grassVar); + const forestGrass = mixRGB(_FOREST_GRASS, _FOREST_GRASS_DARK, grassVar); + const canyonGrass = mixRGB(_CANYON_SAND, _CANYON_SAND_DARK, grassVar); + let c = blendBiome(tundraGrass, forestGrass, canyonGrass, tW, fW, dW); + + // Height-based gradient + const heightGrad = smoothstepCPU(25, 55, height) * 0.3; + const grassHigh = blendBiome( + _TUNDRA_GRASS_HIGH, + _FOREST_GRASS_HIGH, + _CANYON_SAND_HIGH, + tW, + fW, + dW, + ); + c = mixRGB(c, grassHigh, heightGrad); + + // Low-frequency variation + const gVar = Math.max(0, Math.min(1, Math.pow(variationN + 0.3, 5))); + const varColor = blendBiome( + _TUNDRA_VARIATION, + _FOREST_VARIATION, + _CANYON_VARIATION, + tW, + fW, + dW, + ); + c = mixRGB(c, varColor, gVar * 0.25); + + // Biome-blended dirt + const dirtVar = smoothstepCPU(0.3, 0.7, noiseVal2); + const dirtColor = blendBiome( + mixRGB(_TUNDRA_DIRT, _TUNDRA_DIRT_DARK, dirtVar), + mixRGB(_FOREST_DIRT, _FOREST_DIRT_DARK, dirtVar), + mixRGB(_CANYON_ROCK, _CANYON_ROCK_DARK, dirtVar), + tW, + fW, + dW, + ); + + // Biome-blended cliff + const cliffVar = smoothstepCPU(0.3, 0.7, noiseVal); + let cliffColor = blendBiome( + mixRGB(_TUNDRA_CLIFF, _TUNDRA_CLIFF_DARK, cliffVar), + mixRGB(_FOREST_CLIFF, _FOREST_CLIFF_DARK, cliffVar), + mixRGB(_CANYON_CLIFF, _CANYON_CLIFF_DARK, cliffVar), + tW, + fW, + dW, + ); + const rockTexV = Math.pow(distortN, 0.5) * 0.3; + cliffColor = mixRGB(cliffColor, _CLIFF_TINT, rockTexV); + + // Track grass weight: starts at 1, reduced by each non-grass layer + let grassWeight = 1.0; + + // Dirt patches on flat areas + const nDirtF = + smoothstepCPU( + TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD - 0.05, + TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD + 0.15, + noiseVal, + ) * smoothstepCPU(0.3, 0.05, dSlope); + c = mixRGB(c, dirtColor, nDirtF); + grassWeight -= nDirtF; + + // Dirt on moderate slopes + const dirtSlopeF = + smoothstepCPU(0.15, 0.4, dSlope) * smoothstepCPU(0.6, 0.3, dSlope) * 0.6; + c = mixRGB(c, dirtColor, dirtSlopeF); + grassWeight -= dirtSlopeF; + + // Cliff on steep slopes + const cliffF = smoothstepCPU(0.3, 0.55, dSlope); + c = mixRGB(c, cliffColor, cliffF); + grassWeight -= cliffF; + + // Sand near water + const sandBlend = + smoothstepCPU(18, 12, dHeight) * smoothstepCPU(0.25, 0.0, slope); + const sandStr = 0.6 + (0.9 - 0.6) * dW; + const sandF = sandBlend * sandStr; + c = mixRGB(c, _SAND_YELLOW, sandF); + grassWeight -= sandF; + + // Shoreline transitions + const shore1 = smoothstepCPU(22, 14, dHeight) * 0.4; + c = mixRGB(c, _DIRT_DARK_CPU, shore1); + grassWeight -= shore1; + + const shore2 = smoothstepCPU(15, 10, dHeight) * 0.7; + c = mixRGB(c, _MUD_BROWN, shore2); + grassWeight -= shore2; + + const shore3 = smoothstepCPU(11, 7, dHeight) * 0.9; + c = mixRGB(c, _WATER_EDGE, shore3); + grassWeight -= shore3; + + // Saturation boost + const luma = c.r * 0.299 + c.g * 0.587 + c.b * 0.114; + const sat = TERRAIN_SHADER_CONSTANTS.SATURATION_BOOST; + c = { + r: luma + (c.r - luma) * sat, + g: luma + (c.g - luma) * sat, + b: luma + (c.b - luma) * sat, + }; + + return { + r: c.r, + g: c.g, + b: c.b, + grassWeight: Math.max(0, Math.min(1, grassWeight)), + }; +} + // ============================================================================ // TERRAIN MATERIAL - OSRS Style (No Textures) // ============================================================================ @@ -1071,11 +1321,48 @@ export function createTerrainMaterial(): THREE.Material & { ), ); - // === ROAD OVERLAY (disabled) === - // const roadInfluenceAttr = attribute("roadInfluence", "float"); - // const roadMaskState = getRoadInfluenceTextureState(); - // ... road rendering commented out for now - const baseWithRoads = variedColor; + // === ROAD OVERLAY === + const roadInfluenceAttr = attribute("roadInfluence", "float"); + const roadMaskState = getRoadInfluenceTextureState(); + const roadHalfWorld = roadMaskState.uWorldSize.mul(0.5); + const roadUvX = worldPos.x + .sub(roadMaskState.uCenterX) + .add(roadHalfWorld) + .div(roadMaskState.uWorldSize); + const roadUvZ = worldPos.z + .sub(roadMaskState.uCenterZ) + .add(roadHalfWorld) + .div(roadMaskState.uWorldSize); + const roadUV = vec2(roadUvX.clamp(0.001, 0.999), roadUvZ.clamp(0.001, 0.999)); + const roadMask = roadMaskState.textureNode.sample(roadUV).r; + const hasRoadMask = smoothstep( + float(1.0), + float(2.0), + roadMaskState.uWorldSize, + ); + const dx = abs(worldPos.x.sub(roadMaskState.uCenterX)); + const dz = abs(worldPos.z.sub(roadMaskState.uCenterZ)); + const insideMask = step(dx, roadHalfWorld).mul(step(dz, roadHalfWorld)); + const useMask = hasRoadMask.mul(insideMask); + const roadInfluenceRaw = mix(roadInfluenceAttr, roadMask, useMask); + const roadInfluence = smoothstep(float(0.0), float(1.0), roadInfluenceRaw); + + const roadNoiseVar = mul(noiseValue2, float(0.5)); + const roadBaseColor = mix(DIRT_BROWN, DIRT_DARK, roadNoiseVar); + + const stoneNoise = smoothstep(float(0.4), float(0.7), fineNoise); + const stoneColor = mix(ROCK_GRAY, ROCK_DARK, float(0.5)); + + const roadDetailColor = mix( + roadBaseColor, + stoneColor, + mul(stoneNoise, float(0.6)), + ); + + const roadCenterDarken = mul(roadInfluence, float(0.08)); + const compactedRoadColor = sub(roadDetailColor, vec3(roadCenterDarken)); + + const baseWithRoads = mix(variedColor, compactedRoadColor, roadInfluence); // ============================================================================ // HALF-LAMBERT ANIME SHADE (cool shadow tint — Genshin-style) diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts index 2a97e29f6..71929ef72 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -99,6 +99,7 @@ import { updateTerrainVertexLights, createTerrainMaterial, TerrainUniforms, + computeTerrainColorCPU, } from "./TerrainShader"; import { isLamppostLightTextureReady } from "./LamppostLightMask"; import { isCsmEnabled } from "./Environment"; @@ -240,7 +241,6 @@ export class TerrainSystem extends System { private _tempVec2_2 = new THREE.Vector2(); private _tempVec2_3 = new THREE.Vector2(); // For road distance calculations private _tempBox3 = new THREE.Box3(); - private _tempColor = new THREE.Color(); // For biome color parsing private _spectatorFocusPos = new THREE.Vector3(); private lamppostLightUpdateTimer = 0; private lamppostActiveLights: VertexLight[] = []; @@ -947,8 +947,7 @@ export class TerrainSystem extends System { } } - // Set attributes - geometry.setAttribute("color", new THREE.BufferAttribute(colorData, 3)); + // Set attributes (color attribute removed — shader computes colors procedurally) geometry.setAttribute( "biomeId", new THREE.BufferAttribute(new Float32Array(biomeData), 1), @@ -1030,8 +1029,9 @@ export class TerrainSystem extends System { this.applyFlatZoneCarve(geometry, tileX, tileZ); - // Store height data for persistence - this.storeHeightData(tileX, tileZ, Array.from(heightData)); + // Attach heightData to geometry so the caller can store it AFTER the tile + // is added to terrainTiles (storeHeightData requires the tile to exist). + geometry.userData.heightData = Array.from(heightData); return geometry; } @@ -1271,6 +1271,14 @@ export class TerrainSystem extends System { this.terrainTiles.set(key, tile); this.activeChunks.add(key); + // Store height data NOW that the tile is in the map. + // createTileGeometryFromWorkerData attaches heightData to geometry.userData + // because storeHeightData requires the tile to exist in terrainTiles. + if (geometry.userData.heightData) { + tile.heightData = geometry.userData.heightData; + tile.needsSave = true; + } + // Synchronously bake WATER and STEEP_SLOPE collision flags (server-only). // Must match generateTile() — worker-generated tiles need the same // walkability baking (WATER flags, bridge collision overrides, etc.). @@ -1708,6 +1716,11 @@ export class TerrainSystem extends System { ); this.refreshRoadInfluence(); } + + // Rebuild grass chunks so road influence is reflected + if (this.grassVisualManager) { + this.grassVisualManager.rebuildAllChunks(); + } }); // Fallback: If no roads event within 15 seconds, generate tiles anyway @@ -1955,13 +1968,11 @@ export class TerrainSystem extends System { this.CONFIG.WATER_THRESHOLD, ); - // Grass quad-tree visual manager — instanced grass blades on finest terrain leaves const grassContainer = new THREE.Group(); grassContainer.name = "QuadTreeGrassContainer"; if (this.terrainContainer) { this.terrainContainer.parent?.add(grassContainer); } - this.grassVisualManager = new GrassVisualManager( grassContainer, this.world, @@ -1969,9 +1980,11 @@ export class TerrainSystem extends System { this.CONFIG.WATER_THRESHOLD, (wx: number, wz: number) => this.calculateRoadInfluenceAtVertex(wx, wz, 0, 0), + computeTerrainColorCPU, + (wx: number, wz: number) => this.computeBiomeWeightsAtPosition(wx, wz), ); - // Wire terrain, water, and grass managers to the same quad-tree via composite + // Wire terrain, water, grass managers to the same quad-tree via composite const composite = new CompositeQuadTreeListener(); composite.add(this.quadTreeVisualManager); composite.add(this.waterVisualManager); @@ -2583,6 +2596,14 @@ export class TerrainSystem extends System { this.terrainTiles.set(key, tile); this.activeChunks.add(key); + // Store height data NOW that the tile is in the map. + // createTileGeometry attaches heightData to geometry.userData because + // storeHeightData requires the tile to exist in terrainTiles. + if (geometry.userData.heightData) { + tile.heightData = geometry.userData.heightData; + tile.needsSave = true; + } + // Synchronously bake WATER and STEEP_SLOPE collision flags (server-only). // Must complete before any movement query can reach this tile, otherwise // players can walk into water during the gap between tile generation and @@ -2649,8 +2670,9 @@ export class TerrainSystem extends System { // Carve terrain triangles inside building flat zones to avoid overdraw this.applyFlatZoneCarve(geometry, tileX, tileZ); - // Store height data for persistence - this.storeHeightData(tileX, tileZ, heightData); + // Attach heightData to geometry so the caller can store it AFTER the tile + // is added to terrainTiles (storeHeightData requires the tile to exist). + geometry.userData.heightData = heightData; // SERVER: Skip all visual-only attributes (colors, biomeIds, roadInfluences, // forestWeights, canyonWeights) and the expensive per-vertex biome/color @@ -2661,121 +2683,45 @@ export class TerrainSystem extends System { return geometry; } - // CLIENT: Full visual attribute generation - const colors = new Float32Array(positions.count * 3); + // CLIENT: Visual attribute generation (shader computes colors procedurally) const biomeIds = new Float32Array(positions.count); const roadInfluences = new Float32Array(positions.count); const forestWeights = new Float32Array(positions.count); const canyonWeights = new Float32Array(positions.count); - // Verify biome data is loaded - error if not - if (Object.keys(BIOMES).length === 0) { - throw new Error( - "[TerrainSystem] BIOMES data not loaded! DataManager must initialize before terrain generation.", - ); - } - const defaultBiomeData = BIOMES[DEFAULT_BIOME]; - if (!defaultBiomeData) { - throw new Error( - "[TerrainSystem] Default biome not found in BIOMES data!", - ); - } - for (let i = 0; i < positions.count; i++) { const i3 = i * 3; const x = positionsArray[i3] + tileX * this.CONFIG.TILE_SIZE; const z = positionsArray[i3 + 2] + tileZ * this.CONFIG.TILE_SIZE; - const height = positionsArray[i3 + 1]; // Already computed above - // Get biome influences for smooth color blending const { biomeWeightMap, totalWeight } = this.computeBiomeWeightsAtPosition(x, z); - const normalizedHeight = height / MAX_HEIGHT; - // Store dominant biome ID for shader let dominantBiome = DEFAULT_BIOME as string; let dominantWeight = -Infinity; - // PERFORMANCE: Reuse _tempColor to avoid GC pressure (no new THREE.Color per vertex) - // Blend up to 3 biome colors based on influence weights - let colorR = 0, - colorG = 0, - colorB = 0; - if (totalWeight > 0) { - const invTotal = 1 / totalWeight; + const invW = 1 / totalWeight; for (const [type, rawWeight] of biomeWeightMap) { - const weight = rawWeight * invTotal; + const weight = rawWeight * invW; if (weight > dominantWeight) { dominantWeight = weight; dominantBiome = type; } - - const biomeData = BIOMES[type]; - if (!biomeData) { - throw new Error( - `[TerrainSystem] Biome "${type}" not found in BIOMES data!`, - ); - } - this._tempColor.set(biomeData.color); - - colorR += this._tempColor.r * weight; - colorG += this._tempColor.g * weight; - colorB += this._tempColor.b * weight; } - } else { - const biomeData = BIOMES[DEFAULT_BIOME]; - if (!biomeData) { - throw new Error( - `[TerrainSystem] Default biome not found in BIOMES data!`, - ); - } - this._tempColor.set(biomeData.color); - colorR = this._tempColor.r; - colorG = this._tempColor.g; - colorB = this._tempColor.b; - } - biomeIds[i] = this.getBiomeId(dominantBiome); - - if (totalWeight > 0) { - const invW = 1 / totalWeight; forestWeights[i] = (biomeWeightMap.get(BiomeType.Forest) || 0) * invW; canyonWeights[i] = (biomeWeightMap.get(BiomeType.Canyon) || 0) * invW; } + biomeIds[i] = this.getBiomeId(dominantBiome); - // Apply brownish shoreline tint near water level - const waterLevel = WATER_LEVEL_NORMALIZED; - const shorelineThreshold = SHORELINE_CONFIG.THRESHOLD; - if ( - normalizedHeight > waterLevel && - normalizedHeight < shorelineThreshold - ) { - const shoreFactor = - (1.0 - - (normalizedHeight - waterLevel) / - (shorelineThreshold - waterLevel)) * - SHORELINE_CONFIG.STRENGTH; - colorR = colorR + (0.545 - colorR) * shoreFactor; - colorG = colorG + (0.451 - colorG) * shoreFactor; - colorB = colorB + (0.333 - colorB) * shoreFactor; - } - - // Calculate road influence - shader uses this attribute for road coloring - // (vertex colors are NOT modified for roads, shader handles this procedurally) roadInfluences[i] = this.calculateRoadInfluenceAtVertex( x, z, tileX, tileZ, ); - - // Store biome color (kept for potential fallback/debug rendering) - colors[i * 3] = colorR; - colors[i * 3 + 1] = colorG; - colors[i * 3 + 2] = colorB; } - geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); geometry.setAttribute("biomeId", new THREE.BufferAttribute(biomeIds, 1)); geometry.setAttribute( "roadInfluence", @@ -3402,12 +3348,6 @@ export class TerrainSystem extends System { private getHeightAtWithoutShore(worldX: number, worldZ: number): number { this.ensureNoiseInitialized(); - - const flatHeight = this.getFlatZoneHeight(worldX, worldZ); - if (flatHeight !== null) { - return flatHeight; - } - return this.getBaseHeightAt(worldX, worldZ); } @@ -3907,62 +3847,6 @@ export class TerrainSystem extends System { return this.getFlatZoneHeight(worldX, worldZ) !== null; } - /** - * Get terrain color at a world position by sampling the nearest terrain tile vertex. - * Used by ProceduralGrassSystem to match grass color to terrain. - * - * @param worldX - World X coordinate - * @param worldZ - World Z coordinate - * @returns RGB color (0-1 range) or null if no terrain data available - */ - getTerrainColorAt( - worldX: number, - worldZ: number, - ): { r: number; g: number; b: number } | null { - const tileX = this.worldToTerrainTileIndex(worldX); - const tileZ = this.worldToTerrainTileIndex(worldZ); - const key = `${tileX}_${tileZ}`; - - const tile = this.terrainTiles.get(key); - if (!tile?.mesh) { - return null; - } - - // Get vertex colors from geometry - const geometry = tile.mesh.geometry as THREE.BufferGeometry; - const colorAttr = geometry.getAttribute("color") as - | THREE.BufferAttribute - | undefined; - if (!colorAttr) { - return null; - } - - const localX = worldX - tileX * this.CONFIG.TILE_SIZE; - const localZ = worldZ - tileZ * this.CONFIG.TILE_SIZE; - - const resolution = this.CONFIG.TILE_RESOLUTION; - const vertexX = Math.round( - Math.max(0, Math.min(resolution - 1, this.localToGridIndex(localX))), - ); - const vertexZ = Math.round( - Math.max(0, Math.min(resolution - 1, this.localToGridIndex(localZ))), - ); - const vertexIndex = vertexZ * resolution + vertexX; - - if ( - vertexIndex < 0 || - vertexIndex * 3 + 2 >= colorAttr.count * colorAttr.itemSize - ) { - return null; - } - - return { - r: colorAttr.getX(vertexIndex), - g: colorAttr.getY(vertexIndex), - b: colorAttr.getZ(vertexIndex), - }; - } - /** * Register a flat zone and update spatial index. * Spatial index uses terrain tiles (100m each) for efficient lookup. @@ -4077,6 +3961,26 @@ export class TerrainSystem extends System { } } } + + // Invalidate quad-tree visual chunks so they regenerate with flat zone heights. + // Without this, chunks generated before the flat zone was registered would + // show the procedural height instead of the flat zone height. + if (this.quadTreeVisualManager) { + this.quadTreeVisualManager.invalidateRegion( + zoneMinX, + zoneMinZ, + zoneMaxX, + zoneMaxZ, + ); + } + if (this.grassVisualManager) { + this.grassVisualManager.invalidateRegion( + zoneMinX, + zoneMinZ, + zoneMaxX, + zoneMaxZ, + ); + } } /** @@ -4214,6 +4118,18 @@ export class TerrainSystem extends System { } } } + + // Invalidate quad-tree visual chunks so they regenerate without the flat zone. + const minX = zone.centerX - totalRadius; + const maxX = zone.centerX + totalRadius; + const minZ = zone.centerZ - totalRadius; + const maxZ = zone.centerZ + totalRadius; + if (this.quadTreeVisualManager) { + this.quadTreeVisualManager.invalidateRegion(minX, minZ, maxX, maxZ); + } + if (this.grassVisualManager) { + this.grassVisualManager.invalidateRegion(minX, minZ, maxX, maxZ); + } } /** @@ -5223,6 +5139,11 @@ export class TerrainSystem extends System { materialWithUniforms.terrainUniforms.sunDirection.value .copy(env.lightDirection) .negate(); + if (this.grassVisualManager) { + this.grassVisualManager.updateLighting( + materialWithUniforms.terrainUniforms.sunDirection.value, + ); + } } // Fog texture is the shared fogRenderTarget from FogConfig — no sync needed From e687efb17c350474ec674082a131778b9e5453f1 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Fri, 27 Mar 2026 05:23:39 +0800 Subject: [PATCH 53/71] fix: add invalidateRegion to TerrainVisualManager for flat zone updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the flat zone invalidation support — terrain quad-tree chunks overlapping a registered/unregistered flat zone are destroyed and regenerated with correct heights, matching GrassVisualManager behavior. Made-with: Cursor --- .../shared/world/TerrainVisualManager.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/shared/src/systems/shared/world/TerrainVisualManager.ts b/packages/shared/src/systems/shared/world/TerrainVisualManager.ts index 31ab69920..654b95bc7 100644 --- a/packages/shared/src/systems/shared/world/TerrainVisualManager.ts +++ b/packages/shared/src/systems/shared/world/TerrainVisualManager.ts @@ -189,6 +189,37 @@ export class TerrainVisualManager implements QuadTreeListener { this.workerBiomes = biomes; } + /** + * Invalidate quad-tree chunks that overlap a world-space AABB. + * Destroys affected chunks so they get regenerated on the next update + * with current flat-zone / road data. Called when flat zones are + * registered after initial terrain generation. + */ + invalidateRegion( + minX: number, + minZ: number, + maxX: number, + maxZ: number, + ): void { + for (const [key, chunk] of this.chunks) { + const node = chunk.node; + const half = node.size * 0.5; + const nMinX = node.centerX - half; + const nMaxX = node.centerX + half; + const nMinZ = node.centerZ - half; + const nMaxZ = node.centerZ + half; + + if (nMaxX < minX || nMinX > maxX || nMaxZ < minZ || nMinZ > maxZ) { + continue; + } + + this.removeMeshFromScene(chunk); + this.chunks.delete(key); + node.visualChunkKey = null; + node.terrainNeedsUpdate = true; + } + } + // ========================================================================= // QuadTreeListener implementation // ========================================================================= From f912f2f612dc1bb7ed7d21a453d1a604bcdbca1e Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Fri, 27 Mar 2026 17:35:16 +0800 Subject: [PATCH 54/71] fix: grass-terrain color matching and unified custom lighting - Fix CPU color constants: non-texture constants (height gradient, variation, cliff tint, sand, shoreline) now use raw linear values matching GPU vec3() instead of incorrectly applying sRGB-to-linear conversion via lin() - Fix mismatched canyon constants (_CANYON_SAND_HIGH, _CANYON_VARIATION) - Remove fresnel from applyAnimeShade (caused view-dependent color shifts) - Add nightDim to applyCustomLighting matching tree shader pattern - Bypass PBR for both terrain and grass via shared applyCustomLighting - Add dayIntensity sync for terrain and grass day/night cycle - Refactor GrassVisualManager to be biome-agnostic via getTerrainColorAt - Add deferred chunk loading to reduce FPS hiccups on grass spawn - Re-enable terrain texture loading Made-with: Cursor --- .../shared/world/GrassVisualManager.ts | 207 ++++++++---------- .../systems/shared/world/LightingConfig.ts | 42 +++- .../src/systems/shared/world/TerrainShader.ts | 166 ++++++++------ .../src/systems/shared/world/TerrainSystem.ts | 71 +++++- 4 files changed, 308 insertions(+), 178 deletions(-) diff --git a/packages/shared/src/systems/shared/world/GrassVisualManager.ts b/packages/shared/src/systems/shared/world/GrassVisualManager.ts index caa27dd50..7fca4b6e9 100644 --- a/packages/shared/src/systems/shared/world/GrassVisualManager.ts +++ b/packages/shared/src/systems/shared/world/GrassVisualManager.ts @@ -14,8 +14,6 @@ import THREE, { Fn, float, sin, - mix, - smoothstep, time, positionLocal, attribute, @@ -23,26 +21,15 @@ import THREE, { uv, pow, vec3, - dot, - normalize, - add, - mul, - sub, + vec4, + mix, + smoothstep, } from "../../../extras/three/three"; -import { SUN_LIGHT } from "./LightingConfig"; -import { TERRAIN_SHADE } from "./TerrainShader"; +import { SUN_LIGHT, SUN_SHADE, applyCustomLighting } from "./LightingConfig"; +import { applyAnimeShade } from "./TerrainShader"; import { MeshStandardNodeMaterial } from "three/webgpu"; import type { TerrainQuadNode, QuadTreeListener } from "./TerrainQuadTree"; -// sRGB → linear conversion. computeTerrainColorCPU returns sRGB-space values -// (its constants match the textures' sRGB averages). The GPU terrain shader -// auto-converts sRGB textures to linear before blending. Float vertex -// attributes have NO automatic conversion, so we must do it on the CPU side -// before writing to instanceGroundColor. -function srgbToLinear(c: number): number { - return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); -} - // --------------------------------------------------------------------------- // Configuration — tweak these to control grass appearance & performance // --------------------------------------------------------------------------- @@ -88,7 +75,6 @@ export const GRASS_CONFIG = { GRADIENT_FALLOFF: 1.7, // -- Terrain filters ------------------------------------------------------ - MAX_SLOPE: 0.7, /** Minimum grassWeight from terrain color function to place a clump (0-1) */ MIN_GRASS_WEIGHT: 0.3, @@ -153,7 +139,6 @@ function createClumpGeometry( const normals = new Float32Array(totalVerts * 3); const uvs = new Float32Array(totalVerts * 2); const indices = new Uint16Array(totalIdx); - const bladeHashAttr = new Float32Array(totalVerts); const rng = mulberry32(91827364); const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); @@ -182,7 +167,7 @@ function createClumpGeometry( const curveDirX = Math.cos(curveAngle) * arcDist; const curveDirZ = Math.sin(curveAngle) * arcDist; - const bHash = rng(); + rng(); // consume one RNG value to keep deterministic sequence stable const baseVert = vi; for (let i = 0; i < segs; i++) { @@ -203,7 +188,6 @@ function createClumpGeometry( normals[vi * 3 + 2] = cr; uvs[vi * 2] = side; uvs[vi * 2 + 1] = t; - bladeHashAttr[vi] = bHash; vi++; } } @@ -216,7 +200,6 @@ function createClumpGeometry( normals[vi * 3 + 2] = cr; uvs[vi * 2] = 0.5; uvs[vi * 2 + 1] = 1.0; - bladeHashAttr[vi] = bHash; vi++; for (let i = 0; i < segs - 1; i++) { @@ -239,7 +222,6 @@ function createClumpGeometry( geo.setAttribute("position", new THREE.BufferAttribute(positions, 3)); geo.setAttribute("normal", new THREE.BufferAttribute(normals, 3)); geo.setAttribute("uv", new THREE.BufferAttribute(uvs, 2)); - geo.setAttribute("bladeHash", new THREE.BufferAttribute(bladeHashAttr, 1)); geo.setIndex(new THREE.BufferAttribute(indices, 1)); return geo; } @@ -264,19 +246,20 @@ export class GrassVisualManager implements QuadTreeListener { private container: THREE.Group; private getHeightAt: (x: number, z: number) => number; private getRoadInfluence: (wx: number, wz: number) => number; - private sunDirUniform: ReturnType>; - private getTerrainColor: ( - wx: number, - wz: number, - h: number, - slope: number, - fW: number, - cW: number, - ) => { r: number; g: number; b: number; grassWeight: number }; - private getBiomeWeights: ( + private getTerrainColorAt: ( wx: number, wz: number, - ) => { biomeWeightMap: Map; totalWeight: number }; + ) => { + r: number; + g: number; + b: number; + grassWeight: number; + nx: number; + ny: number; + nz: number; + }; + private sunDirUniform: ReturnType>; + private dayIntensityUniform: ReturnType>; private waterThreshold: number; private chunks = new Map(); private material: MeshStandardNodeMaterial; @@ -287,31 +270,24 @@ export class GrassVisualManager implements QuadTreeListener { private playerX = 0; private playerZ = 0; + private pendingNodes: { node: TerrainQuadNode; lod?: number }[] = []; + private static readonly MAX_CHUNKS_PER_FRAME = 2; + constructor( container: THREE.Group, - _world: unknown, getHeightAt: (x: number, z: number) => number, waterThreshold: number, getRoadInfluence: (wx: number, wz: number) => number, - getTerrainColor: ( + getTerrainColorAt: ( wx: number, wz: number, - h: number, - slope: number, - fW: number, - cW: number, ) => { r: number; g: number; b: number; grassWeight: number }, - getBiomeWeights: ( - wx: number, - wz: number, - ) => { biomeWeightMap: Map; totalWeight: number }, ) { this.container = container; this.getHeightAt = getHeightAt; this.waterThreshold = waterThreshold; this.getRoadInfluence = getRoadInfluence; - this.getTerrainColor = getTerrainColor; - this.getBiomeWeights = getBiomeWeights; + this.getTerrainColorAt = getTerrainColorAt; this.lodGeometries = GRASS_CONFIG.LOD_TIERS.map((tier) => createClumpGeometry(tier.bladesPerClump, tier.bladeSegments), @@ -339,9 +315,27 @@ export class GrassVisualManager implements QuadTreeListener { this.sunDirUniform.value.copy(sunDir); } + updateDayIntensity(val: number): void { + this.dayIntensityUniform.value = val; + } + update(playerX: number, playerZ: number, camera?: THREE.Camera): void { this.playerX = playerX; this.playerZ = playerZ; + + // Drain pending queue — build at most N new chunks per frame + let built = 0; + while ( + this.pendingNodes.length > 0 && + built < GrassVisualManager.MAX_CHUNKS_PER_FRAME + ) { + const { node, lod } = this.pendingNodes.shift()!; + if (!this.chunks.has(this.chunkKey(node))) { + this.createChunkMesh(node, lod); + built++; + } + } + if (!camera || this.chunks.size === 0) return; this.projScreenMatrix.multiplyMatrices( @@ -352,25 +346,26 @@ export class GrassVisualManager implements QuadTreeListener { const tiers = GRASS_CONFIG.LOD_TIERS; const hysteresis = GRASS_CONFIG.LOD_HYSTERESIS; - let rebuilds = 0; - const MAX_REBUILDS_PER_FRAME = 2; - for (const [key, chunk] of this.chunks) { + for (const [, chunk] of this.chunks) { chunk.mesh.visible = this.frustum.intersectsBox(chunk.box); - if (!chunk.mesh.visible || rebuilds >= MAX_REBUILDS_PER_FRAME) continue; + if ( + !chunk.mesh.visible || + built >= GrassVisualManager.MAX_CHUNKS_PER_FRAME + ) + continue; - // Check if LOD tier should change const dx = chunk.node.centerX - playerX; const dz = chunk.node.centerZ - playerZ; const dist = Math.sqrt(dx * dx + dz * dz); - const currentTier = tiers[chunk.lodLevel]; const desiredLod = this.getLodLevel(chunk.node); if (desiredLod !== chunk.lodLevel) { + const currentTier = tiers[chunk.lodLevel]; const boundary = desiredLod < chunk.lodLevel - ? currentTier.maxDistance // moving closer: use current tier boundary - : (tiers[desiredLod - 1]?.maxDistance ?? 0); // moving farther: use target-1 boundary + ? currentTier.maxDistance + : (tiers[desiredLod - 1]?.maxDistance ?? 0); const threshold = boundary * (1 + (desiredLod < chunk.lodLevel ? -hysteresis : hysteresis)); @@ -380,9 +375,9 @@ export class GrassVisualManager implements QuadTreeListener { if (shouldSwitch) { if (chunk.mesh.parent) chunk.mesh.parent.remove(chunk.mesh); chunk.mesh.geometry.dispose(); - this.chunks.delete(key); + this.chunks.delete(this.chunkKey(chunk.node)); this.createChunkMesh(chunk.node, desiredLod); - rebuilds++; + built++; } } } @@ -394,7 +389,8 @@ export class GrassVisualManager implements QuadTreeListener { if (!node.isMaxDepth) return; const key = this.chunkKey(node); if (this.chunks.has(key)) return; - this.createChunkMesh(node); + if (this.pendingNodes.some((p) => p.node === node)) return; + this.pendingNodes.push({ node }); } onNodeDestroyGeometry(node: TerrainQuadNode): void { @@ -407,6 +403,7 @@ export class GrassVisualManager implements QuadTreeListener { } destroy(): void { + this.pendingNodes.length = 0; for (const [, chunk] of this.chunks) { if (chunk.mesh.parent) chunk.mesh.parent.remove(chunk.mesh); chunk.mesh.geometry.dispose(); @@ -429,7 +426,7 @@ export class GrassVisualManager implements QuadTreeListener { this.chunks.clear(); for (const node of nodes) { node.visualChunkKey = null; - this.createChunkMesh(node); + this.pendingNodes.push({ node }); } } @@ -462,7 +459,7 @@ export class GrassVisualManager implements QuadTreeListener { this.chunks.delete(key); } for (const node of toRebuild) { - this.createChunkMesh(node); + this.pendingNodes.push({ node }); } } @@ -503,6 +500,10 @@ export class GrassVisualManager implements QuadTreeListener { "instanceGroundColor", new THREE.InstancedBufferAttribute(instanceData.groundColors, 3), ); + geo.setAttribute( + "instanceGroundNormal", + new THREE.InstancedBufferAttribute(instanceData.groundNormals, 3), + ); const mesh = new THREE.InstancedMesh( geo, @@ -541,6 +542,7 @@ export class GrassVisualManager implements QuadTreeListener { offsets: Float32Array; rotScaleHash: Float32Array; groundColors: Float32Array; + groundNormals: Float32Array; count: number; } | null { const spacing = GRASS_CONFIG.CLUMP_SPACING * spacingMul; @@ -553,6 +555,7 @@ export class GrassVisualManager implements QuadTreeListener { const offsets = new Float32Array(maxCount * 3); const rotScaleHash = new Float32Array(maxCount * 3); const groundColors = new Float32Array(maxCount * 3); + const groundNormals = new Float32Array(maxCount * 3); let count = 0; @@ -570,33 +573,15 @@ export class GrassVisualManager implements QuadTreeListener { const roadInf = this.getRoadInfluence(wx, wz); if (roadInf > 0.8) continue; - // Slope via finite differences - const sd = 0.5; - const hL = this.getHeightAt(wx - sd, wz); - const hR = this.getHeightAt(wx + sd, wz); - const hD = this.getHeightAt(wx, wz - sd); - const hU = this.getHeightAt(wx, wz + sd); - const dhdx = (hR - hL) / (2 * sd); - const dhdz = (hU - hD) / (2 * sd); - const gradMag = Math.sqrt(dhdx * dhdx + dhdz * dhdz); - const normalY = 1 / Math.sqrt(1 + gradMag * gradMag); - const slope = 1 - normalY; - - if (slope > GRASS_CONFIG.MAX_SLOPE) continue; - - // Biome weights at this position - const { biomeWeightMap, totalWeight } = this.getBiomeWeights(wx, wz); - const invW = totalWeight > 0 ? 1 / totalWeight : 1; - const forestW = (biomeWeightMap.get("forest") || 0) * invW; - const canyonW = (biomeWeightMap.get("canyon") || 0) * invW; - - // Terrain color + grass weight, reduced by road influence const { r, g, b, grassWeight: rawGW, - } = this.getTerrainColor(wx, wz, ty, slope, forestW, canyonW); + nx, + ny, + nz, + } = this.getTerrainColorAt(wx, wz); const grassWeight = Math.max(0, rawGW - roadInf); if (grassWeight < GRASS_CONFIG.MIN_GRASS_WEIGHT) continue; @@ -614,9 +599,13 @@ export class GrassVisualManager implements QuadTreeListener { rotScaleHash[count * 3 + 1] = scale; rotScaleHash[count * 3 + 2] = clumpRng; - groundColors[count * 3] = srgbToLinear(r); - groundColors[count * 3 + 1] = srgbToLinear(g); - groundColors[count * 3 + 2] = srgbToLinear(b); + groundColors[count * 3] = r; + groundColors[count * 3 + 1] = g; + groundColors[count * 3 + 2] = b; + + groundNormals[count * 3] = nx; + groundNormals[count * 3 + 1] = ny; + groundNormals[count * 3 + 2] = nz; count++; } @@ -627,6 +616,7 @@ export class GrassVisualManager implements QuadTreeListener { offsets: offsets.slice(0, count * 3), rotScaleHash: rotScaleHash.slice(0, count * 3), groundColors: groundColors.slice(0, count * 3), + groundNormals: groundNormals.slice(0, count * 3), count, }; } @@ -635,6 +625,7 @@ export class GrassVisualManager implements QuadTreeListener { private createMaterial(): MeshStandardNodeMaterial { const mat = new MeshStandardNodeMaterial(); + mat.colorNode = vec3(0, 0, 0); mat.side = THREE.DoubleSide; mat.transparent = false; mat.depthWrite = true; @@ -648,6 +639,7 @@ export class GrassVisualManager implements QuadTreeListener { this.sunDirUniform = uniform( new THREE.Vector3(...SUN_LIGHT.DEFAULT_DIRECTION), ); + this.dayIntensityUniform = uniform(1.0); mat.positionNode = Fn(() => { const localPos = positionLocal.toVar("gp"); @@ -700,36 +692,29 @@ export class GrassVisualManager implements QuadTreeListener { return localPos; })(); - // All normals point up so PBR lighting is uniform across all blades - mat.normalNode = vec3(0, 1, 0); - const uSunDir = this.sunDirUniform; + const uDayIntensity = this.dayIntensityUniform; + const shadeColor = vec3(...SUN_SHADE.TINT_COLOR); - mat.colorNode = Fn(() => { + mat.outputNode = Fn(() => { const groundCol = attribute("instanceGroundColor", "vec3"); - const t = uv().y; // 0 = root, 1 = tip - - const colorLerp = smoothstep(float(0.0), float(0.8), t); - const rootCol = groundCol.mul(float(1.5)); - const baseCol = mix(rootCol, groundCol, colorLerp).toVar("grassBaseCol"); - - // Half-lambert anime shade — same as terrain shader so grass - // blends visually at the root boundary. - const sunDir = normalize(vec3(uSunDir)); - const NdotL = dot(vec3(0, 1, 0), sunDir); - const halfLambert = add(mul(NdotL, float(0.5)), float(0.5)); - const shadeFactor = sub(float(1.0), halfLambert); - const coolTint = vec3(...TERRAIN_SHADE.TINT_COLOR); - const tintedBase = mul(baseCol, coolTint); - baseCol.assign( - mix( - baseCol, - tintedBase, - mul(shadeFactor, float(TERRAIN_SHADE.STRENGTH)), - ), + const terrainNormal = attribute("instanceGroundNormal", "vec3"); + const t = uv().y; + const tipCol = groundCol.mul(1.4); + const bladeCol = mix( + groundCol, + tipCol, + smoothstep(float(0.0), float(1.0), t), ); - - return baseCol; + const animeCol = applyAnimeShade(bladeCol, terrainNormal, uSunDir); + const lit = applyCustomLighting( + animeCol, + terrainNormal, + uSunDir, + uDayIntensity, + shadeColor, + ); + return vec4(lit, float(1.0)); })(); return mat; diff --git a/packages/shared/src/systems/shared/world/LightingConfig.ts b/packages/shared/src/systems/shared/world/LightingConfig.ts index 09dfac63c..c7078847c 100644 --- a/packages/shared/src/systems/shared/world/LightingConfig.ts +++ b/packages/shared/src/systems/shared/world/LightingConfig.ts @@ -87,7 +87,7 @@ export const SUN_SHADE = { export const NIGHT = { /** Master night brightness (0 = pitch black, 1 = full day brightness). * Controls both the tree shader nightDim floor and scene light intensity bases. */ - BRIGHTNESS: 0.8, + BRIGHTNESS: 0.5, } as const; // ============================================================================ @@ -149,7 +149,16 @@ export const EXPOSURE = { // SHARED SUN-SHADE SHADER FUNCTION (TSL) // ============================================================================ -import { sub, mul, float, mix, vec3 } from "../../../extras/three/three"; +import { + sub, + mul, + float, + mix, + vec3, + add, + dot, + normalize, +} from "../../../extras/three/three"; /** * Apply a day/night sky-tint to a colour in TSL. @@ -171,6 +180,35 @@ export function applySunShade(color: any, dayIntensity: any, shadeColor: any) { return mix(color, tinted, shadeFactor); } +/** + * Shared custom lighting for terrain and grass — bypasses PBR entirely. + * Applies day/night tint + half-lambert directional shading + ambient floor. + * Both terrain and grass call this with the same parameters to guarantee + * identical pixel output at the same world position. + * + * @param albedo - Pre-lit base color (after anime shade + vertex lights) + * @param normal - Surface normal (terrain slope) + * @param sunDirNode - Sun direction uniform + * @param dayIntensity - Day intensity uniform [0,1] + * @param shadeColor - Night tint color node + */ +export function applyCustomLighting( + albedo: any, + normal: any, + sunDirNode: any, + dayIntensity: any, + shadeColor: any, +): any { + const tinted = applySunShade(albedo, dayIntensity, shadeColor); + const sunDir = normalize(vec3(sunDirNode)); + const NdotL = dot(normal, sunDir); + const halfLambert = add(mul(NdotL, float(0.5)), float(0.5)); + const ambient = float(0.35); + const lighting = add(ambient, mul(float(0.65), halfLambert)); + const nightDim = mix(float(NIGHT.BRIGHTNESS), float(1.0), dayIntensity); + return mul(tinted, mul(lighting, nightDim)); +} + // ============================================================================ // FOG COLORS (day / night scene fog — separate from sky-fog render target) // ============================================================================ diff --git a/packages/shared/src/systems/shared/world/TerrainShader.ts b/packages/shared/src/systems/shared/world/TerrainShader.ts index fa42d4712..b2209cd3c 100644 --- a/packages/shared/src/systems/shared/world/TerrainShader.ts +++ b/packages/shared/src/systems/shared/world/TerrainShader.ts @@ -50,7 +50,7 @@ import { getRoadInfluenceTextureState } from "./RoadInfluenceMask"; import { getLamppostLightTextureState } from "./LamppostLightMask"; import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants"; import { FOG_NEAR_SQ, FOG_FAR_SQ, fogRenderTarget } from "./FogConfig"; -import { SUN_LIGHT, SUN_SHADE } from "./LightingConfig"; +import { SUN_LIGHT, SUN_SHADE, applyCustomLighting } from "./LightingConfig"; export const TERRAIN_SHADER_CONSTANTS = { TRIPLANAR_SCALE: 0.5, @@ -75,10 +75,30 @@ export const TERRAIN_SHADER_CONSTANTS = { export const TERRAIN_SHADE = { TINT_COLOR: SUN_SHADE.TINT_COLOR, STRENGTH: 0.7, - FRESNEL_POWER: 3.0, - FRESNEL_INTENSITY: 0.2, }; +/** + * Shared TSL anime shading: half-lambert cool tint. + * Used by both terrain and grass so the shading stays in sync. + */ +export function applyAnimeShade( + baseColor: any, + normal: any, + sunDirNode: any, +): any { + const sDir = normalize(vec3(sunDirNode)); + const NdotL = dot(normal, sDir); + const halfLambert = add(mul(NdotL, float(0.5)), float(0.5)); + const shadeFactor = sub(float(1.0), halfLambert); + const coolTint = vec3(...TERRAIN_SHADE.TINT_COLOR); + const tintedBase = mul(baseColor, coolTint); + return mix( + baseColor, + tintedBase, + mul(shadeFactor, float(TERRAIN_SHADE.STRENGTH)), + ); +} + const TERRAIN_TEX_TILE = 0.3; const TERRAIN_TEX_DIR = "textures/terrain-biomes"; @@ -704,8 +724,19 @@ export function calculateSlope( type RGB = { r: number; g: number; b: number }; +// sRGB channel → linear. GPU auto-converts SRGBColorSpace textures to linear +// before any math. All CPU constants must also be in linear so the blending +// (mixRGB / blendBiome / darken) produces identical results to the GPU shader. +function srgbCh(c: number): number { + return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); +} +const lin = (r: number, g: number, b: number): RGB => ({ + r: srgbCh(r), + g: srgbCh(g), + b: srgbCh(b), +}); + // GPU shader darkens textures by multiplying with TEX_DARKEN (0.65). -// CPU dark variants = base × 0.65 to match. const TEX_DARKEN_CPU = 0.65; const darken = (c: RGB): RGB => ({ r: c.r * TEX_DARKEN_CPU, @@ -713,41 +744,41 @@ const darken = (c: RGB): RGB => ({ b: c.b * TEX_DARKEN_CPU, }); -// Colors matched to actual texture average colors (sampled from PNGs). -// Tundra/snow biome — snowgrass.png avg (0.79, 0.80, 0.80) -const _TUNDRA_GRASS: RGB = { r: 0.79, g: 0.8, b: 0.8 }; +// Texture-matching constants: sRGB fallback values → linear via lin(). +// Non-texture constants: raw linear values matching GPU vec3() exactly. + +// Tundra/snow — snowgrass.png avg sRGB (0.79, 0.80, 0.80) +const _TUNDRA_GRASS: RGB = lin(0.79, 0.8, 0.8); const _TUNDRA_GRASS_DARK: RGB = darken(_TUNDRA_GRASS); const _TUNDRA_GRASS_HIGH: RGB = { r: 0.68, g: 0.72, b: 0.78 }; const _TUNDRA_VARIATION: RGB = { r: 0.6, g: 0.64, b: 0.7 }; -// snowdirt.png avg (0.78, 0.82, 0.84) -const _TUNDRA_DIRT: RGB = { r: 0.78, g: 0.82, b: 0.84 }; +// snowdirt.png avg sRGB (0.78, 0.82, 0.84) +const _TUNDRA_DIRT: RGB = lin(0.78, 0.82, 0.84); const _TUNDRA_DIRT_DARK: RGB = darken(_TUNDRA_DIRT); -// snowdirt.png used for cliff too -const _TUNDRA_CLIFF: RGB = { r: 0.78, g: 0.82, b: 0.84 }; +const _TUNDRA_CLIFF: RGB = lin(0.78, 0.82, 0.84); const _TUNDRA_CLIFF_DARK: RGB = darken(_TUNDRA_CLIFF); -// Forest biome — grass.png avg (0.39, 0.63, 0.20) -const _FOREST_GRASS: RGB = { r: 0.39, g: 0.63, b: 0.2 }; +// Forest — grass.png avg sRGB (0.39, 0.63, 0.20) +const _FOREST_GRASS: RGB = lin(0.39, 0.63, 0.2); const _FOREST_GRASS_DARK: RGB = darken(_FOREST_GRASS); const _FOREST_GRASS_HIGH: RGB = { r: 0.24, g: 0.45, b: 0.18 }; const _FOREST_VARIATION: RGB = { r: 0.15, g: 0.35, b: 0.1 }; -// dirt.png avg (0.82, 0.64, 0.34) -const _FOREST_DIRT: RGB = { r: 0.82, g: 0.64, b: 0.34 }; +// dirt.png avg sRGB (0.82, 0.64, 0.34) +const _FOREST_DIRT: RGB = lin(0.82, 0.64, 0.34); const _FOREST_DIRT_DARK: RGB = darken(_FOREST_DIRT); -// cliff.png avg (0.71, 0.67, 0.60) -const _FOREST_CLIFF: RGB = { r: 0.71, g: 0.67, b: 0.6 }; +// cliff.png avg sRGB (0.71, 0.67, 0.60) +const _FOREST_CLIFF: RGB = lin(0.71, 0.67, 0.6); const _FOREST_CLIFF_DARK: RGB = darken(_FOREST_CLIFF); -// Canyon/desert biome — desertGrass.png avg (0.51, 0.41, 0.28) -const _CANYON_SAND: RGB = { r: 0.51, g: 0.41, b: 0.28 }; +// Canyon/desert — desertGrass.png avg sRGB (0.51, 0.41, 0.28) +const _CANYON_SAND: RGB = lin(0.51, 0.41, 0.28); const _CANYON_SAND_DARK: RGB = darken(_CANYON_SAND); -const _CANYON_SAND_HIGH: RGB = { r: 0.42, g: 0.34, b: 0.22 }; -const _CANYON_VARIATION: RGB = { r: 0.45, g: 0.34, b: 0.2 }; -// desertDirt.png avg (0.54, 0.42, 0.32) -const _CANYON_ROCK: RGB = { r: 0.54, g: 0.42, b: 0.32 }; +const _CANYON_SAND_HIGH: RGB = { r: 0.62, g: 0.38, b: 0.22 }; +const _CANYON_VARIATION: RGB = { r: 0.58, g: 0.34, b: 0.16 }; +// desertDirt.png avg sRGB (0.54, 0.42, 0.32) +const _CANYON_ROCK: RGB = lin(0.54, 0.42, 0.32); const _CANYON_ROCK_DARK: RGB = darken(_CANYON_ROCK); -// desertDirt.png used for cliff too -const _CANYON_CLIFF: RGB = { r: 0.54, g: 0.42, b: 0.32 }; +const _CANYON_CLIFF: RGB = lin(0.54, 0.42, 0.32); const _CANYON_CLIFF_DARK: RGB = darken(_CANYON_CLIFF); const _CLIFF_TINT: RGB = { r: 0.28, g: 0.3, b: 0.36 }; @@ -780,6 +811,34 @@ function blendBiome( } function sampleNoiseCPU(worldX: number, worldZ: number, scale: number): number { + const tex = cachedNoiseTexture; + if (tex?.image?.data) { + const data = tex.image.data as Uint8Array; + const u = worldX * scale; + const v = worldZ * scale; + // Bilinear sample matching GPU's LinearFilter + RepeatWrapping + const px = (((u % 1) + 1) % 1) * NOISE_SIZE - 0.5; + const py = (((v % 1) + 1) % 1) * NOISE_SIZE - 0.5; + const x0 = Math.floor(px); + const y0 = Math.floor(py); + const fx = px - x0; + const fy = py - y0; + const ix0 = ((x0 % NOISE_SIZE) + NOISE_SIZE) % NOISE_SIZE; + const iy0 = ((y0 % NOISE_SIZE) + NOISE_SIZE) % NOISE_SIZE; + const ix1 = (ix0 + 1) % NOISE_SIZE; + const iy1 = (iy0 + 1) % NOISE_SIZE; + const v00 = data[(iy0 * NOISE_SIZE + ix0) * 4] / 255; + const v10 = data[(iy0 * NOISE_SIZE + ix1) * 4] / 255; + const v01 = data[(iy1 * NOISE_SIZE + ix0) * 4] / 255; + const v11 = data[(iy1 * NOISE_SIZE + ix1) * 4] / 255; + return ( + v00 * (1 - fx) * (1 - fy) + + v10 * fx * (1 - fy) + + v01 * (1 - fx) * fy + + v11 * fx * fy + ); + } + // Fallback: direct computation if texture not yet generated if (!cachedPerm) cachedPerm = createPermutation(12345); const u = worldX * scale; const v = worldZ * scale; @@ -963,6 +1022,7 @@ export type TerrainUniforms = { sunDirection: { value: THREE.Vector3 }; time: { value: number }; fogEnabled: { value: number }; // 1.0 = fog enabled, 0.0 = fog disabled (for minimap) + dayIntensity: { value: number }; // 0 = night, 1 = day // Vertex lighting uniforms (lampposts, etc.) vertexLightPositions: { value: THREE.Vector3 }[]; // Array of 8 light positions vertexLightColors: { value: THREE.Vector3 }[]; // Array of 8 light colors @@ -1364,37 +1424,13 @@ export function createTerrainMaterial(): THREE.Material & { const baseWithRoads = mix(variedColor, compactedRoadColor, roadInfluence); - // ============================================================================ - // HALF-LAMBERT ANIME SHADE (cool shadow tint — Genshin-style) - // Wraps N·L to [0,1] for soft wrapped lighting, then tints shadow side - // with cool blue-teal hue. Applied to albedo before PBR so the hue shift - // survives through subsequent lighting calculations. - // ============================================================================ - const sunDir = normalize(vec3(sunDirectionUniform)); - const NdotL = dot(worldNormal, sunDir); - const halfLambert = add(mul(NdotL, float(0.5)), float(0.5)); - const shadeFactor = sub(float(1.0), halfLambert); - const coolTint = vec3(...TERRAIN_SHADE.TINT_COLOR); - const tintedBase = mul(baseWithRoads, coolTint); - const shadedBase = mix( + // Half-lambert anime shade + fresnel rim (shared with grass) + const animeBase = applyAnimeShade( baseWithRoads, - tintedBase, - mul(shadeFactor, float(TERRAIN_SHADE.STRENGTH)), + worldNormal, + sunDirectionUniform, ); - // Fresnel rim highlight at grazing angles (subtle painterly edge glow) - const viewDir = normalize(sub(worldPos, cameraPosition)); - const rim = clamp( - add(float(1.0), dot(viewDir, worldNormal)), - float(0.0), - float(1.0), - ); - const fresnelRim = mul( - pow(rim, float(TERRAIN_SHADE.FRESNEL_POWER)), - float(TERRAIN_SHADE.FRESNEL_INTENSITY), - ); - const animeBase = add(shadedBase, vec3(fresnelRim, fresnelRim, fresnelRim)); - // ============================================================================ // VERTEX LIGHTING (lampposts, torches, etc.) // Simple additive point lights with smooth attenuation @@ -1543,21 +1579,28 @@ export function createTerrainMaterial(): THREE.Material & { const fogColor = fogTexNode.rgb; // === CREATE MATERIAL === - // Half-lambert anime shade (cool shadow tint + fresnel rim) is baked into - // the albedo above. PBR still applies Lambertian diffuse on top, which - // darkens the shadow side further — the hue shift in the albedo is what - // gives the Genshin-style warm-lit / cool-shadow look. + // Bypass PBR entirely — use applyCustomLighting for both terrain and grass + // so they produce identical pixels at the same world position. + const dayIntensityUniform = uniform(1.0); + const shadeColor = vec3(...SUN_SHADE.TINT_COLOR); + const material = new MeshStandardNodeMaterial(); - material.colorNode = litTerrain; + material.colorNode = vec3(0, 0, 0); material.roughness = 1.0; material.metalness = 0.0; material.side = THREE.FrontSide; material.fog = false; - // Fog applied AFTER PBR lighting (fog blends with sky, must be post-lit) material.outputNode = Fn(() => { - const litColor = output; - return vec4(mix(litColor.rgb, fogColor, fogFactor), litColor.a); + const lit = applyCustomLighting( + litTerrain, + worldNormal, + sunDirectionUniform, + dayIntensityUniform, + shadeColor, + ); + const fogged = mix(lit, fogColor, fogFactor); + return vec4(fogged, float(1.0)); })(); const terrainUniforms: TerrainUniforms = { @@ -1565,6 +1608,7 @@ export function createTerrainMaterial(): THREE.Material & { sunDirection: sunDirectionUniform as unknown as { value: THREE.Vector3 }, time: timeUniform, fogEnabled: fogEnabledUniform, + dayIntensity: dayIntensityUniform as unknown as { value: number }, // Vertex lighting arrays vertexLightPositions: vertexLightPositionUniforms.map( (u) => u as unknown as { value: THREE.Vector3 }, diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts index 71929ef72..2bf491b99 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -1975,13 +1975,11 @@ export class TerrainSystem extends System { } this.grassVisualManager = new GrassVisualManager( grassContainer, - this.world, (x: number, z: number) => this.getHeightAt(x, z), this.CONFIG.WATER_THRESHOLD, (wx: number, wz: number) => this.calculateRoadInfluenceAtVertex(wx, wz, 0, 0), - computeTerrainColorCPU, - (wx: number, wz: number) => this.computeBiomeWeightsAtPosition(wx, wz), + (wx: number, wz: number) => this.getTerrainColorAt(wx, wz), ); // Wire terrain, water, grass managers to the same quad-tree via composite @@ -4586,6 +4584,63 @@ export class TerrainSystem extends System { return { biomeWeightMap, totalWeight }; } + /** + * Single entry point for getting the terrain base color and grass weight + * at a world position. Encapsulates height, slope, biome-weight lookups + * and the CPU terrain-color computation so callers (e.g. GrassVisualManager) + * don't need any biome knowledge. + */ + getTerrainColorAt( + wx: number, + wz: number, + ): { + r: number; + g: number; + b: number; + grassWeight: number; + nx: number; + ny: number; + nz: number; + } { + const height = this.getHeightAt(wx, wz); + + const sd = 0.5; + const hL = this.getHeightAt(wx - sd, wz); + const hR = this.getHeightAt(wx + sd, wz); + const hD = this.getHeightAt(wx, wz - sd); + const hU = this.getHeightAt(wx, wz + sd); + const dhdx = (hR - hL) / (2 * sd); + const dhdz = (hU - hD) / (2 * sd); + const gradMag = Math.sqrt(dhdx * dhdx + dhdz * dhdz); + const normalY = 1 / Math.sqrt(1 + gradMag * gradMag); + const slope = 1 - normalY; + + // Surface normal from height gradient (same derivation as terrain geometry) + const rnx = -dhdx; + const rnz = -dhdz; + const rny = 1.0; + const nLen = Math.sqrt(rnx * rnx + rny * rny + rnz * rnz); + const invLen = 1 / nLen; + + const { biomeWeightMap, totalWeight } = this.computeBiomeWeightsAtPosition( + wx, + wz, + ); + const invW = totalWeight > 0 ? 1 / totalWeight : 1; + const forestW = (biomeWeightMap.get("forest") || 0) * invW; + const canyonW = (biomeWeightMap.get("canyon") || 0) * invW; + + const color = computeTerrainColorCPU( + wx, + wz, + height, + slope, + forestW, + canyonW, + ); + return { ...color, nx: rnx * invLen, ny: rny * invLen, nz: rnz * invLen }; + } + computeBiomeWeightsByPosition( worldX: number, worldZ: number, @@ -5131,9 +5186,10 @@ export class TerrainSystem extends System { if (materialWithUniforms) { materialWithUniforms.terrainUniforms.time.value = this.terrainTime; - // Sync sun direction from Environment system + // Sync sun direction + day intensity from Environment system const env = this.world.getSystem("environment") as { lightDirection?: THREE.Vector3; + getDayIntensity?: () => number; } | null; if (env?.lightDirection) { materialWithUniforms.terrainUniforms.sunDirection.value @@ -5145,6 +5201,13 @@ export class TerrainSystem extends System { ); } } + if (env?.getDayIntensity) { + const dayVal = env.getDayIntensity(); + materialWithUniforms.terrainUniforms.dayIntensity.value = dayVal; + if (this.grassVisualManager) { + this.grassVisualManager.updateDayIntensity(dayVal); + } + } // Fog texture is the shared fogRenderTarget from FogConfig — no sync needed From f440d06b4eb82190194149732c9621914fd044ba Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Fri, 27 Mar 2026 17:37:55 +0800 Subject: [PATCH 55/71] chore: temporarily disable road system Made-with: Cursor --- packages/shared/src/runtime/createClientWorld.ts | 2 +- packages/shared/src/runtime/createServerWorld.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/runtime/createClientWorld.ts b/packages/shared/src/runtime/createClientWorld.ts index 7d1fbc0ad..b6a33a511 100644 --- a/packages/shared/src/runtime/createClientWorld.ts +++ b/packages/shared/src/runtime/createClientWorld.ts @@ -312,7 +312,7 @@ export function createClientWorld() { world.register("towns", TownSystem); world.register("pois", POISystem); - world.register("roads", RoadNetworkSystem); + // world.register("roads", RoadNetworkSystem); // ============================================================================ // BUILDING RENDERING SYSTEM diff --git a/packages/shared/src/runtime/createServerWorld.ts b/packages/shared/src/runtime/createServerWorld.ts index 56f512cef..8ac373b2f 100644 --- a/packages/shared/src/runtime/createServerWorld.ts +++ b/packages/shared/src/runtime/createServerWorld.ts @@ -100,7 +100,7 @@ export async function createServerWorld(): Promise { world.register("towns", TownSystem); world.register("pois", POISystem); - world.register("roads", RoadNetworkSystem); + // world.register("roads", RoadNetworkSystem); // ============================================================================ // RPG GAME SYSTEMS From abda52f5dca6972d224640013766d7f4e3095119 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Fri, 27 Mar 2026 18:41:04 +0800 Subject: [PATCH 56/71] feat: biome-specific grass config, fresnel rim, distance fade, and LOD optimization - Add BiomeGrassConfig with per-biome density, slope, height, and patchiness - Blend grass configs by biome weight in TerrainSystem.getTerrainColorAt - Noise-based patchy grass distribution (uniform for forest, clustered for canyon/tundra) - Restore fresnel rim highlight to applyAnimeShade (always-on, shared by terrain and grass) - Add GPU distance fade: grass shrinks into ground from 350-500m - Prune far chunks beyond MAX_RENDER_DISTANCE to free GPU memory - Tighten LOD tiers: full detail 0-80m, medium 80-200m, low 200-500m - Remove global MIN_GRASS_WEIGHT in favor of per-biome minGrassWeight Made-with: Cursor --- .../shared/world/GrassVisualManager.ts | 111 ++++++++++++++---- .../systems/shared/world/TerrainBiomeTypes.ts | 56 +++++++++ .../src/systems/shared/world/TerrainShader.ts | 18 ++- .../src/systems/shared/world/TerrainSystem.ts | 57 ++++++++- 4 files changed, 216 insertions(+), 26 deletions(-) diff --git a/packages/shared/src/systems/shared/world/GrassVisualManager.ts b/packages/shared/src/systems/shared/world/GrassVisualManager.ts index 7fca4b6e9..249ae77cc 100644 --- a/packages/shared/src/systems/shared/world/GrassVisualManager.ts +++ b/packages/shared/src/systems/shared/world/GrassVisualManager.ts @@ -24,6 +24,10 @@ import THREE, { vec4, mix, smoothstep, + sub, + dot, + clamp, + modelWorldMatrix, } from "../../../extras/three/three"; import { SUN_LIGHT, SUN_SHADE, applyCustomLighting } from "./LightingConfig"; import { applyAnimeShade } from "./TerrainShader"; @@ -74,19 +78,21 @@ export const GRASS_CONFIG = { /** Gradient power curve (higher = root color persists longer up the blade) */ GRADIENT_FALLOFF: 1.7, - // -- Terrain filters ------------------------------------------------------ - /** Minimum grassWeight from terrain color function to place a clump (0-1) */ - MIN_GRASS_WEIGHT: 0.3, + // -- Distance limits ------------------------------------------------------- + /** Grass starts shrinking at this distance (meters) */ + FADE_START: 350, + /** Grass fully invisible / chunks pruned beyond this distance */ + MAX_RENDER_DISTANCE: 500, // -- LOD tiers (by distance from camera) ------------------------------------ LOD_TIERS: [ - { maxDistance: 200, bladesPerClump: 24, bladeSegments: 3, spacingMul: 1.0 }, - { maxDistance: 400, bladesPerClump: 12, bladeSegments: 2, spacingMul: 2.0 }, + { maxDistance: 80, bladesPerClump: 24, bladeSegments: 3, spacingMul: 1.0 }, + { maxDistance: 200, bladesPerClump: 12, bladeSegments: 2, spacingMul: 2.0 }, { - maxDistance: Infinity, - bladesPerClump: 6, + maxDistance: 500, + bladesPerClump: 4, bladeSegments: 1, - spacingMul: 4.0, + spacingMul: 5.0, }, ], LOD_HYSTERESIS: 0.1, @@ -254,12 +260,15 @@ export class GrassVisualManager implements QuadTreeListener { g: number; b: number; grassWeight: number; + grassPlacement: number; + grassHeightScale: number; nx: number; ny: number; nz: number; }; private sunDirUniform: ReturnType>; private dayIntensityUniform: ReturnType>; + private playerPosUniform: ReturnType>; private waterThreshold: number; private chunks = new Map(); private material: MeshStandardNodeMaterial; @@ -281,7 +290,14 @@ export class GrassVisualManager implements QuadTreeListener { getTerrainColorAt: ( wx: number, wz: number, - ) => { r: number; g: number; b: number; grassWeight: number }, + ) => { + r: number; + g: number; + b: number; + grassWeight: number; + grassPlacement: number; + grassHeightScale: number; + }, ) { this.container = container; this.getHeightAt = getHeightAt; @@ -322,6 +338,7 @@ export class GrassVisualManager implements QuadTreeListener { update(playerX: number, playerZ: number, camera?: THREE.Camera): void { this.playerX = playerX; this.playerZ = playerZ; + this.playerPosUniform.value.set(playerX, 0, playerZ); // Drain pending queue — build at most N new chunks per frame let built = 0; @@ -347,7 +364,21 @@ export class GrassVisualManager implements QuadTreeListener { const tiers = GRASS_CONFIG.LOD_TIERS; const hysteresis = GRASS_CONFIG.LOD_HYSTERESIS; - for (const [, chunk] of this.chunks) { + const maxDistSq = + GRASS_CONFIG.MAX_RENDER_DISTANCE * GRASS_CONFIG.MAX_RENDER_DISTANCE; + const pruneKeys: string[] = []; + + for (const [key, chunk] of this.chunks) { + const dx = chunk.node.centerX - playerX; + const dz = chunk.node.centerZ - playerZ; + const distSq = dx * dx + dz * dz; + + if (distSq > maxDistSq) { + pruneKeys.push(key); + chunk.mesh.visible = false; + continue; + } + chunk.mesh.visible = this.frustum.intersectsBox(chunk.box); if ( !chunk.mesh.visible || @@ -355,9 +386,7 @@ export class GrassVisualManager implements QuadTreeListener { ) continue; - const dx = chunk.node.centerX - playerX; - const dz = chunk.node.centerZ - playerZ; - const dist = Math.sqrt(dx * dx + dz * dz); + const dist = Math.sqrt(distSq); const desiredLod = this.getLodLevel(chunk.node); if (desiredLod !== chunk.lodLevel) { @@ -381,12 +410,28 @@ export class GrassVisualManager implements QuadTreeListener { } } } + + for (const key of pruneKeys) { + const chunk = this.chunks.get(key); + if (chunk) { + if (chunk.mesh.parent) chunk.mesh.parent.remove(chunk.mesh); + chunk.mesh.geometry.dispose(); + this.chunks.delete(key); + } + } } // -- QuadTreeListener ----------------------------------------------------- onNodeNeedsGeometry(node: TerrainQuadNode): void { if (!node.isMaxDepth) return; + const dx = node.centerX - this.playerX; + const dz = node.centerZ - this.playerZ; + if ( + dx * dx + dz * dz > + GRASS_CONFIG.MAX_RENDER_DISTANCE * GRASS_CONFIG.MAX_RENDER_DISTANCE + ) + return; const key = this.chunkKey(node); if (this.chunks.has(key)) return; if (this.pendingNodes.some((p) => p.node === node)) return; @@ -577,15 +622,16 @@ export class GrassVisualManager implements QuadTreeListener { r, g, b, - grassWeight: rawGW, + grassPlacement: rawGP, + grassHeightScale, nx, ny, nz, } = this.getTerrainColorAt(wx, wz); - const grassWeight = Math.max(0, rawGW - roadInf); + const grassPlacement = Math.max(0, rawGP - roadInf); - if (grassWeight < GRASS_CONFIG.MIN_GRASS_WEIGHT) continue; - if (clumpRng > grassWeight) continue; + if (grassPlacement <= 0) continue; + if (clumpRng > grassPlacement) continue; offsets[count * 3] = lx; offsets[count * 3 + 1] = ty; @@ -593,8 +639,9 @@ export class GrassVisualManager implements QuadTreeListener { const rotation = rng() * Math.PI * 2; const scale = - GRASS_CONFIG.SCALE_MIN + - clumpRng * (GRASS_CONFIG.SCALE_MAX - GRASS_CONFIG.SCALE_MIN); + (GRASS_CONFIG.SCALE_MIN + + clumpRng * (GRASS_CONFIG.SCALE_MAX - GRASS_CONFIG.SCALE_MIN)) * + grassHeightScale; rotScaleHash[count * 3] = rotation; rotScaleHash[count * 3 + 1] = scale; rotScaleHash[count * 3 + 2] = clumpRng; @@ -640,6 +687,11 @@ export class GrassVisualManager implements QuadTreeListener { new THREE.Vector3(...SUN_LIGHT.DEFAULT_DIRECTION), ); this.dayIntensityUniform = uniform(1.0); + this.playerPosUniform = uniform(new THREE.Vector3(0, 0, 0)); + + const uPlayerPos = this.playerPosUniform; + const uFadeStart = float(GRASS_CONFIG.FADE_START); + const uFadeEnd = float(GRASS_CONFIG.MAX_RENDER_DISTANCE); mat.positionNode = Fn(() => { const localPos = positionLocal.toVar("gp"); @@ -651,9 +703,26 @@ export class GrassVisualManager implements QuadTreeListener { const t = uv().y; - // Scale entire clump + // Compute world-space XZ of the instance base for distance fade. + // offset is chunk-local; modelWorldMatrix translates by mesh.position (chunk center). + const worldBase = modelWorldMatrix.mul( + vec4(offset.x, float(0), offset.z, float(1.0)), + ); + const toPlayer = sub( + vec3(worldBase.x, float(0), worldBase.z), + vec3(uPlayerPos.x, float(0), uPlayerPos.z), + ); + const distSq = dot(toPlayer, toPlayer); + const dist = pow(distSq, float(0.5)); + const fadeFactor = clamp( + sub(float(1.0), smoothstep(uFadeStart, uFadeEnd, dist)), + float(0.0), + float(1.0), + ); + + // Scale entire clump (with distance fade on Y) localPos.x.assign(localPos.x.mul(scale)); - localPos.y.assign(localPos.y.mul(scale)); + localPos.y.assign(localPos.y.mul(scale).mul(fadeFactor)); localPos.z.assign(localPos.z.mul(scale)); // Rotate entire clump around Y — snapshot x/z first because TSL assign diff --git a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts index 8b413e648..e096db627 100644 --- a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts +++ b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts @@ -112,6 +112,62 @@ export function getTreeConfigForBiome(biomeId: string): BiomeTreeConfig { return BIOME_TREE_CONFIGS[biomeId as BiomeType] ?? FOREST_TREE_CONFIG; } +// --------------------------------------------------------------------------- +// Per-biome grass configs +// --------------------------------------------------------------------------- + +export interface BiomeGrassConfig { + /** Overall density multiplier (0 = no grass, 1 = full density) */ + density: number; + /** Max terrain slope (0-1, same metric as GPU shader) for grass placement */ + maxSlope: number; + /** Minimum grassWeight from terrain color to allow placement */ + minGrassWeight: number; + /** Blade height scale relative to global BLADE_HEIGHT_MIN/MAX */ + heightScale: number; + /** Patchiness (0 = uniform spread, 1 = highly clustered islands) */ + patchiness: number; + /** World-space noise frequency for patch mask (higher = smaller patches) */ + patchScale: number; +} + +const FOREST_GRASS_CONFIG: BiomeGrassConfig = { + density: 1.0, + maxSlope: 0.4, + minGrassWeight: 0.8, + heightScale: 1.0, + patchiness: 0.0, + patchScale: 0.02, +}; + +const CANYON_GRASS_CONFIG: BiomeGrassConfig = { + density: 0.15, + maxSlope: 0.15, + minGrassWeight: 0.8, + heightScale: 0.7, + patchiness: 0.8, + patchScale: 0.015, +}; + +const TUNDRA_GRASS_CONFIG: BiomeGrassConfig = { + density: 0.5, + maxSlope: 0.3, + minGrassWeight: 0.8, + heightScale: 0.6, + patchiness: 0.6, + patchScale: 0.018, +}; + +const BIOME_GRASS_CONFIGS: Record = { + [BiomeType.Forest]: FOREST_GRASS_CONFIG, + [BiomeType.Canyon]: CANYON_GRASS_CONFIG, + [BiomeType.Tundra]: TUNDRA_GRASS_CONFIG, +}; + +export function getGrassConfigForBiome(biomeId: string): BiomeGrassConfig { + return BIOME_GRASS_CONFIGS[biomeId as BiomeType] ?? FOREST_GRASS_CONFIG; +} + /** Biome IDs whose tree config has enableSnow set to true. */ export const SNOW_BIOMES: ReadonlySet = new Set( Object.entries(BIOME_TREE_CONFIGS) diff --git a/packages/shared/src/systems/shared/world/TerrainShader.ts b/packages/shared/src/systems/shared/world/TerrainShader.ts index b2209cd3c..de32793a2 100644 --- a/packages/shared/src/systems/shared/world/TerrainShader.ts +++ b/packages/shared/src/systems/shared/world/TerrainShader.ts @@ -75,10 +75,12 @@ export const TERRAIN_SHADER_CONSTANTS = { export const TERRAIN_SHADE = { TINT_COLOR: SUN_SHADE.TINT_COLOR, STRENGTH: 0.7, + FRESNEL_POWER: 3.0, + FRESNEL_INTENSITY: 0.2, }; /** - * Shared TSL anime shading: half-lambert cool tint. + * Shared TSL anime shading: half-lambert cool tint + fresnel rim highlight. * Used by both terrain and grass so the shading stays in sync. */ export function applyAnimeShade( @@ -92,11 +94,23 @@ export function applyAnimeShade( const shadeFactor = sub(float(1.0), halfLambert); const coolTint = vec3(...TERRAIN_SHADE.TINT_COLOR); const tintedBase = mul(baseColor, coolTint); - return mix( + const shaded = mix( baseColor, tintedBase, mul(shadeFactor, float(TERRAIN_SHADE.STRENGTH)), ); + + const viewDir = normalize(sub(positionWorld, cameraPosition)); + const rim = clamp( + add(float(1.0), dot(viewDir, normal)), + float(0.0), + float(1.0), + ); + const fresnelRim = mul( + pow(rim, float(TERRAIN_SHADE.FRESNEL_POWER)), + float(TERRAIN_SHADE.FRESNEL_INTENSITY), + ); + return add(shaded, vec3(fresnelRim, fresnelRim, fresnelRim)); } const TERRAIN_TEX_TILE = 0.3; diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts index 2bf491b99..f99ae4bde 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -75,7 +75,10 @@ import { // generatePlants, // DISABLED - plants not working/looking good yet type ResourceGenerationContext, } from "./BiomeResourceGenerator"; -import { getTreeConfigForBiome } from "./TerrainBiomeTypes"; +import { + getTreeConfigForBiome, + getGrassConfigForBiome, +} from "./TerrainBiomeTypes"; import { setProcgenRockWorld, addRockInstance, @@ -4598,6 +4601,8 @@ export class TerrainSystem extends System { g: number; b: number; grassWeight: number; + grassPlacement: number; + grassHeightScale: number; nx: number; ny: number; nz: number; @@ -4615,7 +4620,6 @@ export class TerrainSystem extends System { const normalY = 1 / Math.sqrt(1 + gradMag * gradMag); const slope = 1 - normalY; - // Surface normal from height gradient (same derivation as terrain geometry) const rnx = -dhdx; const rnz = -dhdz; const rny = 1.0; @@ -4629,6 +4633,7 @@ export class TerrainSystem extends System { const invW = totalWeight > 0 ? 1 / totalWeight : 1; const forestW = (biomeWeightMap.get("forest") || 0) * invW; const canyonW = (biomeWeightMap.get("canyon") || 0) * invW; + const tundraW = 1 - forestW - canyonW; const color = computeTerrainColorCPU( wx, @@ -4638,7 +4643,53 @@ export class TerrainSystem extends System { forestW, canyonW, ); - return { ...color, nx: rnx * invLen, ny: rny * invLen, nz: rnz * invLen }; + + const tCfg = getGrassConfigForBiome(BiomeType.Tundra); + const fCfg = getGrassConfigForBiome(BiomeType.Forest); + const cCfg = getGrassConfigForBiome(BiomeType.Canyon); + + const maxSlope = + tCfg.maxSlope * tundraW + + fCfg.maxSlope * forestW + + cCfg.maxSlope * canyonW; + const minGW = + tCfg.minGrassWeight * tundraW + + fCfg.minGrassWeight * forestW + + cCfg.minGrassWeight * canyonW; + const density = + tCfg.density * tundraW + fCfg.density * forestW + cCfg.density * canyonW; + const grassHeightScale = + tCfg.heightScale * tundraW + + fCfg.heightScale * forestW + + cCfg.heightScale * canyonW; + const patchiness = + tCfg.patchiness * tundraW + + fCfg.patchiness * forestW + + cCfg.patchiness * canyonW; + const patchScale = + tCfg.patchScale * tundraW + + fCfg.patchScale * forestW + + cCfg.patchScale * canyonW; + + const slopeOk = slope <= maxSlope ? 1.0 : 0.0; + const weightOk = color.grassWeight >= minGW ? 1.0 : 0.0; + + // Noise-based patch mask: patchiness 0 = uniform, 1 = tight clusters + const patchThreshold = patchiness * 2 - 1; // maps [0,1] -> [-1,1] + const noiseVal = this.noise.simplex2D(wx * patchScale, wz * patchScale); + const patchMask = noiseVal > patchThreshold ? 1.0 : 0.0; + + const grassPlacement = + color.grassWeight * density * slopeOk * weightOk * patchMask; + + return { + ...color, + grassPlacement, + grassHeightScale, + nx: rnx * invLen, + ny: rny * invLen, + nz: rnz * invLen, + }; } computeBiomeWeightsByPosition( From f1ac41d33ab2e2a7d254abd26273d5c6a67b506c Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Fri, 27 Mar 2026 21:34:55 +0800 Subject: [PATCH 57/71] feat: offload grass instance generation to web worker Move all CPU-intensive grass computation (terrain height, biome color, road influence, placement probability) into GrassWorker running off the main thread, eliminating FPS hiccups during chunk generation and LOD transitions. Main thread only creates InstancedMesh from pre-computed Float32Arrays returned via zero-copy transfer. - Rewrite GrassWorker with full terrain pipeline (shared builders) - GrassVisualManager dispatches to worker pool, sync fallback kept - LOD transitions keep old mesh visible until replacement arrives - Set player position before quad-tree update for correct initial LOD - TerrainSystem passes worker config, biome data, road segments Made-with: Cursor --- .../shared/world/GrassVisualManager.ts | 304 ++++++- .../src/systems/shared/world/TerrainSystem.ts | 150 +++- .../shared/src/utils/workers/GrassWorker.ts | 769 ++++++++++++------ 3 files changed, 951 insertions(+), 272 deletions(-) diff --git a/packages/shared/src/systems/shared/world/GrassVisualManager.ts b/packages/shared/src/systems/shared/world/GrassVisualManager.ts index 249ae77cc..5bd8a8d9c 100644 --- a/packages/shared/src/systems/shared/world/GrassVisualManager.ts +++ b/packages/shared/src/systems/shared/world/GrassVisualManager.ts @@ -30,9 +30,17 @@ import THREE, { modelWorldMatrix, } from "../../../extras/three/three"; import { SUN_LIGHT, SUN_SHADE, applyCustomLighting } from "./LightingConfig"; -import { applyAnimeShade } from "./TerrainShader"; +import { applyAnimeShade, TERRAIN_SHADER_CONSTANTS } from "./TerrainShader"; import { MeshStandardNodeMaterial } from "three/webgpu"; import type { TerrainQuadNode, QuadTreeListener } from "./TerrainQuadTree"; +import { + getGrassWorkerPool, + terminateGrassWorkerPool, + type GrassWorkerInput, + type GrassWorkerOutput, +} from "../../../utils/workers/GrassWorker"; +import type { TerrainWorkerConfig } from "../../../utils/workers/TerrainWorker"; +import type { BiomeGrassConfigWorker } from "../../../utils/workers/GrassWorker"; // --------------------------------------------------------------------------- // Configuration — tweak these to control grass appearance & performance @@ -87,7 +95,7 @@ export const GRASS_CONFIG = { // -- LOD tiers (by distance from camera) ------------------------------------ LOD_TIERS: [ { maxDistance: 80, bladesPerClump: 24, bladeSegments: 3, spacingMul: 1.0 }, - { maxDistance: 200, bladesPerClump: 12, bladeSegments: 2, spacingMul: 2.0 }, + { maxDistance: 200, bladesPerClump: 12, bladeSegments: 2, spacingMul: 1.0 }, { maxDistance: 500, bladesPerClump: 4, @@ -248,6 +256,36 @@ interface GrassChunk { // GrassVisualManager // --------------------------------------------------------------------------- +/** Config data for the grass worker, passed from TerrainSystem at construction. */ +export interface GrassWorkerSetup { + terrainConfig: TerrainWorkerConfig; + seed: number; + biomeCenters: Array<{ + x: number; + z: number; + type: string; + influence: number; + }>; + biomes: Record< + string, + { heightModifier: number; color: { r: number; g: number; b: number } } + >; + grassConfigs: Record; + tileSize: number; + getRoadSegmentsForRegion: ( + minX: number, + minZ: number, + maxX: number, + maxZ: number, + ) => Array<{ + startX: number; + startZ: number; + endX: number; + endZ: number; + width: number; + }>; +} + export class GrassVisualManager implements QuadTreeListener { private container: THREE.Group; private getHeightAt: (x: number, z: number) => number; @@ -282,6 +320,13 @@ export class GrassVisualManager implements QuadTreeListener { private pendingNodes: { node: TerrainQuadNode; lod?: number }[] = []; private static readonly MAX_CHUNKS_PER_FRAME = 2; + private workerSetup: GrassWorkerSetup | null = null; + private workerInflight = new Set(); + private pendingLodSwap = new Map< + string, + { node: TerrainQuadNode; desiredLod: number } + >(); + constructor( container: THREE.Group, getHeightAt: (x: number, z: number) => number, @@ -298,18 +343,23 @@ export class GrassVisualManager implements QuadTreeListener { grassPlacement: number; grassHeightScale: number; }, + workerSetup?: GrassWorkerSetup, ) { this.container = container; this.getHeightAt = getHeightAt; this.waterThreshold = waterThreshold; this.getRoadInfluence = getRoadInfluence; this.getTerrainColorAt = getTerrainColorAt; + this.workerSetup = workerSetup ?? null; this.lodGeometries = GRASS_CONFIG.LOD_TIERS.map((tier) => createClumpGeometry(tier.bladesPerClump, tier.bladeSegments), ); this.material = this.createMaterial(); + const pool = getGrassWorkerPool(); + const workerStatus = pool ? "workers available" : "sync fallback"; + const tierDescs = GRASS_CONFIG.LOD_TIERS.map((t, i) => { const g = this.lodGeometries[i]; return ( @@ -321,12 +371,17 @@ export class GrassVisualManager implements QuadTreeListener { }); console.log( `[GrassVisualManager] ${tierDescs.length} LOD tiers | ` + - `spacing ${GRASS_CONFIG.CLUMP_SPACING}m | ${tierDescs.join(" | ")}`, + `spacing ${GRASS_CONFIG.CLUMP_SPACING}m | ${workerStatus} | ${tierDescs.join(" | ")}`, ); } // -- Public API ----------------------------------------------------------- + setPlayerPosition(x: number, z: number): void { + this.playerX = x; + this.playerZ = z; + } + updateLighting(sunDir: THREE.Vector3): void { this.sunDirUniform.value.copy(sunDir); } @@ -340,14 +395,19 @@ export class GrassVisualManager implements QuadTreeListener { this.playerZ = playerZ; this.playerPosUniform.value.set(playerX, 0, playerZ); - // Drain pending queue — build at most N new chunks per frame + // Drain pending queue — dispatch to worker or build sync (fallback only) let built = 0; + const pool = getGrassWorkerPool(); while ( this.pendingNodes.length > 0 && built < GrassVisualManager.MAX_CHUNKS_PER_FRAME ) { const { node, lod } = this.pendingNodes.shift()!; - if (!this.chunks.has(this.chunkKey(node))) { + const key = this.chunkKey(node); + if (this.chunks.has(key) || this.workerInflight.has(key)) continue; + if (pool && this.workerSetup) { + this.dispatchToWorker(node, key); + } else { this.createChunkMesh(node, lod); built++; } @@ -402,11 +462,23 @@ export class GrassVisualManager implements QuadTreeListener { desiredLod < chunk.lodLevel ? dist < threshold : dist > threshold; if (shouldSwitch) { - if (chunk.mesh.parent) chunk.mesh.parent.remove(chunk.mesh); - chunk.mesh.geometry.dispose(); - this.chunks.delete(this.chunkKey(chunk.node)); - this.createChunkMesh(chunk.node, desiredLod); - built++; + const nodeKey = this.chunkKey(chunk.node); + const workerPool = getGrassWorkerPool(); + if (workerPool && this.workerSetup) { + if (!this.workerInflight.has(nodeKey)) { + this.pendingLodSwap.set(nodeKey, { + node: chunk.node, + desiredLod, + }); + this.dispatchLodSwap(chunk.node, nodeKey, desiredLod); + } + } else { + if (chunk.mesh.parent) chunk.mesh.parent.remove(chunk.mesh); + chunk.mesh.geometry.dispose(); + this.chunks.delete(nodeKey); + this.createChunkMesh(chunk.node, desiredLod); + built++; + } } } } @@ -434,8 +506,213 @@ export class GrassVisualManager implements QuadTreeListener { return; const key = this.chunkKey(node); if (this.chunks.has(key)) return; - if (this.pendingNodes.some((p) => p.node === node)) return; - this.pendingNodes.push({ node }); + if (this.workerInflight.has(key)) return; + + const pool = getGrassWorkerPool(); + if (pool && this.workerSetup) { + this.dispatchToWorker(node, key); + } else { + if (!this.pendingNodes.some((p) => p.node === node)) { + this.pendingNodes.push({ node }); + } + } + } + + private dispatchLodSwap( + node: TerrainQuadNode, + key: string, + desiredLod: number, + ): void { + const ws = this.workerSetup!; + const pool = getGrassWorkerPool()!; + const tier = GRASS_CONFIG.LOD_TIERS[desiredLod]; + + const half = node.halfSize; + const roadSegments = ws.getRoadSegmentsForRegion( + node.centerX - half, + node.centerZ - half, + node.centerX + half, + node.centerZ + half, + ); + + const input: GrassWorkerInput = { + type: "generateGrassInstances", + chunkKey: key, + centerX: node.centerX, + centerZ: node.centerZ, + size: node.size, + spacingMul: tier.spacingMul, + config: ws.terrainConfig, + seed: ws.seed, + biomeCenters: ws.biomeCenters, + biomes: ws.biomes, + grassSeed: GRASS_CONFIG.SEED, + clumpSpacing: GRASS_CONFIG.CLUMP_SPACING, + scaleMin: GRASS_CONFIG.SCALE_MIN, + scaleMax: GRASS_CONFIG.SCALE_MAX, + waterThreshold: this.waterThreshold, + grassConfigs: ws.grassConfigs, + shaderConstants: { + NOISE_SCALE: TERRAIN_SHADER_CONSTANTS.NOISE_SCALE, + DISTORT_NOISE_SCALE: TERRAIN_SHADER_CONSTANTS.DISTORT_NOISE_SCALE, + VARIATION_NOISE_SCALE: TERRAIN_SHADER_CONSTANTS.VARIATION_NOISE_SCALE, + ROCK_DISTORT_STRENGTH: TERRAIN_SHADER_CONSTANTS.ROCK_DISTORT_STRENGTH, + HEIGHT_DISTORT_STRENGTH: + TERRAIN_SHADER_CONSTANTS.HEIGHT_DISTORT_STRENGTH, + DIRT_THRESHOLD: TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD, + SATURATION_BOOST: TERRAIN_SHADER_CONSTANTS.SATURATION_BOOST, + }, + roadSegments, + roadBlendWidth: 0.5, + tileSize: ws.tileSize, + }; + + this.workerInflight.add(key); + + pool + .execute(input) + .then((output: GrassWorkerOutput) => { + this.workerInflight.delete(key); + this.pendingLodSwap.delete(key); + + // Remove old chunk now that replacement is ready + const oldChunk = this.chunks.get(key); + if (oldChunk) { + if (oldChunk.mesh.parent) oldChunk.mesh.parent.remove(oldChunk.mesh); + oldChunk.mesh.geometry.dispose(); + this.chunks.delete(key); + } + + if (output.count === 0) return; + this.createChunkMeshFromWorkerData(node, output, desiredLod); + }) + .catch((err: unknown) => { + this.workerInflight.delete(key); + this.pendingLodSwap.delete(key); + console.warn( + `[GrassVisualManager] LOD swap worker failed for ${key}:`, + err, + ); + }); + } + + private dispatchToWorker(node: TerrainQuadNode, key: string): void { + const ws = this.workerSetup!; + const pool = getGrassWorkerPool()!; + const lod = this.getLodLevel(node); + const tier = GRASS_CONFIG.LOD_TIERS[lod]; + + const half = node.halfSize; + const roadSegments = ws.getRoadSegmentsForRegion( + node.centerX - half, + node.centerZ - half, + node.centerX + half, + node.centerZ + half, + ); + + const input: GrassWorkerInput = { + type: "generateGrassInstances", + chunkKey: key, + centerX: node.centerX, + centerZ: node.centerZ, + size: node.size, + spacingMul: tier.spacingMul, + config: ws.terrainConfig, + seed: ws.seed, + biomeCenters: ws.biomeCenters, + biomes: ws.biomes, + grassSeed: GRASS_CONFIG.SEED, + clumpSpacing: GRASS_CONFIG.CLUMP_SPACING, + scaleMin: GRASS_CONFIG.SCALE_MIN, + scaleMax: GRASS_CONFIG.SCALE_MAX, + waterThreshold: this.waterThreshold, + grassConfigs: ws.grassConfigs, + shaderConstants: { + NOISE_SCALE: TERRAIN_SHADER_CONSTANTS.NOISE_SCALE, + DISTORT_NOISE_SCALE: TERRAIN_SHADER_CONSTANTS.DISTORT_NOISE_SCALE, + VARIATION_NOISE_SCALE: TERRAIN_SHADER_CONSTANTS.VARIATION_NOISE_SCALE, + ROCK_DISTORT_STRENGTH: TERRAIN_SHADER_CONSTANTS.ROCK_DISTORT_STRENGTH, + HEIGHT_DISTORT_STRENGTH: + TERRAIN_SHADER_CONSTANTS.HEIGHT_DISTORT_STRENGTH, + DIRT_THRESHOLD: TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD, + SATURATION_BOOST: TERRAIN_SHADER_CONSTANTS.SATURATION_BOOST, + }, + roadSegments, + roadBlendWidth: 0.5, + tileSize: ws.tileSize, + }; + + this.workerInflight.add(key); + + pool + .execute(input) + .then((output: GrassWorkerOutput) => { + this.workerInflight.delete(key); + if (this.chunks.has(key)) return; + if (output.count === 0) return; + this.createChunkMeshFromWorkerData(node, output, lod); + }) + .catch((err: unknown) => { + this.workerInflight.delete(key); + console.warn( + `[GrassVisualManager] Worker failed for ${key}, falling back to sync:`, + err, + ); + if (!this.chunks.has(key)) { + this.pendingNodes.push({ node }); + } + }); + } + + private createChunkMeshFromWorkerData( + node: TerrainQuadNode, + data: GrassWorkerOutput, + lodLevel: number, + ): void { + const key = data.chunkKey; + if (this.chunks.has(key)) return; + if (data.count === 0) return; + + const geo = this.lodGeometries[lodLevel].clone(); + geo.setAttribute( + "instanceOffset", + new THREE.InstancedBufferAttribute(data.offsets, 3), + ); + geo.setAttribute( + "instanceRotScaleHash", + new THREE.InstancedBufferAttribute(data.rotScaleHash, 3), + ); + geo.setAttribute( + "instanceGroundColor", + new THREE.InstancedBufferAttribute(data.groundColors, 3), + ); + geo.setAttribute( + "instanceGroundNormal", + new THREE.InstancedBufferAttribute(data.groundNormals, 3), + ); + + const mesh = new THREE.InstancedMesh(geo, this.material, data.count); + mesh.position.set(node.centerX, 0, node.centerZ); + mesh.name = `GrassQT_${key}`; + mesh.frustumCulled = false; + mesh.receiveShadow = false; + mesh.castShadow = false; + mesh.userData = { type: "grass", walkable: false, clickable: false }; + + const identity = new THREE.Matrix4(); + for (let i = 0; i < data.count; i++) { + mesh.setMatrixAt(i, identity); + } + mesh.instanceMatrix.needsUpdate = true; + + const half = node.halfSize; + const box = new THREE.Box3( + new THREE.Vector3(node.centerX - half, -50, node.centerZ - half), + new THREE.Vector3(node.centerX + half, 200, node.centerZ + half), + ); + + this.container.add(mesh); + this.chunks.set(key, { nodeId: node.id, mesh, box, lodLevel, node }); } onNodeDestroyGeometry(node: TerrainQuadNode): void { @@ -449,6 +726,9 @@ export class GrassVisualManager implements QuadTreeListener { destroy(): void { this.pendingNodes.length = 0; + this.workerInflight.clear(); + this.pendingLodSwap.clear(); + terminateGrassWorkerPool(); for (const [, chunk] of this.chunks) { if (chunk.mesh.parent) chunk.mesh.parent.remove(chunk.mesh); chunk.mesh.geometry.dispose(); diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts index f99ae4bde..9b7bf405e 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -119,7 +119,11 @@ import { type VisualManagerTerrainProvider, } from "./TerrainVisualManager"; import { WaterVisualManager } from "./WaterVisualManager"; -import { GrassVisualManager } from "./GrassVisualManager"; +import { + GrassVisualManager, + type GrassWorkerSetup, +} from "./GrassVisualManager"; +import { terminateGrassWorkerPool } from "../../../utils/workers/GrassWorker"; import { CompositeQuadTreeListener } from "./TerrainQuadTree"; import type { QuadChunkWorkerConfig } from "../../../utils/workers/QuadChunkWorker"; import { terminateQuadChunkWorkerPool } from "../../../utils/workers/QuadChunkWorker"; @@ -1976,6 +1980,7 @@ export class TerrainSystem extends System { if (this.terrainContainer) { this.terrainContainer.parent?.add(grassContainer); } + const grassWorkerSetup = this.buildGrassWorkerSetup(); this.grassVisualManager = new GrassVisualManager( grassContainer, (x: number, z: number) => this.getHeightAt(x, z), @@ -1983,6 +1988,7 @@ export class TerrainSystem extends System { (wx: number, wz: number) => this.calculateRoadInfluenceAtVertex(wx, wz, 0, 0), (wx: number, wz: number) => this.getTerrainColorAt(wx, wz), + grassWorkerSetup, ); // Wire terrain, water, grass managers to the same quad-tree via composite @@ -2000,6 +2006,140 @@ export class TerrainSystem extends System { ); } + private buildGrassWorkerSetup(): GrassWorkerSetup { + const workerConfig: TerrainWorkerConfig = { + TILE_SIZE: this.CONFIG.TILE_SIZE, + TILE_RESOLUTION: this.CONFIG.TILE_RESOLUTION, + MAX_HEIGHT: MAX_HEIGHT, + BIOME_GAUSSIAN_COEFF: BIOME_CONFIG.gaussianCoeff, + BIOME_BOUNDARY_NOISE_SCALE: BIOME_CONFIG.boundaryNoiseScale, + BIOME_BOUNDARY_NOISE_AMOUNT: BIOME_CONFIG.boundaryNoiseAmount, + WATER_THRESHOLD: this.CONFIG.WATER_THRESHOLD, + WATER_LEVEL_NORMALIZED: WATER_LEVEL_NORMALIZED, + SHORELINE_THRESHOLD: SHORELINE_CONFIG.THRESHOLD, + SHORELINE_STRENGTH: SHORELINE_CONFIG.STRENGTH, + SHORELINE_MIN_SLOPE: SHORELINE_CONFIG.MIN_SLOPE, + SHORELINE_SLOPE_SAMPLE_DISTANCE: SHORELINE_CONFIG.SLOPE_SAMPLE_DISTANCE, + SHORELINE_LAND_BAND: SHORELINE_CONFIG.LAND_BAND, + SHORELINE_LAND_MAX_MULTIPLIER: SHORELINE_CONFIG.LAND_MAX_MULTIPLIER, + SHORELINE_UNDERWATER_BAND: SHORELINE_CONFIG.UNDERWATER_BAND, + UNDERWATER_DEPTH_MULTIPLIER: SHORELINE_CONFIG.UNDERWATER_DEPTH_MULTIPLIER, + }; + + const biomeData: Record< + string, + { heightModifier: number; color: { r: number; g: number; b: number } } + > = {}; + for (const [name, biome] of Object.entries(BIOMES)) { + const color = new THREE.Color(biome.color); + biomeData[name] = { + heightModifier: biome.terrainMultiplier || 1, + color: { r: color.r, g: color.g, b: color.b }, + }; + } + + const biomeCenters = this.terrainGenerator + .getBiomeSystem() + .getBiomeCenters() as BiomeCenter[]; + + const tCfg = getGrassConfigForBiome(BiomeType.Tundra); + const fCfg = getGrassConfigForBiome(BiomeType.Forest); + const cCfg = getGrassConfigForBiome(BiomeType.Canyon); + + const grassConfigs: Record< + string, + { + density: number; + maxSlope: number; + minGrassWeight: number; + heightScale: number; + patchiness: number; + patchScale: number; + } + > = { + [BiomeType.Tundra]: { ...tCfg }, + [BiomeType.Forest]: { ...fCfg }, + [BiomeType.Canyon]: { ...cCfg }, + }; + + const tileSize = this.CONFIG.TILE_SIZE; + + return { + terrainConfig: workerConfig, + seed: this.computeSeedFromWorldId(), + biomeCenters: biomeCenters.map((c) => ({ + x: c.x, + z: c.z, + type: c.type, + influence: c.influence, + })), + biomes: biomeData, + grassConfigs, + tileSize, + getRoadSegmentsForRegion: ( + minX: number, + minZ: number, + maxX: number, + maxZ: number, + ) => this.getWorldSpaceRoadSegmentsForRegion(minX, minZ, maxX, maxZ), + }; + } + + /** + * Collect road segments overlapping a world-space AABB, returned in + * world coordinates for the grass worker. + */ + private getWorldSpaceRoadSegmentsForRegion( + minX: number, + minZ: number, + maxX: number, + maxZ: number, + ): Array<{ + startX: number; + startZ: number; + endX: number; + endZ: number; + width: number; + }> { + this.roadNetworkSystem ??= this.world.getSystem("roads") as + | RoadNetworkSystem + | undefined; + if (!this.roadNetworkSystem) return []; + + const ts = this.CONFIG.TILE_SIZE; + const minTX = Math.floor(minX / ts); + const minTZ = Math.floor(minZ / ts); + const maxTX = Math.floor(maxX / ts); + const maxTZ = Math.floor(maxZ / ts); + + const result: Array<{ + startX: number; + startZ: number; + endX: number; + endZ: number; + width: number; + }> = []; + + for (let tx = minTX; tx <= maxTX; tx++) { + for (let tz = minTZ; tz <= maxTZ; tz++) { + const segs = this.roadNetworkSystem.getRoadSegmentsForTile(tx, tz); + const originX = tx * ts; + const originZ = tz * ts; + for (const seg of segs) { + result.push({ + startX: originX + seg.start.x, + startZ: originZ + seg.start.z, + endX: originX + seg.end.x, + endZ: originZ + seg.end.z, + width: seg.width, + }); + } + } + } + + return result; + } + private registerInstancedMeshes(): void { // Register tree mesh - now with automatic pooling (1000 visible max) const treeSize = { x: 1.2, y: 3.0, z: 1.2 }; @@ -5194,6 +5334,13 @@ export class TerrainSystem extends System { const centers = this.getTerrainCenters(); if (centers.length > 0) { const pos = centers[0].position; + + // Set grass player position BEFORE quad-tree update so + // onNodeNeedsGeometry dispatches with correct LOD & distance + if (this.grassVisualManager) { + this.grassVisualManager.setPlayerPosition(pos.x, pos.z); + } + this.quadTreeVisualManager.update(pos.x, pos.z); if (this.grassVisualManager) { @@ -6808,6 +6955,7 @@ export class TerrainSystem extends System { // Terminate worker pools to free resources terminateTerrainWorkerPool(); terminateQuadChunkWorkerPool(); + terminateGrassWorkerPool(); // Clear pending worker results this.pendingWorkerResults.clear(); diff --git a/packages/shared/src/utils/workers/GrassWorker.ts b/packages/shared/src/utils/workers/GrassWorker.ts index 69dc0e4f7..3f94a148d 100644 --- a/packages/shared/src/utils/workers/GrassWorker.ts +++ b/packages/shared/src/utils/workers/GrassWorker.ts @@ -1,78 +1,117 @@ /** - * GrassWorker.ts - Web Worker for Grass Placement Computation + * GrassWorker.ts - Web Worker for FULL Grass Instance Generation * - * Offloads CPU-intensive grass placement calculations to a worker thread: - * - Grid-jittered placement for even distribution - * - Noise-based density filtering - * - Position candidate generation with deterministic RNG + * Offloads ALL CPU-intensive grass computation to a worker thread: + * - Terrain height sampling (getHeightComputed) + * - Biome weight & terrain color computation (computeTerrainColorCPU) + * - Road influence calculation + * - Grass placement probability (biome configs, slope, patchiness) + * - Instance attribute generation (offsets, rotation, scale, ground color, normal) * - * The main thread remains responsible for: - * - Terrain height lookups (requires TerrainSystem) - * - Building collision checks - * - Slope/grassiness validation - * - InstancedMesh attribute updates - * - GPU operations + * The main thread only creates InstancedMesh from pre-computed Float32Arrays. * - * Communication: - * - Input: { type: 'generatePlacements', chunkKey, originX, originZ, size, baseDensity, seed } - * - Output: { type: 'placementResult', chunkKey, placements: GrassPlacementData[] } + * Uses the same shared builder functions as TerrainWorker/QuadChunkWorker + * for height/biome computation to stay perfectly in sync. */ import { WorkerPool } from "./WorkerPool"; +import { + buildGetBaseHeightAtJS, + buildComputeBiomeWeightsJS, + MAX_HEIGHT, + WATER_LEVEL_NORMALIZED, +} from "../../systems/shared/world/TerrainHeightParams"; +import { buildBiomeConstantsJS } from "../../systems/shared/world/TerrainBiomeTypes"; +import { + buildNoiseGeneratorJS, + buildHeightHelpersJS, + buildBiomeInfluencesJS, + buildCreateBiomeNoiseSetsJS, +} from "./TerrainWorkerShared"; +import type { TerrainWorkerConfig } from "./TerrainWorker"; // ============================================================================ // TYPES // ============================================================================ export interface GrassPlacementData { - /** World X position */ x: number; - /** World Z position */ z: number; - /** Height scale (0.7-1.3) */ heightScale: number; - /** Rotation in radians (0-2π) */ rotation: number; - /** Width scale (0.8-1.2) */ widthScale: number; - /** Color variation (0-1) */ colorVar: number; - /** Phase offset for wind animation (0-2π) */ phaseOffset: number; } +export interface BiomeGrassConfigWorker { + density: number; + maxSlope: number; + minGrassWeight: number; + heightScale: number; + patchiness: number; + patchScale: number; +} + export interface GrassWorkerInput { - type: "generatePlacements"; - /** Chunk key "x_z" */ + type: "generateGrassInstances"; chunkKey: string; - /** World X origin of chunk */ - originX: number; - /** World Z origin of chunk */ - originZ: number; - /** Chunk size in meters */ + centerX: number; + centerZ: number; size: number; - /** Base density (instances per square meter) */ - baseDensity: number; - /** Seed for deterministic generation */ + spacingMul: number; + config: TerrainWorkerConfig; seed: number; - /** Biome height multiplier (default 1.0) */ - heightMultiplier?: number; - /** Maximum instances for this chunk */ - maxInstances?: number; + biomeCenters: Array<{ + x: number; + z: number; + type: string; + influence: number; + }>; + biomes: Record< + string, + { heightModifier: number; color: { r: number; g: number; b: number } } + >; + grassSeed: number; + clumpSpacing: number; + scaleMin: number; + scaleMax: number; + waterThreshold: number; + grassConfigs: Record; + shaderConstants: { + NOISE_SCALE: number; + DISTORT_NOISE_SCALE: number; + VARIATION_NOISE_SCALE: number; + ROCK_DISTORT_STRENGTH: number; + HEIGHT_DISTORT_STRENGTH: number; + DIRT_THRESHOLD: number; + SATURATION_BOOST: number; + }; + roadSegments: Array<{ + startX: number; + startZ: number; + endX: number; + endZ: number; + width: number; + }>; + roadBlendWidth: number; + tileSize: number; } export interface GrassWorkerOutput { - type: "placementResult"; - /** Chunk key matching input */ + type: "grassInstanceResult"; chunkKey: string; - /** Generated placements (position + variation data) */ - placements: GrassPlacementData[]; - /** Stats for debugging */ - stats: { - candidatesGenerated: number; - placementsCreated: number; - timeMs: number; - }; + offsets: Float32Array; + rotScaleHash: Float32Array; + groundColors: Float32Array; + groundNormals: Float32Array; + count: number; +} + +export interface GrassBatchResult { + results: GrassWorkerOutput[]; + workersAvailable: boolean; + failedCount: number; } // ============================================================================ @@ -80,161 +119,447 @@ export interface GrassWorkerOutput { // ============================================================================ /** - * Inline worker code for grass placement generation - * This code runs in a separate thread, isolated from the main thread. + * Build a JS string for the sampleNoiseCPU pipeline — exact match of + * TerrainShader.ts lines 422-549 + 827-861. + * Worker has no texture, so we always use the seamlessFbm path. + */ +function buildSampleNoiseJS(): string { + return ` + function _fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); } + function _lerp(a, b, t) { return a + t * (b - a); } + function _grad(hash, x, y) { + var h = hash & 3; + var u = h < 2 ? x : y; + var v = h < 2 ? y : x; + return ((h & 1) === 0 ? u : -u) + ((h & 2) === 0 ? v : -v); + } + + function createPermutation(seed) { + var p = []; + for (var i = 0; i < 256; i++) p[i] = i; + var s = seed; + for (var i = 255; i > 0; i--) { + s = (s * 1103515245 + 12345) & 0x7fffffff; + var j = s % (i + 1); + var tmp = p[i]; p[i] = p[j]; p[j] = tmp; + } + return p.concat(p); + } + + function perlin2DPerm(x, y, perm) { + var X = Math.floor(x) & 255; + var Y = Math.floor(y) & 255; + var xf = x - Math.floor(x); + var yf = y - Math.floor(y); + var u = _fade(xf); + var v = _fade(yf); + var aa = perm[perm[X] + Y]; + var ab = perm[perm[X] + Y + 1]; + var ba = perm[perm[X + 1] + Y]; + var bb = perm[perm[X + 1] + Y + 1]; + var x1 = _lerp(_grad(aa, xf, yf), _grad(ba, xf - 1, yf), u); + var x2 = _lerp(_grad(ab, xf, yf - 1), _grad(bb, xf - 1, yf - 1), u); + return _lerp(x1, x2, v); + } + + function seamlessPerlin2D(x, y, perm) { + var TWO_PI = Math.PI * 2; + var angleX = x * TWO_PI; + var angleY = y * TWO_PI; + var nx = Math.cos(angleX); + var ny = Math.sin(angleX); + var nz = Math.cos(angleY); + var nw = Math.sin(angleY); + var n1 = perlin2DPerm(nx * 4 + 100, nz * 4 + 100, perm); + var n2 = perlin2DPerm(ny * 4 + 200, nw * 4 + 200, perm); + var n3 = perlin2DPerm(nx * 4 + ny * 4 + 300, nz * 4 + nw * 4 + 300, perm); + return (n1 + n2 + n3) / 3; + } + + function seamlessFbm(x, y, perm, octaves) { + var value = 0, amplitude = 0.5, maxValue = 0; + for (var i = 0; i < octaves; i++) { + var ox = x + i * 17.3; + var oy = y + i * 31.7; + value += amplitude * seamlessPerlin2D(ox, oy, perm); + maxValue += amplitude; + amplitude *= 0.5; + } + return value / maxValue; + } + + var _noisePerm = createPermutation(12345); + + function sampleNoiseCPU(worldX, worldZ, scale) { + var u = worldX * scale; + var v = worldZ * scale; + var wu = u - Math.floor(u); + var wv = v - Math.floor(v); + return (seamlessFbm(wu, wv, _noisePerm, 4) + 1) * 0.5; + } +`; +} + +/** + * Build a JS string for computeTerrainColorCPU — exact match of + * TerrainShader.ts lines 869-1022 with all color constants baked + * as pre-computed linear-space values. + */ +function buildComputeTerrainColorJS(): string { + return ` + function smoothstepCPU(edge0, edge1, x) { + var t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0))); + return t * t * (3 - 2 * t); + } + function mixRGB(a, b, t) { + return { r: a.r + (b.r - a.r) * t, g: a.g + (b.g - a.g) * t, b: a.b + (b.b - a.b) * t }; + } + function blendBiome(tundra, forest, canyon, tW, fW, dW) { + return { + r: tundra.r * tW + forest.r * fW + canyon.r * dW, + g: tundra.g * tW + forest.g * fW + canyon.g * dW, + b: tundra.b * tW + forest.b * fW + canyon.b * dW + }; + } + + var _TUNDRA_GRASS = {r:0.587016,g:0.603827,b:0.603827}; + var _TUNDRA_GRASS_DARK = {r:0.381561,g:0.392488,b:0.392488}; + var _TUNDRA_GRASS_HIGH = {r:0.68,g:0.72,b:0.78}; + var _TUNDRA_VARIATION = {r:0.6,g:0.64,b:0.7}; + var _TUNDRA_DIRT = {r:0.570482,g:0.638283,b:0.673860}; + var _TUNDRA_DIRT_DARK = {r:0.370813,g:0.414884,b:0.438009}; + var _TUNDRA_CLIFF = {r:0.570482,g:0.638283,b:0.673860}; + var _TUNDRA_CLIFF_DARK = {r:0.370813,g:0.414884,b:0.438009}; + + var _FOREST_GRASS = {r:0.125967,g:0.354692,b:0.033105}; + var _FOREST_GRASS_DARK = {r:0.081879,g:0.230550,b:0.021518}; + var _FOREST_GRASS_HIGH = {r:0.24,g:0.45,b:0.18}; + var _FOREST_VARIATION = {r:0.15,g:0.35,b:0.1}; + var _FOREST_DIRT = {r:0.638283,g:0.367246,b:0.094630}; + var _FOREST_DIRT_DARK = {r:0.414884,g:0.238710,b:0.061509}; + var _FOREST_CLIFF = {r:0.462361,g:0.406448,b:0.318547}; + var _FOREST_CLIFF_DARK = {r:0.300535,g:0.264191,b:0.207055}; + + var _CANYON_SAND = {r:0.223414,g:0.139985,b:0.063724}; + var _CANYON_SAND_DARK = {r:0.145219,g:0.090990,b:0.041420}; + var _CANYON_SAND_HIGH = {r:0.62,g:0.38,b:0.22}; + var _CANYON_VARIATION = {r:0.58,g:0.34,b:0.16}; + var _CANYON_ROCK = {r:0.252950,g:0.147319,b:0.083535}; + var _CANYON_ROCK_DARK = {r:0.164418,g:0.095757,b:0.054298}; + var _CANYON_CLIFF = {r:0.252950,g:0.147319,b:0.083535}; + var _CANYON_CLIFF_DARK = {r:0.164418,g:0.095757,b:0.054298}; + + var _CLIFF_TINT = {r:0.28,g:0.3,b:0.36}; + var _SAND_YELLOW = {r:0.7,g:0.6,b:0.38}; + var _DIRT_DARK_CPU = {r:0.22,g:0.15,b:0.08}; + var _MUD_BROWN = {r:0.18,g:0.12,b:0.08}; + var _WATER_EDGE = {r:0.08,g:0.06,b:0.04}; + + function computeTerrainColorCPU(worldX, worldZ, height, slope, forestW, canyonW, sc) { + var fW = forestW, dW = canyonW, tW = 1 - fW - dW; + + var noiseVal = sampleNoiseCPU(worldX, worldZ, sc.NOISE_SCALE); + var noiseVal2 = Math.sin(noiseVal * 6.28) * 0.3 + 0.5; + var distortN = sampleNoiseCPU(worldX, worldZ, sc.DISTORT_NOISE_SCALE); + var variationN = sampleNoiseCPU(worldX, worldZ, sc.VARIATION_NOISE_SCALE); + + var distortedNY = 1 - slope + (distortN - 0.5) * sc.ROCK_DISTORT_STRENGTH; + var dSlope = 1 - distortedNY; + var dHeight = height + (distortN - 0.5) * sc.HEIGHT_DISTORT_STRENGTH; + + var grassVar = smoothstepCPU(0.4, 0.6, noiseVal2); + var tundraGrass = mixRGB(_TUNDRA_GRASS, _TUNDRA_GRASS_DARK, grassVar); + var forestGrass = mixRGB(_FOREST_GRASS, _FOREST_GRASS_DARK, grassVar); + var canyonGrass = mixRGB(_CANYON_SAND, _CANYON_SAND_DARK, grassVar); + var c = blendBiome(tundraGrass, forestGrass, canyonGrass, tW, fW, dW); + + var heightGrad = smoothstepCPU(25, 55, height) * 0.3; + var grassHigh = blendBiome(_TUNDRA_GRASS_HIGH, _FOREST_GRASS_HIGH, _CANYON_SAND_HIGH, tW, fW, dW); + c = mixRGB(c, grassHigh, heightGrad); + + var gVar = Math.max(0, Math.min(1, Math.pow(variationN + 0.3, 5))); + var varColor = blendBiome(_TUNDRA_VARIATION, _FOREST_VARIATION, _CANYON_VARIATION, tW, fW, dW); + c = mixRGB(c, varColor, gVar * 0.25); + + var dirtVar = smoothstepCPU(0.3, 0.7, noiseVal2); + var dirtColor = blendBiome( + mixRGB(_TUNDRA_DIRT, _TUNDRA_DIRT_DARK, dirtVar), + mixRGB(_FOREST_DIRT, _FOREST_DIRT_DARK, dirtVar), + mixRGB(_CANYON_ROCK, _CANYON_ROCK_DARK, dirtVar), + tW, fW, dW); + + var cliffVar = smoothstepCPU(0.3, 0.7, noiseVal); + var cliffColor = blendBiome( + mixRGB(_TUNDRA_CLIFF, _TUNDRA_CLIFF_DARK, cliffVar), + mixRGB(_FOREST_CLIFF, _FOREST_CLIFF_DARK, cliffVar), + mixRGB(_CANYON_CLIFF, _CANYON_CLIFF_DARK, cliffVar), + tW, fW, dW); + var rockTexV = Math.pow(distortN, 0.5) * 0.3; + cliffColor = mixRGB(cliffColor, _CLIFF_TINT, rockTexV); + + var grassWeight = 1.0; + + var nDirtF = smoothstepCPU(sc.DIRT_THRESHOLD - 0.05, sc.DIRT_THRESHOLD + 0.15, noiseVal) * smoothstepCPU(0.3, 0.05, dSlope); + c = mixRGB(c, dirtColor, nDirtF); + grassWeight -= nDirtF; + + var dirtSlopeF = smoothstepCPU(0.15, 0.4, dSlope) * smoothstepCPU(0.6, 0.3, dSlope) * 0.6; + c = mixRGB(c, dirtColor, dirtSlopeF); + grassWeight -= dirtSlopeF; + + var cliffF = smoothstepCPU(0.3, 0.55, dSlope); + c = mixRGB(c, cliffColor, cliffF); + grassWeight -= cliffF; + + var sandBlend = smoothstepCPU(18, 12, dHeight) * smoothstepCPU(0.25, 0.0, slope); + var sandStr = 0.6 + (0.9 - 0.6) * dW; + var sandF = sandBlend * sandStr; + c = mixRGB(c, _SAND_YELLOW, sandF); + grassWeight -= sandF; + + var shore1 = smoothstepCPU(22, 14, dHeight) * 0.4; + c = mixRGB(c, _DIRT_DARK_CPU, shore1); + grassWeight -= shore1; + + var shore2 = smoothstepCPU(15, 10, dHeight) * 0.7; + c = mixRGB(c, _MUD_BROWN, shore2); + grassWeight -= shore2; + + var shore3 = smoothstepCPU(11, 7, dHeight) * 0.9; + c = mixRGB(c, _WATER_EDGE, shore3); + grassWeight -= shore3; + + var luma = c.r * 0.299 + c.g * 0.587 + c.b * 0.114; + var sat = sc.SATURATION_BOOST; + c = { r: luma + (c.r - luma) * sat, g: luma + (c.g - luma) * sat, b: luma + (c.b - luma) * sat }; + + return { r: c.r, g: c.g, b: c.b, grassWeight: Math.max(0, Math.min(1, grassWeight)) }; + } +`; +} + +/** + * Inline worker code for complete grass instance generation. * - * The worker generates candidate positions with variation data. - * Height lookups, building checks, and grassiness validation happen on main thread. + * Embeds: terrain height, biome weights, terrain color, road influence, + * grass placement, and instance attribute generation. */ const GRASS_WORKER_CODE = ` -// Deterministic PRNG for reproducible grass placement -function createRng(seed) { - let state = seed >>> 0; +${buildNoiseGeneratorJS()} +${buildBiomeConstantsJS()} + +var BIOME_IDS = {}; +BIOME_IDS[BT_TUNDRA] = 0; +BIOME_IDS[BT_FOREST] = 1; +BIOME_IDS[BT_CANYON] = 2; + +${buildSampleNoiseJS()} +${buildComputeTerrainColorJS()} + +function mulberry32(seed) { + var s = seed | 0; return function() { - state = (1664525 * state + 1013904223) >>> 0; - return state / 0xFFFFFFFF; + s = (s + 0x6d2b79f5) | 0; + var t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } -// Hash function for position-based seed derivation -function hashPosition(x, z) { - const h1 = Math.imul(Math.floor(x * 100) ^ 0x85ebca6b, 0x85ebca6b); - const h2 = Math.imul(Math.floor(z * 100) ^ 0xc2b2ae35, 0xc2b2ae35); - return Math.abs((h1 ^ h2) | 0); +function distToSegSq(px, pz, x1, z1, x2, z2) { + var dx = x2 - x1, dz = z2 - z1; + var lenSq = dx * dx + dz * dz; + if (lenSq === 0) { var ddx = px - x1, ddz = pz - z1; return ddx * ddx + ddz * ddz; } + var t = Math.max(0, Math.min(1, ((px - x1) * dx + (pz - z1) * dz) / lenSq)); + var projX = x1 + t * dx, projZ = z1 + t * dz; + var ddx2 = px - projX, ddz2 = pz - projZ; + return ddx2 * ddx2 + ddz2 * ddz2; } -// Simple 2D value noise for density variation -function noise2D(x, z, seed) { - const ix = Math.floor(x); - const iz = Math.floor(z); - const fx = x - ix; - const fz = z - iz; - - // Smooth interpolation - const sx = fx * fx * (3 - 2 * fx); - const sz = fz * fz * (3 - 2 * fz); - - // Hash corners using simple PRNG - function hash(a, b) { - let h = a * 374761393 + b * 668265263 + seed; - h = ((h ^ (h >> 13)) * 1274126177) >>> 0; - return (h & 0xFFFFFF) / 0xFFFFFF; +function calculateRoadInfluence(wx, wz, roadSegments, roadBlendWidth) { + if (roadSegments.length === 0) return 0; + var minDistSq = Infinity, closestWidth = 6; + for (var i = 0; i < roadSegments.length; i++) { + var seg = roadSegments[i]; + var dSq = distToSegSq(wx, wz, seg.startX, seg.startZ, seg.endX, seg.endZ); + if (dSq < minDistSq) { minDistSq = dSq; closestWidth = seg.width; } } - - const h00 = hash(ix, iz); - const h10 = hash(ix + 1, iz); - const h01 = hash(ix, iz + 1); - const h11 = hash(ix + 1, iz + 1); - - // Bilinear interpolation - const v0 = h00 + sx * (h10 - h00); - const v1 = h01 + sx * (h11 - h01); - return v0 + sz * (v1 - v0); + var halfW = closestWidth / 2; + var totalW = halfW + roadBlendWidth; + if (minDistSq >= totalW * totalW) return 0; + if (minDistSq <= halfW * halfW) return 1.0; + var minDist = Math.sqrt(minDistSq); + var t = 1.0 - (minDist - halfW) / roadBlendWidth; + return t * t * (3 - 2 * t); } -/** - * Main placement generation function - * Generates grid-jittered positions with instance variation data - */ -function generatePlacements(input) { - const startTime = performance.now(); - const { chunkKey, originX, originZ, size, baseDensity, seed, heightMultiplier = 1.0, maxInstances = 131072 } = input; - - const placements = []; - let candidatesGenerated = 0; - - // Calculate instance count - const instanceCount = Math.min( - Math.floor(size * size * baseDensity), - maxInstances - ); - - if (instanceCount === 0) { +function generateGrassInstances(input) { + var startTime = performance.now(); + var centerX = input.centerX, centerZ = input.centerZ, size = input.size; + var spacingMul = input.spacingMul; + var config = input.config; + var biomeCenters = input.biomeCenters; + var biomes = input.biomes; + var sc = input.shaderConstants; + var grassConfigs = input.grassConfigs; + + var BIOME_GAUSSIAN_COEFF = config.BIOME_GAUSSIAN_COEFF; + var BIOME_BOUNDARY_NOISE_SCALE = config.BIOME_BOUNDARY_NOISE_SCALE; + var BIOME_BOUNDARY_NOISE_AMOUNT = config.BIOME_BOUNDARY_NOISE_AMOUNT; + var WATER_THRESHOLD = input.waterThreshold; + var SHORELINE_THRESHOLD = config.SHORELINE_THRESHOLD; + var SHORELINE_STRENGTH = config.SHORELINE_STRENGTH; + var SHORELINE_MIN_SLOPE = config.SHORELINE_MIN_SLOPE; + var SHORELINE_SLOPE_SAMPLE_DISTANCE = config.SHORELINE_SLOPE_SAMPLE_DISTANCE; + var SHORELINE_LAND_BAND = config.SHORELINE_LAND_BAND; + var SHORELINE_LAND_MAX_MULTIPLIER = config.SHORELINE_LAND_MAX_MULTIPLIER; + var SHORELINE_UNDERWATER_BAND = config.SHORELINE_UNDERWATER_BAND; + var UNDERWATER_DEPTH_MULTIPLIER = config.UNDERWATER_DEPTH_MULTIPLIER; + var MAX_HEIGHT = config.MAX_HEIGHT; + + var noise = new NoiseGenerator(input.seed); + + ${buildComputeBiomeWeightsJS()} + ${buildGetBaseHeightAtJS()} + ${buildCreateBiomeNoiseSetsJS()} + var biomeNoiseSets = createBiomeNoiseSets(input.seed); + ${buildHeightHelpersJS()} + ${buildBiomeInfluencesJS()} + + var spacing = input.clumpSpacing * spacingMul; + var maxCount = Math.ceil((size * size) / (spacing * spacing)); + var rng = mulberry32(input.grassSeed ^ ((centerX * 374761393 + centerZ * 668265263) | 0)); + + var offsets = new Float32Array(maxCount * 3); + var rotScaleHash = new Float32Array(maxCount * 3); + var groundColors = new Float32Array(maxCount * 3); + var groundNormals = new Float32Array(maxCount * 3); + var count = 0; + + var tCfg = grassConfigs[BT_TUNDRA] || grassConfigs["tundra"]; + var fCfg = grassConfigs[BT_FOREST] || grassConfigs["forest"]; + var cCfg = grassConfigs[BT_CANYON] || grassConfigs["canyon"]; + + for (var i = 0; i < maxCount; i++) { + var lx = (rng() - 0.5) * size; + var lz = (rng() - 0.5) * size; + var clumpRng = rng(); + + var wx = centerX + lx; + var wz = centerZ + lz; + var ty = getHeightComputed(wx, wz); + + if (ty < WATER_THRESHOLD + 0.1) continue; + + var roadInf = calculateRoadInfluence(wx, wz, input.roadSegments, input.roadBlendWidth); + if (roadInf > 0.8) continue; + + // Normal via finite differences (matches TerrainSystem.getTerrainColorAt) + var sd = 0.5; + var hL = getHeightComputed(wx - sd, wz); + var hR = getHeightComputed(wx + sd, wz); + var hD = getHeightComputed(wx, wz - sd); + var hU = getHeightComputed(wx, wz + sd); + var dhdx = (hR - hL) / (2 * sd); + var dhdz = (hU - hD) / (2 * sd); + var gradMag = Math.sqrt(dhdx * dhdx + dhdz * dhdz); + var normalY = 1 / Math.sqrt(1 + gradMag * gradMag); + var slope = 1 - normalY; + + var rnx = -dhdx, rny = 1.0, rnz = -dhdz; + var nLen = Math.sqrt(rnx * rnx + rny * rny + rnz * rnz); + var invLen = 1 / nLen; + + // Biome weights + var bw = computeBiomeWeightsByPosition(wx, wz); + var forestW = bw[BT_FOREST] || 0; + var canyonW = bw[BT_CANYON] || 0; + var tundraW = 1 - forestW - canyonW; + + var color = computeTerrainColorCPU(wx, wz, ty, slope, forestW, canyonW, sc); + + // Biome-blended grass params + var maxSlope = tCfg.maxSlope * tundraW + fCfg.maxSlope * forestW + cCfg.maxSlope * canyonW; + var minGW = tCfg.minGrassWeight * tundraW + fCfg.minGrassWeight * forestW + cCfg.minGrassWeight * canyonW; + var density = tCfg.density * tundraW + fCfg.density * forestW + cCfg.density * canyonW; + var grassHeightScale = tCfg.heightScale * tundraW + fCfg.heightScale * forestW + cCfg.heightScale * canyonW; + var patchiness = tCfg.patchiness * tundraW + fCfg.patchiness * forestW + cCfg.patchiness * canyonW; + var patchScale = tCfg.patchScale * tundraW + fCfg.patchScale * forestW + cCfg.patchScale * canyonW; + + var slopeOk = slope <= maxSlope ? 1.0 : 0.0; + var weightOk = color.grassWeight >= minGW ? 1.0 : 0.0; + + var patchThreshold = patchiness * 2 - 1; + var noiseVal = noise.simplex2D(wx * patchScale, wz * patchScale); + var patchMask = noiseVal > patchThreshold ? 1.0 : 0.0; + + var rawGP = color.grassWeight * density * slopeOk * weightOk * patchMask; + var grassPlacement = Math.max(0, rawGP - roadInf); + + if (grassPlacement <= 0) continue; + if (clumpRng > grassPlacement) continue; + + offsets[count * 3] = lx; + offsets[count * 3 + 1] = ty; + offsets[count * 3 + 2] = lz; + + var rotation = rng() * Math.PI * 2; + var scale = (input.scaleMin + clumpRng * (input.scaleMax - input.scaleMin)) * grassHeightScale; + rotScaleHash[count * 3] = rotation; + rotScaleHash[count * 3 + 1] = scale; + rotScaleHash[count * 3 + 2] = clumpRng; + + groundColors[count * 3] = color.r; + groundColors[count * 3 + 1] = color.g; + groundColors[count * 3 + 2] = color.b; + + groundNormals[count * 3] = rnx * invLen; + groundNormals[count * 3 + 1] = rny * invLen; + groundNormals[count * 3 + 2] = rnz * invLen; + + count++; + } + + if (count === 0) { return { - type: "placementResult", - chunkKey, - placements: [], - stats: { candidatesGenerated: 0, placementsCreated: 0, timeMs: performance.now() - startTime } + type: "grassInstanceResult", + chunkKey: input.chunkKey, + offsets: new Float32Array(0), + rotScaleHash: new Float32Array(0), + groundColors: new Float32Array(0), + groundNormals: new Float32Array(0), + count: 0 }; } - - // Deterministic RNG for this chunk - const chunkSeed = hashPosition(originX, originZ) ^ seed; - let rngState = chunkSeed; - const nextRandom = () => { - rngState = (rngState * 1103515245 + 12345) & 0x7fffffff; - return rngState / 0x7fffffff; - }; - - const spacing = Math.sqrt(1 / baseDensity); - - // Randomized placement with loose grid for even coverage but organic look - for (let gx = 0; gx < size && placements.length < instanceCount; gx += spacing) { - for (let gz = 0; gz < size && placements.length < instanceCount; gz += spacing) { - candidatesGenerated++; - - // Large jitter (1.2x spacing) allows grass to cross cell boundaries for organic look - const jitterX = (nextRandom() - 0.5) * spacing * 1.2; - const jitterZ = (nextRandom() - 0.5) * spacing * 1.2; - - // Secondary noise-based offset to break up grid pattern - // Uses position-seeded noise for determinism - const noiseOffsetX = (noise2D(gx * 0.5, gz * 0.5, chunkSeed) - 0.5) * spacing * 0.6; - const noiseOffsetZ = (noise2D(gz * 0.5 + 100, gx * 0.5 + 100, chunkSeed) - 0.5) * spacing * 0.6; - - const worldX = originX + gx + jitterX + noiseOffsetX; - const worldZ = originZ + gz + jitterZ + noiseOffsetZ; - - // NO noise-based filtering - generates even grass coverage - // Density variation is handled by distance-based LOD at render time - - // Instance variation - const heightScale = (0.7 + nextRandom() * 0.6) * heightMultiplier; - const rotation = nextRandom() * Math.PI * 2; - const widthScale = 0.8 + nextRandom() * 0.4; - const colorVar = nextRandom(); - const phaseOffset = nextRandom() * Math.PI * 2; - - placements.push({ - x: worldX, - z: worldZ, - heightScale, - rotation, - widthScale, - colorVar, - phaseOffset - }); - } - } - + return { - type: "placementResult", - chunkKey, - placements, - stats: { - candidatesGenerated, - placementsCreated: placements.length, - timeMs: performance.now() - startTime - } + type: "grassInstanceResult", + chunkKey: input.chunkKey, + offsets: offsets.subarray(0, count * 3), + rotScaleHash: rotScaleHash.subarray(0, count * 3), + groundColors: groundColors.subarray(0, count * 3), + groundNormals: groundNormals.subarray(0, count * 3), + count: count }; } -// Worker message handler -// CRITICAL: Must match WorkerPool's expected message format: -// - Success: { result: } -// - Error: { error: } self.onmessage = function(e) { - const input = e.data; - - try { - if (input.type === "generatePlacements") { - const result = generatePlacements(input); - self.postMessage({ result }); - } else { - self.postMessage({ error: "Unknown message type: " + input.type }); + var input = e.data; + if (input.type === "generateGrassInstances") { + try { + var result = generateGrassInstances(input); + // Transfer Float32Array buffers for zero-copy + var transfers = []; + if (result.offsets.buffer.byteLength > 0) transfers.push(result.offsets.buffer); + if (result.rotScaleHash.buffer.byteLength > 0) transfers.push(result.rotScaleHash.buffer); + if (result.groundColors.buffer.byteLength > 0) transfers.push(result.groundColors.buffer); + if (result.groundNormals.buffer.byteLength > 0) transfers.push(result.groundNormals.buffer); + self.postMessage({ result: result }, transfers); + } catch (err) { + self.postMessage({ error: err.message || "Grass worker error" }); } - } catch (err) { - self.postMessage({ error: err.message || "Grass worker error" }); + } else { + self.postMessage({ error: "Unknown message type: " + input.type }); } }; `; @@ -243,27 +568,19 @@ self.onmessage = function(e) { // WORKER POOL MANAGEMENT // ============================================================================ -/** Singleton worker pool for grass placement */ let grassWorkerPool: WorkerPool | null = null; -/** Track if workers are available */ let workersChecked = false; let workersAvailable = false; -/** - * Check if grass workers are available (client-side with Worker + Blob URL support) - * Bun provides Worker and Blob but doesn't support blob URLs for workers - */ export function isGrassWorkerAvailable(): boolean { if (!workersChecked) { workersChecked = true; - // Check basic Worker/Blob availability if (typeof Worker === "undefined" || typeof Blob === "undefined") { workersAvailable = false; return workersAvailable; } - // Detect Bun runtime - Bun has Worker/Blob but blob URLs don't work for workers if ( typeof process !== "undefined" && process.versions && @@ -272,7 +589,6 @@ export function isGrassWorkerAvailable(): boolean { workersAvailable = false; return workersAvailable; } - // Detect Node.js runtime (no browser globals like window) if (typeof window === "undefined") { workersAvailable = false; return workersAvailable; @@ -282,11 +598,6 @@ export function isGrassWorkerAvailable(): boolean { return workersAvailable; } -/** - * Get or create the grass worker pool - * @param poolSize - Number of workers (defaults to CPU cores - 1) - * @returns Worker pool, or null if workers unavailable (server-side) - */ export function getGrassWorkerPool( poolSize?: number, ): WorkerPool | null { @@ -303,87 +614,30 @@ export function getGrassWorkerPool( return grassWorkerPool; } -/** - * Generate grass placements using web worker - * Returns immediately with a promise that resolves when the worker completes - * Returns null if workers are not available - */ export async function generateGrassPlacementsAsync( - chunkKey: string, - originX: number, - originZ: number, - size: number, - baseDensity: number, - seed: number, - heightMultiplier?: number, - maxInstances?: number, + input: GrassWorkerInput, ): Promise { const pool = getGrassWorkerPool(); if (!pool) { return null; } - return pool.execute({ - type: "generatePlacements", - chunkKey, - originX, - originZ, - size, - baseDensity, - seed, - heightMultiplier, - maxInstances, - }); -} - -/** - * Result of batch grass generation - */ -export interface GrassBatchResult { - /** Successfully generated chunks */ - results: GrassWorkerOutput[]; - /** Whether workers were available */ - workersAvailable: boolean; - /** Number of chunks that failed to generate */ - failedCount: number; + return pool.execute(input); } -/** - * Generate multiple chunks in parallel using worker pool - */ export async function generateGrassChunksBatch( - chunks: Array<{ - chunkKey: string; - originX: number; - originZ: number; - size: number; - }>, - baseDensity: number, - seed: number, - heightMultiplier?: number, - maxInstances?: number, + inputs: GrassWorkerInput[], ): Promise { const pool = getGrassWorkerPool(); if (!pool) { - return { results: [], workersAvailable: false, failedCount: chunks.length }; + return { results: [], workersAvailable: false, failedCount: inputs.length }; } const results: GrassWorkerOutput[] = []; let failedCount = 0; - // Execute all chunks in parallel using the worker pool - const promises = chunks.map((chunk) => + const promises = inputs.map((input) => pool - .execute({ - type: "generatePlacements", - chunkKey: chunk.chunkKey, - originX: chunk.originX, - originZ: chunk.originZ, - size: chunk.size, - baseDensity, - seed, - heightMultiplier, - maxInstances, - }) + .execute(input) .then((result) => { results.push(result); }) @@ -397,9 +651,6 @@ export async function generateGrassChunksBatch( return { results, workersAvailable: true, failedCount }; } -/** - * Terminate the grass worker pool - */ export function terminateGrassWorkerPool(): void { if (grassWorkerPool) { grassWorkerPool.terminate(); From f38858e75dd22a61c363d7c72f9ed5b9eff626d9 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Fri, 27 Mar 2026 21:44:33 +0800 Subject: [PATCH 58/71] fix: exclude grass from flat zones (arenas, hospitals, buildings) Pass flat zone rectangles to the grass worker and add an isInFlatZone check in both the worker and sync fallback paths so grass is no longer placed inside artificially flattened areas. Made-with: Cursor --- .../shared/world/GrassVisualManager.ts | 31 ++++++++++ .../src/systems/shared/world/TerrainSystem.ts | 62 +++++++++++++++++++ .../shared/src/utils/workers/GrassWorker.ts | 21 +++++++ 3 files changed, 114 insertions(+) diff --git a/packages/shared/src/systems/shared/world/GrassVisualManager.ts b/packages/shared/src/systems/shared/world/GrassVisualManager.ts index 5bd8a8d9c..5980f91f3 100644 --- a/packages/shared/src/systems/shared/world/GrassVisualManager.ts +++ b/packages/shared/src/systems/shared/world/GrassVisualManager.ts @@ -284,12 +284,25 @@ export interface GrassWorkerSetup { endZ: number; width: number; }>; + getFlatZonesForRegion: ( + minX: number, + minZ: number, + maxX: number, + maxZ: number, + ) => Array<{ + centerX: number; + centerZ: number; + halfWidth: number; + halfDepth: number; + blendRadius: number; + }>; } export class GrassVisualManager implements QuadTreeListener { private container: THREE.Group; private getHeightAt: (x: number, z: number) => number; private getRoadInfluence: (wx: number, wz: number) => number; + private isInFlatZone: (wx: number, wz: number) => boolean; private getTerrainColorAt: ( wx: number, wz: number, @@ -332,6 +345,7 @@ export class GrassVisualManager implements QuadTreeListener { getHeightAt: (x: number, z: number) => number, waterThreshold: number, getRoadInfluence: (wx: number, wz: number) => number, + isInFlatZone: (wx: number, wz: number) => boolean, getTerrainColorAt: ( wx: number, wz: number, @@ -349,6 +363,7 @@ export class GrassVisualManager implements QuadTreeListener { this.getHeightAt = getHeightAt; this.waterThreshold = waterThreshold; this.getRoadInfluence = getRoadInfluence; + this.isInFlatZone = isInFlatZone; this.getTerrainColorAt = getTerrainColorAt; this.workerSetup = workerSetup ?? null; @@ -534,6 +549,12 @@ export class GrassVisualManager implements QuadTreeListener { node.centerX + half, node.centerZ + half, ); + const flatZones = ws.getFlatZonesForRegion( + node.centerX - half, + node.centerZ - half, + node.centerX + half, + node.centerZ + half, + ); const input: GrassWorkerInput = { type: "generateGrassInstances", @@ -565,6 +586,7 @@ export class GrassVisualManager implements QuadTreeListener { roadSegments, roadBlendWidth: 0.5, tileSize: ws.tileSize, + flatZones, }; this.workerInflight.add(key); @@ -609,6 +631,12 @@ export class GrassVisualManager implements QuadTreeListener { node.centerX + half, node.centerZ + half, ); + const flatZones = ws.getFlatZonesForRegion( + node.centerX - half, + node.centerZ - half, + node.centerX + half, + node.centerZ + half, + ); const input: GrassWorkerInput = { type: "generateGrassInstances", @@ -640,6 +668,7 @@ export class GrassVisualManager implements QuadTreeListener { roadSegments, roadBlendWidth: 0.5, tileSize: ws.tileSize, + flatZones, }; this.workerInflight.add(key); @@ -895,6 +924,8 @@ export class GrassVisualManager implements QuadTreeListener { if (ty < this.waterThreshold + 0.1) continue; + if (this.isInFlatZone(wx, wz)) continue; + const roadInf = this.getRoadInfluence(wx, wz); if (roadInf > 0.8) continue; diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts index 9b7bf405e..12ff2efb3 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -1987,6 +1987,7 @@ export class TerrainSystem extends System { this.CONFIG.WATER_THRESHOLD, (wx: number, wz: number) => this.calculateRoadInfluenceAtVertex(wx, wz, 0, 0), + (wx: number, wz: number) => this.isInFlatZone(wx, wz), (wx: number, wz: number) => this.getTerrainColorAt(wx, wz), grassWorkerSetup, ); @@ -2082,6 +2083,12 @@ export class TerrainSystem extends System { maxX: number, maxZ: number, ) => this.getWorldSpaceRoadSegmentsForRegion(minX, minZ, maxX, maxZ), + getFlatZonesForRegion: ( + minX: number, + minZ: number, + maxX: number, + maxZ: number, + ) => this.getFlatZonesForRegion(minX, minZ, maxX, maxZ), }; } @@ -2140,6 +2147,61 @@ export class TerrainSystem extends System { return result; } + /** + * Collect flat zones overlapping a world-space AABB, returned as simplified + * rectangles for the grass worker to exclude grass from buildings/arenas. + */ + private getFlatZonesForRegion( + minX: number, + minZ: number, + maxX: number, + maxZ: number, + ): Array<{ + centerX: number; + centerZ: number; + halfWidth: number; + halfDepth: number; + blendRadius: number; + }> { + if (this.flatZones.size === 0) return []; + + const tileSize = this.CONFIG.TILE_SIZE; + const halfTile = tileSize / 2; + const minTX = Math.floor((minX + halfTile) / tileSize); + const minTZ = Math.floor((minZ + halfTile) / tileSize); + const maxTX = Math.floor((maxX + halfTile) / tileSize); + const maxTZ = Math.floor((maxZ + halfTile) / tileSize); + + const seen = new Set(); + const result: Array<{ + centerX: number; + centerZ: number; + halfWidth: number; + halfDepth: number; + blendRadius: number; + }> = []; + + for (let tx = minTX; tx <= maxTX; tx++) { + for (let tz = minTZ; tz <= maxTZ; tz++) { + const zones = this.flatZonesByTile.get(`${tx}_${tz}`); + if (!zones) continue; + for (const zone of zones) { + if (seen.has(zone.id)) continue; + seen.add(zone.id); + result.push({ + centerX: zone.centerX, + centerZ: zone.centerZ, + halfWidth: zone.width / 2, + halfDepth: zone.depth / 2, + blendRadius: zone.blendRadius, + }); + } + } + } + + return result; + } + private registerInstancedMeshes(): void { // Register tree mesh - now with automatic pooling (1000 visible max) const treeSize = { x: 1.2, y: 3.0, z: 1.2 }; diff --git a/packages/shared/src/utils/workers/GrassWorker.ts b/packages/shared/src/utils/workers/GrassWorker.ts index 3f94a148d..94e460a5a 100644 --- a/packages/shared/src/utils/workers/GrassWorker.ts +++ b/packages/shared/src/utils/workers/GrassWorker.ts @@ -96,6 +96,13 @@ export interface GrassWorkerInput { }>; roadBlendWidth: number; tileSize: number; + flatZones: Array<{ + centerX: number; + centerZ: number; + halfWidth: number; + halfDepth: number; + blendRadius: number; + }>; } export interface GrassWorkerOutput { @@ -393,6 +400,18 @@ function calculateRoadInfluence(wx, wz, roadSegments, roadBlendWidth) { return t * t * (3 - 2 * t); } +function isInFlatZone(wx, wz, flatZones) { + for (var i = 0; i < flatZones.length; i++) { + var fz = flatZones[i]; + var dx = Math.abs(wx - fz.centerX); + var dz = Math.abs(wz - fz.centerZ); + if (dx <= fz.halfWidth + fz.blendRadius && dz <= fz.halfDepth + fz.blendRadius) { + return true; + } + } + return false; +} + function generateGrassInstances(input) { var startTime = performance.now(); var centerX = input.centerX, centerZ = input.centerZ, size = input.size; @@ -451,6 +470,8 @@ function generateGrassInstances(input) { if (ty < WATER_THRESHOLD + 0.1) continue; + if (input.flatZones.length > 0 && isInFlatZone(wx, wz, input.flatZones)) continue; + var roadInf = calculateRoadInfluence(wx, wz, input.roadSegments, input.roadBlendWidth); if (roadInf > 0.8) continue; From 471b01600ff9a928968ee50a9dd933e6e28457d8 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Fri, 27 Mar 2026 22:09:50 +0800 Subject: [PATCH 59/71] fix: render sun/moon behind clouds via SKY_RENDER_ORDER constants Sun and moon were rendering in front of clouds due to higher renderOrder. Move celestial bodies before clouds in the render pipeline and extract all render order values into a SKY_RENDER_ORDER constant object. Made-with: Cursor --- .../src/systems/shared/world/SkySystem.ts | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/shared/src/systems/shared/world/SkySystem.ts b/packages/shared/src/systems/shared/world/SkySystem.ts index 719357bf3..7e085a5b8 100644 --- a/packages/shared/src/systems/shared/world/SkySystem.ts +++ b/packages/shared/src/systems/shared/world/SkySystem.ts @@ -49,6 +49,14 @@ import { DAY_CYCLE, SUN_LIGHT } from "./LightingConfig"; const SKY_DOME_RADIUS = 5000; +const SKY_RENDER_ORDER = { + SKY_DOME: -1000, + CELESTIAL_GLOW_OUTER: -999, + CELESTIAL_GLOW_INNER: -998, + CELESTIAL_DISC: -997, + CLOUDS: -995, +} as const; + // ----------------------------- // Utility: Procedural noise textures (avoids external deps) // ----------------------------- @@ -648,7 +656,7 @@ export class SkySystem extends System { this.sun = new THREE.Mesh(sunGeom, sunMat); this.sun.name = "SkySun"; this.sun.frustumCulled = false; - this.sun.renderOrder = 1000; // Render AFTER terrain so depth test works + this.sun.renderOrder = SKY_RENDER_ORDER.CELESTIAL_DISC; this.sun.layers.set(1); // Main camera only, not minimap this.group.add(this.sun); @@ -682,7 +690,7 @@ export class SkySystem extends System { const innerGlow = new THREE.Mesh(innerGlowGeom, innerGlowMat); innerGlow.name = "SkySunInnerGlow"; innerGlow.frustumCulled = false; - innerGlow.renderOrder = 999; // Render after terrain, before sun + innerGlow.renderOrder = SKY_RENDER_ORDER.CELESTIAL_GLOW_INNER; innerGlow.layers.set(1); // Main camera only, not minimap this.group.add(innerGlow); // Store for position updates @@ -723,7 +731,7 @@ export class SkySystem extends System { this.sunGlow = new THREE.Mesh(outerGlowGeom, outerGlowMat); this.sunGlow.name = "SkySunGlow"; this.sunGlow.frustumCulled = false; - this.sunGlow.renderOrder = 998; // Render after terrain, before inner glow + this.sunGlow.renderOrder = SKY_RENDER_ORDER.CELESTIAL_GLOW_OUTER; this.sunGlow.layers.set(1); // Main camera only, not minimap this.group.add(this.sunGlow); } @@ -767,7 +775,7 @@ export class SkySystem extends System { this.moon = new THREE.Mesh(moonGeom, moonMat); this.moon.name = "SkyMoon"; this.moon.frustumCulled = false; - this.moon.renderOrder = 1000; // Render AFTER terrain so depth test works + this.moon.renderOrder = SKY_RENDER_ORDER.CELESTIAL_DISC; this.moon.layers.set(1); // Main camera only, not minimap this.group.add(this.moon); @@ -803,7 +811,7 @@ export class SkySystem extends System { this.moonGlow = new THREE.Mesh(moonGlowGeom, moonGlowMat); this.moonGlow.name = "SkyMoonGlow"; this.moonGlow.frustumCulled = false; - this.moonGlow.renderOrder = 999; // Render after terrain, before moon + this.moonGlow.renderOrder = SKY_RENDER_ORDER.CELESTIAL_GLOW_INNER; this.moonGlow.layers.set(1); // Main camera only, not minimap this.group.add(this.moonGlow); } @@ -1052,7 +1060,7 @@ export class SkySystem extends System { this.skyMesh = new THREE.Mesh(skyGeom, skyMat); this.skyMesh.frustumCulled = false; - this.skyMesh.renderOrder = -1000; // Render first, behind everything + this.skyMesh.renderOrder = SKY_RENDER_ORDER.SKY_DOME; this.skyMesh.name = "AdvancedSkydome"; this.skyMesh.layers.set(1); // Main camera only, not minimap this.group.add(this.skyMesh); @@ -1332,7 +1340,7 @@ export class SkySystem extends System { const mesh = new THREE.Mesh(geom, mat); mesh.frustumCulled = false; - mesh.renderOrder = -995; + mesh.renderOrder = SKY_RENDER_ORDER.CLOUDS; mesh.position.set(cx, cy, cz); mesh.rotation.y = azRad + Math.PI; mesh.scale.set(def.w * 10, def.h * 10, 1); @@ -1508,10 +1516,6 @@ export class SkySystem extends System { ); } - // Ensure render order - render AFTER terrain so depth test works (terrain occludes celestials) - if (this.sun) this.sun.renderOrder = 1000; - if (this.moon) this.moon.renderOrder = 1000; - // Clouds are static on the ring — movement comes from the shader's // noise UV distortion and alpha oscillation. } From b9b78cb5e2691a947ea0fe310a7ec6f7af2598e1 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Sat, 28 Mar 2026 00:23:20 +0800 Subject: [PATCH 60/71] feat: grass slope tilt, PBR shadow, and terrain-grass color blending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Rodrigues rotation to tilt grass blades along terrain slope - Override grass normalNode with terrain normal (world→view via cameraViewMatrix.transformDirection) so PBR N·L matches terrain - Enable receiveShadow on grass meshes for shadow map sampling - Apply applyAnimeShade as albedo tint on both terrain and grass; let PBR handle single Lambert N·L + shadow on top - Terrain colorNode feeds anime-shaded albedo + vertex lights into PBR; outputNode only applies fog to PBR result Made-with: Cursor --- .../shared/world/GrassVisualManager.ts | 74 ++++++++++++++----- .../src/systems/shared/world/TerrainShader.ts | 21 ++---- 2 files changed, 62 insertions(+), 33 deletions(-) diff --git a/packages/shared/src/systems/shared/world/GrassVisualManager.ts b/packages/shared/src/systems/shared/world/GrassVisualManager.ts index 5980f91f3..d5e84cc44 100644 --- a/packages/shared/src/systems/shared/world/GrassVisualManager.ts +++ b/packages/shared/src/systems/shared/world/GrassVisualManager.ts @@ -27,9 +27,12 @@ import THREE, { sub, dot, clamp, + mul, modelWorldMatrix, + cameraViewMatrix, + output, } from "../../../extras/three/three"; -import { SUN_LIGHT, SUN_SHADE, applyCustomLighting } from "./LightingConfig"; +import { SUN_LIGHT } from "./LightingConfig"; import { applyAnimeShade, TERRAIN_SHADER_CONSTANTS } from "./TerrainShader"; import { MeshStandardNodeMaterial } from "three/webgpu"; import type { TerrainQuadNode, QuadTreeListener } from "./TerrainQuadTree"; @@ -724,7 +727,7 @@ export class GrassVisualManager implements QuadTreeListener { mesh.position.set(node.centerX, 0, node.centerZ); mesh.name = `GrassQT_${key}`; mesh.frustumCulled = false; - mesh.receiveShadow = false; + mesh.receiveShadow = true; mesh.castShadow = false; mesh.userData = { type: "grass", walkable: false, clickable: false }; @@ -867,7 +870,7 @@ export class GrassVisualManager implements QuadTreeListener { mesh.position.set(node.centerX, 0, node.centerZ); mesh.name = `GrassQT_${key}`; mesh.frustumCulled = false; - mesh.receiveShadow = false; + mesh.receiveShadow = true; mesh.castShadow = false; mesh.userData = { type: "grass", walkable: false, clickable: false }; @@ -983,7 +986,6 @@ export class GrassVisualManager implements QuadTreeListener { private createMaterial(): MeshStandardNodeMaterial { const mat = new MeshStandardNodeMaterial(); - mat.colorNode = vec3(0, 0, 0); mat.side = THREE.DoubleSide; mat.transparent = false; mat.depthWrite = true; @@ -1045,6 +1047,39 @@ export class GrassVisualManager implements QuadTreeListener { localPos.x.assign(preRotX.mul(cosR).sub(preRotZ.mul(sinR))); localPos.z.assign(preRotX.mul(sinR).add(preRotZ.mul(cosR))); + // Tilt grass to align with terrain slope (axis-angle rotation from Y-up to ground normal). + // Uses Rodrigues' rotation matrix with axis = cross((0,1,0), N) = (nz, 0, -nx). + // The 1/(1+ny) substitution avoids acos/sin and has no singularity for upward-facing normals. + const gn = attribute("instanceGroundNormal", "vec3"); + const nx = gn.x; + const ny = gn.y; + const nz = gn.z; + const invOnePlusNy = float(1.0).div(ny.add(float(1.0))); + const nxnzTerm = nx.mul(nz).mul(invOnePlusNy).negate(); + + const preTiltX = localPos.x.toVar("preTiltX"); + const preTiltY = localPos.y.toVar("preTiltY"); + const preTiltZ = localPos.z.toVar("preTiltZ"); + + localPos.x.assign( + preTiltX + .mul(ny.add(nz.mul(nz).mul(invOnePlusNy))) + .add(preTiltY.mul(nx)) + .add(preTiltZ.mul(nxnzTerm)), + ); + localPos.y.assign( + preTiltX + .mul(nx.negate()) + .add(preTiltY.mul(ny)) + .add(preTiltZ.mul(nz.negate())), + ); + localPos.z.assign( + preTiltX + .mul(nxnzTerm) + .add(preTiltY.mul(nz)) + .add(preTiltZ.mul(ny.add(nx.mul(nx).mul(invOnePlusNy)))), + ); + // Wind: displace tips via sine waves keyed to world-space offset const wt = time.mul(uWindSpeed); const bendFactor = pow(t, float(1.8)); @@ -1073,12 +1108,19 @@ export class GrassVisualManager implements QuadTreeListener { })(); const uSunDir = this.sunDirUniform; - const uDayIntensity = this.dayIntensityUniform; - const shadeColor = vec3(...SUN_SHADE.TINT_COLOR); - - mat.outputNode = Fn(() => { + const terrainNormal = attribute("instanceGroundNormal", "vec3"); + + // Override PBR surface normal to terrain normal (world→view transform). + // mat4.transformDirection(vec3) is left-multiply: matrixWorldInverse * normal + // = correct world→view. With normalNode set, PBR's faceDirection flip is + // bypassed so both sides of a blade get the same terrain N·L. + mat.normalNode = cameraViewMatrix.transformDirection(terrainNormal); + + // Anime shade (half-lambert cool tint + fresnel rim) tints the ALBEDO + // using the terrain normal so grass root color blends with terrain. + // PBR then adds a single Lambert N·L + shadow on top. + mat.colorNode = Fn(() => { const groundCol = attribute("instanceGroundColor", "vec3"); - const terrainNormal = attribute("instanceGroundNormal", "vec3"); const t = uv().y; const tipCol = groundCol.mul(1.4); const bladeCol = mix( @@ -1086,15 +1128,11 @@ export class GrassVisualManager implements QuadTreeListener { tipCol, smoothstep(float(0.0), float(1.0), t), ); - const animeCol = applyAnimeShade(bladeCol, terrainNormal, uSunDir); - const lit = applyCustomLighting( - animeCol, - terrainNormal, - uSunDir, - uDayIntensity, - shadeColor, - ); - return vec4(lit, float(1.0)); + return applyAnimeShade(bladeCol, terrainNormal, uSunDir); + })(); + + mat.outputNode = Fn(() => { + return vec4(output.rgb, output.a); })(); return mat; diff --git a/packages/shared/src/systems/shared/world/TerrainShader.ts b/packages/shared/src/systems/shared/world/TerrainShader.ts index de32793a2..48a7adb94 100644 --- a/packages/shared/src/systems/shared/world/TerrainShader.ts +++ b/packages/shared/src/systems/shared/world/TerrainShader.ts @@ -50,7 +50,7 @@ import { getRoadInfluenceTextureState } from "./RoadInfluenceMask"; import { getLamppostLightTextureState } from "./LamppostLightMask"; import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants"; import { FOG_NEAR_SQ, FOG_FAR_SQ, fogRenderTarget } from "./FogConfig"; -import { SUN_LIGHT, SUN_SHADE, applyCustomLighting } from "./LightingConfig"; +import { SUN_LIGHT, SUN_SHADE } from "./LightingConfig"; export const TERRAIN_SHADER_CONSTANTS = { TRIPLANAR_SCALE: 0.5, @@ -1438,7 +1438,8 @@ export function createTerrainMaterial(): THREE.Material & { const baseWithRoads = mix(variedColor, compactedRoadColor, roadInfluence); - // Half-lambert anime shade + fresnel rim (shared with grass) + // Half-lambert cool tint + fresnel rim — tints the ALBEDO before PBR. + // PBR then adds a single Lambert N·L + shadow on top. const animeBase = applyAnimeShade( baseWithRoads, worldNormal, @@ -1593,28 +1594,18 @@ export function createTerrainMaterial(): THREE.Material & { const fogColor = fogTexNode.rgb; // === CREATE MATERIAL === - // Bypass PBR entirely — use applyCustomLighting for both terrain and grass - // so they produce identical pixels at the same world position. + // Base color + vertex lights only. PBR handles Lambert N·L + shadow. const dayIntensityUniform = uniform(1.0); - const shadeColor = vec3(...SUN_SHADE.TINT_COLOR); const material = new MeshStandardNodeMaterial(); - material.colorNode = vec3(0, 0, 0); + material.colorNode = litTerrain; material.roughness = 1.0; material.metalness = 0.0; material.side = THREE.FrontSide; material.fog = false; material.outputNode = Fn(() => { - const lit = applyCustomLighting( - litTerrain, - worldNormal, - sunDirectionUniform, - dayIntensityUniform, - shadeColor, - ); - const fogged = mix(lit, fogColor, fogFactor); - return vec4(fogged, float(1.0)); + return vec4(mix(output.rgb, fogColor, fogFactor), output.a); })(); const terrainUniforms: TerrainUniforms = { From 6e70b8202c93b4dc151d3cffd82362e7d9ca4955 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Sat, 28 Mar 2026 00:33:22 +0800 Subject: [PATCH 61/71] night brightness --- packages/shared/src/systems/shared/world/LightingConfig.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/systems/shared/world/LightingConfig.ts b/packages/shared/src/systems/shared/world/LightingConfig.ts index c7078847c..9ab6cb008 100644 --- a/packages/shared/src/systems/shared/world/LightingConfig.ts +++ b/packages/shared/src/systems/shared/world/LightingConfig.ts @@ -87,7 +87,7 @@ export const SUN_SHADE = { export const NIGHT = { /** Master night brightness (0 = pitch black, 1 = full day brightness). * Controls both the tree shader nightDim floor and scene light intensity bases. */ - BRIGHTNESS: 0.5, + BRIGHTNESS: 0.8, } as const; // ============================================================================ From 18c866a51a3c84d1421b555220b2c91944abda20 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Sat, 28 Mar 2026 01:13:26 +0800 Subject: [PATCH 62/71] feat: per-biome grass tint with terrain-blended roots Add optional tintColor/tintStrength to BiomeGrassConfig so each biome can tint grass tips while keeping roots blended with the terrain. Tint data is passed as a separate per-instance vec4 attribute (instanceGrassTint) interleaved with instanceGroundColor into a single InstancedInterleavedBuffer to stay within WebGPU's 8 vertex buffer limit. Made-with: Cursor --- .../shared/world/GrassVisualManager.ts | 81 ++++++++++++++++--- .../systems/shared/world/TerrainBiomeTypes.ts | 16 +++- .../src/systems/shared/world/TerrainSystem.ts | 58 ++++++++++++- .../shared/src/utils/workers/GrassWorker.ts | 21 +++++ 4 files changed, 159 insertions(+), 17 deletions(-) diff --git a/packages/shared/src/systems/shared/world/GrassVisualManager.ts b/packages/shared/src/systems/shared/world/GrassVisualManager.ts index d5e84cc44..87c89b0b4 100644 --- a/packages/shared/src/systems/shared/world/GrassVisualManager.ts +++ b/packages/shared/src/systems/shared/world/GrassVisualManager.ts @@ -112,6 +112,42 @@ export const GRASS_CONFIG = { SEED: 73856093, }; +// --------------------------------------------------------------------------- +// Interleave groundColor (vec3) + grassTint (vec4) into a single vertex buffer +// to stay within WebGPU's 8-buffer limit. +// --------------------------------------------------------------------------- + +function setColorTintInterleaved( + geo: THREE.BufferGeometry, + groundColors: Float32Array, + grassTints: Float32Array, + count: number, +): void { + const stride = 7; // 3 (color) + 4 (tint) + const buf = new Float32Array(count * stride); + for (let i = 0; i < count; i++) { + const s = i * stride; + const c = i * 3; + const t = i * 4; + buf[s] = groundColors[c]; + buf[s + 1] = groundColors[c + 1]; + buf[s + 2] = groundColors[c + 2]; + buf[s + 3] = grassTints[t]; + buf[s + 4] = grassTints[t + 1]; + buf[s + 5] = grassTints[t + 2]; + buf[s + 6] = grassTints[t + 3]; + } + const ib = new THREE.InstancedInterleavedBuffer(buf, stride); + geo.setAttribute( + "instanceGroundColor", + new THREE.InterleavedBufferAttribute(ib, 3, 0), + ); + geo.setAttribute( + "instanceGrassTint", + new THREE.InterleavedBufferAttribute(ib, 4, 3), + ); +} + // --------------------------------------------------------------------------- // Seeded PRNG // --------------------------------------------------------------------------- @@ -316,6 +352,10 @@ export class GrassVisualManager implements QuadTreeListener { grassWeight: number; grassPlacement: number; grassHeightScale: number; + tintR: number; + tintG: number; + tintB: number; + tintStrength: number; nx: number; ny: number; nz: number; @@ -714,9 +754,11 @@ export class GrassVisualManager implements QuadTreeListener { "instanceRotScaleHash", new THREE.InstancedBufferAttribute(data.rotScaleHash, 3), ); - geo.setAttribute( - "instanceGroundColor", - new THREE.InstancedBufferAttribute(data.groundColors, 3), + setColorTintInterleaved( + geo, + data.groundColors, + data.grassTints, + data.count, ); geo.setAttribute( "instanceGroundNormal", @@ -853,9 +895,11 @@ export class GrassVisualManager implements QuadTreeListener { "instanceRotScaleHash", new THREE.InstancedBufferAttribute(instanceData.rotScaleHash, 3), ); - geo.setAttribute( - "instanceGroundColor", - new THREE.InstancedBufferAttribute(instanceData.groundColors, 3), + setColorTintInterleaved( + geo, + instanceData.groundColors, + instanceData.grassTints, + instanceData.count, ); geo.setAttribute( "instanceGroundNormal", @@ -899,6 +943,7 @@ export class GrassVisualManager implements QuadTreeListener { offsets: Float32Array; rotScaleHash: Float32Array; groundColors: Float32Array; + grassTints: Float32Array; groundNormals: Float32Array; count: number; } | null { @@ -912,6 +957,7 @@ export class GrassVisualManager implements QuadTreeListener { const offsets = new Float32Array(maxCount * 3); const rotScaleHash = new Float32Array(maxCount * 3); const groundColors = new Float32Array(maxCount * 3); + const grassTints = new Float32Array(maxCount * 4); const groundNormals = new Float32Array(maxCount * 3); let count = 0; @@ -938,6 +984,10 @@ export class GrassVisualManager implements QuadTreeListener { b, grassPlacement: rawGP, grassHeightScale, + tintR, + tintG, + tintB, + tintStrength, nx, ny, nz, @@ -964,6 +1014,11 @@ export class GrassVisualManager implements QuadTreeListener { groundColors[count * 3 + 1] = g; groundColors[count * 3 + 2] = b; + grassTints[count * 4] = tintR; + grassTints[count * 4 + 1] = tintG; + grassTints[count * 4 + 2] = tintB; + grassTints[count * 4 + 3] = tintStrength; + groundNormals[count * 3] = nx; groundNormals[count * 3 + 1] = ny; groundNormals[count * 3 + 2] = nz; @@ -977,6 +1032,7 @@ export class GrassVisualManager implements QuadTreeListener { offsets: offsets.slice(0, count * 3), rotScaleHash: rotScaleHash.slice(0, count * 3), groundColors: groundColors.slice(0, count * 3), + grassTints: grassTints.slice(0, count * 4), groundNormals: groundNormals.slice(0, count * 3), count, }; @@ -1116,13 +1172,18 @@ export class GrassVisualManager implements QuadTreeListener { // bypassed so both sides of a blade get the same terrain N·L. mat.normalNode = cameraViewMatrix.transformDirection(terrainNormal); - // Anime shade (half-lambert cool tint + fresnel rim) tints the ALBEDO - // using the terrain normal so grass root color blends with terrain. - // PBR then adds a single Lambert N·L + shadow on top. mat.colorNode = Fn(() => { const groundCol = attribute("instanceGroundColor", "vec3"); + const tint = attribute("instanceGrassTint", "vec4"); + const tintCol = tint.xyz; + const tintStr = tint.w; const t = uv().y; - const tipCol = groundCol.mul(1.4); + const tintedCol = mix(groundCol, tintCol, tintStr); + const tipCol = mix( + groundCol, + tintedCol, + smoothstep(float(0.0), float(1.0), t), + ).mul(1.4); const bladeCol = mix( groundCol, tipCol, diff --git a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts index e096db627..435ccd871 100644 --- a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts +++ b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts @@ -129,6 +129,10 @@ export interface BiomeGrassConfig { patchiness: number; /** World-space noise frequency for patch mask (higher = smaller patches) */ patchScale: number; + /** Optional grass tint color [r, g, b] in 0-1 range. Blended over the terrain color. */ + tintColor?: [number, number, number]; + /** How strongly tintColor is applied (0 = terrain color, 1 = full tint). Default 0. */ + tintStrength?: number; } const FOREST_GRASS_CONFIG: BiomeGrassConfig = { @@ -141,21 +145,25 @@ const FOREST_GRASS_CONFIG: BiomeGrassConfig = { }; const CANYON_GRASS_CONFIG: BiomeGrassConfig = { - density: 0.15, + density: 1.0, maxSlope: 0.15, minGrassWeight: 0.8, - heightScale: 0.7, - patchiness: 0.8, + heightScale: 1.7, + patchiness: 0.95, patchScale: 0.015, + tintColor: [0.35, 0.4, 0.15], + tintStrength: 0.4, }; const TUNDRA_GRASS_CONFIG: BiomeGrassConfig = { density: 0.5, maxSlope: 0.3, minGrassWeight: 0.8, - heightScale: 0.6, + heightScale: 1.0, patchiness: 0.6, patchScale: 0.018, + tintColor: [1.0, 1.0, 1.0], + tintStrength: 0.4, }; const BIOME_GRASS_CONFIGS: Record = { diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts index 12ff2efb3..545c8e2a3 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -2047,6 +2047,19 @@ export class TerrainSystem extends System { const fCfg = getGrassConfigForBiome(BiomeType.Forest); const cCfg = getGrassConfigForBiome(BiomeType.Canyon); + const resolveTint = (cfg: ReturnType) => ({ + density: cfg.density, + maxSlope: cfg.maxSlope, + minGrassWeight: cfg.minGrassWeight, + heightScale: cfg.heightScale, + patchiness: cfg.patchiness, + patchScale: cfg.patchScale, + tintR: cfg.tintColor?.[0] ?? 0, + tintG: cfg.tintColor?.[1] ?? 0, + tintB: cfg.tintColor?.[2] ?? 0, + tintStrength: cfg.tintStrength ?? 0, + }); + const grassConfigs: Record< string, { @@ -2056,11 +2069,15 @@ export class TerrainSystem extends System { heightScale: number; patchiness: number; patchScale: number; + tintR: number; + tintG: number; + tintB: number; + tintStrength: number; } > = { - [BiomeType.Tundra]: { ...tCfg }, - [BiomeType.Forest]: { ...fCfg }, - [BiomeType.Canyon]: { ...cCfg }, + [BiomeType.Tundra]: resolveTint(tCfg), + [BiomeType.Forest]: resolveTint(fCfg), + [BiomeType.Canyon]: resolveTint(cCfg), }; const tileSize = this.CONFIG.TILE_SIZE; @@ -4805,6 +4822,10 @@ export class TerrainSystem extends System { grassWeight: number; grassPlacement: number; grassHeightScale: number; + tintR: number; + tintG: number; + tintB: number; + tintStrength: number; nx: number; ny: number; nz: number; @@ -4884,10 +4905,41 @@ export class TerrainSystem extends System { const grassPlacement = color.grassWeight * density * slopeOk * weightOk * patchMask; + // Biome-blended grass tint (returned separately for tip-only application) + const tintStrength = + (tCfg.tintStrength ?? 0) * tundraW + + (fCfg.tintStrength ?? 0) * forestW + + (cCfg.tintStrength ?? 0) * canyonW; + let tintR = 0, + tintG = 0, + tintB = 0; + if (tintStrength > 0) { + const wR = + (tCfg.tintColor?.[0] ?? 0) * (tCfg.tintStrength ?? 0) * tundraW + + (fCfg.tintColor?.[0] ?? 0) * (fCfg.tintStrength ?? 0) * forestW + + (cCfg.tintColor?.[0] ?? 0) * (cCfg.tintStrength ?? 0) * canyonW; + const wG = + (tCfg.tintColor?.[1] ?? 0) * (tCfg.tintStrength ?? 0) * tundraW + + (fCfg.tintColor?.[1] ?? 0) * (fCfg.tintStrength ?? 0) * forestW + + (cCfg.tintColor?.[1] ?? 0) * (cCfg.tintStrength ?? 0) * canyonW; + const wB = + (tCfg.tintColor?.[2] ?? 0) * (tCfg.tintStrength ?? 0) * tundraW + + (fCfg.tintColor?.[2] ?? 0) * (fCfg.tintStrength ?? 0) * forestW + + (cCfg.tintColor?.[2] ?? 0) * (cCfg.tintStrength ?? 0) * canyonW; + const inv = 1 / tintStrength; + tintR = wR * inv; + tintG = wG * inv; + tintB = wB * inv; + } + return { ...color, grassPlacement, grassHeightScale, + tintR, + tintG, + tintB, + tintStrength, nx: rnx * invLen, ny: rny * invLen, nz: rnz * invLen, diff --git a/packages/shared/src/utils/workers/GrassWorker.ts b/packages/shared/src/utils/workers/GrassWorker.ts index 94e460a5a..2ee2a7991 100644 --- a/packages/shared/src/utils/workers/GrassWorker.ts +++ b/packages/shared/src/utils/workers/GrassWorker.ts @@ -51,6 +51,10 @@ export interface BiomeGrassConfigWorker { heightScale: number; patchiness: number; patchScale: number; + tintR: number; + tintG: number; + tintB: number; + tintStrength: number; } export interface GrassWorkerInput { @@ -111,6 +115,7 @@ export interface GrassWorkerOutput { offsets: Float32Array; rotScaleHash: Float32Array; groundColors: Float32Array; + grassTints: Float32Array; groundNormals: Float32Array; count: number; } @@ -452,6 +457,7 @@ function generateGrassInstances(input) { var offsets = new Float32Array(maxCount * 3); var rotScaleHash = new Float32Array(maxCount * 3); var groundColors = new Float32Array(maxCount * 3); + var grassTints = new Float32Array(maxCount * 4); var groundNormals = new Float32Array(maxCount * 3); var count = 0; @@ -534,6 +540,18 @@ function generateGrassInstances(input) { groundColors[count * 3 + 1] = color.g; groundColors[count * 3 + 2] = color.b; + var tS = tCfg.tintStrength * tundraW + fCfg.tintStrength * forestW + cCfg.tintStrength * canyonW; + if (tS > 0) { + var tR = tCfg.tintR * tCfg.tintStrength * tundraW + fCfg.tintR * fCfg.tintStrength * forestW + cCfg.tintR * cCfg.tintStrength * canyonW; + var tG = tCfg.tintG * tCfg.tintStrength * tundraW + fCfg.tintG * fCfg.tintStrength * forestW + cCfg.tintG * cCfg.tintStrength * canyonW; + var tB = tCfg.tintB * tCfg.tintStrength * tundraW + fCfg.tintB * fCfg.tintStrength * forestW + cCfg.tintB * cCfg.tintStrength * canyonW; + var inv = 1 / tS; + grassTints[count * 4] = tR * inv; + grassTints[count * 4 + 1] = tG * inv; + grassTints[count * 4 + 2] = tB * inv; + grassTints[count * 4 + 3] = tS; + } + groundNormals[count * 3] = rnx * invLen; groundNormals[count * 3 + 1] = rny * invLen; groundNormals[count * 3 + 2] = rnz * invLen; @@ -548,6 +566,7 @@ function generateGrassInstances(input) { offsets: new Float32Array(0), rotScaleHash: new Float32Array(0), groundColors: new Float32Array(0), + grassTints: new Float32Array(0), groundNormals: new Float32Array(0), count: 0 }; @@ -559,6 +578,7 @@ function generateGrassInstances(input) { offsets: offsets.subarray(0, count * 3), rotScaleHash: rotScaleHash.subarray(0, count * 3), groundColors: groundColors.subarray(0, count * 3), + grassTints: grassTints.subarray(0, count * 4), groundNormals: groundNormals.subarray(0, count * 3), count: count }; @@ -574,6 +594,7 @@ self.onmessage = function(e) { if (result.offsets.buffer.byteLength > 0) transfers.push(result.offsets.buffer); if (result.rotScaleHash.buffer.byteLength > 0) transfers.push(result.rotScaleHash.buffer); if (result.groundColors.buffer.byteLength > 0) transfers.push(result.groundColors.buffer); + if (result.grassTints.buffer.byteLength > 0) transfers.push(result.grassTints.buffer); if (result.groundNormals.buffer.byteLength > 0) transfers.push(result.groundNormals.buffer); self.postMessage({ result: result }, transfers); } catch (err) { From b3413f072e42de6d83567c67ea40204c1fb0f671 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Sat, 28 Mar 2026 02:31:35 +0800 Subject: [PATCH 63/71] grass config --- packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts index 435ccd871..2c8cb70e3 100644 --- a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts +++ b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts @@ -148,9 +148,9 @@ const CANYON_GRASS_CONFIG: BiomeGrassConfig = { density: 1.0, maxSlope: 0.15, minGrassWeight: 0.8, - heightScale: 1.7, + heightScale: 1.5, patchiness: 0.95, - patchScale: 0.015, + patchScale: 0.025, tintColor: [0.35, 0.4, 0.15], tintStrength: 0.4, }; From cb4fb391d7e6eb381578b04c1cd053d20ddf7d19 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Sat, 28 Mar 2026 02:48:54 +0800 Subject: [PATCH 64/71] grass config --- packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts index 2c8cb70e3..07fa96d02 100644 --- a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts +++ b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts @@ -156,7 +156,7 @@ const CANYON_GRASS_CONFIG: BiomeGrassConfig = { }; const TUNDRA_GRASS_CONFIG: BiomeGrassConfig = { - density: 0.5, + density: 1.0, maxSlope: 0.3, minGrassWeight: 0.8, heightScale: 1.0, From 99d90bbee9ce763cdbf1a381059cb6d0074e69e5 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Sat, 28 Mar 2026 02:54:38 +0800 Subject: [PATCH 65/71] fix: adjust cloud day-dark color to match sky blue hue Made-with: Cursor --- packages/shared/src/systems/shared/world/SkySystem.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/systems/shared/world/SkySystem.ts b/packages/shared/src/systems/shared/world/SkySystem.ts index 7e085a5b8..43197748b 100644 --- a/packages/shared/src/systems/shared/world/SkySystem.ts +++ b/packages/shared/src/systems/shared/world/SkySystem.ts @@ -1301,7 +1301,7 @@ export class SkySystem extends System { ); const darkColor = mix( vec3(0.024, 0.32, 0.59), - vec3(0.141, 0.807, 0.94), + vec3(0.22, 0.5, 0.85), sunNightStep, ); From c72f0d188358ec422a5593bef3a5f92da620d551 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Sat, 28 Mar 2026 10:32:24 +0800 Subject: [PATCH 66/71] fix: memory leaks, race conditions, and LOD bugs across tree/grass systems WorkerPool: track active tasks per worker and reject them on terminate() so in-flight promises no longer dangle. GLBTreeBatchedInstancer: guard against duplicate addInstance for same entityId; make addToPool atomic (pre-validate all geoIds, use fixed- length ids array aligned with batches). GLBTreeInstancer: add MAX_INSTANCES capacity check in addToPool; check target LOD pool instead of just LOD0; guard duplicate entityId; clone attributes in createSharedGeometry so disposal doesn't corrupt the model cache. ProcgenTreeInstancer: use tracked.preset in removeInstance instead of caller-provided preset; add capacity guard in showInMesh to prevent slot overwrite on wrap. GrassVisualManager: add destroyed flag to guard async callbacks; cancel workerInflight/pendingLodSwap on prune, destroy, invalidate, and rebuild; read latest pendingLodSwap in LOD swap completion for freshest target; dispose lodGeometries in destroy(); deduplicate pendingNodes pushes. Made-with: Cursor --- .../shared/world/GLBTreeBatchedInstancer.ts | 25 ++++++--- .../systems/shared/world/GLBTreeInstancer.ts | 36 ++++++++----- .../shared/world/GrassVisualManager.ts | 51 ++++++++++++++----- .../shared/world/ProcgenTreeInstancer.ts | 16 +++--- .../shared/src/utils/workers/WorkerPool.ts | 23 +++++---- 5 files changed, 102 insertions(+), 49 deletions(-) diff --git a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts index b534c624c..7cf3dc300 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts @@ -579,23 +579,28 @@ function addToPool( variantIndex: number, snowWeight = 0, ): void { - const color = - snowWeight > 0 ? _snowColor.setRGB(1, 1, snowWeight) : _defaultColor; - const ids: number[] = []; for (let i = 0; i < pool.batches.length; i++) { const numVariants = pool.geometryIds[i].length; const clampedIdx = variantIndex % numVariants; - const geoId = pool.geometryIds[i][clampedIdx]; - if (geoId === undefined) { + if (pool.geometryIds[i][clampedIdx] === undefined) { console.warn( - `[GLBTreeBatchedInstancer] geoId undefined: slot=${i} variant=${clampedIdx} available=${numVariants}`, + `[GLBTreeBatchedInstancer] geoId undefined: slot=${i} variant=${clampedIdx} available=${numVariants}, aborting addToPool`, ); - continue; + return; } + } + + const color = + snowWeight > 0 ? _snowColor.setRGB(1, 1, snowWeight) : _defaultColor; + const ids: number[] = new Array(pool.batches.length); + for (let i = 0; i < pool.batches.length; i++) { + const numVariants = pool.geometryIds[i].length; + const clampedIdx = variantIndex % numVariants; + const geoId = pool.geometryIds[i][clampedIdx]; const instId = pool.batches[i].addInstance(geoId); pool.batches[i].setMatrixAt(instId, mat); pool.batches[i].setColorAt(instId, color); - ids.push(instId); + ids[i] = instId; } pool.instanceIds.set(entityId, ids); } @@ -677,6 +682,10 @@ export async function addInstance( ): Promise { if (!scene || !world) return false; + if (entityToTreeType.has(entityId)) { + removeInstance(entityId); + } + try { const pool = await ensureTreeTypePool( treeType, diff --git a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts index 523433539..ba47b7487 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts @@ -112,12 +112,14 @@ function createSharedGeometry( ): THREE.BufferGeometry { const geo = new THREE.BufferGeometry(); for (const name in source.attributes) { - geo.setAttribute(name, source.attributes[name]); + geo.setAttribute(name, source.attributes[name].clone()); } - if (source.index) geo.setIndex(source.index); + if (source.index) geo.setIndex(source.index.clone()); if (source.morphAttributes) { for (const name in source.morphAttributes) { - geo.morphAttributes[name] = source.morphAttributes[name]; + geo.morphAttributes[name] = source.morphAttributes[name].map((a) => + a.clone(), + ); } } if (source.groups.length > 0) { @@ -385,7 +387,12 @@ function composeInstanceMatrix( return _matrix.compose(_position, _quaternion, _scale); } -function addToPool(pool: LODPool, entityId: string, mat: THREE.Matrix4): void { +function addToPool( + pool: LODPool, + entityId: string, + mat: THREE.Matrix4, +): boolean { + if (pool.activeCount >= MAX_INSTANCES) return false; const idx = pool.activeCount; for (const im of pool.meshes) { im.setMatrixAt(idx, mat); @@ -394,6 +401,7 @@ function addToPool(pool: LODPool, entityId: string, mat: THREE.Matrix4): void { pool.slots.set(entityId, idx); pool.activeCount++; pool.dirty = true; + return true; } function removeFromPool(pool: LODPool, entityId: string): void { @@ -463,6 +471,10 @@ export async function addInstance( ): Promise { if (!scene || !world) return false; + if (entityToModel.has(entityId)) { + removeInstance(entityId); + } + try { const pool = await ensureModelPool( modelPath, @@ -471,13 +483,6 @@ export async function addInstance( lod2ModelPath, ); - if (pool.lod0 && pool.lod0.activeCount >= MAX_INSTANCES) { - console.warn( - `[GLBTreeInstancer] LOD0 pool full for ${modelPath}, cannot add ${entityId}`, - ); - return false; - } - // Pick initial LOD based on camera distance to avoid LOD0 pop-in at range let initialLOD: 0 | 1 | 2 = 0; if (world?.camera) { @@ -509,7 +514,14 @@ export async function addInstance( const mat = composeInstanceMatrix(position, rotation, scale, pool.yOffset); const initialPool = initialLOD === 0 ? pool.lod0 : initialLOD === 1 ? pool.lod1 : pool.lod2; - if (initialPool) addToPool(initialPool, entityId, mat); + if (initialPool && !addToPool(initialPool, entityId, mat)) { + console.warn( + `[GLBTreeInstancer] LOD${initialLOD} pool full for ${modelPath}, cannot add ${entityId}`, + ); + pool.instances.delete(entityId); + entityToModel.delete(entityId); + return false; + } return true; } catch (error) { diff --git a/packages/shared/src/systems/shared/world/GrassVisualManager.ts b/packages/shared/src/systems/shared/world/GrassVisualManager.ts index 87c89b0b4..2ae1792a5 100644 --- a/packages/shared/src/systems/shared/world/GrassVisualManager.ts +++ b/packages/shared/src/systems/shared/world/GrassVisualManager.ts @@ -382,6 +382,7 @@ export class GrassVisualManager implements QuadTreeListener { string, { node: TerrainQuadNode; desiredLod: number } >(); + private destroyed = false; constructor( container: THREE.Group, @@ -523,11 +524,11 @@ export class GrassVisualManager implements QuadTreeListener { const nodeKey = this.chunkKey(chunk.node); const workerPool = getGrassWorkerPool(); if (workerPool && this.workerSetup) { + this.pendingLodSwap.set(nodeKey, { + node: chunk.node, + desiredLod, + }); if (!this.workerInflight.has(nodeKey)) { - this.pendingLodSwap.set(nodeKey, { - node: chunk.node, - desiredLod, - }); this.dispatchLodSwap(chunk.node, nodeKey, desiredLod); } } else { @@ -548,6 +549,8 @@ export class GrassVisualManager implements QuadTreeListener { chunk.mesh.geometry.dispose(); this.chunks.delete(key); } + this.workerInflight.delete(key); + this.pendingLodSwap.delete(key); } } @@ -638,9 +641,12 @@ export class GrassVisualManager implements QuadTreeListener { .execute(input) .then((output: GrassWorkerOutput) => { this.workerInflight.delete(key); + if (this.destroyed) return; + + const latest = this.pendingLodSwap.get(key); + const finalLod = latest ? latest.desiredLod : desiredLod; this.pendingLodSwap.delete(key); - // Remove old chunk now that replacement is ready const oldChunk = this.chunks.get(key); if (oldChunk) { if (oldChunk.mesh.parent) oldChunk.mesh.parent.remove(oldChunk.mesh); @@ -649,11 +655,12 @@ export class GrassVisualManager implements QuadTreeListener { } if (output.count === 0) return; - this.createChunkMeshFromWorkerData(node, output, desiredLod); + this.createChunkMeshFromWorkerData(node, output, finalLod); }) .catch((err: unknown) => { this.workerInflight.delete(key); this.pendingLodSwap.delete(key); + if (this.destroyed) return; console.warn( `[GrassVisualManager] LOD swap worker failed for ${key}:`, err, @@ -720,17 +727,22 @@ export class GrassVisualManager implements QuadTreeListener { .execute(input) .then((output: GrassWorkerOutput) => { this.workerInflight.delete(key); + if (this.destroyed) return; if (this.chunks.has(key)) return; if (output.count === 0) return; this.createChunkMeshFromWorkerData(node, output, lod); }) .catch((err: unknown) => { this.workerInflight.delete(key); + if (this.destroyed) return; console.warn( `[GrassVisualManager] Worker failed for ${key}, falling back to sync:`, err, ); - if (!this.chunks.has(key)) { + if ( + !this.chunks.has(key) && + !this.pendingNodes.some((p) => p.node === node) + ) { this.pendingNodes.push({ node }); } }); @@ -792,13 +804,17 @@ export class GrassVisualManager implements QuadTreeListener { onNodeDestroyGeometry(node: TerrainQuadNode): void { const key = this.chunkKey(node); const chunk = this.chunks.get(key); - if (!chunk) return; - if (chunk.mesh.parent) chunk.mesh.parent.remove(chunk.mesh); - chunk.mesh.geometry.dispose(); - this.chunks.delete(key); + if (chunk) { + if (chunk.mesh.parent) chunk.mesh.parent.remove(chunk.mesh); + chunk.mesh.geometry.dispose(); + this.chunks.delete(key); + } + this.workerInflight.delete(key); + this.pendingLodSwap.delete(key); } destroy(): void { + this.destroyed = true; this.pendingNodes.length = 0; this.workerInflight.clear(); this.pendingLodSwap.clear(); @@ -808,6 +824,7 @@ export class GrassVisualManager implements QuadTreeListener { chunk.mesh.geometry.dispose(); } this.chunks.clear(); + this.lodGeometries.forEach((g) => g.dispose()); if (this.material) this.material.dispose(); if (this.container.parent) this.container.parent.remove(this.container); } @@ -823,9 +840,13 @@ export class GrassVisualManager implements QuadTreeListener { chunk.mesh.geometry.dispose(); } this.chunks.clear(); + this.workerInflight.clear(); + this.pendingLodSwap.clear(); for (const node of nodes) { node.visualChunkKey = null; - this.pendingNodes.push({ node }); + if (!this.pendingNodes.some((p) => p.node === node)) { + this.pendingNodes.push({ node }); + } } } @@ -856,9 +877,13 @@ export class GrassVisualManager implements QuadTreeListener { if (chunk.mesh.parent) chunk.mesh.parent.remove(chunk.mesh); chunk.mesh.geometry.dispose(); this.chunks.delete(key); + this.workerInflight.delete(key); + this.pendingLodSwap.delete(key); } for (const node of toRebuild) { - this.pendingNodes.push({ node }); + if (!this.pendingNodes.some((p) => p.node === node)) { + this.pendingNodes.push({ node }); + } } } diff --git a/packages/shared/src/systems/shared/world/ProcgenTreeInstancer.ts b/packages/shared/src/systems/shared/world/ProcgenTreeInstancer.ts index 44db39279..b10d83eff 100644 --- a/packages/shared/src/systems/shared/world/ProcgenTreeInstancer.ts +++ b/packages/shared/src/systems/shared/world/ProcgenTreeInstancer.ts @@ -3245,22 +3245,17 @@ export class ProcgenTreeInstancer { inst.hasGlobalLeaves = false; } - removeInstance(preset: string, id: string, _lodLevel = 0): void { + removeInstance(_preset: string, id: string, _lodLevel = 0): void { const tracked = this.instances.get(id); if (!tracked) return; - // Remove leaves and clusters from global buffers this.removeTreeLeavesFromGlobal(tracked.inst); this.removeTreeClustersFromGlobal(tracked.inst); - // NOTE: Grass exclusion is handled via texture regeneration when needed. - // Call ProceduralGrass.collectAndRefreshExclusionTexture() after major changes. - this.transitions.delete(id); for (let lod = 0; lod < 4; lod++) - this.removeFromLOD(preset, tracked.inst, lod); + this.removeFromLOD(tracked.preset, tracked.inst, lod); - // Remove from spatial partitioning this.removeFromSpatialChunk(id); this.instances.delete(id); @@ -3725,6 +3720,13 @@ export class ProcgenTreeInstancer { } private showInMesh(data: MeshData, inst: TreeInstance): number { + if (data.count >= MAX_INSTANCES) { + console.warn( + `[ProcgenTreeInstancer] MAX_INSTANCES (${MAX_INSTANCES}) reached, cannot show ${inst.id}`, + ); + return -1; + } + const idx = data.nextIdx++; if (data.nextIdx >= MAX_INSTANCES) data.nextIdx = 0; diff --git a/packages/shared/src/utils/workers/WorkerPool.ts b/packages/shared/src/utils/workers/WorkerPool.ts index 11e9e2e88..2b2459fe5 100644 --- a/packages/shared/src/utils/workers/WorkerPool.ts +++ b/packages/shared/src/utils/workers/WorkerPool.ts @@ -19,14 +19,15 @@ type WorkerTask = { reject: (error: Error) => void; }; -interface PoolWorker { +interface PoolWorker { worker: Worker; busy: boolean; taskCount: number; + activeTask?: WorkerTask; } export class WorkerPool { - private workers: PoolWorker[] = []; + private workers: PoolWorker[] = []; private taskQueue: WorkerTask[] = []; private nextWorkerIndex = 0; private terminated = false; @@ -232,19 +233,22 @@ export class WorkerPool { */ terminate(): void { this.terminated = true; - for (const { worker } of this.workers) { - worker.terminate(); + for (const pw of this.workers) { + if (pw.activeTask) { + pw.activeTask.reject(new Error("WorkerPool terminated")); + pw.activeTask = undefined; + } + pw.worker.terminate(); } this.workers = []; - // Reject any pending tasks for (const task of this.taskQueue) { task.reject(new Error("WorkerPool terminated")); } this.taskQueue = []; } - private getAvailableWorker(): PoolWorker | null { + private getAvailableWorker(): PoolWorker | null { // Round-robin with availability check const startIndex = this.nextWorkerIndex; for (let i = 0; i < this.workers.length; i++) { @@ -259,15 +263,17 @@ export class WorkerPool { } private runTask( - poolWorker: PoolWorker, + poolWorker: PoolWorker, task: WorkerTask, ): void { poolWorker.busy = true; + poolWorker.activeTask = task; const handleMessage = (e: MessageEvent) => { poolWorker.worker.removeEventListener("message", handleMessage); poolWorker.worker.removeEventListener("error", handleError); poolWorker.busy = false; + poolWorker.activeTask = undefined; poolWorker.taskCount++; if (e.data.error) { @@ -276,7 +282,6 @@ export class WorkerPool { task.resolve(e.data.result as TOutput); } - // Process next queued task this.processQueue(); }; @@ -284,10 +289,10 @@ export class WorkerPool { poolWorker.worker.removeEventListener("message", handleMessage); poolWorker.worker.removeEventListener("error", handleError); poolWorker.busy = false; + poolWorker.activeTask = undefined; task.reject(new Error(e.message || "Worker error")); - // Process next queued task this.processQueue(); }; From b5b68977eeda029759e0787e582cf821395b5652 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Sat, 28 Mar 2026 10:37:03 +0800 Subject: [PATCH 67/71] fog --- packages/shared/src/systems/shared/world/FogConfig.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/systems/shared/world/FogConfig.ts b/packages/shared/src/systems/shared/world/FogConfig.ts index 7e11d434a..b863e0e18 100644 --- a/packages/shared/src/systems/shared/world/FogConfig.ts +++ b/packages/shared/src/systems/shared/world/FogConfig.ts @@ -49,8 +49,8 @@ import { // Fog distance parameters // smoothstep(NEAR_SQ, FAR_SQ, distSq) gives 0% fog at NEAR, 100% at FAR. // --------------------------------------------------------------------------- -export const FOG_NEAR = 500; -export const FOG_FAR = 1000; +export const FOG_NEAR = 400; +export const FOG_FAR = 800; // Pre-computed squared distances — avoids per-fragment sqrt on the GPU. // Shaders compare dot(toCamera, toCamera) directly against these. From af4f07cf93301b92d4d33cb6efbd05690be10b69 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Sat, 28 Mar 2026 10:53:37 +0800 Subject: [PATCH 68/71] remove hardcoded river carving from canyon height function Canyon's computeCanyonHeight had a hardcoded water carving (riverWidthVar/edge1/edge2) that was always active even with riverWidth: 0. Removed so canyon water features are controlled purely by the rivers/lakes/lakesFalloff config params like other biomes. Made-with: Cursor --- .../shared/world/TerrainHeightParams.ts | 24 ++++--------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts index bb134a982..933873ef6 100644 --- a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts +++ b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts @@ -387,7 +387,7 @@ function computeCanyonHeight( ns: BiomeNoiseSet, coordScale: number, ): { y: number; water: number } { - const { main, variation } = ns; + const { main } = ns; const nx = x * coordScale; const nz = z * coordScale; @@ -401,15 +401,7 @@ function computeCanyonHeight( cfg.lacunarity, cfg.noiseOffset, ); - let terrainNoise = normalizeFbmRange(Math.abs(canyonFbm - cfg.noiseOffset)); - - const riverWidthVar = normalizeFbmRange( - variation.simplexFbm2D(nx + 1000, nz + 1000, 1, 1.0, 0.012, 0.5, 2.0, 0), - ); - const rw = cfg.riverWidth; - const edge1 = (0.2 + rw * 0.25) * (0.75 + riverWidthVar * 0.4); - const edge2 = (0.3 + rw * 0.25) * (0.75 + riverWidthVar * 0.4); - const water = mapRangeSmooth(terrainNoise, edge1, edge2, 1, 0) * 0.2; + const terrainNoise = normalizeFbmRange(Math.abs(canyonFbm - cfg.noiseOffset)); const cliffs = mapRangeSmooth( terrainNoise, @@ -418,9 +410,8 @@ function computeCanyonHeight( 0, 1, ); - const y = cliffs - water; - return { y, water: water * 5 }; + return { y: cliffs, water: 0 }; } // --------------------------------------------------------------------------- @@ -701,15 +692,8 @@ export function buildGetBaseHeightAtJS(): string { var nz = z * coordScale; var canyonFbm = ns.main.simplexFbm2D(nx, nz, cfg.octaves, cfg.amplitude * cfg.canyonAmpScale, cfg.frequency * cfg.canyonFreqScale, cfg.gain, cfg.lacunarity, cfg.noiseOffset); var terrainNoise = _normalizeFbmRange(Math.abs(canyonFbm - cfg.noiseOffset)); - - var riverWidthVar = _normalizeFbmRange(ns.variation.simplexFbm2D(nx + 1000, nz + 1000, 1, 1.0, 0.012, 0.5, 2.0, 0)); - var rw = cfg.riverWidth; - var edge1 = (0.2 + rw * 0.25) * (0.75 + riverWidthVar * 0.4); - var edge2 = (0.3 + rw * 0.25) * (0.75 + riverWidthVar * 0.4); - var water = _mapRangeSmooth(terrainNoise, edge1, edge2, 1, 0) * 0.2; var cliffs = _mapRangeSmooth(terrainNoise, cfg.cliffLow, cfg.cliffHigh, 0, 1); - var y = cliffs - water; - return { y: y, water: water * 5 }; + return { y: cliffs, water: 0 }; } function computeBiomeHeight(x, z, cfg, ns, coordScale) { From 09f23998b5e51e9e16ce771d8b2f32823b87f29d Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Sat, 28 Mar 2026 11:29:26 +0800 Subject: [PATCH 69/71] water: add flow-mapped normals and shift palette to deep blue Replace fixed 4-layer scrolling normals with two-phase flow crossfade (FlowUVW technique from cloud-sea shader) for organic, non-repeating water surface motion. Load waterNormal.png and noise28.png textures with procedural fallbacks. Shift cosine gradient palette from teal/cyan toward deep blue with subtle indigo undertone. Made-with: Cursor --- packages/client/public/textures/noise28.png | Bin 0 -> 390459 bytes .../src/systems/shared/world/WaterSystem.ts | 259 ++++++++++++++---- 2 files changed, 212 insertions(+), 47 deletions(-) create mode 100644 packages/client/public/textures/noise28.png diff --git a/packages/client/public/textures/noise28.png b/packages/client/public/textures/noise28.png new file mode 100644 index 0000000000000000000000000000000000000000..21440201487a7dac4aa728d6a09b403297a72489 GIT binary patch literal 390459 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�Z*>3IU{fAs*}-LyD*U!TYq@8AM6bmU2(z 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у2k8MY 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

LfVw+%dmLyXSeWAaKlYE4v#Ff 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�*JAIktdUJqR_${S(~-3#HTYu98Jm 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%0A8({UM3mn21+uvl(hgKgr97FvH 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�j`+rLaXL33)x=$Ckc8|g`Pgne zr}{rT_SF4S{ijJw>h20dDVZCl?7UsmxV8Zqbe^P!={7z-7N+(5^cHhs&x>LQWf&z{ zd~b)Ii04Aaa`T<<_51hlZ;v`R`%zEjLkJ)xwqg)cv6f3zL!8=+G9-oR_;L3h+wzMd z+i`r?-;uMWQ3*4_hDh>QQl~BL(5`Ex-`loPE)7qGLXo>nu%k_lrEgnwRNRvR*WUPq zwtKuHo4{Y?QEg%moUXIYu$o{4C3aQ#-CgJ+PYm;KxGIK(gnOSs7N7 z!hdrh)oY1l3!cKWzV+Bt^;9B#p=7qaI|R?_ELF|8TJ_wGOZIM&_~Q@`RZ;JyKpQNrlfgJq^&1gU{7O`O102elFh{gQ_*Do zkzm&Xq~S0STOFJL;QX+_U7n?gD5J10k$8##k`KGml^`^LGC4d4m&0rbj1~VbAx!Sh ze5yLUK)EHIwCHfIV*TVT=B`a5N#F1Ec#K@r2E>YFenteK_%GR5VGn?znV1ZCrU5Dc z+k5Y_=}L0T1V3W=BaBCI@g<_>*gq8eofqPSWFPqkbTV-9sKMSzCP>KaPZ$k0{~dM0 zdFhz50?xZilPM8(Nd0xV*6Xo!7b7-@G@i+M3kcdew|?*FF@u@&_rG=jIWSSBbG2c- zHVKdEs|6`8==p#C^Pf)vV8-l0e5!&gW(PNe68P<+;|Ha-W6%z(b1^Na;V zvevz8WVv{_U}29SbtH*7(8d_Y4n;KcD@06};ZM3wuuv<5oiyz3loI)-dmReai)M>> zbrnCQp>Q$0$Ynf@yag9!mONTVh4&9AJ8WZyKp68o?oLXR z69!!NkVN28E}!CVp16<5pcO9o{=xh-Pmqjv9jVkPDj#eK z#=PP7mP!1f6)D%&#I7bV>J z%c-kG7GNrx3yfD5Td4vQis18W!7Xr~k7cjy-}G6z7v;%S<1m7x{6$;R1fwP$t<)A* z2q6^~6T&4*8l6i);~Xe|H1`#Cdq~&_FwMwLWi7?Xn^}#(CAXJ zVTFfk#&oI|Gh*ME*mxwlCYGm|4B%+U{LG$9KQipeBrkLPV;75)y%hK7_HMzKY0~py z^&_a#d0Sr$_4^84KZkrZ1ywHbegZQhl?H{`HH54+ez5yvR{Za zNtf(|tE+(F>im86pXqHF7A}cijFj~5N{rlnr=%ij7^NvVJ4Kh$q@hQ;U70z*Oit1v zpT@u277xp~l`52k&GkUaW|;;h3{4$_sHCQedo%B_){ z2C&Y-v3AFRe?3HcPc|DZ|GYFg>H9i@mac3hvb*C#NmbglxG1eX2KYrjv|r+8{QGK5 zwvEZ>Fxjzq8%re~-kPn>Dy_o#jj{u<%mH_g(sH(Xf1#}KsTXtVE}HxbUq6> zT4GbnoY~K`4n|B!FWDP2ovTh^+VI47(LuzaOw5oqlvX%~nz@WbDx`ER;+pJ+RP*5G zhfxyOx-%KtY~$(qnKXxXD=!|hX&jnbI6-z&`7sn6B&DTT?#`Q?!;O3ZL@#LQ!F+>l0nZuF&ZfwCjv~^o7JkB$soE>#Ot^tMM|iPI_Wv1t_P@Z1LbF-~f3S%Ll?lku_q z1tb6?PJx*fNxE5#-38Zh23nfHpTG7=�uI^hv9?0bU2-ZpWEd$=se>lAnmpg_0xz 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 @@ -196,6 +208,8 @@ export class WaterSystem { // Wind system reference for coordinated wind effects private windSystem: Wind | null = null; + private static _textureLoader = new THREE.TextureLoader(); + constructor(world: World) { this.world = world; } @@ -302,7 +316,38 @@ export class WaterSystem { async init(): Promise { if (this.world.isServer) return; - this.normalTex = await this.createNormalMap(512, 1.0, 42); + const cachedLoader = WaterSystem._textureLoader; + + const loadTex = (url: string): Promise => + new Promise((resolve, reject) => { + cachedLoader.load( + url, + (t) => { + t.wrapS = THREE.RepeatWrapping; + t.wrapT = THREE.RepeatWrapping; + t.magFilter = THREE.LinearFilter; + t.minFilter = THREE.LinearMipmapLinearFilter; + t.generateMipmaps = true; + resolve(t); + }, + undefined, + (e) => reject(e), + ); + }); + + const [normalResult, flowResult] = await Promise.allSettled([ + loadTex("/textures/waterNormal.png"), + loadTex("/textures/noise28.png"), + ]); + + this.normalTex = + normalResult.status === "fulfilled" + ? normalResult.value + : await this.createNormalMap(512, 1.0, 42); + this.flowTex = + flowResult.status === "fulfilled" + ? flowResult.value + : this.createFlowFallback(256); this.foamTex = await this.createFoamTexture(128); // TSL reflector: handles render target, camera mirroring, oblique clipping @@ -311,11 +356,7 @@ export class WaterSystem { this.reflection.target.position.y = this.waterLevel; this.lakeMaterial = this.createLakeMaterial(); - - // Create ocean material without reflections (different visual style) this.oceanMaterial = this.createOceanMaterial(); - - // Lake (reflective) and ocean (non-reflective) shaders initialized } /** @@ -344,13 +385,40 @@ export class WaterSystem { } // ========================================================================== - // PROCEDURAL TEXTURES (Async with yielding to prevent main thread blocking) + // PROCEDURAL TEXTURE FALLBACKS // ========================================================================== - /** - * Create a procedural normal map texture. - * Processes rows in batches, yielding between batches to prevent main thread blocking. - */ + private createFlowFallback(size: number): THREE.Texture { + const data = new Uint8Array(size * size * 4); + let s = 77777; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const nx = x / size, + ny = y / size; + const r = Math.floor( + (Math.sin(nx * 6.28 * 2 + ny * 3.7) * 0.5 + 0.5) * 255, + ); + const g = Math.floor( + (Math.cos(ny * 6.28 * 3 + nx * 2.3) * 0.5 + 0.5) * 255, + ); + s = (s * 1103515245 + 12345) & 0x7fffffff; + const a = (s >>> 8) & 0xff; + const idx = (y * size + x) * 4; + data[idx] = r; + data[idx + 1] = g; + data[idx + 2] = 128; + data[idx + 3] = a; + } + } + const tex = new THREE.DataTexture(data, size, size, THREE.RGBAFormat); + tex.wrapS = tex.wrapT = THREE.RepeatWrapping; + tex.magFilter = THREE.LinearFilter; + tex.minFilter = THREE.LinearMipmapLinearFilter; + tex.generateMipmaps = true; + tex.needsUpdate = true; + return tex; + } + /** * Generate a seamless water normal map using FBM value noise + finite * differences. Produces organic ripple patterns matching a tangent-space @@ -565,6 +633,7 @@ export class WaterSystem { material.fog = false; const nTex = this.normalTex!; + const fTex = this.flowTex!; const foamTex = this.foamTex!; const reflNode = this.reflection!; @@ -707,29 +776,77 @@ export class WaterSystem { ); const waterColor = vec3(cosR, cosG, cosB); - // --- 4-layer scrolling normal noise (matches reference scroll speeds) --- - const baseUV = mul(wUV, float(5)); + // --- Flow-mapped 4-scroll normal noise (FlowUVW two-phase crossfade) --- + const flowSampleUV = mul(wUV, float(WATER.FLOW_UV_SCALE)); + const flowSample = texture(fTex, flowSampleUV); + const flowVec = mul( + sub(mul(flowSample.rg, float(2)), float(1)), + float(WATER.FLOW_STRENGTH), + ); + const flowTime = add(mul(uTime, float(WATER.FLOW_SPEED)), flowSample.a); + + const progressA = fract(flowTime); + const progressB = fract(add(flowTime, float(0.5))); + const weightA = sub( + float(1), + abs(sub(mul(progressA, float(2)), float(1))), + ); + const weightB = sub( + float(1), + abs(sub(mul(progressB, float(2)), float(1))), + ); + + const jumpVec = vec2( + float(WATER.FLOW_JUMP[0]), + float(WATER.FLOW_JUMP[1]), + ); + + // Phase A: flow-distorted base UV + const baseA = add( + mul( + sub(wUV, mul(flowVec, add(progressA, float(WATER.FLOW_OFFSET)))), + float(5), + ), + mul(sub(flowTime, progressA), jumpVec), + ); + // Phase B: offset by 0.5 to avoid sampling same location + const baseB = add( + add( + mul( + sub(wUV, mul(flowVec, add(progressB, float(WATER.FLOW_OFFSET)))), + float(5), + ), + float(0.5), + ), + mul(sub(flowTime, progressB), jumpVec), + ); + + // Phase A: scroll layers 0 + 2 (large + ultra-fine scale) const nUV0 = add( - div(baseUV, float(103)), + div(baseA, float(103)), vec2(div(uTime, float(17)), div(uTime, float(29))), ); - const nUV1 = add( - div(baseUV, float(107)), - vec2(div(uTime, float(19)), mul(div(uTime, float(31)), float(-1))), - ); const nUV2 = add( - vec2(div(baseUV.x, float(8907)), div(baseUV.y, float(9803))), + vec2(div(baseA.x, float(8907)), div(baseA.y, float(9803))), vec2(div(uTime, float(101)), div(uTime, float(97))), ); + // Phase B: scroll layers 1 + 3 (large + medium-fine scale) + const nUV1 = add( + div(baseB, float(107)), + vec2(div(uTime, float(19)), mul(div(uTime, float(31)), float(-1))), + ); const nUV3 = add( - vec2(div(baseUV.x, float(1091)), div(baseUV.y, float(1027))), + vec2(div(baseB.x, float(1091)), div(baseB.y, float(1027))), vec2(mul(div(uTime, float(109)), float(-1)), div(uTime, float(113))), ); - const n0 = texture(nTex, nUV0); - const n1 = texture(nTex, nUV1); - const n2 = texture(nTex, nUV2); - const n3 = texture(nTex, nUV3); - const noiseSum = add(add(add(n0, n1), n2), n3); + + const noiseSum = mul( + add( + mul(add(texture(nTex, nUV0), texture(nTex, nUV2)), weightA), + mul(add(texture(nTex, nUV1), texture(nTex, nUV3)), weightB), + ), + float(2), + ); const noise = sub(mul(noiseSum, float(0.5)), float(1)); const surfaceNormal = normalize( vec3( @@ -890,6 +1007,7 @@ export class WaterSystem { material.fog = false; const nTex = this.normalTex!; + const fTex = this.flowTex!; const foamTex = this.foamTex!; const wavePhase = ( @@ -1013,27 +1131,72 @@ export class WaterSystem { ); const waterColor = vec3(cosR, cosG, cosB); - // --- 4-layer normal noise (matches reference scroll speeds) --- - const baseUV = mul(wUV, float(5)); + // --- Flow-mapped 4-scroll normal noise (FlowUVW two-phase crossfade) --- + const flowSampleUV = mul(wUV, float(WATER.FLOW_UV_SCALE)); + const flowSample = texture(fTex, flowSampleUV); + const flowVec = mul( + sub(mul(flowSample.rg, float(2)), float(1)), + float(WATER.FLOW_STRENGTH), + ); + const flowTime = add(mul(uTime, float(WATER.FLOW_SPEED)), flowSample.a); + + const progressA = fract(flowTime); + const progressB = fract(add(flowTime, float(0.5))); + const weightA = sub( + float(1), + abs(sub(mul(progressA, float(2)), float(1))), + ); + const weightB = sub( + float(1), + abs(sub(mul(progressB, float(2)), float(1))), + ); + + const jumpVec = vec2( + float(WATER.FLOW_JUMP[0]), + float(WATER.FLOW_JUMP[1]), + ); + + const baseA = add( + mul( + sub(wUV, mul(flowVec, add(progressA, float(WATER.FLOW_OFFSET)))), + float(5), + ), + mul(sub(flowTime, progressA), jumpVec), + ); + const baseB = add( + add( + mul( + sub(wUV, mul(flowVec, add(progressB, float(WATER.FLOW_OFFSET)))), + float(5), + ), + float(0.5), + ), + mul(sub(flowTime, progressB), jumpVec), + ); + const nUV0 = add( - div(baseUV, float(103)), + div(baseA, float(103)), vec2(div(uTime, float(17)), div(uTime, float(29))), ); - const nUV1 = add( - div(baseUV, float(107)), - vec2(div(uTime, float(19)), mul(div(uTime, float(31)), float(-1))), - ); const nUV2 = add( - vec2(div(baseUV.x, float(8907)), div(baseUV.y, float(9803))), + vec2(div(baseA.x, float(8907)), div(baseA.y, float(9803))), vec2(div(uTime, float(101)), div(uTime, float(97))), ); + const nUV1 = add( + div(baseB, float(107)), + vec2(div(uTime, float(19)), mul(div(uTime, float(31)), float(-1))), + ); const nUV3 = add( - vec2(div(baseUV.x, float(1091)), div(baseUV.y, float(1027))), + vec2(div(baseB.x, float(1091)), div(baseB.y, float(1027))), vec2(mul(div(uTime, float(109)), float(-1)), div(uTime, float(113))), ); - const noiseSum = add( - add(add(texture(nTex, nUV0), texture(nTex, nUV1)), texture(nTex, nUV2)), - texture(nTex, nUV3), + + const noiseSum = mul( + add( + mul(add(texture(nTex, nUV0), texture(nTex, nUV2)), weightA), + mul(add(texture(nTex, nUV1), texture(nTex, nUV3)), weightB), + ), + float(2), ); const noise = sub(mul(noiseSum, float(0.5)), float(1)); const surfaceNormal = normalize( @@ -1088,7 +1251,7 @@ export class WaterSystem { const fresnelSky = pow(sub(float(1), NdotV), float(4)); color = add( color, - mul(vec3(0.4, 0.5, 0.65), mul(fresnelSky, float(0.2))), + mul(vec3(0.38, 0.42, 0.68), mul(fresnelSky, float(0.2))), ); // --- Foam (more whitecaps on ocean) --- @@ -1108,7 +1271,7 @@ export class WaterSystem { const foamIntensity = mul(crestFoam, foamPattern); color = mix( color, - vec3(0.9, 0.92, 0.95), + vec3(0.9, 0.91, 0.96), clamp(foamIntensity, float(0), float(0.75)), ); @@ -1465,9 +1628,11 @@ export class WaterSystem { this.oceanMaterial?.dispose(); this.oceanMaterial = undefined; - // Dispose procedural textures + // Dispose textures this.normalTex?.dispose(); this.normalTex = undefined; + this.flowTex?.dispose(); + this.flowTex = undefined; this.foamTex?.dispose(); this.foamTex = undefined; From bdb086c99da85825491f8a7cc77c3b35e7c6dfbd Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Sat, 28 Mar 2026 12:11:04 +0800 Subject: [PATCH 70/71] remove unused Fir tree type, add green to water color Remove TreeId.Fir and TREE_TYPES.fir since Fir is not used in any biome config or woodcutting manifest. Bump water green offset from -0.30 to -0.22 for a blue-green tint. Made-with: Cursor --- packages/shared/src/constants/TreeTypes.ts | 2 -- packages/shared/src/systems/shared/world/WaterSystem.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/shared/src/constants/TreeTypes.ts b/packages/shared/src/constants/TreeTypes.ts index d54cc94b1..4276d6ec4 100644 --- a/packages/shared/src/constants/TreeTypes.ts +++ b/packages/shared/src/constants/TreeTypes.ts @@ -20,7 +20,6 @@ * Use this instead of hardcoded "tree_xxx" strings everywhere. */ export enum TreeId { - Fir = "tree_fir", Pine = "tree_pine", Oak = "tree_oak", Birch = "tree_birch", @@ -85,7 +84,6 @@ export interface TreeTypeDefinition { * in TerrainBiomeTypes.ts. */ export const TREE_TYPES = { - fir: { name: "Fir Tree", levelRequired: 1 }, pine: { name: "Pine Tree", levelRequired: 1, snowCapable: true }, oak: { name: "Oak Tree", levelRequired: 15 }, birch: { name: "Birch Tree", levelRequired: 1 }, diff --git a/packages/shared/src/systems/shared/world/WaterSystem.ts b/packages/shared/src/systems/shared/world/WaterSystem.ts index 0bded4643..71ecde85a 100644 --- a/packages/shared/src/systems/shared/world/WaterSystem.ts +++ b/packages/shared/src/systems/shared/world/WaterSystem.ts @@ -89,7 +89,7 @@ const WATER = { COS_PHASES: [0.5, 0.48, 0.5] as const, COS_AMPLITUDES: [0.04, 0.16, 0.15] as const, COS_FREQUENCIES: [0.5, 0.48, 0.5] as const, - COS_OFFSETS: [-0.46, -0.3, -0.03] as const, + COS_OFFSETS: [-0.46, -0.22, -0.03] as const, // Normal noise strength (xz multiplier for surface normal) NORMAL_STRENGTH: 1.5, From f933ed0035e9b5a105a0a078a1a292ceb6a81f62 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Sat, 28 Mar 2026 12:57:45 +0800 Subject: [PATCH 71/71] fix: move snow weight to batch color G channel to coexist with dissolve Main's dissolve system uses the B channel (1.0 - dissolveVal), which collided with our snow weight that was also in B. Reassign channels: R = highlight, G = snow weight, B = dissolve. Update shader reads in GPUMaterials to match (snow from batchColor.y, highlight from batchColor.x only). Made-with: Cursor --- .../shared/world/GLBTreeBatchedInstancer.ts | 30 +++++++++++-------- .../src/systems/shared/world/GPUMaterials.ts | 11 ++++--- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts index 7a9674d47..534da7251 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts @@ -44,9 +44,10 @@ const _scale = new THREE.Vector3(); // ---- Batch color channel layout ---- // R = highlight intensity (1.0 = normal, >1.0 = highlighted via HL_COLOR_INTENSITY) -// G = highlight intensity (same as R — shader detects highlight via step(1.01, max(R, G))) +// G = biome snow weight (0.0 = no snow, 1.0 = full snow) — set once on add/LOD swap // B = 1.0 - dissolveVal (1.0 = fully visible, 0.0 = fully dissolved) -// Only modify channels through applyHighlightColor (R/G) and applyDissolveColor (B). +// Only modify channels through applyHighlightColor (R), applyDissolveColor (B), +// and addToPool (G for snow). // NOTE: If the underlying color buffer is Uint8 (256 levels), dissolve precision is // ~0.004 per step. At 0.3s duration / 60fps (~18 steps) this is more than sufficient. const _defaultColor = new THREE.Color(1, 1, 1); @@ -533,6 +534,7 @@ function addToPool( mat: THREE.Matrix4, variantIndex: number, dissolve = 0, + snowWeight = 0, ): void { for (let i = 0; i < pool.batches.length; i++) { const numVariants = pool.geometryIds[i].length; @@ -546,18 +548,14 @@ function addToPool( } const ids: number[] = new Array(pool.batches.length); + _tmpColor.setRGB(1, snowWeight, 1.0 - dissolve); for (let i = 0; i < pool.batches.length; i++) { const numVariants = pool.geometryIds[i].length; const clampedIdx = variantIndex % numVariants; const geoId = pool.geometryIds[i][clampedIdx]; const instId = pool.batches[i].addInstance(geoId); pool.batches[i].setMatrixAt(instId, mat); - if (dissolve > 0) { - _tmpColor.setRGB(1, 1, 1.0 - dissolve); - pool.batches[i].setColorAt(instId, _tmpColor); - } else { - pool.batches[i].setColorAt(instId, _defaultColor); - } + pool.batches[i].setColorAt(instId, _tmpColor); ids[i] = instId; } pool.instanceIds.set(entityId, ids); @@ -586,11 +584,11 @@ function applyHighlightColor( ): void { const ids = pool.instanceIds.get(entityId); if (!ids) return; - const rg = on ? HL_COLOR_INTENSITY : 1.0; + const r = on ? HL_COLOR_INTENSITY : 1.0; for (let i = 0; i < pool.batches.length; i++) { - // Preserve blue channel (encodes dissolve state) + // Only modify R (highlight); preserve G (snow weight) and B (dissolve) pool.batches[i].getColorAt(ids[i], _tmpColor); - _tmpColor.setRGB(rg, rg, _tmpColor.b); + _tmpColor.setRGB(r, _tmpColor.g, _tmpColor.b); pool.batches[i].setColorAt(ids[i], _tmpColor); } } @@ -701,7 +699,14 @@ export async function addInstance( const initialPool = initialLOD === 0 ? pool.lod0 : initialLOD === 1 ? pool.lod1 : pool.lod2; if (initialPool) - addToPool(initialPool, entityId, mat, variantIndex, initialDissolve); + addToPool( + initialPool, + entityId, + mat, + variantIndex, + initialDissolve, + snowWeight, + ); return true; } catch (error) { @@ -941,6 +946,7 @@ export function updateGLBTreeBatchedInstancer(deltaTime: number): void { mat, slot.variantIndex, wasDissolveVal, + slot.snowWeight, ); if (wasHl) applyHighlightColor(newPool, slot.entityId, true); } diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index 4490c11fe..5d342d4f0 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -130,9 +130,8 @@ export const GPU_VEG_CONFIG = { NEAR_CAMERA_FADE_END: 0.05, // ========== TREE DEPLETION DISSOLVE ========== - // BatchedMesh encodes dissolve in the **blue channel** of per-instance batch - // colors (blue = 1.0 - dissolveVal). R/G channels carry highlight intensity. - // See GLBTreeBatchedInstancer.applyDissolveColor / applyHighlightColor. + // BatchedMesh channel layout: R = highlight (1.0 or >1.0), G = snow weight, + // B = 1.0 - dissolveVal. See GLBTreeBatchedInstancer channel layout comment. // InstancedMesh uses a dedicated per-instance `instanceDissolve` float attribute. /** @@ -1191,7 +1190,7 @@ export function createTreeDissolveMaterial( } const batchColor = varyingProperty("vec3", "vBatchColor"); - const biomeSnowStrength = clamp(batchColor.z, float(0.0), float(1.0)); + const biomeSnowStrength = clamp(batchColor.y, float(0.0), float(1.0)); const snowBase = vec3(...SNOW_COLOR); const snowAO = vec3(...SNOW_AO_TINT); const snowCol = mix(snowAO, snowBase, aoFactor); @@ -1274,8 +1273,8 @@ export function createTreeDissolveMaterial( let hlIntensity; if (options.batched) { const batchColor = varyingProperty("vec3", "vBatchColor"); - // Only check R/G for highlight — blue channel is reserved for dissolve state - hlIntensity = step(float(1.01), max(batchColor.x, batchColor.y)); + // Only check R for highlight — G = snow weight, B = dissolve state + hlIntensity = step(float(1.01), batchColor.x); } else { hlIntensity = attribute("instanceHighlight", "float"); }