From 6e79a9f4fcd48448b12f23aa3cd68a31f4524576 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 3 Mar 2026 00:21:40 +0800 Subject: [PATCH 01/48] feat(trees): GLB model variants with shader-based highlight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace procgen tree rendering with GLB model loading via modelVariants array in woodcutting manifest. Consolidate tree types to single fir tree with 5 model variations. Refactor GLBTreeInstancer to support multi-material/multi-primitive GLB meshes, dynamic collision proxy sizing, double-sided leaf textures with repeat wrapping, and per-instance fresnel rim highlight driven by an InstancedBufferAttribute — eliminating the previous highlight mesh overlay and outline post-process pass for trees. Made-with: Cursor --- packages/shared/src/constants/TreeTypes.ts | 69 +++ packages/shared/src/data/DataManager.ts | 34 +- .../src/entities/world/ResourceEntity.ts | 14 + .../world/visuals/ResourceVisualStrategy.ts | 3 + .../world/visuals/TreeGLBVisualStrategy.ts | 23 +- .../world/visuals/createVisualStrategy.ts | 5 +- .../services/EntityHighlightService.ts | 34 +- .../src/systems/shared/entities/Entities.ts | 1 + .../systems/shared/entities/ResourceSystem.ts | 19 + .../shared/world/BiomeResourceGenerator.ts | 22 +- .../systems/shared/world/GLBTreeInstancer.ts | 404 +++++++++++------- .../src/systems/shared/world/GPUVegetation.ts | 44 ++ .../src/systems/shared/world/TerrainSystem.ts | 9 +- .../shared/src/types/entities/entities.ts | 6 + packages/shared/src/types/world/terrain.ts | 13 +- 15 files changed, 494 insertions(+), 206 deletions(-) create mode 100644 packages/shared/src/constants/TreeTypes.ts diff --git a/packages/shared/src/constants/TreeTypes.ts b/packages/shared/src/constants/TreeTypes.ts new file mode 100644 index 000000000..7d64d01cd --- /dev/null +++ b/packages/shared/src/constants/TreeTypes.ts @@ -0,0 +1,69 @@ +/** + * Tree Types — Single Source of Truth + * + * All tree type definitions live here. Every other file that needs tree type + * information imports from this module instead of defining its own copy. + * + * To add a new tree type: add an entry to TREE_TYPES below. + * To rename a tree type: change the key and update the manifest ID accordingly. + * To remove a tree type: delete the entry. + * + * The manifest (woodcutting.json) must have a matching "id": "tree_" entry + * for each tree type listed here. The manifest holds runtime data (drops, XP, + * model paths) while this file holds the structural/gameplay config. + */ + +export interface TreeTypeDefinition { + /** Display name shown in UI (e.g., "Oak Tree") */ + name: string; + /** Woodcutting level required to chop */ + levelRequired: number; + /** Spawn weight in default biome (0 = doesn't spawn by default) */ + spawnWeight: number; +} + +/** + * Master tree type registry. + * + * Keys are subtypes (e.g., "oak"). The manifest ID is "tree_". + * Add, rename, or remove entries here — everything else derives from this. + */ +export const TREE_TYPES = { + fir: { + name: "Fir Tree", + levelRequired: 1, + spawnWeight: 100, + }, +} as const satisfies Record; + +/** All valid tree subtype keys (e.g., "oak", "willow") */ +export type TreeSubType = keyof typeof TREE_TYPES; + +/** All valid tree subtype keys as a runtime array */ +export const TREE_SUBTYPE_KEYS = Object.keys(TREE_TYPES) as TreeSubType[]; + +/** + * Get the level requirement for a tree subtype. + * Returns 1 for unknown types. + */ +export function getTreeLevelRequired(subType: string): number { + return ( + (TREE_TYPES as Record)[subType] + ?.levelRequired ?? 1 + ); +} + +/** + * Build the spawn distribution map for BiomeTreeConfig. + * Only includes types with spawnWeight > 0. + * Keys are "tree_" to match manifest IDs. + */ +export function getDefaultTreeDistribution(): Record { + const distribution: Record = {}; + for (const [key, def] of Object.entries(TREE_TYPES)) { + if (def.spawnWeight > 0) { + distribution[`tree_${key}`] = def.spawnWeight; + } + } + return distribution; +} diff --git a/packages/shared/src/data/DataManager.ts b/packages/shared/src/data/DataManager.ts index 462a29970..4eb6dae52 100644 --- a/packages/shared/src/data/DataManager.ts +++ b/packages/shared/src/data/DataManager.ts @@ -169,6 +169,8 @@ export interface ExternalResourceData { * If specified, runtime procedural generation will be used instead of GLB model. */ procgenPreset?: string; + /** Multiple GLB model paths for visual variation (hash-picked per instance) */ + modelVariants?: string[]; scale: number; depletedScale: number; harvestSkill: string; @@ -1521,16 +1523,42 @@ export class DataManager { const gatheringDir = path.join(manifestsDir, "gathering"); - // Load woodcutting (trees) + // Local manifests directory (in main codebase, takes priority over git repo) + const localManifestsDir = path.join( + manifestsDir, + "..", + "..", + "..", + "manifests", + "gathering", + ); + + // Load woodcutting (trees) — check local manifest first, then fall back to git repo try { - const woodcuttingPath = path.join(gatheringDir, "woodcutting.json"); - const woodcuttingData = await fs.readFile(woodcuttingPath, "utf-8"); + let woodcuttingData: string | null = null; + let source = ""; + + // Try local manifest first (packages/server/manifests/gathering/woodcutting.json) + try { + const localPath = path.join(localManifestsDir, "woodcutting.json"); + woodcuttingData = await fs.readFile(localPath, "utf-8"); + source = localPath; + } catch { + // Local not found, fall back to git repo manifest + const repoPath = path.join(gatheringDir, "woodcutting.json"); + woodcuttingData = await fs.readFile(repoPath, "utf-8"); + source = repoPath; + } + const woodcuttingManifest = JSON.parse( woodcuttingData, ) as WoodcuttingManifest; for (const tree of woodcuttingManifest.trees) { resourcesMap.set(tree.id, tree); } + console.log( + `[DataManager] ✅ Loaded woodcutting manifest (${woodcuttingManifest.trees.length} trees) from: ${source}`, + ); } catch { console.warn( "[DataManager] gathering/woodcutting.json not found, trying legacy resources.json", diff --git a/packages/shared/src/entities/world/ResourceEntity.ts b/packages/shared/src/entities/world/ResourceEntity.ts index 22bc4448d..aa3b3e135 100644 --- a/packages/shared/src/entities/world/ResourceEntity.ts +++ b/packages/shared/src/entities/world/ResourceEntity.ts @@ -134,6 +134,18 @@ export class ResourceEntity extends InteractableEntity { return null; } + /** + * Shader-based highlight toggle. Returns true if the strategy handled it, + * false if EntityHighlightService should fall back to the outline pass. + */ + public setShaderHighlight(on: boolean): boolean { + if (typeof this.visual.setShaderHighlight === "function") { + this.visual.setShaderHighlight(this.getVisualCtx(), on); + return true; + } + return false; + } + // =========================================================================== // Collision // =========================================================================== @@ -357,6 +369,7 @@ export class ResourceEntity extends InteractableEntity { depletedModelScale: this.config.depletedModelScale, depletedModelPath: this.config.depletedModelPath, procgenPreset: this.config.procgenPreset, + modelVariants: this.config.modelVariants, } as EntityData; } @@ -377,6 +390,7 @@ export class ResourceEntity extends InteractableEntity { depletedModelScale: this.config.depletedModelScale, depletedModelPath: this.config.depletedModelPath, procgenPreset: this.config.procgenPreset, + modelVariants: this.config.modelVariants, }; } diff --git a/packages/shared/src/entities/world/visuals/ResourceVisualStrategy.ts b/packages/shared/src/entities/world/visuals/ResourceVisualStrategy.ts index 5920f7826..5d250d485 100644 --- a/packages/shared/src/entities/world/visuals/ResourceVisualStrategy.ts +++ b/packages/shared/src/entities/world/visuals/ResourceVisualStrategy.ts @@ -57,4 +57,7 @@ export interface ResourceVisualStrategy { /** Return a temporary mesh positioned at this instance for the outline pass. */ getHighlightMesh?(ctx: ResourceVisualContext): THREE.Object3D | null; + + /** Shader-based rim highlight: set on/off per instance (no extra meshes). */ + setShaderHighlight?(ctx: ResourceVisualContext, on: boolean): void; } diff --git a/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts b/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts index 597323101..d7d45cc8f 100644 --- a/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts +++ b/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts @@ -12,7 +12,8 @@ import { removeInstance as removeGLBTreeInstance, setDepleted as setGLBTreeDepleted, hasDepleted as hasGLBTreeDepleted, - getHighlightMesh as getGLBTreeHighlightMesh, + setHighlight as setGLBTreeHighlight, + getModelDimensions as getGLBModelDimensions, updateGLBTreeInstancer, } from "../../../systems/shared/world/GLBTreeInstancer"; import type { @@ -21,8 +22,9 @@ import type { } from "./ResourceVisualStrategy"; function createCollisionProxy(ctx: ResourceVisualContext, scale: number): void { - const height = 8 * scale; - const radius = 1 * scale; + const dims = getGLBModelDimensions(ctx.id); + const height = (dims?.height ?? 8) * scale; + const radius = (dims?.radius ?? 1) * scale; const geometry = new THREE.CylinderGeometry(radius, radius, height, 6); const material = new MeshBasicNodeMaterial(); material.visible = false; @@ -46,7 +48,14 @@ function createCollisionProxy(ctx: ResourceVisualContext, scale: number): void { export class TreeGLBVisualStrategy implements ResourceVisualStrategy { async createVisual(ctx: ResourceVisualContext): Promise { const { config, id, position } = ctx; - if (!config.model) return; + + // Pick model: hash-select from variants or fall back to direct modelPath + let modelPath = config.model; + if (config.modelVariants?.length) { + const hash = ctx.hashString(id) >>> 0; + modelPath = config.modelVariants[hash % config.modelVariants.length]; + } + if (!modelPath) return; const baseScale = config.modelScale ?? 3.0; const worldPos = new THREE.Vector3(); @@ -58,7 +67,7 @@ export class TreeGLBVisualStrategy implements ResourceVisualStrategy { const rotation = ((rotHash % 1000) / 1000) * Math.PI * 2; const success = await addGLBTreeInstance( - config.model, + modelPath, id, worldPos, rotation, @@ -82,8 +91,8 @@ export class TreeGLBVisualStrategy implements ResourceVisualStrategy { return hasGLBTreeDepleted(ctx.id); } - getHighlightMesh(ctx: ResourceVisualContext): THREE.Object3D | null { - return getGLBTreeHighlightMesh(ctx.id); + setShaderHighlight(ctx: ResourceVisualContext, on: boolean): void { + setGLBTreeHighlight(ctx.id, on); } async onRespawn(ctx: ResourceVisualContext): Promise { diff --git a/packages/shared/src/entities/world/visuals/createVisualStrategy.ts b/packages/shared/src/entities/world/visuals/createVisualStrategy.ts index 4e10dc2f1..445c70380 100644 --- a/packages/shared/src/entities/world/visuals/createVisualStrategy.ts +++ b/packages/shared/src/entities/world/visuals/createVisualStrategy.ts @@ -25,7 +25,10 @@ export function createVisualStrategy( if (config.resourceType === "tree" && config.procgenPreset) return new TreeProcgenVisualStrategy(); - if (config.resourceType === "tree" && hasModel(config)) + if ( + config.resourceType === "tree" && + (config.modelVariants?.length || hasModel(config)) + ) return new TreeGLBVisualStrategy(); // if (hasModel(config)) return new StandardModelVisualStrategy(); diff --git a/packages/shared/src/systems/client/interaction/services/EntityHighlightService.ts b/packages/shared/src/systems/client/interaction/services/EntityHighlightService.ts index c3cd7cfa4..f1d051222 100644 --- a/packages/shared/src/systems/client/interaction/services/EntityHighlightService.ts +++ b/packages/shared/src/systems/client/interaction/services/EntityHighlightService.ts @@ -56,6 +56,8 @@ export class EntityHighlightService { private composer: PostProcessingComposer | null = null; /** Temporary highlight mesh added to the scene for instanced entities */ private activeHighlightMesh: THREE.Object3D | null = null; + /** Entity using shader-based rim highlight (needs clearing on un-hover) */ + private shaderHighlightEntity: Record | null = null; constructor(private world: World) {} @@ -81,18 +83,31 @@ export class EntityHighlightService { const newId = target?.entityId ?? null; if (newId === this.currentTargetId) return; + this.clearShaderHighlight(); this.removeActiveHighlightMesh(); this.currentTargetId = newId; - if (!this.composer) return; - if (!target || !target.entity) { - this.composer.setOutlineObjects([]); + if (this.composer) this.composer.setOutlineObjects([]); return; } - // Try instanced highlight path first + // Try shader-based rim highlight first (no extra meshes / draw calls) const entity = target.entity as unknown as Record; + if (typeof entity.setShaderHighlight === "function") { + const handled = (entity.setShaderHighlight as (on: boolean) => boolean)( + true, + ); + if (handled) { + this.shaderHighlightEntity = entity; + if (this.composer) this.composer.setOutlineObjects([]); + return; + } + } + + if (!this.composer) return; + + // Try instanced highlight path (legacy) if (typeof entity.getHighlightRoot === "function") { const hlRoot = (entity.getHighlightRoot as () => THREE.Object3D | null)(); if (hlRoot) { @@ -134,6 +149,7 @@ export class EntityHighlightService { clearHover(): void { if (this.currentTargetId === null) return; this.currentTargetId = null; + this.clearShaderHighlight(); this.removeActiveHighlightMesh(); if (this.composer) { this.composer.setOutlineObjects([]); @@ -147,6 +163,16 @@ export class EntityHighlightService { } } + private clearShaderHighlight(): void { + if (this.shaderHighlightEntity) { + const entity = this.shaderHighlightEntity as { + setShaderHighlight?: (on: boolean) => boolean; + }; + entity.setShaderHighlight?.(false); + this.shaderHighlightEntity = null; + } + } + /** * Collect all Mesh objects from an entity's scene graph node. * Uses the visual mesh (not raycast proxies) for accurate outlines. diff --git a/packages/shared/src/systems/shared/entities/Entities.ts b/packages/shared/src/systems/shared/entities/Entities.ts index 747ff8faf..692e141d4 100644 --- a/packages/shared/src/systems/shared/entities/Entities.ts +++ b/packages/shared/src/systems/shared/entities/Entities.ts @@ -724,6 +724,7 @@ export class Entities extends SystemBase implements IEntities { depletedModelPath: (data as { depletedModelPath?: string }) .depletedModelPath, procgenPreset: (data as { procgenPreset?: string }).procgenPreset, + modelVariants: (data as { modelVariants?: string[] }).modelVariants, }; const ResourceEntityClass = getEntityType("resource")!; diff --git a/packages/shared/src/systems/shared/entities/ResourceSystem.ts b/packages/shared/src/systems/shared/entities/ResourceSystem.ts index 2c7523451..98942f7d9 100644 --- a/packages/shared/src/systems/shared/entities/ResourceSystem.ts +++ b/packages/shared/src/systems/shared/entities/ResourceSystem.ts @@ -970,6 +970,11 @@ export class ResourceSystem extends SystemBase { resource.type, spawnPoint.subType, ), + // Model variants for visual variation (hash-picked per instance) + modelVariants: this.getModelVariantsForResource( + resource.type, + spawnPoint.subType, + ), // OSRS-ACCURACY: Tile-based positioning for face direction and interaction footprint, anchorTile, @@ -1114,6 +1119,20 @@ export class ResourceSystem extends SystemBase { return manifestData.procgenPreset; } + /** + * Get model variants for resource type from manifest. + * Returns undefined if not specified (single model or procgen). + */ + private getModelVariantsForResource( + type: string, + subType?: string, + ): string[] | undefined { + const variantKey = subType ? `${type}_${subType}` : `${type}_normal`; + const manifestData = getExternalResource(variantKey); + if (!manifestData?.modelVariants?.length) return undefined; + return manifestData.modelVariants; + } + /** * Get drops for resource type from manifest * Fails fast if manifest data not found diff --git a/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts b/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts index 129f5fe7f..b23079f1a 100644 --- a/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts +++ b/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts @@ -22,6 +22,7 @@ import type { ResourceSubType, } from "../../../types/world/terrain"; import type { VegetationInstance } from "../../../types/world/world-types"; +import { getTreeLevelRequired } from "../../../constants/TreeTypes"; /** * Context provided by TerrainSystem for resource generation. @@ -46,19 +47,13 @@ export interface ResourceGenerationContext { } /** - * Level requirements for tree types (OSRS woodcutting levels). - * Single source of truth - used by both generation and tests. + * @deprecated Use getTreeLevelRequired() from TreeTypes.ts instead. + * Kept for backward compatibility — delegates to the single source of truth. */ -export const TREE_LEVEL_REQUIREMENTS: Record = { - normal: 1, - oak: 15, - willow: 30, - teak: 35, - maple: 45, - mahogany: 50, - yew: 60, - magic: 75, -}; +export const TREE_LEVEL_REQUIREMENTS: Record = new Proxy( + {} as Record, + { get: (_target, prop: string) => getTreeLevelRequired(prop) }, +); /** * Mapping from game tree subtypes to @hyperscape/procgen presets. @@ -109,9 +104,10 @@ export const ORE_LEVEL_REQUIREMENTS: Record = { /** * Get the level requirement for a tree type. + * @deprecated Use getTreeLevelRequired() from TreeTypes.ts directly. */ export function getTreeLevelRequirement(subType: string): number { - return TREE_LEVEL_REQUIREMENTS[subType] ?? 1; + return getTreeLevelRequired(subType); } /** diff --git a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts index 8028f1ef6..d82397515 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts @@ -47,12 +47,15 @@ interface TreeSlot { } interface LODPool { - mesh: THREE.InstancedMesh; - material: DissolveMaterial; - /** entityId → slot index in this pool's InstancedMesh */ + /** One InstancedMesh per sub-mesh/primitive in the GLB */ + meshes: THREE.InstancedMesh[]; + materials: DissolveMaterial[]; + /** entityId → slot index (same across all meshes) */ slots: Map; activeCount: number; dirty: boolean; + /** Shared backing array for per-instance highlight intensity (0 or 1) */ + highlightData: Float32Array; } interface ModelPool { @@ -64,8 +67,10 @@ interface ModelPool { instances: Map; yOffset: number; depletedYOffset: number; - highlightMesh: THREE.Mesh | null; - depletedHighlightMesh: THREE.Mesh | null; + /** Unscaled model height from bounding box */ + modelHeight: number; + /** Unscaled model horizontal radius from bounding box */ + modelRadius: number; } const resourceLOD = getLODDistances("resource"); @@ -85,25 +90,25 @@ function inferLOD2Path(lod0Path: string): string { // ---- Geometry extraction (portfolio pattern: reference, not clone) ---- -function extractGeometryAndMaterial( - root: THREE.Object3D, -): { geometry: THREE.BufferGeometry; material: THREE.Material } | null { - let result: { - geometry: THREE.BufferGeometry; - material: THREE.Material; - } | null = null; +interface MeshPart { + geometry: THREE.BufferGeometry; + material: THREE.Material; +} + +function extractAllMeshParts(root: THREE.Object3D): MeshPart[] { + const parts: MeshPart[] = []; root.traverse((child) => { - if (result) return; if (child instanceof THREE.Mesh && child.geometry) { - result = { - geometry: child.geometry, - material: Array.isArray(child.material) - ? child.material[0] - : child.material, - }; + if (Array.isArray(child.material)) { + for (const mat of child.material) { + parts.push({ geometry: child.geometry, material: mat }); + } + } else { + parts.push({ geometry: child.geometry, material: child.material }); + } } }); - return result; + return parts; } function createSharedGeometry( @@ -119,44 +124,73 @@ function createSharedGeometry( geo.morphAttributes[name] = source.morphAttributes[name]; } } + if (source.groups.length > 0) { + for (const group of source.groups) { + geo.addGroup(group.start, group.count, group.materialIndex); + } + } if (source.boundingBox) geo.boundingBox = source.boundingBox.clone(); if (source.boundingSphere) geo.boundingSphere = source.boundingSphere.clone(); return geo; } -function computeYOffset(root: THREE.Object3D, scale: number): number { +function computeModelBounds( + root: THREE.Object3D, + scale: number, +): { + yOffset: number; + height: number; + radius: number; +} { const saved = root.scale.clone(); root.scale.set(scale, scale, scale); const bbox = new THREE.Box3().setFromObject(root); root.scale.copy(saved); - return -bbox.min.y; + const height = bbox.max.y - bbox.min.y; + const dx = Math.max(Math.abs(bbox.min.x), Math.abs(bbox.max.x)); + const dz = Math.max(Math.abs(bbox.min.z), Math.abs(bbox.max.z)); + return { yOffset: -bbox.min.y, height, radius: Math.max(dx, dz) }; } // ---- LODPool creation ---- function createLODPool( - geometry: THREE.BufferGeometry, - material: DissolveMaterial, + parts: { geometry: THREE.BufferGeometry; material: DissolveMaterial }[], ): LODPool { - const geo = createSharedGeometry(geometry); - const mesh = new THREE.InstancedMesh(geo, material, MAX_INSTANCES); - mesh.count = 0; - mesh.frustumCulled = false; - mesh.castShadow = true; - mesh.receiveShadow = true; - mesh.layers.set(1); - scene!.add(mesh); - - return { mesh, material, slots: new Map(), activeCount: 0, dirty: false }; + const meshes: THREE.InstancedMesh[] = []; + const materials: DissolveMaterial[] = []; + const hlData = new Float32Array(MAX_INSTANCES); + for (const part of parts) { + const geo = createSharedGeometry(part.geometry); + const hlAttr = new THREE.InstancedBufferAttribute(hlData, 1); + hlAttr.setUsage(THREE.DynamicDrawUsage); + geo.setAttribute("instanceHighlight", hlAttr); + + const im = new THREE.InstancedMesh(geo, part.material, MAX_INSTANCES); + im.count = 0; + im.frustumCulled = false; + im.castShadow = true; + im.receiveShadow = false; + im.layers.set(1); + scene!.add(im); + meshes.push(im); + materials.push(part.material); + } + return { + meshes, + materials, + slots: new Map(), + activeCount: 0, + dirty: false, + highlightData: hlData, + }; } -async function loadLODModel(path: string): Promise<{ - geometry: THREE.BufferGeometry; - material: THREE.Material; -} | null> { +async function loadLODParts(path: string): Promise { try { const { scene: lodScene } = await modelCache.loadModel(path, world!); - return extractGeometryAndMaterial(lodScene); + const parts = extractAllMeshParts(lodScene); + return parts.length > 0 ? parts : null; } catch { return null; } @@ -166,18 +200,26 @@ async function loadLODModel(path: string): Promise<{ const pendingEnsure = new Map>(); -function createHighlightMesh( - geometry: THREE.BufferGeometry, - material: THREE.Material, -): THREE.Mesh { - const geo = createSharedGeometry(geometry); - const mesh = new THREE.Mesh(geo, material); - mesh.frustumCulled = false; - mesh.castShadow = false; - mesh.receiveShadow = false; - mesh.layers.set(1); - mesh.visible = true; - return mesh; +function enableTextureRepeat(mat: DissolveMaterial): void { + const texProps = [ + "map", + "normalMap", + "roughnessMap", + "metalnessMap", + "aoMap", + "emissiveMap", + "alphaMap", + ] as const; + for (const key of texProps) { + const tex = (mat as unknown as Record)[key] as + | THREE.Texture + | undefined; + if (tex) { + tex.wrapS = THREE.RepeatWrapping; + tex.wrapT = THREE.RepeatWrapping; + tex.needsUpdate = true; + } + } } async function ensureModelPool( @@ -196,57 +238,49 @@ async function ensureModelPool( if (pending) return pending; const promise = (async (): Promise => { - // LOD0 - const { scene: lod0Scene } = await modelCache.loadModel(modelPath, world!); - const lod0Data = extractGeometryAndMaterial(lod0Scene); - if (!lod0Data) throw new Error(`No mesh found in ${modelPath}`); - - const yOffset = computeYOffset(lod0Scene, 1); - - const lod0Material = createDissolveMaterial(lod0Data.material, { + const dissolveOpts = { fadeStart: GPU_VEG_CONFIG.FADE_START, fadeEnd: GPU_VEG_CONFIG.FADE_END, enableNearFade: false, enableWaterCulling: false, enableOcclusionDissolve: false, - }); - world!.setupMaterial(lod0Material); - const lod0Pool = createLODPool(lod0Data.geometry, lod0Material); - - // Preload highlight mesh from LOD0 geometry + original material - const highlightMesh = createHighlightMesh( - lod0Data.geometry, - lod0Data.material, - ); + enableRimHighlight: true, + }; + + function buildDissolveParts( + parts: MeshPart[], + ): { geometry: THREE.BufferGeometry; material: DissolveMaterial }[] { + return parts.map((p) => { + const dm = createDissolveMaterial(p.material, dissolveOpts); + dm.side = THREE.DoubleSide; + enableTextureRepeat(dm); + world!.setupMaterial(dm); + return { geometry: p.geometry, material: dm }; + }); + } + + // LOD0 + const { scene: lod0Scene } = await modelCache.loadModel(modelPath, world!); + const lod0Parts = extractAllMeshParts(lod0Scene); + if (lod0Parts.length === 0) + throw new Error(`No mesh found in ${modelPath}`); + + const bounds = computeModelBounds(lod0Scene, 1); + + const lod0Pool = createLODPool(buildDissolveParts(lod0Parts)); // LOD1 let lod1Pool: LODPool | null = null; - const lod1Data = await loadLODModel(inferLOD1Path(modelPath)); - if (lod1Data) { - const lod1Material = createDissolveMaterial(lod1Data.material, { - fadeStart: GPU_VEG_CONFIG.FADE_START, - fadeEnd: GPU_VEG_CONFIG.FADE_END, - enableNearFade: false, - enableWaterCulling: false, - enableOcclusionDissolve: false, - }); - world!.setupMaterial(lod1Material); - lod1Pool = createLODPool(lod1Data.geometry, lod1Material); + const lod1Parts = await loadLODParts(inferLOD1Path(modelPath)); + if (lod1Parts) { + lod1Pool = createLODPool(buildDissolveParts(lod1Parts)); } // LOD2 let lod2Pool: LODPool | null = null; - const lod2Data = await loadLODModel(inferLOD2Path(modelPath)); - if (lod2Data) { - const lod2Material = createDissolveMaterial(lod2Data.material, { - fadeStart: GPU_VEG_CONFIG.FADE_START, - fadeEnd: GPU_VEG_CONFIG.FADE_END, - enableNearFade: false, - enableWaterCulling: false, - enableOcclusionDissolve: false, - }); - world!.setupMaterial(lod2Material); - lod2Pool = createLODPool(lod2Data.geometry, lod2Material); + const lod2Parts = await loadLODParts(inferLOD2Path(modelPath)); + if (lod2Parts) { + lod2Pool = createLODPool(buildDissolveParts(lod2Parts)); } const pool: ModelPool = { @@ -256,10 +290,10 @@ async function ensureModelPool( lod2: lod2Pool, depleted: null, instances: new Map(), - yOffset, + yOffset: bounds.yOffset, depletedYOffset: 0, - highlightMesh, - depletedHighlightMesh: null, + modelHeight: bounds.height, + modelRadius: bounds.radius, }; pools.set(modelPath, pool); @@ -283,8 +317,8 @@ async function loadDepletedPool( depletedModelPath: string, ): Promise { if (pool.depleted) return; - const depletedData = await loadLODModel(depletedModelPath); - if (!depletedData) return; + const depletedParts = await loadLODParts(depletedModelPath); + if (!depletedParts) return; let depletedYOffset = 0; try { @@ -292,25 +326,28 @@ async function loadDepletedPool( depletedModelPath, world!, ); - depletedYOffset = computeYOffset(depScene, 1); + depletedYOffset = computeModelBounds(depScene, 1).yOffset; } catch { /* use 0 */ } - const depletedMaterial = createDissolveMaterial(depletedData.material, { + const dissolveOpts = { fadeStart: GPU_VEG_CONFIG.FADE_START, fadeEnd: GPU_VEG_CONFIG.FADE_END, enableNearFade: false, enableWaterCulling: false, enableOcclusionDissolve: false, + enableRimHighlight: true, + }; + const depletedDissolveParts = depletedParts.map((p) => { + const dm = createDissolveMaterial(p.material, dissolveOpts); + dm.side = THREE.DoubleSide; + enableTextureRepeat(dm); + world!.setupMaterial(dm); + return { geometry: p.geometry, material: dm }; }); - world!.setupMaterial(depletedMaterial); - pool.depleted = createLODPool(depletedData.geometry, depletedMaterial); + pool.depleted = createLODPool(depletedDissolveParts); pool.depletedYOffset = depletedYOffset; - pool.depletedHighlightMesh = createHighlightMesh( - depletedData.geometry, - depletedData.material, - ); } // ---- Instance matrix helper ---- @@ -329,10 +366,12 @@ function composeInstanceMatrix( function addToPool(pool: LODPool, entityId: string, mat: THREE.Matrix4): void { const idx = pool.activeCount; - pool.mesh.setMatrixAt(idx, mat); + for (const im of pool.meshes) { + im.setMatrixAt(idx, mat); + im.count = idx + 1; + } pool.slots.set(entityId, idx); pool.activeCount++; - pool.mesh.count = pool.activeCount; pool.dirty = true; } @@ -342,11 +381,12 @@ function removeFromPool(pool: LODPool, entityId: string): void { const lastIdx = pool.activeCount - 1; if (idx !== lastIdx) { - // Swap last instance into the removed slot - pool.mesh.getMatrixAt(lastIdx, _swapMatrix); - pool.mesh.setMatrixAt(idx, _swapMatrix); + for (const im of pool.meshes) { + im.getMatrixAt(lastIdx, _swapMatrix); + im.setMatrixAt(idx, _swapMatrix); + } + pool.highlightData[idx] = pool.highlightData[lastIdx]; - // Find the entity that owned the last slot and update its index for (const [eid, eidIdx] of pool.slots) { if (eidIdx === lastIdx) { pool.slots.set(eid, idx); @@ -354,10 +394,13 @@ function removeFromPool(pool: LODPool, entityId: string): void { } } } + pool.highlightData[lastIdx] = 0; pool.slots.delete(entityId); pool.activeCount--; - pool.mesh.count = pool.activeCount; + for (const im of pool.meshes) { + im.count = pool.activeCount; + } pool.dirty = true; } @@ -372,15 +415,11 @@ export function destroyGLBTreeInstancer(): void { for (const pool of pools.values()) { for (const lodPool of [pool.lod0, pool.lod1, pool.lod2, pool.depleted]) { if (!lodPool) continue; - scene?.remove(lodPool.mesh); - lodPool.mesh.geometry.dispose(); - lodPool.material.dispose(); - } - if (pool.highlightMesh) { - pool.highlightMesh.geometry.dispose(); - } - if (pool.depletedHighlightMesh) { - pool.depletedHighlightMesh.geometry.dispose(); + for (const im of lodPool.meshes) { + scene?.remove(im); + im.geometry.dispose(); + } + for (const mat of lodPool.materials) mat.dispose(); } } pools.clear(); @@ -470,15 +509,6 @@ export function setDepleted(entityId: string, depleted: boolean): void { const slot = pool.instances.get(entityId); if (!slot || slot.depleted === depleted) return; - // If the previous highlight mesh is in the scene (entity is hovered), remove it - // so the stale model doesn't linger during the state transition. - const oldHlMesh = slot.depleted - ? pool.depletedHighlightMesh - : pool.highlightMesh; - if (oldHlMesh?.parent) { - oldHlMesh.parent.remove(oldHlMesh); - } - slot.depleted = depleted; if (depleted) { @@ -528,6 +558,20 @@ export function hasInstance(entityId: string): boolean { return entityToModel.has(entityId); } +/** + * Returns the unscaled model dimensions for an instanced entity. + * Used to size collision proxies to match the actual model. + */ +export function getModelDimensions( + entityId: string, +): { height: number; radius: number } | null { + const modelPath = entityToModel.get(entityId); + if (!modelPath) return null; + const pool = pools.get(modelPath); + if (!pool) return null; + return { height: pool.modelHeight, radius: pool.modelRadius }; +} + /** * Returns true if the instancer has a depleted pool for this entity's model. * When true, ResourceEntity can skip loading an individual depleted model. @@ -539,35 +583,58 @@ export function hasDepleted(entityId: string): boolean { return !!pool?.depleted; } +/** Track which entity is currently highlighted so we can clear it */ +let highlightedEntityId: string | null = null; + /** - * Returns a positioned highlight mesh for outlining an instanced entity. - * The mesh is temporarily added to the scene by EntityHighlightService. + * Set or clear shader-based rim highlight for an instanced tree entity. + * Sets the per-instance `instanceHighlight` attribute to 1 or 0. */ -export function getHighlightMesh(entityId: string): THREE.Object3D | null { +export function setHighlight(entityId: string, on: boolean): void { + if (on && highlightedEntityId && highlightedEntityId !== entityId) { + setHighlight(highlightedEntityId, false); + } + const modelPath = entityToModel.get(entityId); - if (!modelPath) return null; + if (!modelPath) return; const pool = pools.get(modelPath); - if (!pool) return null; + if (!pool) return; const slot = pool.instances.get(entityId); - if (!slot) return null; - - const mesh = slot.depleted ? pool.depletedHighlightMesh : pool.highlightMesh; - if (!mesh) return null; - - const s = slot.depleted ? slot.depletedScale : slot.scale; - const yOff = slot.depleted ? pool.depletedYOffset : pool.yOffset; - mesh.position.set( - slot.position.x, - slot.position.y + yOff * s, - slot.position.z, - ); - mesh.rotation.set(0, slot.rotation, 0); - mesh.scale.set(s, s, s); - mesh.updateMatrixWorld(true); - - return mesh; + if (!slot) return; + + const lodPool = slot.depleted + ? pool.depleted + : slot.currentLOD === 0 + ? pool.lod0 + : slot.currentLOD === 1 + ? pool.lod1 + : pool.lod2; + if (!lodPool) return; + + const idx = lodPool.slots.get(entityId); + if (idx === undefined) return; + + const value = on ? 1.0 : 0.0; + lodPool.highlightData[idx] = value; + for (const im of lodPool.meshes) { + const attr = im.geometry.getAttribute("instanceHighlight"); + if (attr) { + (attr as THREE.InstancedBufferAttribute).needsUpdate = true; + } + } + + highlightedEntityId = on ? entityId : null; +} + +/** + * Clear any active shader highlight (e.g. when hover leaves all entities). + */ +export function clearHighlight(): void { + if (highlightedEntityId) { + setHighlight(highlightedEntityId, false); + } } let lastUpdateFrame = -1; @@ -622,6 +689,10 @@ export function updateGLBTreeInstancer(): void { const newPool = targetLOD === 0 ? pool.lod0 : targetLOD === 1 ? pool.lod1 : pool.lod2; + const wasHighlighted = + oldPool && oldPool.slots.has(slot.entityId) + ? oldPool.highlightData[oldPool.slots.get(slot.entityId)!] + : 0; if (oldPool) removeFromPool(oldPool, slot.entityId); if (newPool) { const mat = composeInstanceMatrix( @@ -631,6 +702,17 @@ export function updateGLBTreeInstancer(): void { slot.yOffset, ); addToPool(newPool, slot.entityId, mat); + if (wasHighlighted > 0) { + const newIdx = newPool.slots.get(slot.entityId); + if (newIdx !== undefined) { + newPool.highlightData[newIdx] = wasHighlighted; + for (const im of newPool.meshes) { + const attr = im.geometry.getAttribute("instanceHighlight"); + if (attr) + (attr as THREE.InstancedBufferAttribute).needsUpdate = true; + } + } + } } slot.currentLOD = targetLOD; } @@ -647,20 +729,20 @@ export function updateGLBTreeInstancer(): void { if (!lodPool) continue; if (lodPool.dirty) { - lodPool.mesh.instanceMatrix.needsUpdate = true; + for (const im of lodPool.meshes) { + im.instanceMatrix.needsUpdate = true; + } lodPool.dirty = false; } - lodPool.material.dissolveUniforms.cameraPos.value.set( - camPos.x, - camY, - camPos.z, - ); - lodPool.material.dissolveUniforms.playerPos.value.set( - playerPos.x, - playerPos.y, - playerPos.z, - ); + for (const mat of lodPool.materials) { + mat.dissolveUniforms.cameraPos.value.set(camPos.x, camY, camPos.z); + mat.dissolveUniforms.playerPos.value.set( + playerPos.x, + playerPos.y, + playerPos.z, + ); + } } } } diff --git a/packages/shared/src/systems/shared/world/GPUVegetation.ts b/packages/shared/src/systems/shared/world/GPUVegetation.ts index 4dcd98b46..4e64bcd62 100644 --- a/packages/shared/src/systems/shared/world/GPUVegetation.ts +++ b/packages/shared/src/systems/shared/world/GPUVegetation.ts @@ -54,6 +54,11 @@ import { floor, abs, viewportCoordinate, + normalView, + normalize, + pow, + attribute, + positionView, } from "../../../extras/three/three"; import { FOG_NEAR_SQ, FOG_FAR_SQ, fogRenderTarget } from "./FogConfig"; import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants"; @@ -581,6 +586,8 @@ export type DissolveMaterialOptions = { enableWaterCulling?: boolean; /** Enable camera-to-player occlusion dissolve (default: true) */ enableOcclusionDissolve?: boolean; + /** Enable per-instance rim highlight driven by an instanced attribute */ + enableRimHighlight?: boolean; }; /** @@ -595,6 +602,8 @@ export type DissolveMaterial = THREE.MeshStandardNodeMaterial & { nearFadeStart: { value: number }; nearFadeEnd: { value: number }; }; + /** Present when enableRimHighlight was true at creation */ + highlightColor?: { value: THREE.Color }; }; // ============================================================================ @@ -1144,6 +1153,41 @@ export function createDissolveMaterial( nearFadeEnd: uNearFadeEnd, }; + // Per-instance flat glow highlight (driven by instanceHighlight attribute) + // When highlighted, replaces PBR shading with a flat unlit emissive color + // plus a subtle fresnel rim brightening at silhouette edges. + if (options.enableRimHighlight) { + const uHighlightColor = uniform(new THREE.Color(0x00ffff)); + dissolveMat.highlightColor = uHighlightColor; + + const BASE_BRIGHTNESS = 0.35; + const RIM_POWER = 2.5; + const RIM_BOOST = 0.5; + + material.outputNode = Fn(() => { + const litColor = output; + const hlIntensity = attribute("instanceHighlight", "float"); + + const N = normalize(normalView); + const V = normalize(sub(vec3(0, 0, 0), positionView.xyz)); + const NdotV = clamp(dot(N, V), float(0.0), float(1.0)); + + // Fresnel rim — brighter at silhouette edges + const rim = pow(sub(float(1.0), NdotV), float(RIM_POWER)); + + // Flat unlit glow: base brightness + rim boost, tinted by highlight color + const brightness = add( + float(BASE_BRIGHTNESS), + mul(rim, float(RIM_BOOST)), + ); + const flatGlow = mul(vec3(uHighlightColor), brightness); + + // When highlighted, fully replace the lit result with the flat glow + const finalRgb = mix(litColor.rgb, flatGlow, hlIntensity); + return vec4(finalRgb, litColor.a); + })(); + } + material.needsUpdate = true; return dissolveMat; } diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts index a5083d5b4..ebe261122 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -85,6 +85,7 @@ import { // generatePlants, // DISABLED - plants not working/looking good yet type ResourceGenerationContext, } from "./BiomeResourceGenerator"; +import { getDefaultTreeDistribution } from "../../../constants/TreeTypes"; import { setProcgenRockWorld, addRockInstance, @@ -4677,13 +4678,7 @@ export class TerrainSystem extends System { */ private static readonly DEFAULT_TREE_CONFIG: BiomeTreeConfig = { enabled: true, - distribution: { - tree_normal: 40, // Quaking Aspen - tree_oak: 30, // Black Oak - tree_maple: 15, // Acer (Japanese Maple) - tree_willow: 10, // Weeping Willow - tree_yew: 5, // European Larch - }, + distribution: getDefaultTreeDistribution(), density: 20, // ~8 trees per 64m tile minSpacing: 18, // Minimum 18m between trees for natural spacing clustering: true, diff --git a/packages/shared/src/types/entities/entities.ts b/packages/shared/src/types/entities/entities.ts index b2e39f6b9..257d5ecb6 100644 --- a/packages/shared/src/types/entities/entities.ts +++ b/packages/shared/src/types/entities/entities.ts @@ -309,6 +309,12 @@ export interface ResourceEntityConfig extends EntityConfig in woodcutting.json manifest. + * Derived from the single source of truth in constants/TreeTypes.ts. */ -export type TreeSubType = - | "normal" - | "oak" - | "willow" - | "teak" - | "maple" - | "mahogany" - | "yew" - | "magic"; +export type TreeSubType = _TreeSubType; /** * Ore subtypes that can spawn in the world. From 7cf2ca12aaa4987941528db7be3e16bcfe062959 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 3 Mar 2026 00:30:33 +0800 Subject: [PATCH 02/48] perf: disable receiveShadow on non-terrain meshes Shadow receiving on VRM characters, duel arena objects, GLB resources, and placeholder instancers is a significant FPS bottleneck with no meaningful visual benefit for these objects. Made-with: Cursor --- packages/shared/src/extras/three/createVRMFactory.ts | 2 +- .../src/systems/client/DuelArenaVisualsSystem.ts | 10 +++++----- .../src/systems/shared/world/GLBResourceInstancer.ts | 2 +- .../src/systems/shared/world/PlaceholderInstancer.ts | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/extras/three/createVRMFactory.ts b/packages/shared/src/extras/three/createVRMFactory.ts index 260e28e9e..9769174ff 100644 --- a/packages/shared/src/extras/three/createVRMFactory.ts +++ b/packages/shared/src/extras/three/createVRMFactory.ts @@ -138,7 +138,7 @@ export function createVRMFactory( glb.scene.traverse((obj) => { if (isMeshLike(obj)) { obj.castShadow = true; - obj.receiveShadow = true; + obj.receiveShadow = false; // Convert materials to MeshStandardMaterial for proper sun/moon/environment lighting const convertMaterial = ( diff --git a/packages/shared/src/systems/client/DuelArenaVisualsSystem.ts b/packages/shared/src/systems/client/DuelArenaVisualsSystem.ts index ee730c87e..a5ddb9658 100644 --- a/packages/shared/src/systems/client/DuelArenaVisualsSystem.ts +++ b/packages/shared/src/systems/client/DuelArenaVisualsSystem.ts @@ -711,7 +711,7 @@ export class DuelArenaVisualsSystem extends System { TOTAL_FENCE_POSTS, ); postsIM.castShadow = true; - postsIM.receiveShadow = true; + postsIM.receiveShadow = false; postsIM.layers.set(1); postsIM.userData = { type: "arena-fence", walkable: false }; @@ -784,7 +784,7 @@ export class DuelArenaVisualsSystem extends System { TOTAL_X_RAILS, ); railsXIM.castShadow = true; - railsXIM.receiveShadow = true; + railsXIM.receiveShadow = false; railsXIM.layers.set(1); let railXIdx = 0; @@ -823,7 +823,7 @@ export class DuelArenaVisualsSystem extends System { TOTAL_Z_RAILS, ); railsZIM.castShadow = true; - railsZIM.receiveShadow = true; + railsZIM.receiveShadow = false; railsZIM.layers.set(1); let railZIdx = 0; @@ -889,7 +889,7 @@ export class DuelArenaVisualsSystem extends System { for (const im of [basesIM, shaftsIM, capitalsIM]) { im.castShadow = true; - im.receiveShadow = true; + im.receiveShadow = false; im.layers.set(1); } @@ -1253,7 +1253,7 @@ export class DuelArenaVisualsSystem extends System { const pillar = new THREE.Mesh(geom, this.forfeitPillarMat!); pillar.position.set(x, terrainY + FORFEIT_PILLAR_HEIGHT / 2, z); pillar.castShadow = true; - pillar.receiveShadow = true; + pillar.receiveShadow = false; pillar.name = entityId; pillar.userData = { entityId, diff --git a/packages/shared/src/systems/shared/world/GLBResourceInstancer.ts b/packages/shared/src/systems/shared/world/GLBResourceInstancer.ts index 8ff6f6a9c..cd1f8486e 100644 --- a/packages/shared/src/systems/shared/world/GLBResourceInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBResourceInstancer.ts @@ -138,7 +138,7 @@ function createLODPool( mesh.count = 0; mesh.frustumCulled = false; mesh.castShadow = true; - mesh.receiveShadow = true; + mesh.receiveShadow = false; mesh.layers.set(1); scene!.add(mesh); diff --git a/packages/shared/src/systems/shared/world/PlaceholderInstancer.ts b/packages/shared/src/systems/shared/world/PlaceholderInstancer.ts index 489116d7d..f78a6ae7f 100644 --- a/packages/shared/src/systems/shared/world/PlaceholderInstancer.ts +++ b/packages/shared/src/systems/shared/world/PlaceholderInstancer.ts @@ -76,7 +76,7 @@ function createPool(resourceType: string): Pool { mesh.count = 0; mesh.frustumCulled = false; mesh.castShadow = true; - mesh.receiveShadow = true; + mesh.receiveShadow = false; mesh.layers.set(1); mesh.name = `PlaceholderPool_${resourceType}`; _scene!.add(mesh); @@ -110,7 +110,7 @@ function growPool(pool: Pool): void { newMesh.instanceMatrix.needsUpdate = true; newMesh.frustumCulled = false; newMesh.castShadow = true; - newMesh.receiveShadow = true; + newMesh.receiveShadow = false; newMesh.layers.set(1); newMesh.name = mesh.name; From 2f95ef2714d8b088e8707893bd31d149a3c00c47 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 3 Mar 2026 01:15:28 +0800 Subject: [PATCH 03/48] perf: shader-based highlight for all instancers, split GPUVegetation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace post-processing outline highlight with per-instance Fresnel rim shader across GLBResourceInstancer, PlaceholderInstancer, and their visual strategies. This eliminates the costly extra render pass and separate highlight meshes, significantly reducing FPS drops on hover. Also split the monolithic GPUVegetation.ts (1370 lines) into: - LODConfig.ts: LOD distance table, scaling, caching (pure data/math) - GPUMaterials.ts: material factories (vegetation, dissolve, imposter) Tuned highlight brightness (BRIGHTEN 0.2→0.08) to reduce whitewash. Made-with: Cursor --- .../shared/src/constants/GameConstants.ts | 2 +- packages/shared/src/entities/Entity.ts | 2 +- .../visuals/InstancedModelVisualStrategy.ts | 8 +- .../visuals/PlaceholderVisualStrategy.ts | 7 + .../visuals/StandardModelVisualStrategy.ts | 4 +- .../shared/world/BuildingRenderingSystem.ts | 4 +- .../src/systems/shared/world/FogConfig.ts | 2 +- .../shared/world/GLBResourceInstancer.ts | 157 ++--- .../systems/shared/world/GLBTreeInstancer.ts | 4 +- .../{GPUVegetation.ts => GPUMaterials.ts} | 607 ++---------------- .../src/systems/shared/world/LODConfig.ts | 401 ++++++++++++ .../shared/world/PlaceholderInstancer.ts | 63 +- .../systems/shared/world/VegetationSystem.ts | 20 +- ...egetation.test.ts => GPUMaterials.test.ts} | 14 +- .../src/utils/rendering/DistanceFade.ts | 2 +- .../rendering/__tests__/LODQuality.test.ts | 2 +- 16 files changed, 622 insertions(+), 677 deletions(-) rename packages/shared/src/systems/shared/world/{GPUVegetation.ts => GPUMaterials.ts} (56%) create mode 100644 packages/shared/src/systems/shared/world/LODConfig.ts rename packages/shared/src/systems/shared/world/__tests__/{GPUVegetation.test.ts => GPUMaterials.test.ts} (99%) diff --git a/packages/shared/src/constants/GameConstants.ts b/packages/shared/src/constants/GameConstants.ts index 43d429ae0..b1fa4d98f 100644 --- a/packages/shared/src/constants/GameConstants.ts +++ b/packages/shared/src/constants/GameConstants.ts @@ -79,7 +79,7 @@ export const TERRAIN_CONSTANTS = { /** * Water threshold in world Y units. * Terrain below this height is underwater and impassable. - * Used by: TerrainSystem, VegetationSystem, GPUVegetation, RoadNetworkSystem, ResourceSystem + * Used by: TerrainSystem, VegetationSystem, DissolveMaterial, RoadNetworkSystem, ResourceSystem */ WATER_THRESHOLD: 9.0, diff --git a/packages/shared/src/entities/Entity.ts b/packages/shared/src/entities/Entity.ts index 0e2264403..e405405ab 100644 --- a/packages/shared/src/entities/Entity.ts +++ b/packages/shared/src/entities/Entity.ts @@ -135,7 +135,7 @@ import { import { getLODConfig, type LODDistancesWithSq, -} from "../systems/shared/world/GPUVegetation"; +} from "../systems/shared/world/LODConfig"; // Re-export types for external use export type { EntityConfig }; diff --git a/packages/shared/src/entities/world/visuals/InstancedModelVisualStrategy.ts b/packages/shared/src/entities/world/visuals/InstancedModelVisualStrategy.ts index a78ed185e..dcfb0d500 100644 --- a/packages/shared/src/entities/world/visuals/InstancedModelVisualStrategy.ts +++ b/packages/shared/src/entities/world/visuals/InstancedModelVisualStrategy.ts @@ -16,7 +16,7 @@ import { removeInstance as removeResourceInstance, setDepleted as setResourceDepleted, hasDepleted as hasResourceDepleted, - getHighlightMesh as getResourceHighlightMesh, + setHighlight as setResourceHighlight, updateGLBResourceInstancer, } from "../../../systems/shared/world/GLBResourceInstancer"; import type { @@ -109,9 +109,9 @@ export class InstancedModelVisualStrategy implements ResourceVisualStrategy { return hasResourceDepleted(ctx.id); } - getHighlightMesh(ctx: ResourceVisualContext): THREE.Object3D | null { - if (this.fallback) return null; - return getResourceHighlightMesh(ctx.id); + setShaderHighlight(ctx: ResourceVisualContext, on: boolean): void { + if (this.fallback) return; + setResourceHighlight(ctx.id, on); } async onRespawn(ctx: ResourceVisualContext): Promise { diff --git a/packages/shared/src/entities/world/visuals/PlaceholderVisualStrategy.ts b/packages/shared/src/entities/world/visuals/PlaceholderVisualStrategy.ts index ad2ef4608..981cc7991 100644 --- a/packages/shared/src/entities/world/visuals/PlaceholderVisualStrategy.ts +++ b/packages/shared/src/entities/world/visuals/PlaceholderVisualStrategy.ts @@ -13,6 +13,7 @@ import { addPlaceholderInstance, removePlaceholderInstance, setPlaceholderVisible, + setPlaceholderHighlight, } from "../../../systems/shared/world/PlaceholderInstancer"; import type { ResourceVisualContext, @@ -93,6 +94,12 @@ export class PlaceholderVisualStrategy implements ResourceVisualStrategy { } } + setShaderHighlight(ctx: ResourceVisualContext, on: boolean): void { + if (this.instanced) { + setPlaceholderHighlight(ctx.id, on); + } + } + update(): void {} destroy(ctx: ResourceVisualContext): void { diff --git a/packages/shared/src/entities/world/visuals/StandardModelVisualStrategy.ts b/packages/shared/src/entities/world/visuals/StandardModelVisualStrategy.ts index d33ca8740..9bbe63d6f 100644 --- a/packages/shared/src/entities/world/visuals/StandardModelVisualStrategy.ts +++ b/packages/shared/src/entities/world/visuals/StandardModelVisualStrategy.ts @@ -11,10 +11,10 @@ import { modelCache } from "../../../utils/rendering/ModelCache"; import { createDissolveMaterial, isDissolveMaterial, - getLODDistances, GPU_VEG_CONFIG, type DissolveMaterial, -} from "../../../systems/shared/world/GPUVegetation"; +} from "../../../systems/shared/world/GPUMaterials"; +import { getLODDistances } from "../../../systems/shared/world/LODConfig"; import { getCameraPosition } from "../../../utils/rendering/AnimationLOD"; import type { ResourceVisualContext, diff --git a/packages/shared/src/systems/shared/world/BuildingRenderingSystem.ts b/packages/shared/src/systems/shared/world/BuildingRenderingSystem.ts index 4f3dcdd34..887bca61e 100644 --- a/packages/shared/src/systems/shared/world/BuildingRenderingSystem.ts +++ b/packages/shared/src/systems/shared/world/BuildingRenderingSystem.ts @@ -42,7 +42,7 @@ * - Depends on TownSystem for building placement data * - Depends on TerrainSystem for height queries * - Uses BuildingGenerator from @hyperscape/procgen for mesh generation - * - Uses unified LOD configuration from GPUVegetation + * - Uses unified LOD configuration from LODConfig * - Uses ImpostorManager for octahedral impostor baking * * **Runs on:** Client only (buildings are purely visual on client) @@ -322,7 +322,7 @@ import { snapToBuildingGrid, computeTangentsForNonIndexed, } from "@hyperscape/procgen/building"; -import { getLODDistances, type LODDistancesWithSq } from "./GPUVegetation"; +import { getLODDistances, type LODDistancesWithSq } from "./LODConfig"; import { ImpostorManager, BakePriority, diff --git a/packages/shared/src/systems/shared/world/FogConfig.ts b/packages/shared/src/systems/shared/world/FogConfig.ts index 7cb5529dc..496b176ff 100644 --- a/packages/shared/src/systems/shared/world/FogConfig.ts +++ b/packages/shared/src/systems/shared/world/FogConfig.ts @@ -3,7 +3,7 @@ * * Single source of truth for all fog-related constants. * Imported by SkySystem (fog render target), TerrainShader, WaterSystem, - * GPUVegetation, and any other system that needs fog parameters. + * DissolveMaterial, and any other system that needs fog parameters. * * FOG TECHNIQUE: * The sky dome is rendered to a low-res offscreen texture each frame. diff --git a/packages/shared/src/systems/shared/world/GLBResourceInstancer.ts b/packages/shared/src/systems/shared/world/GLBResourceInstancer.ts index cd1f8486e..8f9027689 100644 --- a/packages/shared/src/systems/shared/world/GLBResourceInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBResourceInstancer.ts @@ -19,10 +19,10 @@ import type { World } from "../../../core/World"; import { modelCache } from "../../../utils/rendering/ModelCache"; import { createDissolveMaterial, - getLODDistances, GPU_VEG_CONFIG, type DissolveMaterial, -} from "./GPUVegetation"; +} from "./GPUMaterials"; +import { getLODDistances } from "./LODConfig"; const MAX_INSTANCES = 512; @@ -49,6 +49,7 @@ interface LODPool { slots: Map; activeCount: number; dirty: boolean; + highlightData: Float32Array; } interface ModelPool { @@ -60,8 +61,6 @@ interface ModelPool { instances: Map; yOffset: number; depletedYOffset: number; - highlightMesh: THREE.Mesh | null; - depletedHighlightMesh: THREE.Mesh | null; } const resourceLOD = getLODDistances("resource"); @@ -134,6 +133,11 @@ function createLODPool( material: DissolveMaterial, ): LODPool { const geo = createSharedGeometry(geometry); + const hlData = new Float32Array(MAX_INSTANCES); + const hlAttr = new THREE.InstancedBufferAttribute(hlData, 1); + hlAttr.setUsage(THREE.DynamicDrawUsage); + geo.setAttribute("instanceHighlight", hlAttr); + const mesh = new THREE.InstancedMesh(geo, material, MAX_INSTANCES); mesh.count = 0; mesh.frustumCulled = false; @@ -142,7 +146,14 @@ function createLODPool( mesh.layers.set(1); scene!.add(mesh); - return { mesh, material, slots: new Map(), activeCount: 0, dirty: false }; + return { + mesh, + material, + slots: new Map(), + activeCount: 0, + dirty: false, + highlightData: hlData, + }; } async function loadLODModel(path: string): Promise<{ @@ -163,20 +174,6 @@ async function loadLODModel(path: string): Promise<{ const pendingEnsure = new Map>(); -function createHighlightMesh( - geometry: THREE.BufferGeometry, - material: THREE.Material, -): THREE.Mesh { - const geo = createSharedGeometry(geometry); - const mesh = new THREE.Mesh(geo, material); - mesh.frustumCulled = false; - mesh.castShadow = false; - mesh.receiveShadow = false; - mesh.layers.set(1); - mesh.visible = true; - return mesh; -} - async function ensureModelPool( modelPath: string, depletedModelPath?: string | null, @@ -199,31 +196,29 @@ async function ensureModelPool( const yOffset = computeYOffset(lod0Scene, 1); - const lod0Material = createDissolveMaterial(lod0Data.material, { + const dissolveOpts = { fadeStart: GPU_VEG_CONFIG.FADE_START, fadeEnd: GPU_VEG_CONFIG.FADE_END, enableNearFade: false, enableWaterCulling: false, enableOcclusionDissolve: false, - }); - world!.setupMaterial(lod0Material); - const lod0Pool = createLODPool(lod0Data.geometry, lod0Material); + enableRimHighlight: true, + }; - const highlightMesh = createHighlightMesh( - lod0Data.geometry, + const lod0Material = createDissolveMaterial( lod0Data.material, + dissolveOpts, ); + world!.setupMaterial(lod0Material); + const lod0Pool = createLODPool(lod0Data.geometry, lod0Material); let lod1Pool: LODPool | null = null; const lod1Data = await loadLODModel(inferLOD1Path(modelPath)); if (lod1Data) { - const lod1Material = createDissolveMaterial(lod1Data.material, { - fadeStart: GPU_VEG_CONFIG.FADE_START, - fadeEnd: GPU_VEG_CONFIG.FADE_END, - enableNearFade: false, - enableWaterCulling: false, - enableOcclusionDissolve: false, - }); + const lod1Material = createDissolveMaterial( + lod1Data.material, + dissolveOpts, + ); world!.setupMaterial(lod1Material); lod1Pool = createLODPool(lod1Data.geometry, lod1Material); } @@ -231,13 +226,10 @@ async function ensureModelPool( let lod2Pool: LODPool | null = null; const lod2Data = await loadLODModel(inferLOD2Path(modelPath)); if (lod2Data) { - const lod2Material = createDissolveMaterial(lod2Data.material, { - fadeStart: GPU_VEG_CONFIG.FADE_START, - fadeEnd: GPU_VEG_CONFIG.FADE_END, - enableNearFade: false, - enableWaterCulling: false, - enableOcclusionDissolve: false, - }); + const lod2Material = createDissolveMaterial( + lod2Data.material, + dissolveOpts, + ); world!.setupMaterial(lod2Material); lod2Pool = createLODPool(lod2Data.geometry, lod2Material); } @@ -251,8 +243,6 @@ async function ensureModelPool( instances: new Map(), yOffset, depletedYOffset: 0, - highlightMesh, - depletedHighlightMesh: null, }; pools.set(modelPath, pool); @@ -296,14 +286,11 @@ async function loadDepletedPool( enableNearFade: false, enableWaterCulling: false, enableOcclusionDissolve: false, + enableRimHighlight: true, }); world!.setupMaterial(depletedMaterial); pool.depleted = createLODPool(depletedData.geometry, depletedMaterial); pool.depletedYOffset = depletedYOffset; - pool.depletedHighlightMesh = createHighlightMesh( - depletedData.geometry, - depletedData.material, - ); } // --------------------------------------------------------------------------- @@ -339,6 +326,7 @@ function removeFromPool(pool: LODPool, entityId: string): void { if (idx !== lastIdx) { pool.mesh.getMatrixAt(lastIdx, _swapMatrix); pool.mesh.setMatrixAt(idx, _swapMatrix); + pool.highlightData[idx] = pool.highlightData[lastIdx]; for (const [eid, eidIdx] of pool.slots) { if (eidIdx === lastIdx) { @@ -347,6 +335,7 @@ function removeFromPool(pool: LODPool, entityId: string): void { } } } + pool.highlightData[lastIdx] = 0; pool.slots.delete(entityId); pool.activeCount--; @@ -371,9 +360,6 @@ export function destroyGLBResourceInstancer(): void { lodPool.mesh.geometry.dispose(); lodPool.material.dispose(); } - if (pool.highlightMesh) pool.highlightMesh.geometry.dispose(); - if (pool.depletedHighlightMesh) - pool.depletedHighlightMesh.geometry.dispose(); } pools.clear(); entityToModel.clear(); @@ -462,13 +448,6 @@ export function setDepleted(entityId: string, depleted: boolean): void { const slot = pool.instances.get(entityId); if (!slot || slot.depleted === depleted) return; - const oldHlMesh = slot.depleted - ? pool.depletedHighlightMesh - : pool.highlightMesh; - if (oldHlMesh?.parent) { - oldHlMesh.parent.remove(oldHlMesh); - } - slot.depleted = depleted; if (depleted) { @@ -521,31 +500,45 @@ export function hasDepleted(entityId: string): boolean { return !!pool?.depleted; } -export function getHighlightMesh(entityId: string): THREE.Object3D | null { +let highlightedEntityId: string | null = null; + +export function setHighlight(entityId: string, on: boolean): void { + if (on && highlightedEntityId && highlightedEntityId !== entityId) { + setHighlight(highlightedEntityId, false); + } + const modelPath = entityToModel.get(entityId); - if (!modelPath) return null; + if (!modelPath) return; const pool = pools.get(modelPath); - if (!pool) return null; + if (!pool) return; const slot = pool.instances.get(entityId); - if (!slot) return null; - - const mesh = slot.depleted ? pool.depletedHighlightMesh : pool.highlightMesh; - if (!mesh) return null; - - const s = slot.depleted ? slot.depletedScale : slot.scale; - const yOff = slot.depleted ? pool.depletedYOffset : pool.yOffset; - mesh.position.set( - slot.position.x, - slot.position.y + yOff * s, - slot.position.z, - ); - mesh.rotation.set(0, slot.rotation, 0); - mesh.scale.set(s, s, s); - mesh.updateMatrixWorld(true); - - return mesh; + if (!slot) return; + + const lodPool = slot.depleted + ? pool.depleted + : slot.currentLOD === 0 + ? pool.lod0 + : slot.currentLOD === 1 + ? pool.lod1 + : pool.lod2; + if (!lodPool) return; + + const idx = lodPool.slots.get(entityId); + if (idx === undefined) return; + + lodPool.highlightData[idx] = on ? 1.0 : 0.0; + const attr = lodPool.mesh.geometry.getAttribute("instanceHighlight"); + if (attr) (attr as THREE.InstancedBufferAttribute).needsUpdate = true; + + highlightedEntityId = on ? entityId : null; +} + +export function clearHighlight(): void { + if (highlightedEntityId) { + setHighlight(highlightedEntityId, false); + } } let lastUpdateFrame = -1; @@ -599,6 +592,10 @@ export function updateGLBResourceInstancer(): void { const newPool = targetLOD === 0 ? pool.lod0 : targetLOD === 1 ? pool.lod1 : pool.lod2; + const wasHighlighted = + oldPool && oldPool.slots.has(slot.entityId) + ? oldPool.highlightData[oldPool.slots.get(slot.entityId)!] + : 0; if (oldPool) removeFromPool(oldPool, slot.entityId); if (newPool) { const mat = composeInstanceMatrix( @@ -608,6 +605,16 @@ export function updateGLBResourceInstancer(): void { slot.yOffset, ); addToPool(newPool, slot.entityId, mat); + if (wasHighlighted > 0) { + const newIdx = newPool.slots.get(slot.entityId); + if (newIdx !== undefined) { + newPool.highlightData[newIdx] = wasHighlighted; + const attr = + newPool.mesh.geometry.getAttribute("instanceHighlight"); + if (attr) + (attr as THREE.InstancedBufferAttribute).needsUpdate = true; + } + } } slot.currentLOD = targetLOD; } diff --git a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts index d82397515..3b05a8163 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts @@ -22,10 +22,10 @@ import type { World } from "../../../core/World"; import { modelCache } from "../../../utils/rendering/ModelCache"; import { createDissolveMaterial, - getLODDistances, GPU_VEG_CONFIG, type DissolveMaterial, -} from "./GPUVegetation"; +} from "./GPUMaterials"; +import { getLODDistances } from "./LODConfig"; const MAX_INSTANCES = 512; diff --git a/packages/shared/src/systems/shared/world/GPUVegetation.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts similarity index 56% rename from packages/shared/src/systems/shared/world/GPUVegetation.ts rename to packages/shared/src/systems/shared/world/GPUMaterials.ts index 4e64bcd62..d133d3f5a 100644 --- a/packages/shared/src/systems/shared/world/GPUVegetation.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -1,28 +1,21 @@ /** - * GPUVegetation.ts - GPU-Driven Dissolve Rendering + * GPUMaterials.ts - GPU-Driven Material Factories * - * Provides GPU-accelerated dissolve rendering using WebGPU/Three.js TSL. - * This module provides SHARED dissolve functionality for vegetation, mobs, and resources. + * Provides GPU-accelerated materials using WebGPU/Three.js TSL. + * Shared across vegetation, mobs, resources, and imposters. * - * ## Features - * - Screen-space dithered dissolve (smooth per-fragment fade in/out) - * - Both NEAR and FAR camera dissolve support - * - Water level culling - * - Vertex color support (no texture sampling) - * - Cutout rendering (alphaTest, not transparent) for opaque pipeline performance + * ## Material Factories + * - `createGPUVegetationMaterial()` - Vegetation (far dissolve + water culling + fog) + * - `createDissolveMaterial()` - Generic dissolve for instanced models (resources, mobs) + * - `createImposterMaterial()` - Billboard imposter with dithered dissolve * - * ## Dissolve Effect - * Uses world-position-based dithering so entities smoothly dissolve in/out - * as the player approaches or moves away. Each fragment has a pseudo-random threshold - * compared against the distance-based fade factor - more fragments pass - * based on distance, creating an organic dissolve effect. + * ## Shared Shader Features + * - Screen-space dithered dissolve (Bayer 4x4) + * - Camera-to-player occlusion cone (RuneScape-style) + * - Near-camera depth fade + * - Per-instance Fresnel rim highlight * - * ## Shared System - * - `createGPUVegetationMaterial()` - For vegetation (far dissolve + water culling) - * - `createDissolveMaterial()` - Generic dissolve for any material (mobs, resources) - * - Both use identical shader logic for consistent visual appearance - * - * @module GPUVegetation + * @module GPUMaterials */ import * as THREE from "../../../extras/three/three"; @@ -68,8 +61,8 @@ import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants"; // ============================================================================ /** - * GPU dissolve rendering configuration. - * Central configuration for all dissolve effects (vegetation, resources, mobs). + * GPU rendering configuration shared by all material factories. + * Controls dissolve distances, occlusion, near-camera fade, and water culling. */ export const GPU_VEG_CONFIG = { /** Distance where far fade begins (fully opaque inside) - default for generic dissolve */ @@ -128,411 +121,6 @@ export const GPU_VEG_CONFIG = { NEAR_CAMERA_FADE_END: 0.05, } as const; -// ============================================================================ -// UNIFIED LOD CONFIGURATION -// ============================================================================ - -/** - * LOD distances for a category. - * All distances in meters from camera. - * - * LOD Pipeline: - * - LOD0 (0 to lod1Distance): Full detail mesh - * - LOD1 (lod1Distance to lod2Distance): Low-poly mesh (~10% verts) - * - LOD2 (lod2Distance to imposterDistance): Very low-poly mesh (~3% verts) - * - Impostor (imposterDistance to fadeDistance): Billboard - * - Culled (> fadeDistance): Hidden - */ -export interface LODDistances { - /** Distance to switch from LOD0 (full detail) to LOD1 (low poly ~10%) */ - lod1Distance: number; - /** Distance to switch from LOD1 to LOD2 (very low poly ~3%) */ - lod2Distance: number; - /** Distance to switch from 3D mesh to billboard imposter */ - imposterDistance: number; - /** Distance at which to completely fade out/cull */ - fadeDistance: number; -} - -/** - * LOD distances with pre-computed squared values for performance. - * Use squared distances to avoid Math.sqrt in hot paths. - */ -export interface LODDistancesWithSq extends LODDistances { - lod1DistanceSq: number; - lod2DistanceSq: number; - imposterDistanceSq: number; - fadeDistanceSq: number; -} - -/** - * UNIFIED LOD CONFIGURATION - Single source of truth for all LOD distances. - * - * This is the canonical configuration used by: - * - VegetationSystem (trees, bushes, grass, etc.) - * - ResourceEntity (harvestable resources) - * - Any other LOD systems - * - * Categories can be customized based on object size and visual importance. - */ -export const LOD_DISTANCES: Record = { - // Large vegetation - aggressive LOD for performance - tree: { - lod1Distance: 30, - lod2Distance: 60, - imposterDistance: 100, - fadeDistance: 180, - }, - - // Medium vegetation - bush: { - lod1Distance: 40, - lod2Distance: 80, - imposterDistance: 120, - fadeDistance: 200, - }, - fern: { - lod1Distance: 30, - lod2Distance: 55, - imposterDistance: 80, - fadeDistance: 120, - }, - rock: { - lod1Distance: 50, - lod2Distance: 100, - imposterDistance: 150, - fadeDistance: 250, - }, - fallen_tree: { - lod1Distance: 45, - lod2Distance: 90, - imposterDistance: 130, - fadeDistance: 200, - }, - - // Small vegetation (aggressive culling - skip LOD2, go straight to impostor) - flower: { - lod1Distance: 25, - lod2Distance: 45, // Same as imposter - skip LOD2 - imposterDistance: 60, - fadeDistance: 100, - }, - mushroom: { - lod1Distance: 15, - lod2Distance: 30, // Same as imposter - skip LOD2 - imposterDistance: 40, - fadeDistance: 80, - }, - grass: { - lod1Distance: 10, - lod2Distance: 20, // Same as imposter - skip LOD2 - imposterDistance: 30, - fadeDistance: 60, - }, - - // Resources (harvestable objects) - resource: { - lod1Distance: 45, - lod2Distance: 85, - imposterDistance: 120, - fadeDistance: 200, - }, - tree_resource: { - lod1Distance: 25, // Switch to LOD1 very early - lod2Distance: 50, - imposterDistance: 80, // Switch to impostor ASAP - fadeDistance: 150, // Fade out earlier - }, - rock_resource: { - lod1Distance: 50, - lod2Distance: 100, - imposterDistance: 150, - fadeDistance: 250, - }, - - // Buildings - simple geometry, skip intermediate LODs and go directly to impostor - // Buildings use batched static meshes, so LOD stages are unnecessary - // LOD0 (0-80m): Full detail batched mesh with shadows - // Impostor (80-200m): Octahedral billboard - // Culled (>200m): Hidden - building: { - lod1Distance: 80, // Same as impostor - skip intermediate LOD - lod2Distance: 80, // Same as impostor - skip intermediate LOD - imposterDistance: 80, // Switch to impostor at 80m - fadeDistance: 200, // Cull at 200m - }, - station: { - lod1Distance: 60, - lod2Distance: 140, - imposterDistance: 200, - fadeDistance: 350, - }, - - // Mobs and NPCs (skip LOD2 - use LOD1 to impostor) - mob: { - lod1Distance: 40, - lod2Distance: 70, - imposterDistance: 100, - fadeDistance: 150, - }, - npc: { - lod1Distance: 50, - lod2Distance: 90, - imposterDistance: 120, - fadeDistance: 180, - }, - player: { - lod1Distance: 50, - lod2Distance: 90, - imposterDistance: 120, - fadeDistance: 200, - }, - - // Items (skip LOD2) - item: { - lod1Distance: 25, - lod2Distance: 45, - imposterDistance: 60, - fadeDistance: 100, - }, -}; - -/** Default LOD distances for unknown categories */ -export const DEFAULT_LOD_DISTANCES: LODDistances = { - lod1Distance: 45, - lod2Distance: 85, - imposterDistance: 120, - fadeDistance: 200, -}; - -/** - * Reference size (in meters) for LOD distance scaling. - * Objects larger than this get proportionally extended draw distances. - * Objects smaller than this get proportionally reduced draw distances. - * - * Based on typical "medium" object size (a small tree or large bush). - */ -export const LOD_REFERENCE_SIZE = 5.0; - -/** - * Minimum scale factor (prevents tiny objects from having 0 draw distance) - */ -export const LOD_MIN_SCALE = 0.3; - -/** - * Maximum scale factor (prevents huge objects from having infinite draw distance) - */ -export const LOD_MAX_SCALE = 10.0; - -/** - * Calculate size-based LOD distance scale factor. - * - * Formula: scale = clamp(boundingSize / referenceSize, minScale, maxScale) - * - * Examples: - * - 5m object (reference): scale = 1.0x (normal distances) - * - 10m object: scale = 2.0x (double distances) - * - 50m object: scale = 10.0x (max, capped) - * - 2m object: scale = 0.4x (40% of normal) - * - 0.5m object: scale = 0.3x (min, capped) - * - * @param boundingSize - Bounding box diagonal or sphere diameter in meters - * @returns Scale factor to multiply with base LOD distances - */ -export function calculateLODScaleFactor(boundingSize: number): number { - if (boundingSize <= 0) return 1.0; - const rawScale = boundingSize / LOD_REFERENCE_SIZE; - return Math.max(LOD_MIN_SCALE, Math.min(LOD_MAX_SCALE, rawScale)); -} - -/** Cache for LOD configs with pre-computed squared distances */ -const lodDistanceCache = new Map(); - -/** Cache for size-scaled LOD configs */ -const lodDistanceSizeCache = new Map(); - -/** - * Get LOD distances for a category with pre-computed squared values. - * Caches results for performance. - * - * @param category - Category name (e.g., "tree", "bush", "resource") - * @returns LOD distances with squared values for distance comparisons - */ -export function getLODDistances(category: string): LODDistancesWithSq { - // Check cache first - const cached = lodDistanceCache.get(category); - if (cached) return cached; - - // Get base config or use default - const base = LOD_DISTANCES[category] ?? DEFAULT_LOD_DISTANCES; - - // Pre-compute squared distances - const withSq: LODDistancesWithSq = { - ...base, - lod1DistanceSq: base.lod1Distance * base.lod1Distance, - lod2DistanceSq: base.lod2Distance * base.lod2Distance, - imposterDistanceSq: base.imposterDistance * base.imposterDistance, - fadeDistanceSq: base.fadeDistance * base.fadeDistance, - }; - - // Cache and return - lodDistanceCache.set(category, withSq); - return withSq; -} - -/** - * Get LOD distances scaled by object size. - * - * Larger objects (bigger bounding box) get proportionally extended draw distances, - * allowing them to be visible from farther away as imposters. - * - * This enables: - * - Giant trees visible as imposters from 1km+ away - * - Small mushrooms culled at 50m - * - Medium bushes at default distances - * - * @param category - Category name (e.g., "tree", "bush", "resource") - * @param boundingSize - Bounding box diagonal or sphere diameter in meters - * @returns LOD distances scaled by object size with pre-computed squared values - */ -export function getLODDistancesScaled( - category: string, - boundingSize: number, -): LODDistancesWithSq { - // Create cache key combining category and size (rounded to avoid cache bloat) - const roundedSize = Math.round(boundingSize * 10) / 10; // Round to 0.1m precision - const cacheKey = `${category}_${roundedSize}`; - - // Check cache first - const cached = lodDistanceSizeCache.get(cacheKey); - if (cached) return cached; - - // Get base config - const base = LOD_DISTANCES[category] ?? DEFAULT_LOD_DISTANCES; - - // Calculate scale factor - const scale = calculateLODScaleFactor(boundingSize); - - // Apply scaling to all distances - const scaled: LODDistancesWithSq = { - lod1Distance: base.lod1Distance * scale, - lod2Distance: base.lod2Distance * scale, - imposterDistance: base.imposterDistance * scale, - fadeDistance: base.fadeDistance * scale, - lod1DistanceSq: (base.lod1Distance * scale) ** 2, - lod2DistanceSq: (base.lod2Distance * scale) ** 2, - imposterDistanceSq: (base.imposterDistance * scale) ** 2, - fadeDistanceSq: (base.fadeDistance * scale) ** 2, - }; - - // Cache and return - lodDistanceSizeCache.set(cacheKey, scaled); - return scaled; -} - -/** - * Get LOD configuration for an entity or object, with size-based scaling. - * - * Convenience function that extracts bounding size from common object types. - * - * @param category - Category name - * @param object - THREE.Object3D, bounding box, or bounding sphere - * @returns Scaled LOD distances - */ -export function getLODConfig( - category: string, - object?: - | THREE.Object3D - | THREE.Box3 - | THREE.Sphere - | { boundingSize: number } - | number, -): LODDistancesWithSq { - // If no object, return base distances - if (object === undefined || object === null) { - return getLODDistances(category); - } - - // Extract bounding size based on object type - let boundingSize: number; - - if (typeof object === "number") { - // Direct size value - boundingSize = object; - } else if ("boundingSize" in object) { - // Object with explicit boundingSize property - boundingSize = object.boundingSize; - } else if (object instanceof THREE.Box3) { - // Bounding box - use diagonal - const size = new THREE.Vector3(); - object.getSize(size); - boundingSize = size.length(); - } else if (object instanceof THREE.Sphere) { - // Bounding sphere - use diameter - boundingSize = object.radius * 2; - } else if (object instanceof THREE.Object3D) { - // THREE.Object3D - compute bounding box - const box = new THREE.Box3().setFromObject(object); - const size = new THREE.Vector3(); - box.getSize(size); - boundingSize = size.length(); - } else { - // Unknown type, use base distances - return getLODDistances(category); - } - - return getLODDistancesScaled(category, boundingSize); -} - -/** - * Clear the LOD distance cache. - * Call this if LOD_DISTANCES is modified at runtime. - */ -export function clearLODDistanceCache(): void { - lodDistanceCache.clear(); - lodDistanceSizeCache.clear(); -} - -/** - * Apply LOD settings from a manifest or external configuration. - * Merges with existing configuration and clears the cache. - * - * @param settings - LOD settings with distanceThresholds - */ -export function applyLODSettings(settings: { - distanceThresholds?: Record< - string, - { lod1?: number; lod2?: number; imposter: number; fadeOut: number } - >; -}): void { - if (!settings.distanceThresholds) return; - - for (const [category, thresholds] of Object.entries( - settings.distanceThresholds, - )) { - // Map category names (e.g., "fallen" -> "fallen_tree") - const configKey = category === "fallen" ? "fallen_tree" : category; - - // Calculate lod2Distance: if not provided, use midpoint between lod1 and imposter - const lod1 = thresholds.lod1 ?? 0; - const lod2 = thresholds.lod2 ?? (lod1 + thresholds.imposter) / 2; - - LOD_DISTANCES[configKey] = { - lod1Distance: lod1, - lod2Distance: lod2, - imposterDistance: thresholds.imposter, - fadeDistance: thresholds.fadeOut, - }; - } - - // Clear cache to force recalculation with new values - clearLODDistanceCache(); - - console.log( - `[GPUVegetation] Applied LOD settings for ${Object.keys(settings.distanceThresholds).length} categories`, - ); -} - // ============================================================================ // TYPES // ============================================================================ @@ -642,7 +230,7 @@ export function createGPUVegetationMaterial( const fadeEndSq = mul(uFadeEnd, uFadeEnd); // Occlusion dissolve constants (RuneScape-style cone) - const enableOcclusion = options.enableOcclusionDissolve !== false; // Default: enabled + const enableOcclusion = options.enableOcclusionDissolve !== false; const occlusionCameraRadius = float(GPU_VEG_CONFIG.OCCLUSION_CAMERA_RADIUS); const occlusionPlayerRadius = float(GPU_VEG_CONFIG.OCCLUSION_PLAYER_RADIUS); const occlusionDistanceScale = float(GPU_VEG_CONFIG.OCCLUSION_DISTANCE_SCALE); @@ -656,15 +244,11 @@ export function createGPUVegetationMaterial( const nearCameraFadeEnd = float(GPU_VEG_CONFIG.NEAR_CAMERA_FADE_END); // ========== ALPHA TEST (DITHERED DISSOLVE + OCCLUSION + NEAR-CAMERA FADE) ========== - // alphaTestNode controls fragment discard directly - // Fragment is discarded when alpha < alphaTestNode value - // We compute a per-fragment threshold, so higher threshold = more likely to discard material.alphaTestNode = Fn(() => { - // Use positionWorld which includes instance transform const worldPos = positionWorld; // 1. Water check: if below water, use threshold of 2.0 to always discard - const belowWater = step(worldPos.y, waterCutoff); // 1 if below, 0 if above + const belowWater = step(worldPos.y, waterCutoff); // 2. Distance calculation from WORLD position to player (horizontal only, squared) const toPlayer = sub(worldPos, uPlayerPos); @@ -674,30 +258,23 @@ export function createGPUVegetationMaterial( ); // 3. Distance factor: 0.0 when close (keep fragment), 1.0 when far (discard fragment) - // smoothstep(edge0, edge1, x): returns 0 when x<=edge0, 1 when x>=edge1 const distanceFade = smoothstep(fadeStartSq, fadeEndSq, distSq); // 4. CAMERA-TO-PLAYER OCCLUSION DISSOLVE (RuneScape-style) - // Dissolve objects between camera and player using a CONE shape - // The cone is narrow at the camera and wide around the player - // This creates a natural "bubble" of visibility around the player const occlusionFade = enableOcclusion ? (() => { - // Camera to player vector (CT) const camToPlayer = vec3( sub(uPlayerPos.x, uCameraPos.x), sub(uPlayerPos.y, uCameraPos.y), sub(uPlayerPos.z, uCameraPos.z), ); - // Camera to fragment vector (CP) const camToFrag = vec3( sub(worldPos.x, uCameraPos.x), sub(worldPos.y, uCameraPos.y), sub(worldPos.z, uCameraPos.z), ); - // Length of camera-to-player vector const ctLengthSq = add( add( mul(camToPlayer.x, camToPlayer.x), @@ -707,29 +284,23 @@ export function createGPUVegetationMaterial( ); const ctLength = sqrt(ctLengthSq); - // Normalized camera-to-player direction const ctDirX = div(camToPlayer.x, ctLength); const ctDirY = div(camToPlayer.y, ctLength); const ctDirZ = div(camToPlayer.z, ctLength); - // Project fragment onto camera-player line (dot product of CP and normalized CT) - // projDist = how far along the camera->player line this fragment projects const projDist = add( add(mul(camToFrag.x, ctDirX), mul(camToFrag.y, ctDirY)), mul(camToFrag.z, ctDirZ), ); - // Check if fragment is between camera and player (with small margins) const inRangeNear = step(occlusionNearMargin, projDist); const inRangeFar = step(projDist, sub(ctLength, occlusionFarMargin)); const inRange = mul(inRangeNear, inRangeFar); - // Calculate perpendicular distance from fragment to the camera-player line const projX = add(uCameraPos.x, mul(projDist, ctDirX)); const projY = add(uCameraPos.y, mul(projDist, ctDirY)); const projZ = add(uCameraPos.z, mul(projDist, ctDirZ)); - // Perpendicular distance (how far from the center line) const perpX = sub(worldPos.x, projX); const perpY = sub(worldPos.y, projY); const perpZ = sub(worldPos.z, projZ); @@ -739,12 +310,8 @@ export function createGPUVegetationMaterial( ); const perpDist = sqrt(perpDistSq); - // CONE RADIUS CALCULATION (RuneScape-style) - // t = normalized position along camera->player (0 at camera, 1 at player) const t = clamp(div(projDist, ctLength), float(0.0), float(1.0)); - // Cone radius: linearly interpolates from camera radius to player radius - // Plus extra radius based on total camera distance (for zoom-out support) const coneRadius = add( add( occlusionCameraRadius, @@ -753,8 +320,6 @@ export function createGPUVegetationMaterial( mul(ctLength, occlusionDistanceScale), ); - // SHARP EDGE FALLOFF (RuneScape-style binary disappearance) - // Uses a narrow smoothstep band for sharper cutoff const edgeStart = mul( coneRadius, sub(float(1.0), occlusionEdgeSharpness), @@ -764,13 +329,11 @@ export function createGPUVegetationMaterial( smoothstep(edgeStart, coneRadius, perpDist), ); - // Apply occlusion strength and range check return mul(mul(rawOcclusionFade, occlusionStrength), inRange); })() : float(0.0); // 5. NEAR-CAMERA DISSOLVE (RuneScape-style depth fade) - // Prevents hard geometry clipping when camera clips through vegetation const camToFrag = sub(worldPos, uCameraPos); const camDistSq = dot(camToFrag, camToFrag); const camDist = sqrt(camDistSq); @@ -779,11 +342,10 @@ export function createGPUVegetationMaterial( smoothstep(nearCameraFadeEnd, nearCameraFadeStart, camDist), ); - // 6. Combine all fade factors (max ensures all effects work independently) + // 6. Combine all fade factors const combinedFade = max(max(distanceFade, occlusionFade), nearCameraFade); // 7. SCREEN-SPACE 4x4 BAYER DITHERING (RuneScape 3 style) - // 4x4 Bayer matrix: [ 0, 8, 2,10; 12, 4,14, 6; 3,11, 1, 9; 15, 7,13, 5]/16 const ix = mod(floor(viewportCoordinate.x), float(4.0)); const iy = mod(floor(viewportCoordinate.y), float(4.0)); @@ -804,8 +366,6 @@ export function createGPUVegetationMaterial( const ditherValue = mul(bayerInt, float(0.0625)); // 8. RS3-style threshold: discard when fade >= dither - // step returns 0 or 1, multiply by 2 so threshold > 1.0 causes discard - // IMPORTANT: Only apply dithering when combinedFade > 0, otherwise step(0,0)=1 causes holes const hasAnyFade = step(float(0.001), combinedFade); const ditherThreshold = mul( mul(step(ditherValue, combinedFade), hasAnyFade), @@ -816,7 +376,7 @@ export function createGPUVegetationMaterial( return threshold; })(); - // ========== SKY-COLOR FOG (smoothstep + NEAR_SQ, applied in outputNode) ========== + // ========== SKY-COLOR FOG ========== const fogTexNode = texture(fogRenderTarget.texture, screenUV); const toCam = sub(cameraPosition, positionWorld); @@ -827,8 +387,6 @@ export function createGPUVegetationMaterial( fogDistSq, ); - // colorNode reads vertex colors manually; vertexColors must be FALSE to - // prevent the PBR pipeline from multiplying them a second time. material.colorNode = options.vertexColors ? Fn(() => vertexColor().rgb)() : undefined; @@ -838,26 +396,20 @@ export function createGPUVegetationMaterial( } material.fog = false; - // Apply fog AFTER PBR lighting via outputNode so fog color isn't darkened material.outputNode = Fn(() => { const litColor = output; return vec4(mix(litColor.rgb, fogTexNode.rgb, fogFactor), litColor.a); })(); - // CUTOUT rendering with dynamic alphaTestNode - // alphaTestNode returns per-fragment threshold, fragment discarded when alpha < threshold - // This is more efficient than transparent blending and provides proper depth sorting - material.transparent = false; // Opaque rendering for performance - material.opacity = 1.0; // Full opacity - alphaTestNode controls visibility - material.alphaTest = 0.5; // Fallback (alphaTestNode overrides this) + material.transparent = false; + material.opacity = 1.0; + material.alphaTest = 0.5; material.side = THREE.DoubleSide; material.depthWrite = true; - // Matte vegetation material.roughness = 0.95; material.metalness = 0.0; - // Attach uniforms const gpuMaterial = material as unknown as GPUVegetationMaterial; gpuMaterial.gpuUniforms = { playerPos: uPlayerPos, @@ -883,23 +435,6 @@ export function createGPUVegetationMaterial( * @param source - Source material to clone properties from * @param options - Dissolve configuration options * @returns Material with dissolve shader attached - * - * @example - * ```ts - * // Create dissolve material from existing material - * const dissolveMat = createDissolveMaterial(originalMaterial, { - * fadeStart: 270, - * fadeEnd: 300, - * enableNearFade: true, - * nearFadeStart: 1, - * nearFadeEnd: 3, - * enableOcclusionDissolve: true, - * }); - * - * // Update positions each frame - * dissolveMat.dissolveUniforms.playerPos.value.set(px, py, pz); - * dissolveMat.dissolveUniforms.cameraPos.value.set(cx, cy, cz); - * ``` */ export function createDissolveMaterial( source: THREE.MeshStandardMaterial | THREE.Material, @@ -962,7 +497,7 @@ export function createDissolveMaterial( const nearFadeEndSq = mul(uNearFadeEnd, uNearFadeEnd); const enableNearFade = options.enableNearFade ?? true; const enableWaterCulling = options.enableWaterCulling ?? false; - const enableOcclusion = options.enableOcclusionDissolve !== false; // Default: enabled + const enableOcclusion = options.enableOcclusionDissolve !== false; const waterCutoff = float( GPU_VEG_CONFIG.WATER_LEVEL + GPU_VEG_CONFIG.WATER_BUFFER, ); @@ -984,25 +519,23 @@ export function createDissolveMaterial( material.alphaTestNode = Fn(() => { const worldPos = positionWorld; - // Distance calculation from world position to player (horizontal only, squared) const toPlayer = sub(worldPos, uPlayerPos); const distSq = add( mul(toPlayer.x, toPlayer.x), mul(toPlayer.z, toPlayer.z), ); - // FAR fade: 0.0 when close (keep), 1.0 when far (discard) + // FAR fade const farFade = smoothstep(fadeStartSq, fadeEndSq, distSq); - // NEAR fade: 0.0 when outside near zone (keep), 1.0 when too close (discard) + // NEAR fade const nearFade = enableNearFade ? sub(float(1.0), smoothstep(nearFadeStartSq, nearFadeEndSq, distSq)) : float(0.0); - // Combined distance fade (max of near and far fade) const distanceFadeBase = max(farFade, nearFade); - // NEAR-CAMERA DISSOLVE (prevents hard clipping when camera clips through objects) + // NEAR-CAMERA DISSOLVE const camToFrag = sub(worldPos, uCameraPos); const camDistSq = dot(camToFrag, camToFrag); const camDist = sqrt(camDistSq); @@ -1012,24 +545,20 @@ export function createDissolveMaterial( ); // CAMERA-TO-PLAYER OCCLUSION DISSOLVE (RuneScape-style) - // Uses a CONE shape that expands from camera toward player const occlusionFade = enableOcclusion ? (() => { - // Camera to player vector (CT) const camToPlayer = vec3( sub(uPlayerPos.x, uCameraPos.x), sub(uPlayerPos.y, uCameraPos.y), sub(uPlayerPos.z, uCameraPos.z), ); - // Camera to fragment vector (CP) const camToFrag = vec3( sub(worldPos.x, uCameraPos.x), sub(worldPos.y, uCameraPos.y), sub(worldPos.z, uCameraPos.z), ); - // Length of camera-to-player vector const ctLengthSq = add( add( mul(camToPlayer.x, camToPlayer.x), @@ -1039,23 +568,19 @@ export function createDissolveMaterial( ); const ctLength = sqrt(ctLengthSq); - // Normalized camera-to-player direction const ctDirX = div(camToPlayer.x, ctLength); const ctDirY = div(camToPlayer.y, ctLength); const ctDirZ = div(camToPlayer.z, ctLength); - // Project fragment onto camera-player line const projDist = add( add(mul(camToFrag.x, ctDirX), mul(camToFrag.y, ctDirY)), mul(camToFrag.z, ctDirZ), ); - // Check if fragment is between camera and player (with margins) const inRangeNear = step(occlusionNearMargin, projDist); const inRangeFar = step(projDist, sub(ctLength, occlusionFarMargin)); const inRange = mul(inRangeNear, inRangeFar); - // Calculate perpendicular distance from fragment to the camera-player line const projX = add(uCameraPos.x, mul(projDist, ctDirX)); const projY = add(uCameraPos.y, mul(projDist, ctDirY)); const projZ = add(uCameraPos.z, mul(projDist, ctDirZ)); @@ -1069,7 +594,6 @@ export function createDissolveMaterial( ); const perpDist = sqrt(perpDistSq); - // CONE RADIUS CALCULATION (RuneScape-style) const t = clamp(div(projDist, ctLength), float(0.0), float(1.0)); const coneRadius = add( add( @@ -1079,7 +603,6 @@ export function createDissolveMaterial( mul(ctLength, occlusionDistanceScale), ); - // SHARP EDGE FALLOFF (RuneScape-style binary disappearance) const edgeStart = mul( coneRadius, sub(float(1.0), occlusionEdgeSharpness), @@ -1089,12 +612,10 @@ export function createDissolveMaterial( smoothstep(edgeStart, coneRadius, perpDist), ); - // Apply occlusion strength and range check return mul(mul(rawOcclusionFade, occlusionStrength), inRange); })() : float(0.0); - // Combine distance fade, occlusion fade, and near-camera fade const distanceFade = max( max(distanceFadeBase, occlusionFade), nearCameraFade, @@ -1120,16 +641,12 @@ export function createDissolveMaterial( ); const ditherValue = mul(bayerInt, float(0.0625)); - // RS3-style: discard when fade >= dither - // step returns 0 or 1, multiply by 2 so threshold > 1.0 causes discard - // IMPORTANT: Only apply dithering when distanceFade > 0, otherwise step(0,0)=1 causes holes const hasAnyFade = step(float(0.001), distanceFade); const ditherThreshold = mul( mul(step(ditherValue, distanceFade), hasAnyFade), float(2.0), ); - // Water culling (optional) const waterCullValue = enableWaterCulling ? mul(step(worldPos.y, waterCutoff), float(2.0)) : float(0.0); @@ -1138,11 +655,9 @@ export function createDissolveMaterial( return threshold; })(); - // Material settings for cutout rendering - material.alphaTest = 0.5; // Fallback + material.alphaTest = 0.5; material.forceSinglePass = true; - // Attach dissolve uniforms for per-frame updates const dissolveMat = material as DissolveMaterial; dissolveMat.dissolveUniforms = { playerPos: uPlayerPos, @@ -1153,16 +668,14 @@ export function createDissolveMaterial( nearFadeEnd: uNearFadeEnd, }; - // Per-instance flat glow highlight (driven by instanceHighlight attribute) - // When highlighted, replaces PBR shading with a flat unlit emissive color - // plus a subtle fresnel rim brightening at silhouette edges. + // Per-instance glow highlight (driven by instanceHighlight attribute) if (options.enableRimHighlight) { const uHighlightColor = uniform(new THREE.Color(0x00ffff)); dissolveMat.highlightColor = uHighlightColor; - const BASE_BRIGHTNESS = 0.35; + const BRIGHTEN = 0.08; const RIM_POWER = 2.5; - const RIM_BOOST = 0.5; + const RIM_STRENGTH = 0.4; material.outputNode = Fn(() => { const litColor = output; @@ -1172,18 +685,18 @@ export function createDissolveMaterial( const V = normalize(sub(vec3(0, 0, 0), positionView.xyz)); const NdotV = clamp(dot(N, V), float(0.0), float(1.0)); - // Fresnel rim — brighter at silhouette edges - const rim = pow(sub(float(1.0), NdotV), float(RIM_POWER)); - - // Flat unlit glow: base brightness + rim boost, tinted by highlight color - const brightness = add( - float(BASE_BRIGHTNESS), - mul(rim, float(RIM_BOOST)), + // Fresnel rim — glow at silhouette edges only + const rim = mul( + pow(sub(float(1.0), NdotV), float(RIM_POWER)), + float(RIM_STRENGTH), ); - const flatGlow = mul(vec3(uHighlightColor), brightness); - // When highlighted, fully replace the lit result with the flat glow - const finalRgb = mix(litColor.rgb, flatGlow, hlIntensity); + // Gentle brighten + rim-only highlight color + const brightened = add(litColor.rgb, float(BRIGHTEN)); + const rimGlow = mul(vec3(uHighlightColor), rim); + const highlighted = add(brightened, rimGlow); + + const finalRgb = mix(litColor.rgb, highlighted, hlIntensity); return vec4(finalRgb, litColor.a); })(); } @@ -1202,7 +715,7 @@ export function isDissolveMaterial( } // ============================================================================ -// IMPOSTER BILLBOARD MATERIAL (FOR VEGETATION/RESOURCE IMPOSTERS) +// IMPOSTER BILLBOARD MATERIAL // ============================================================================ /** @@ -1239,69 +752,38 @@ export type ImposterMaterial = THREE.MeshStandardNodeMaterial & { * - Adds distance-based dithered dissolve (matches 3D vegetation) * - Uses same lighting properties as 3D vegetation (roughness, metalness) * - * The dissolve is achieved by dynamically adjusting the alpha test threshold: - * - Close range: threshold = alphaTest (normal texture cutout) - * - Far range: threshold increases, discarding more fragments via dither - * * @param options - Imposter material configuration * @returns Material with dissolve shader and uniforms - * - * @example - * ```ts - * const material = createImposterMaterial({ - * texture: preRenderedTexture, - * fadeStart: 300, // Start dissolve at 300m - * fadeEnd: 350, // Fully dissolved at 350m - * }); - * - * // Update player position each frame - * material.imposterUniforms.playerPos.value.set(px, 0, pz); - * ``` */ export function createImposterMaterial( options: ImposterMaterialOptions, ): ImposterMaterial { const material = new MeshStandardNodeMaterial(); - // Set the pre-rendered texture material.map = options.texture; // ========== UNIFORMS ========== const uPlayerPos = uniform(new THREE.Vector3(0, 0, 0)); - // Store fade distances for runtime access (if needed) const fadeStartVal = options.fadeStart ?? 300; const fadeEndVal = options.fadeEnd ?? 350; const uFadeStart = uniform(fadeStartVal); const uFadeEnd = uniform(fadeEndVal); // ========== CONSTANTS (PRE-COMPUTED ON CPU) ========== - // OPTIMIZATION: Pre-compute squared distances on CPU rather than per-fragment - // These don't change at runtime, so avoid redundant GPU multiplication const fadeStartSq = float(fadeStartVal * fadeStartVal); const fadeEndSq = float(fadeEndVal * fadeEndVal); const baseAlphaThreshold = float(options.alphaTest ?? 0.5); // ========== ALPHA TEST (TEXTURE CUTOUT + DITHERED DISSOLVE) ========== - // This combines two alpha test behaviors: - // 1. Texture alpha cutout (for tree silhouette) - // 2. Distance-based dithered dissolve (for LOD fade) - // - // The texture has alpha=0 for background, alpha=1 for tree. - // alphaTestNode returns threshold: fragment discarded when texture.alpha < threshold - // - // - Close: threshold = baseAlphaThreshold (0.5) → normal cutout - // - Far: threshold = baseAlphaThreshold + dissolve → even opaque pixels discard material.alphaTestNode = Fn(() => { const worldPos = positionWorld; - // Distance calculation from world position to player (horizontal only, squared) const toPlayer = sub(worldPos, uPlayerPos); const distSq = add( mul(toPlayer.x, toPlayer.x), mul(toPlayer.z, toPlayer.z), ); - // FAR fade: 0.0 when close (keep), 1.0 when far (discard) const farFade = smoothstep(fadeStartSq, fadeEndSq, distSq); // SCREEN-SPACE 4x4 BAYER DITHERING (RuneScape 3 style) @@ -1324,9 +806,6 @@ export function createImposterMaterial( ); const ditherValue = mul(bayerInt, float(0.0625)); - // RS3-style: combine texture cutout with dithered distance fade - // step returns 0 or 1, multiply by 2 so threshold > 1.0 causes discard - // IMPORTANT: Only apply dithering when farFade > 0, otherwise step(0,0)=1 causes holes const hasAnyFade = step(float(0.001), farFade); const ditherDiscard = mul( mul(step(ditherValue, farFade), hasAnyFade), @@ -1338,15 +817,13 @@ export function createImposterMaterial( })(); // ========== MATERIAL SETTINGS ========== - // Match 3D vegetation material for consistent lighting material.roughness = 0.95; material.metalness = 0.0; material.side = THREE.DoubleSide; - // Cutout rendering (not transparent blend) for performance material.transparent = false; material.opacity = 1.0; - material.alphaTest = options.alphaTest ?? 0.5; // Fallback + material.alphaTest = options.alphaTest ?? 0.5; material.depthWrite = true; // ========== ATTACH UNIFORMS ========== diff --git a/packages/shared/src/systems/shared/world/LODConfig.ts b/packages/shared/src/systems/shared/world/LODConfig.ts new file mode 100644 index 000000000..82586ff0a --- /dev/null +++ b/packages/shared/src/systems/shared/world/LODConfig.ts @@ -0,0 +1,401 @@ +/** + * LODConfig.ts - Unified LOD Distance Configuration + * + * Single source of truth for all Level-of-Detail distances across the engine. + * Pure data + utility math — no shader code, no Three.js material dependencies. + * + * Used by: VegetationSystem, ResourceEntity, BuildingRenderingSystem, etc. + * + * @module LODConfig + */ + +import * as THREE from "../../../extras/three/three"; + +// ============================================================================ +// TYPES +// ============================================================================ + +/** + * LOD distances for a category. + * All distances in meters from camera. + * + * LOD Pipeline: + * - LOD0 (0 to lod1Distance): Full detail mesh + * - LOD1 (lod1Distance to lod2Distance): Low-poly mesh (~10% verts) + * - LOD2 (lod2Distance to imposterDistance): Very low-poly mesh (~3% verts) + * - Impostor (imposterDistance to fadeDistance): Billboard + * - Culled (> fadeDistance): Hidden + */ +export interface LODDistances { + /** Distance to switch from LOD0 (full detail) to LOD1 (low poly ~10%) */ + lod1Distance: number; + /** Distance to switch from LOD1 to LOD2 (very low poly ~3%) */ + lod2Distance: number; + /** Distance to switch from 3D mesh to billboard imposter */ + imposterDistance: number; + /** Distance at which to completely fade out/cull */ + fadeDistance: number; +} + +/** + * LOD distances with pre-computed squared values for performance. + * Use squared distances to avoid Math.sqrt in hot paths. + */ +export interface LODDistancesWithSq extends LODDistances { + lod1DistanceSq: number; + lod2DistanceSq: number; + imposterDistanceSq: number; + fadeDistanceSq: number; +} + +// ============================================================================ +// DISTANCE TABLE +// ============================================================================ + +/** + * UNIFIED LOD CONFIGURATION - Single source of truth for all LOD distances. + * + * This is the canonical configuration used by: + * - VegetationSystem (trees, bushes, grass, etc.) + * - ResourceEntity (harvestable resources) + * - Any other LOD systems + * + * Categories can be customized based on object size and visual importance. + */ +export const LOD_DISTANCES: Record = { + // Large vegetation - aggressive LOD for performance + tree: { + lod1Distance: 30, + lod2Distance: 60, + imposterDistance: 100, + fadeDistance: 180, + }, + + // Medium vegetation + bush: { + lod1Distance: 40, + lod2Distance: 80, + imposterDistance: 120, + fadeDistance: 200, + }, + fern: { + lod1Distance: 30, + lod2Distance: 55, + imposterDistance: 80, + fadeDistance: 120, + }, + rock: { + lod1Distance: 50, + lod2Distance: 100, + imposterDistance: 150, + fadeDistance: 250, + }, + fallen_tree: { + lod1Distance: 45, + lod2Distance: 90, + imposterDistance: 130, + fadeDistance: 200, + }, + + // Small vegetation (aggressive culling - skip LOD2, go straight to impostor) + flower: { + lod1Distance: 25, + lod2Distance: 45, // Same as imposter - skip LOD2 + imposterDistance: 60, + fadeDistance: 100, + }, + mushroom: { + lod1Distance: 15, + lod2Distance: 30, // Same as imposter - skip LOD2 + imposterDistance: 40, + fadeDistance: 80, + }, + grass: { + lod1Distance: 10, + lod2Distance: 20, // Same as imposter - skip LOD2 + imposterDistance: 30, + fadeDistance: 60, + }, + + // Resources (harvestable objects) + resource: { + lod1Distance: 45, + lod2Distance: 85, + imposterDistance: 120, + fadeDistance: 200, + }, + tree_resource: { + lod1Distance: 25, // Switch to LOD1 very early + lod2Distance: 50, + imposterDistance: 80, // Switch to impostor ASAP + fadeDistance: 150, // Fade out earlier + }, + rock_resource: { + lod1Distance: 50, + lod2Distance: 100, + imposterDistance: 150, + fadeDistance: 250, + }, + + // Buildings - simple geometry, skip intermediate LODs and go directly to impostor + // LOD0 (0-80m): Full detail batched mesh with shadows + // Impostor (80-200m): Octahedral billboard + // Culled (>200m): Hidden + building: { + lod1Distance: 80, // Same as impostor - skip intermediate LOD + lod2Distance: 80, // Same as impostor - skip intermediate LOD + imposterDistance: 80, // Switch to impostor at 80m + fadeDistance: 200, // Cull at 200m + }, + station: { + lod1Distance: 60, + lod2Distance: 140, + imposterDistance: 200, + fadeDistance: 350, + }, + + // Mobs and NPCs (skip LOD2 - use LOD1 to impostor) + mob: { + lod1Distance: 40, + lod2Distance: 70, + imposterDistance: 100, + fadeDistance: 150, + }, + npc: { + lod1Distance: 50, + lod2Distance: 90, + imposterDistance: 120, + fadeDistance: 180, + }, + player: { + lod1Distance: 50, + lod2Distance: 90, + imposterDistance: 120, + fadeDistance: 200, + }, + + // Items (skip LOD2) + item: { + lod1Distance: 25, + lod2Distance: 45, + imposterDistance: 60, + fadeDistance: 100, + }, +}; + +/** Default LOD distances for unknown categories */ +export const DEFAULT_LOD_DISTANCES: LODDistances = { + lod1Distance: 45, + lod2Distance: 85, + imposterDistance: 120, + fadeDistance: 200, +}; + +// ============================================================================ +// SIZE-BASED SCALING +// ============================================================================ + +/** + * Reference size (in meters) for LOD distance scaling. + * Objects larger than this get proportionally extended draw distances. + * Objects smaller than this get proportionally reduced draw distances. + * + * Based on typical "medium" object size (a small tree or large bush). + */ +export const LOD_REFERENCE_SIZE = 5.0; + +/** + * Minimum scale factor (prevents tiny objects from having 0 draw distance) + */ +export const LOD_MIN_SCALE = 0.3; + +/** + * Maximum scale factor (prevents huge objects from having infinite draw distance) + */ +export const LOD_MAX_SCALE = 10.0; + +/** + * Calculate size-based LOD distance scale factor. + * + * Formula: scale = clamp(boundingSize / referenceSize, minScale, maxScale) + * + * Examples: + * - 5m object (reference): scale = 1.0x (normal distances) + * - 10m object: scale = 2.0x (double distances) + * - 50m object: scale = 10.0x (max, capped) + * - 2m object: scale = 0.4x (40% of normal) + * - 0.5m object: scale = 0.3x (min, capped) + * + * @param boundingSize - Bounding box diagonal or sphere diameter in meters + * @returns Scale factor to multiply with base LOD distances + */ +export function calculateLODScaleFactor(boundingSize: number): number { + if (boundingSize <= 0) return 1.0; + const rawScale = boundingSize / LOD_REFERENCE_SIZE; + return Math.max(LOD_MIN_SCALE, Math.min(LOD_MAX_SCALE, rawScale)); +} + +// ============================================================================ +// CACHED LOOKUPS +// ============================================================================ + +/** Cache for LOD configs with pre-computed squared distances */ +const lodDistanceCache = new Map(); + +/** Cache for size-scaled LOD configs */ +const lodDistanceSizeCache = new Map(); + +/** + * Get LOD distances for a category with pre-computed squared values. + * Caches results for performance. + * + * @param category - Category name (e.g., "tree", "bush", "resource") + * @returns LOD distances with squared values for distance comparisons + */ +export function getLODDistances(category: string): LODDistancesWithSq { + const cached = lodDistanceCache.get(category); + if (cached) return cached; + + const base = LOD_DISTANCES[category] ?? DEFAULT_LOD_DISTANCES; + + const withSq: LODDistancesWithSq = { + ...base, + lod1DistanceSq: base.lod1Distance * base.lod1Distance, + lod2DistanceSq: base.lod2Distance * base.lod2Distance, + imposterDistanceSq: base.imposterDistance * base.imposterDistance, + fadeDistanceSq: base.fadeDistance * base.fadeDistance, + }; + + lodDistanceCache.set(category, withSq); + return withSq; +} + +/** + * Get LOD distances scaled by object size. + * + * Larger objects (bigger bounding box) get proportionally extended draw distances, + * allowing them to be visible from farther away as imposters. + * + * @param category - Category name (e.g., "tree", "bush", "resource") + * @param boundingSize - Bounding box diagonal or sphere diameter in meters + * @returns LOD distances scaled by object size with pre-computed squared values + */ +export function getLODDistancesScaled( + category: string, + boundingSize: number, +): LODDistancesWithSq { + const roundedSize = Math.round(boundingSize * 10) / 10; + const cacheKey = `${category}_${roundedSize}`; + + const cached = lodDistanceSizeCache.get(cacheKey); + if (cached) return cached; + + const base = LOD_DISTANCES[category] ?? DEFAULT_LOD_DISTANCES; + const scale = calculateLODScaleFactor(boundingSize); + + const scaled: LODDistancesWithSq = { + lod1Distance: base.lod1Distance * scale, + lod2Distance: base.lod2Distance * scale, + imposterDistance: base.imposterDistance * scale, + fadeDistance: base.fadeDistance * scale, + lod1DistanceSq: (base.lod1Distance * scale) ** 2, + lod2DistanceSq: (base.lod2Distance * scale) ** 2, + imposterDistanceSq: (base.imposterDistance * scale) ** 2, + fadeDistanceSq: (base.fadeDistance * scale) ** 2, + }; + + lodDistanceSizeCache.set(cacheKey, scaled); + return scaled; +} + +/** + * Get LOD configuration for an entity or object, with size-based scaling. + * + * Convenience function that extracts bounding size from common object types. + * + * @param category - Category name + * @param object - THREE.Object3D, bounding box, or bounding sphere + * @returns Scaled LOD distances + */ +export function getLODConfig( + category: string, + object?: + | THREE.Object3D + | THREE.Box3 + | THREE.Sphere + | { boundingSize: number } + | number, +): LODDistancesWithSq { + if (object === undefined || object === null) { + return getLODDistances(category); + } + + let boundingSize: number; + + if (typeof object === "number") { + boundingSize = object; + } else if ("boundingSize" in object) { + boundingSize = object.boundingSize; + } else if (object instanceof THREE.Box3) { + const size = new THREE.Vector3(); + object.getSize(size); + boundingSize = size.length(); + } else if (object instanceof THREE.Sphere) { + boundingSize = object.radius * 2; + } else if (object instanceof THREE.Object3D) { + const box = new THREE.Box3().setFromObject(object); + const size = new THREE.Vector3(); + box.getSize(size); + boundingSize = size.length(); + } else { + return getLODDistances(category); + } + + return getLODDistancesScaled(category, boundingSize); +} + +/** + * Clear the LOD distance cache. + * Call this if LOD_DISTANCES is modified at runtime. + */ +export function clearLODDistanceCache(): void { + lodDistanceCache.clear(); + lodDistanceSizeCache.clear(); +} + +/** + * Apply LOD settings from a manifest or external configuration. + * Merges with existing configuration and clears the cache. + * + * @param settings - LOD settings with distanceThresholds + */ +export function applyLODSettings(settings: { + distanceThresholds?: Record< + string, + { lod1?: number; lod2?: number; imposter: number; fadeOut: number } + >; +}): void { + if (!settings.distanceThresholds) return; + + for (const [category, thresholds] of Object.entries( + settings.distanceThresholds, + )) { + const configKey = category === "fallen" ? "fallen_tree" : category; + + const lod1 = thresholds.lod1 ?? 0; + const lod2 = thresholds.lod2 ?? (lod1 + thresholds.imposter) / 2; + + LOD_DISTANCES[configKey] = { + lod1Distance: lod1, + lod2Distance: lod2, + imposterDistance: thresholds.imposter, + fadeDistance: thresholds.fadeOut, + }; + } + + clearLODDistanceCache(); + + console.log( + `[LODConfig] Applied LOD settings for ${Object.keys(settings.distanceThresholds).length} categories`, + ); +} diff --git a/packages/shared/src/systems/shared/world/PlaceholderInstancer.ts b/packages/shared/src/systems/shared/world/PlaceholderInstancer.ts index f78a6ae7f..ca17d57e0 100644 --- a/packages/shared/src/systems/shared/world/PlaceholderInstancer.ts +++ b/packages/shared/src/systems/shared/world/PlaceholderInstancer.ts @@ -18,7 +18,7 @@ import { createDissolveMaterial, GPU_VEG_CONFIG, type DissolveMaterial, -} from "./GPUVegetation"; +} from "./GPUMaterials"; const INITIAL_CAPACITY = 64; @@ -38,6 +38,7 @@ interface Pool { instances: InstanceSlot[]; activeCount: number; capacity: number; + highlightData: Float32Array; } // ---- Module state ---- @@ -61,6 +62,15 @@ function createBaseMaterial(resourceType: string): MeshStandardNodeMaterial { // ---- Pool lifecycle ---- +function attachHighlightAttr( + geo: THREE.BufferGeometry, + hlData: Float32Array, +): void { + const attr = new THREE.InstancedBufferAttribute(hlData, 1); + attr.setUsage(THREE.DynamicDrawUsage); + geo.setAttribute("instanceHighlight", attr); +} + function createPool(resourceType: string): Pool { const geometry = createGeometry(resourceType); const baseMat = createBaseMaterial(resourceType); @@ -70,8 +80,12 @@ function createPool(resourceType: string): Pool { enableNearFade: true, enableWaterCulling: true, enableOcclusionDissolve: true, + enableRimHighlight: true, }); + const hlData = new Float32Array(INITIAL_CAPACITY); + attachHighlightAttr(geometry, hlData); + const mesh = new THREE.InstancedMesh(geometry, material, INITIAL_CAPACITY); mesh.count = 0; mesh.frustumCulled = false; @@ -88,6 +102,7 @@ function createPool(resourceType: string): Pool { instances: [], activeCount: 0, capacity: INITIAL_CAPACITY, + highlightData: hlData, }; } @@ -95,11 +110,14 @@ function growPool(pool: Pool): void { const newCapacity = pool.capacity * 2; const { mesh } = pool; - const newMesh = new THREE.InstancedMesh( - mesh.geometry, - mesh.material, - newCapacity, - ); + const newHlData = new Float32Array(newCapacity); + newHlData.set(pool.highlightData); + pool.highlightData = newHlData; + + const newGeo = mesh.geometry.clone(); + attachHighlightAttr(newGeo, newHlData); + + const newMesh = new THREE.InstancedMesh(newGeo, mesh.material, newCapacity); for (let i = 0; i < pool.activeCount; i++) { newMesh.setMatrixAt( i, @@ -202,9 +220,11 @@ export function removePlaceholderInstance(entityId: string): void { index, lastSlot.visible ? lastSlot.matrix : _zeroScale, ); + pool.highlightData[index] = pool.highlightData[lastIndex]; pool.instances[index] = lastSlot; pool.slots.set(lastSlot.entityId, index); } + pool.highlightData[lastIndex] = 0; pool.activeCount--; pool.mesh.count = pool.activeCount; @@ -234,3 +254,34 @@ export function setPlaceholderVisible( pool.mesh.setMatrixAt(index, visible ? slot.matrix : _zeroScale); pool.mesh.instanceMatrix.needsUpdate = true; } + +// ---- Shader-based highlight ---- + +let _highlightedEntityId: string | null = null; + +export function setPlaceholderHighlight(entityId: string, on: boolean): void { + if (on && _highlightedEntityId && _highlightedEntityId !== entityId) { + setPlaceholderHighlight(_highlightedEntityId, false); + } + + const resourceType = _entityToType.get(entityId); + if (!resourceType) return; + + const pool = _pools.get(resourceType); + if (!pool) return; + + const index = pool.slots.get(entityId); + if (index === undefined) return; + + pool.highlightData[index] = on ? 1.0 : 0.0; + const attr = pool.mesh.geometry.getAttribute("instanceHighlight"); + if (attr) (attr as THREE.InstancedBufferAttribute).needsUpdate = true; + + _highlightedEntityId = on ? entityId : null; +} + +export function clearPlaceholderHighlight(): void { + if (_highlightedEntityId) { + setPlaceholderHighlight(_highlightedEntityId, false); + } +} diff --git a/packages/shared/src/systems/shared/world/VegetationSystem.ts b/packages/shared/src/systems/shared/world/VegetationSystem.ts index 2ebab301e..464620aec 100644 --- a/packages/shared/src/systems/shared/world/VegetationSystem.ts +++ b/packages/shared/src/systems/shared/world/VegetationSystem.ts @@ -48,12 +48,14 @@ import { } from "../../../utils/workers/VegetationWorker"; import { createGPUVegetationMaterial, + type GPUVegetationMaterial, +} from "./GPUMaterials"; +import { getLODDistances, getLODDistancesScaled, applyLODSettings, - type GPUVegetationMaterial, type LODDistancesWithSq, -} from "./GPUVegetation"; +} from "./LODConfig"; import { csmLevels } from "./Environment"; import type { RoadNetworkSystem } from "./RoadNetworkSystem"; import { updateTreeInstances } from "./ProcgenTreeCache"; @@ -120,7 +122,7 @@ 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) -// NOTE: Per-category LOD distances are defined in GPUVegetation.ts LOD_DISTANCES +// NOTE: Per-category LOD distances are defined in LODConfig.ts LOD_DISTANCES // Access via getLODDistances(category) for actual LOD decisions. // The module-level FADE_START/FADE_END above are used for shared material creation. @@ -236,7 +238,7 @@ const DEFAULT_CONFIG: VegetationConfig = { }; /** - * LOD CONFIGURATION - Now uses unified system from GPUVegetation.ts + * LOD CONFIGURATION - Now uses unified system from LODConfig.ts * * The 3-tier LOD system: * - LOD0 (full detail): 0 to lod1Distance @@ -244,7 +246,7 @@ const DEFAULT_CONFIG: VegetationConfig = { * - Imposter (billboard): imposterDistance to fadeDistance * - Culled: beyond fadeDistance * - * Configuration is defined in GPUVegetation.ts LOD_DISTANCES and accessed via getLODDistances() + * Configuration is defined in LODConfig.ts LOD_DISTANCES and accessed via getLODDistances() */ /** @@ -772,7 +774,7 @@ export class VegetationSystem extends System { // Imposter distances - switch to billboard before dissolve zone // LOD transition: 3D mesh -> Billboard imposter -> Dissolve -> Cull - // Per-category LOD distances are defined in GPUVegetation.ts LOD_DISTANCES + // Per-category LOD distances are defined in LODConfig.ts LOD_DISTANCES // The shared material uses FADE_START/FADE_END for dissolve shader console.log( `[VegetationSystem] Synced with shadows "${shadowsLevel}": fade ${FADE_START.toFixed(0)}-${FADE_END.toFixed(0)}m, chunks ${CHUNK_RENDER_DISTANCE.toFixed(0)}m (per-category LOD via getLODDistances)`, @@ -2529,9 +2531,9 @@ export class VegetationSystem extends System { radius, ); if (!indexed) { - console.warn( - `[VegetationSystem] Chunk ${chunkKey} at (${centerX.toFixed(0)}, ${centerZ.toFixed(0)}) outside quadtree bounds - using fallback linear iteration`, - ); + // console.warn( + // `[VegetationSystem] Chunk ${chunkKey} at (${centerX.toFixed(0)}, ${centerZ.toFixed(0)}) outside quadtree bounds - using fallback linear iteration`, + // ); } } } diff --git a/packages/shared/src/systems/shared/world/__tests__/GPUVegetation.test.ts b/packages/shared/src/systems/shared/world/__tests__/GPUMaterials.test.ts similarity index 99% rename from packages/shared/src/systems/shared/world/__tests__/GPUVegetation.test.ts rename to packages/shared/src/systems/shared/world/__tests__/GPUMaterials.test.ts index a1899e604..1987ec69a 100644 --- a/packages/shared/src/systems/shared/world/__tests__/GPUVegetation.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/GPUMaterials.test.ts @@ -1,5 +1,5 @@ /** - * GPUVegetation Unit Tests + * GPUMaterials & LODConfig Unit Tests * * Tests for GPU-driven dissolve rendering and unified LOD configuration: * - LOD distance configuration (getLODDistances, applyLODSettings) @@ -7,14 +7,11 @@ * - Imposter material creation and type guards * - Boundary conditions and edge cases * - Configuration caching and cache invalidation - * - * Based on packages/shared/src/systems/shared/world/GPUVegetation.ts */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import THREE from "../../../../extras/three/three"; import { - GPU_VEG_CONFIG, LOD_DISTANCES, DEFAULT_LOD_DISTANCES, LOD_REFERENCE_SIZE, @@ -26,13 +23,16 @@ import { calculateLODScaleFactor, clearLODDistanceCache, applyLODSettings, + type LODDistances, +} from "../LODConfig"; +import { + GPU_VEG_CONFIG, isDissolveMaterial, isImposterMaterial, createDissolveMaterial, createGPUVegetationMaterial, createImposterMaterial, - type LODDistances, -} from "../GPUVegetation"; +} from "../GPUMaterials"; /** * Helper to save and restore LOD_DISTANCES global state. @@ -114,7 +114,7 @@ describe("GPUVegetation", () => { [15, 7, 13, 5], ]; - // The shader formula (must match what's in GPUVegetation.ts) + // The shader formula (must match what's in GPUMaterials.ts) function bayerFormula(x: number, y: number): number { const bit0_x = x % 2; const bit1_x = Math.floor(x / 2); diff --git a/packages/shared/src/utils/rendering/DistanceFade.ts b/packages/shared/src/utils/rendering/DistanceFade.ts index 19c8c24b0..03b6bd575 100644 --- a/packages/shared/src/utils/rendering/DistanceFade.ts +++ b/packages/shared/src/utils/rendering/DistanceFade.ts @@ -34,7 +34,7 @@ import THREE, { dot, } from "../../extras/three/three"; import { DISTANCE_CONSTANTS } from "../../constants/GameConstants"; -import { GPU_VEG_CONFIG } from "../../systems/shared/world/GPUVegetation"; +import { GPU_VEG_CONFIG } from "../../systems/shared/world/GPUMaterials"; export interface DistanceFadeConfig { fadeStart: number; diff --git a/packages/shared/src/utils/rendering/__tests__/LODQuality.test.ts b/packages/shared/src/utils/rendering/__tests__/LODQuality.test.ts index 7102a08ae..2081afdb7 100644 --- a/packages/shared/src/utils/rendering/__tests__/LODQuality.test.ts +++ b/packages/shared/src/utils/rendering/__tests__/LODQuality.test.ts @@ -19,7 +19,7 @@ import { LOD_REFERENCE_SIZE, LOD_MIN_SCALE, LOD_MAX_SCALE, -} from "../../../systems/shared/world/GPUVegetation"; +} from "../../../systems/shared/world/LODConfig"; import { DISTANCE_CONSTANTS } from "../../../constants/GameConstants"; // ============================================================================ From 6d4724ce1622b60a2a8c99229b8773e0ba06756e Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 3 Mar 2026 01:51:15 +0800 Subject: [PATCH 04/48] perf: uniform-based shader highlight for all entities, disable receiveShadow on stations Extend shader-based Fresnel rim highlight to all non-instanced entities (stations, items, mobs, NPCs, players) via base Entity class. Uses a per-material uniform toggle instead of instanced attributes, lazily applied on first hover. Eliminates post-processing outline pass for these entities, reducing GPU overhead. - Add applyRimHighlight() to GPUMaterials.ts for uniform-based highlight - Add setShaderHighlight() to base Entity class with lazy initialization - Remove dead legacy highlight code (getHighlightRoot, getHighlightMesh, activeHighlightMesh) from ResourceEntity, ResourceVisualStrategy, and EntityHighlightService - Disable receiveShadow on all station entities for performance Made-with: Cursor --- packages/shared/src/entities/Entity.ts | 42 ++++++++++++++ .../shared/src/entities/world/AltarEntity.ts | 4 +- .../shared/src/entities/world/AnvilEntity.ts | 4 +- .../shared/src/entities/world/BankEntity.ts | 4 +- .../src/entities/world/FurnaceEntity.ts | 4 +- .../src/entities/world/HeadstoneEntity.ts | 4 +- .../shared/src/entities/world/RangeEntity.ts | 4 +- .../src/entities/world/ResourceEntity.ts | 14 +---- .../entities/world/RunecraftingAltarEntity.ts | 4 +- .../src/entities/world/StarterChestEntity.ts | 2 +- .../world/visuals/ResourceVisualStrategy.ts | 3 - .../services/EntityHighlightService.ts | 29 +--------- .../src/systems/shared/world/GPUMaterials.ts | 56 +++++++++++++++++++ 13 files changed, 115 insertions(+), 59 deletions(-) diff --git a/packages/shared/src/entities/Entity.ts b/packages/shared/src/entities/Entity.ts index e405405ab..958c8bfa8 100644 --- a/packages/shared/src/entities/Entity.ts +++ b/packages/shared/src/entities/Entity.ts @@ -136,6 +136,7 @@ import { getLODConfig, type LODDistancesWithSq, } from "../systems/shared/world/LODConfig"; +import { applyRimHighlight } from "../systems/shared/world/GPUMaterials"; // Re-export types for external use export type { EntityConfig }; @@ -218,6 +219,8 @@ export class Entity implements IEntity { protected config: EntityConfig; public mesh: THREE.Mesh | THREE.Group | THREE.Object3D | null = null; + /** Cached per-material highlight uniforms (lazy-initialized on first hover) */ + private _highlightUniforms: Array<{ value: number }> | null = null; public nodes: Map = new Map(); // Child nodes by ID public worldNodes: Set = new Set(); // Nodes added to world public listeners: Record> = {}; // Event listeners @@ -1200,6 +1203,45 @@ export class Entity implements IEntity { return distance <= (this.config.interactionDistance || 5); } + // ============================================================================ + // SHADER HIGHLIGHT (uniform-based, for non-instanced entities) + // ============================================================================ + + /** + * Toggle shader-based Fresnel rim highlight on this entity's mesh. + * On first call the highlight shader is lazily injected into every + * compatible material found under `this.mesh`. Subsequent calls just + * flip the uniform value. + * + * @returns true if highlight was handled, false if no compatible materials + */ + public setShaderHighlight(on: boolean): boolean { + const root = this.mesh ?? this.node; + if (!root) return false; + + if (!this._highlightUniforms) { + this._highlightUniforms = []; + root.traverse((child: THREE.Object3D) => { + if (!(child instanceof THREE.Mesh)) return; + const materials = Array.isArray(child.material) + ? child.material + : [child.material]; + for (const mat of materials) { + const u = applyRimHighlight(mat); + if (u) this._highlightUniforms!.push(u); + } + }); + } + + if (this._highlightUniforms.length === 0) return false; + + const val = on ? 1.0 : 0.0; + for (const u of this._highlightUniforms) { + u.value = val; + } + return true; + } + /** * Check if we're running in a client environment with full browser APIs * This is more robust than just checking world.isClient as it verifies actual Canvas API availability diff --git a/packages/shared/src/entities/world/AltarEntity.ts b/packages/shared/src/entities/world/AltarEntity.ts index 105d4f978..b9d82cde1 100644 --- a/packages/shared/src/entities/world/AltarEntity.ts +++ b/packages/shared/src/entities/world/AltarEntity.ts @@ -200,7 +200,7 @@ export class AltarEntity extends InteractableEntity { child.layers.set(1); if (child instanceof THREE.Mesh) { child.castShadow = true; - child.receiveShadow = true; + child.receiveShadow = false; } }); @@ -250,7 +250,7 @@ export class AltarEntity extends InteractableEntity { const mesh = new THREE.Mesh(geometry, material); mesh.name = `Altar_${this.id}`; mesh.castShadow = true; - mesh.receiveShadow = true; + mesh.receiveShadow = false; // Offset mesh up so it sits on the ground (BoxGeometry is centered at origin) mesh.position.y = boxHeight / 2; // Set layer for raycasting (required for interaction detection) diff --git a/packages/shared/src/entities/world/AnvilEntity.ts b/packages/shared/src/entities/world/AnvilEntity.ts index a7ef0a139..6610a41e0 100644 --- a/packages/shared/src/entities/world/AnvilEntity.ts +++ b/packages/shared/src/entities/world/AnvilEntity.ts @@ -202,7 +202,7 @@ export class AnvilEntity extends InteractableEntity { child.layers.set(1); if (child instanceof THREE.Mesh) { child.castShadow = true; - child.receiveShadow = true; + child.receiveShadow = false; } }); @@ -249,7 +249,7 @@ export class AnvilEntity extends InteractableEntity { mesh.name = `Anvil_${this.id}`; mesh.position.y = 0.4; // Raise so bottom is at ground level mesh.castShadow = true; - mesh.receiveShadow = true; + mesh.receiveShadow = false; // Set up userData for interaction detection mesh.userData = { diff --git a/packages/shared/src/entities/world/BankEntity.ts b/packages/shared/src/entities/world/BankEntity.ts index 4151ef7aa..116ae43b1 100644 --- a/packages/shared/src/entities/world/BankEntity.ts +++ b/packages/shared/src/entities/world/BankEntity.ts @@ -188,7 +188,7 @@ export class BankEntity extends InteractableEntity { child.layers.set(1); if (child instanceof THREE.Mesh) { child.castShadow = true; - child.receiveShadow = true; + child.receiveShadow = false; } }); @@ -238,7 +238,7 @@ export class BankEntity extends InteractableEntity { const mesh = new THREE.Mesh(geometry, material); mesh.name = `Bank_${this.id}`; mesh.castShadow = true; - mesh.receiveShadow = true; + mesh.receiveShadow = false; // Offset mesh up so it sits on the ground (BoxGeometry is centered at origin) mesh.position.y = boxHeight / 2; this.mesh = mesh; diff --git a/packages/shared/src/entities/world/FurnaceEntity.ts b/packages/shared/src/entities/world/FurnaceEntity.ts index 94daba160..ebbd8dc13 100644 --- a/packages/shared/src/entities/world/FurnaceEntity.ts +++ b/packages/shared/src/entities/world/FurnaceEntity.ts @@ -198,7 +198,7 @@ export class FurnaceEntity extends InteractableEntity { child.layers.set(1); if (child instanceof THREE.Mesh) { child.castShadow = true; - child.receiveShadow = true; + child.receiveShadow = false; } }); @@ -245,7 +245,7 @@ export class FurnaceEntity extends InteractableEntity { mesh.name = `Furnace_${this.id}`; mesh.position.y = 0.7; // Raise so bottom is at ground level mesh.castShadow = true; - mesh.receiveShadow = true; + mesh.receiveShadow = false; // Set up userData for interaction detection mesh.userData = { diff --git a/packages/shared/src/entities/world/HeadstoneEntity.ts b/packages/shared/src/entities/world/HeadstoneEntity.ts index 2a6dca316..56fbc9554 100644 --- a/packages/shared/src/entities/world/HeadstoneEntity.ts +++ b/packages/shared/src/entities/world/HeadstoneEntity.ts @@ -119,7 +119,7 @@ export class HeadstoneEntity extends InteractableEntity { child.layers.set(1); if (child instanceof THREE.Mesh) { child.castShadow = true; - child.receiveShadow = true; + child.receiveShadow = false; } }); } catch (error) { @@ -167,7 +167,7 @@ export class HeadstoneEntity extends InteractableEntity { const mesh = new THREE.Mesh(geometry, material); mesh.name = `Corpse_${this.id}`; mesh.castShadow = true; - mesh.receiveShadow = true; + mesh.receiveShadow = false; mesh.layers.set(1); this.mesh = mesh; } diff --git a/packages/shared/src/entities/world/RangeEntity.ts b/packages/shared/src/entities/world/RangeEntity.ts index d0953efad..be4c30a39 100644 --- a/packages/shared/src/entities/world/RangeEntity.ts +++ b/packages/shared/src/entities/world/RangeEntity.ts @@ -204,7 +204,7 @@ export class RangeEntity extends InteractableEntity { child.layers.set(1); if (child instanceof THREE.Mesh) { child.castShadow = true; - child.receiveShadow = true; + child.receiveShadow = false; } }); @@ -253,7 +253,7 @@ export class RangeEntity extends InteractableEntity { const mesh = new THREE.Mesh(geometry, material); mesh.name = `Range_${this.id}`; mesh.castShadow = true; - mesh.receiveShadow = true; + mesh.receiveShadow = false; // Offset mesh up so it sits on the ground (BoxGeometry is centered at origin) mesh.position.y = boxHeight / 2; // Set layer for raycasting (required for interaction detection) diff --git a/packages/shared/src/entities/world/ResourceEntity.ts b/packages/shared/src/entities/world/ResourceEntity.ts index aa3b3e135..8ebc97c54 100644 --- a/packages/shared/src/entities/world/ResourceEntity.ts +++ b/packages/shared/src/entities/world/ResourceEntity.ts @@ -122,18 +122,6 @@ export class ResourceEntity extends InteractableEntity { return this.visualCtx; } - /** - * Returns a temporary highlight mesh positioned at this entity's instanced - * location. Used by EntityHighlightService for outline rendering when the - * entity is instanced (no individual scene-graph mesh to outline). - */ - public getHighlightRoot(): THREE.Object3D | null { - if (typeof this.visual.getHighlightMesh === "function") { - return this.visual.getHighlightMesh(this.getVisualCtx()); - } - return null; - } - /** * Shader-based highlight toggle. Returns true if the strategy handled it, * false if EntityHighlightService should fall back to the outline pass. @@ -143,7 +131,7 @@ export class ResourceEntity extends InteractableEntity { this.visual.setShaderHighlight(this.getVisualCtx(), on); return true; } - return false; + return super.setShaderHighlight(on); } // =========================================================================== diff --git a/packages/shared/src/entities/world/RunecraftingAltarEntity.ts b/packages/shared/src/entities/world/RunecraftingAltarEntity.ts index 0669af737..7eaf4a81a 100644 --- a/packages/shared/src/entities/world/RunecraftingAltarEntity.ts +++ b/packages/shared/src/entities/world/RunecraftingAltarEntity.ts @@ -199,7 +199,7 @@ export class RunecraftingAltarEntity extends InteractableEntity { child.layers.set(1); if (child instanceof THREE.Mesh) { child.castShadow = true; - child.receiveShadow = true; + child.receiveShadow = false; } }); @@ -242,7 +242,7 @@ export class RunecraftingAltarEntity extends InteractableEntity { const mesh = new THREE.Mesh(geometry, material); mesh.name = `RunecraftingAltar_${this.id}`; mesh.castShadow = true; - mesh.receiveShadow = true; + mesh.receiveShadow = false; mesh.position.y = boxHeight / 2; mesh.layers.set(1); this.mesh = mesh; diff --git a/packages/shared/src/entities/world/StarterChestEntity.ts b/packages/shared/src/entities/world/StarterChestEntity.ts index eccd42b6b..899567b69 100644 --- a/packages/shared/src/entities/world/StarterChestEntity.ts +++ b/packages/shared/src/entities/world/StarterChestEntity.ts @@ -224,7 +224,7 @@ export class StarterChestEntity extends InteractableEntity { const mesh = new THREE.Mesh(geometry, material); mesh.name = `StarterChest_${this.id}`; mesh.castShadow = true; - mesh.receiveShadow = true; + mesh.receiveShadow = false; mesh.position.y = boxHeight / 2; // Add golden trim on top (lid edge) diff --git a/packages/shared/src/entities/world/visuals/ResourceVisualStrategy.ts b/packages/shared/src/entities/world/visuals/ResourceVisualStrategy.ts index 5d250d485..58636e66c 100644 --- a/packages/shared/src/entities/world/visuals/ResourceVisualStrategy.ts +++ b/packages/shared/src/entities/world/visuals/ResourceVisualStrategy.ts @@ -55,9 +55,6 @@ export interface ResourceVisualStrategy { update(ctx: ResourceVisualContext, deltaTime: number): void; destroy(ctx: ResourceVisualContext): void; - /** Return a temporary mesh positioned at this instance for the outline pass. */ - getHighlightMesh?(ctx: ResourceVisualContext): THREE.Object3D | null; - /** Shader-based rim highlight: set on/off per instance (no extra meshes). */ setShaderHighlight?(ctx: ResourceVisualContext, on: boolean): void; } diff --git a/packages/shared/src/systems/client/interaction/services/EntityHighlightService.ts b/packages/shared/src/systems/client/interaction/services/EntityHighlightService.ts index f1d051222..d2dcc014f 100644 --- a/packages/shared/src/systems/client/interaction/services/EntityHighlightService.ts +++ b/packages/shared/src/systems/client/interaction/services/EntityHighlightService.ts @@ -54,8 +54,6 @@ const _meshBuffer: THREE.Object3D[] = []; export class EntityHighlightService { private currentTargetId: string | null = null; private composer: PostProcessingComposer | null = null; - /** Temporary highlight mesh added to the scene for instanced entities */ - private activeHighlightMesh: THREE.Object3D | null = null; /** Entity using shader-based rim highlight (needs clearing on un-hover) */ private shaderHighlightEntity: Record | null = null; @@ -84,7 +82,6 @@ export class EntityHighlightService { if (newId === this.currentTargetId) return; this.clearShaderHighlight(); - this.removeActiveHighlightMesh(); this.currentTargetId = newId; if (!target || !target.entity) { @@ -107,23 +104,7 @@ export class EntityHighlightService { if (!this.composer) return; - // Try instanced highlight path (legacy) - if (typeof entity.getHighlightRoot === "function") { - const hlRoot = (entity.getHighlightRoot as () => THREE.Object3D | null)(); - if (hlRoot) { - this.world.stage?.scene?.add?.(hlRoot); - this.activeHighlightMesh = hlRoot; - const meshes = this.collectMeshes(hlRoot); - if (meshes.length > 0) { - const color = this.getHighlightColor(target.entityType); - this.composer.setOutlineColor(color); - this.composer.setOutlineObjects(meshes); - return; - } - } - } - - // Fallback: use entity's own scene-graph mesh + // Fallback: use entity's own scene-graph mesh (non-instanced entities) const mesh = target.entity.mesh; const node = target.entity.node; const root = mesh ?? node; @@ -150,19 +131,11 @@ export class EntityHighlightService { if (this.currentTargetId === null) return; this.currentTargetId = null; this.clearShaderHighlight(); - this.removeActiveHighlightMesh(); if (this.composer) { this.composer.setOutlineObjects([]); } } - private removeActiveHighlightMesh(): void { - if (this.activeHighlightMesh) { - this.world.stage?.scene?.remove?.(this.activeHighlightMesh); - this.activeHighlightMesh = null; - } - } - private clearShaderHighlight(): void { if (this.shaderHighlightEntity) { const entity = this.shaderHighlightEntity as { diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index d133d3f5a..576ed7c14 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -846,3 +846,59 @@ export function isImposterMaterial( ): material is ImposterMaterial { return material != null && "imposterUniforms" in material; } + +// ============================================================================ +// UNIFORM-BASED RIM HIGHLIGHT (FOR NON-INSTANCED ENTITIES) +// ============================================================================ + +/** + * Apply a Fresnel rim highlight to an individual (non-instanced) node material. + * Uses a uniform toggle instead of an instanced attribute. + * + * Call this once per material; returns the uniform whose `.value` you set to + * `1.0` (highlighted) or `0.0` (normal) at runtime. + * + * @param material - A MeshStandardNodeMaterial (duck-typed via `outputNode` check) + * @param color - Highlight rim color (default: cyan 0x00ffff) + * @returns The highlight uniform, or null if the material is incompatible + */ +export function applyRimHighlight( + material: THREE.Material, + color: THREE.Color = new THREE.Color(0x00ffff), +): { value: number } | null { + const mat = material as THREE.MeshStandardNodeMaterial; + if (!mat || !("outputNode" in mat)) return null; + + const uHighlight = uniform(0.0); + const uHighlightColor = uniform(color); + + const BRIGHTEN = 0.08; + const RIM_POWER = 2.5; + const RIM_STRENGTH = 0.4; + + const prevOutput = mat.outputNode; + + mat.outputNode = Fn(() => { + const litColor = prevOutput ? prevOutput : output; + const hlIntensity = uHighlight; + + const N = normalize(normalView); + const V = normalize(sub(vec3(0, 0, 0), positionView.xyz)); + const NdotV = clamp(dot(N, V), float(0.0), float(1.0)); + + const rim = mul( + pow(sub(float(1.0), NdotV), float(RIM_POWER)), + float(RIM_STRENGTH), + ); + + const brightened = add(litColor.rgb, float(BRIGHTEN)); + const rimGlow = mul(vec3(uHighlightColor), rim); + const highlighted = add(brightened, rimGlow); + + const finalRgb = mix(litColor.rgb, highlighted, hlIntensity); + return vec4(finalRgb, litColor.a); + })(); + + mat.needsUpdate = true; + return uHighlight as unknown as { value: number }; +} From dc15266a5372a8df8151d5f19709b4e44c7d2c41 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 3 Mar 2026 02:29:38 +0800 Subject: [PATCH 05/48] fix: track resource variants for all types, not just trees The resourceVariants map was only populated for tree-type resources, causing ore/fishing/herb gathering to fall back to 'tree_normal' which no longer exists in manifests after tree type consolidation. Made-with: Cursor --- .../systems/shared/entities/ResourceSystem.ts | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/shared/src/systems/shared/entities/ResourceSystem.ts b/packages/shared/src/systems/shared/entities/ResourceSystem.ts index 98942f7d9..0d03b78bf 100644 --- a/packages/shared/src/systems/shared/entities/ResourceSystem.ts +++ b/packages/shared/src/systems/shared/entities/ResourceSystem.ts @@ -868,14 +868,11 @@ export class ResourceSystem extends SystemBase { `[ResourceSystem] Stored resource in map: id="${resource.id}", rid="${rid}", map size=${this.resources.size}${isManifest ? " (manifest)" : ""}`, ); } - // Track variant/subtype for tuning (e.g., 'tree_oak') - if (resource.type === "tree") { - // Build full key: if subType is "normal", key is "tree_normal" - const variant = spawnPoint.subType - ? `tree_${spawnPoint.subType}` - : "tree_normal"; - this.resourceVariants.set(rid, variant); - } + // Track variant/subtype for tuning (e.g., 'tree_oak', 'ore_copper') + const variant = spawnPoint.subType + ? `${resource.type}_${spawnPoint.subType}` + : `${resource.type}_normal`; + this.resourceVariants.set(rid, variant); // OSRS-ACCURACY: Initialize fishing spot movement timer if ( @@ -1616,8 +1613,14 @@ export class ResourceSystem extends SystemBase { const currentTick = this.world.currentTick || 0; // Compute tick-based cycle interval - const variant = - this.resourceVariants.get(sessionResourceId) || "tree_normal"; + const variant = this.resourceVariants.get(sessionResourceId); + if (!variant) { + console.error( + `[ResourceSystem] No variant tracked for resource '${resource.id}' (type: ${resource.type}). ` + + `Was the resource registered via spawnResources()?`, + ); + return; + } const tuned = this.getVariantTuning(variant); // Get best tool tier using unified tool system @@ -2942,7 +2945,8 @@ export class ResourceSystem extends SystemBase { */ private getResourceDespawnTicks(resourceId: ResourceID): number { // Get the variant key (e.g., "tree_oak", "tree_willow") - const variantKey = this.resourceVariants.get(resourceId) || "tree_normal"; + const variantKey = this.resourceVariants.get(resourceId); + if (!variantKey) return 0; // Extract tree type from variant key (e.g., "tree_oak" -> "oak") const parts = variantKey.split("_"); From 1d99ac36bd5d62b73100015a5d84a8040cddc35c Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 3 Mar 2026 15:12:43 +0800 Subject: [PATCH 06/48] feat(trees): Fortnite-style tree shader with AO, sun shade, and shared terrain lighting - Add createTreeDissolveMaterial with vertex-color AO, saturation boost, and per-instance rim highlight for trees - Extract shared applyTerrainSunShade function used by both terrain and tree shaders for consistent shadow-side sky tinting - Refactor TerrainShader to use shared computeTerrainBaseColor function and applyTerrainSunShade in outputNode - Sync sun direction and shade color uniforms from Environment system for both terrain and tree materials (day/night cycle) - Remove failed terrain color blending at tree base Made-with: Cursor --- .../systems/shared/world/GLBTreeInstancer.ts | 50 +++- .../src/systems/shared/world/GPUMaterials.ts | 157 +++++++++++++ .../src/systems/shared/world/TerrainShader.ts | 220 ++++++++++-------- .../src/systems/shared/world/TerrainSystem.ts | 37 ++- 4 files changed, 352 insertions(+), 112 deletions(-) diff --git a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts index 3b05a8163..58b879dc6 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts @@ -21,9 +21,10 @@ import THREE from "../../../extras/three/three"; import type { World } from "../../../core/World"; import { modelCache } from "../../../utils/rendering/ModelCache"; import { - createDissolveMaterial, + createTreeDissolveMaterial, GPU_VEG_CONFIG, type DissolveMaterial, + type TreeDissolveMaterial, } from "./GPUMaterials"; import { getLODDistances } from "./LODConfig"; @@ -162,6 +163,7 @@ function createLODPool( const hlData = new Float32Array(MAX_INSTANCES); for (const part of parts) { const geo = createSharedGeometry(part.geometry); + const hlAttr = new THREE.InstancedBufferAttribute(hlData, 1); hlAttr.setUsage(THREE.DynamicDrawUsage); geo.setAttribute("instanceHighlight", hlAttr); @@ -247,11 +249,11 @@ async function ensureModelPool( enableRimHighlight: true, }; - function buildDissolveParts( + function buildTreeParts( parts: MeshPart[], ): { geometry: THREE.BufferGeometry; material: DissolveMaterial }[] { return parts.map((p) => { - const dm = createDissolveMaterial(p.material, dissolveOpts); + const dm = createTreeDissolveMaterial(p.material, dissolveOpts); dm.side = THREE.DoubleSide; enableTextureRepeat(dm); world!.setupMaterial(dm); @@ -267,20 +269,20 @@ async function ensureModelPool( const bounds = computeModelBounds(lod0Scene, 1); - const lod0Pool = createLODPool(buildDissolveParts(lod0Parts)); + const lod0Pool = createLODPool(buildTreeParts(lod0Parts)); // LOD1 let lod1Pool: LODPool | null = null; const lod1Parts = await loadLODParts(inferLOD1Path(modelPath)); if (lod1Parts) { - lod1Pool = createLODPool(buildDissolveParts(lod1Parts)); + lod1Pool = createLODPool(buildTreeParts(lod1Parts)); } // LOD2 let lod2Pool: LODPool | null = null; const lod2Parts = await loadLODParts(inferLOD2Path(modelPath)); if (lod2Parts) { - lod2Pool = createLODPool(buildDissolveParts(lod2Parts)); + lod2Pool = createLODPool(buildTreeParts(lod2Parts)); } const pool: ModelPool = { @@ -340,7 +342,7 @@ async function loadDepletedPool( enableRimHighlight: true, }; const depletedDissolveParts = depletedParts.map((p) => { - const dm = createDissolveMaterial(p.material, dissolveOpts); + const dm = createTreeDissolveMaterial(p.material, dissolveOpts); dm.side = THREE.DoubleSide; enableTextureRepeat(dm); world!.setupMaterial(dm); @@ -724,6 +726,13 @@ export function updateGLBTreeInstancer(): void { const localPlayer = players && players.length > 0 ? players[0] : null; const playerPos = localPlayer?.node?.position ?? camPos; + // Get sun direction from Environment system + const env = world.getSystem("environment") as { + sunLight?: { intensity: number }; + lightDirection?: THREE.Vector3; + hemisphereLight?: { color: THREE.Color }; + } | null; + for (const pool of pools.values()) { for (const lodPool of [pool.lod0, pool.lod1, pool.lod2, pool.depleted]) { if (!lodPool) continue; @@ -742,6 +751,33 @@ export function updateGLBTreeInstancer(): void { playerPos.y, playerPos.z, ); + + // Sync tree-specific uniforms (sun direction, intensity, shade color) + const treeMat = mat as TreeDissolveMaterial; + if (treeMat.treeUniforms) { + if (env?.lightDirection) { + treeMat.treeUniforms.sunDirection.value + .copy(env.lightDirection) + .negate(); + } + if (env?.sunLight) { + treeMat.treeUniforms.sunIntensity.value = Math.min( + env.sunLight.intensity, + 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, + ); + } + } + } } } } diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index 576ed7c14..ef3d26f47 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -48,6 +48,7 @@ import { abs, viewportCoordinate, normalView, + normalWorld, normalize, pow, attribute, @@ -121,6 +122,37 @@ 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 // ============================================================================ @@ -902,3 +934,128 @@ export function applyRimHighlight( mat.needsUpdate = true; return uHighlight as unknown as { value: number }; } + +// ============================================================================ +// TREE DISSOLVE MATERIAL (FORTNITE-STYLE FOLIAGE SHADING) +// ============================================================================ + +/** + * Tree-specific dissolve material with stylized foliage shading. + * Extends DissolveMaterial with: + * - Vertex-color AO (G channel darkens crevices) + * - Back-SSS translucency (warm glow when backlit by sun) + * - Subtle saturation boost for rich foliage colors + * - Per-instance rim highlight + */ +export type TreeDissolveMaterial = DissolveMaterial & { + treeUniforms: { + sunDirection: { value: THREE.Vector3 }; + sunIntensity: { value: number }; + shadeColor: { value: THREE.Color }; + }; +}; + +/** + * Creates a tree dissolve material from an existing source material. + * Builds on the standard dissolve material (dithered fade, water culling, etc.) + * and layers Fortnite-inspired foliage shading on top: + * + * 1. **AO** — Reads the vertex color G channel as ambient occlusion, + * darkening crevices and inner branches for depth. + * 2. **Sun shade** — Shadow-side surfaces get tinted by the sky's ambient + * color (from the hemisphere light), shifting cool during day and + * dark-blue at night. + * 3. **Saturation** — Subtle boost keeps colors rich without being cartoonish. + * 4. **Rim highlight** — Per-instance Fresnel glow for hover feedback. + * + * @param source - Source material to clone PBR properties from + * @param options - Dissolve configuration (fade distances, etc.) + */ +export function createTreeDissolveMaterial( + source: THREE.MeshStandardMaterial | THREE.Material, + options: DissolveMaterialOptions = {}, +): TreeDissolveMaterial { + const baseDm = createDissolveMaterial(source, { + ...options, + enableRimHighlight: false, + }); + + const material = baseDm as unknown as THREE.MeshStandardNodeMaterial; + + // Prevent the standard pipeline from multiplying vertex colors into diffuse. + // We read vertex color manually in the shader for AO only (G channel). + material.vertexColors = false; + + // Boost normal map effect when present for more realistic surface detail + const srcStd = source as THREE.MeshStandardMaterial; + if (srcStd.normalMap) { + material.normalScale = new THREE.Vector2(2, 2); + } + + // --- Tree-specific uniforms --- + const uSunDir = uniform(new THREE.Vector3(0.5, 0.8, 0.3)); + const uSunIntensity = uniform(1.0); + const uShadeColor = uniform(new THREE.Color(0.7, 1.08, 1.22)); + const uHighlightColor = uniform(new THREE.Color(0x00ffff)); + + // --- Tuning constants --- + const AO_POWER = 1.8; + const AO_DARK = 0.35; + const SAT_BOOST = 1.1; + const HL_BRIGHTEN = 0.08; + const HL_RIM_POWER = 2.5; + const HL_RIM_STRENGTH = 0.4; + + material.outputNode = Fn(() => { + const litColor = output; + + // ---- Vertex-color AO ---- + const aoRaw = attribute("color", "vec3").y; + const aoFactor = pow(aoRaw, float(AO_POWER)); + const aoMul = mix(float(AO_DARK), float(1.0), aoFactor); + const aoResult = mul(litColor.rgb, aoMul); + + // ---- Sun shade ---- + const shadeResult = applyTerrainSunShade( + aoResult, + normalWorld, + vec3(uSunDir), + vec3(uShadeColor), + ); + + // ---- Saturation boost ---- + const luma = dot(shadeResult, vec3(0.299, 0.587, 0.114)); + const boosted = add( + mul(sub(shadeResult, vec3(luma, luma, luma)), float(SAT_BOOST)), + vec3(luma, luma, luma), + ); + + // ---- Instance rim highlight (hover) ---- + const hlIntensity = attribute("instanceHighlight", "float"); + const NV = normalize(normalView); + const Vv = normalize(sub(vec3(0, 0, 0), positionView.xyz)); + const NdotV = clamp(dot(NV, Vv), float(0.0), float(1.0)); + const rim = mul( + pow(sub(float(1.0), NdotV), float(HL_RIM_POWER)), + float(HL_RIM_STRENGTH), + ); + const brightened = add(boosted, float(HL_BRIGHTEN)); + const rimGlow = mul(vec3(uHighlightColor), rim); + const highlighted = add(brightened, rimGlow); + const finalRgb = mix(boosted, highlighted, hlIntensity); + + return vec4(finalRgb, litColor.a); + })(); + + material.needsUpdate = true; + + const treeMat = baseDm as TreeDissolveMaterial; + treeMat.highlightColor = uHighlightColor; + treeMat.treeUniforms = { + sunDirection: uSunDir as unknown as { value: THREE.Vector3 }, + sunIntensity: uSunIntensity as unknown as { value: number }, + shadeColor: uShadeColor as unknown as { value: THREE.Color }, + }; + + return treeMat; +} diff --git a/packages/shared/src/systems/shared/world/TerrainShader.ts b/packages/shared/src/systems/shared/world/TerrainShader.ts index 38e65d698..e8d04c440 100644 --- a/packages/shared/src/systems/shared/world/TerrainShader.ts +++ b/packages/shared/src/systems/shared/world/TerrainShader.ts @@ -43,6 +43,7 @@ import { import { getRoadInfluenceTextureState } from "./RoadInfluenceMask"; import { getLamppostLightTextureState } from "./LamppostLightMask"; import { FOG_NEAR_SQ, FOG_FAR_SQ, fogRenderTarget } from "./FogConfig"; +import { applyTerrainSunShade } from "./GPUMaterials"; export const TERRAIN_CONSTANTS = { TRIPLANAR_SCALE: 0.5, @@ -54,6 +55,99 @@ export const TERRAIN_CONSTANTS = { WATER_LEVEL: 5.0, }; +// ============================================================================ +// SHARED TERRAIN BASE COLOR (used by terrain shader AND tree ground-blend) +// ============================================================================ + +// OSRS palette — duplicated as module-level so the shared function and the +// terrain shader itself both reference the same values. +const GRASS_GREEN = vec3(0.3, 0.55, 0.15); +const GRASS_DARK = vec3(0.22, 0.42, 0.1); +const DIRT_BROWN = vec3(0.45, 0.32, 0.18); +const DIRT_DARK = vec3(0.32, 0.22, 0.12); +const ROCK_GRAY = vec3(0.45, 0.42, 0.38); +const ROCK_DARK = vec3(0.3, 0.28, 0.25); +const SAND_YELLOW = vec3(0.7, 0.6, 0.38); +const SNOW_WHITE = vec3(0.92, 0.94, 0.96); +const MUD_BROWN = vec3(0.18, 0.12, 0.08); +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 + */ +export function computeTerrainBaseColor( + height: any, + slope: any, + noiseVal: any, + noiseVal2: any, +) { + const grassVariation = smoothstep(float(0.4), float(0.6), noiseVal2); + let c = mix(GRASS_GREEN, GRASS_DARK, grassVariation); + + // Dirt patches (noise-based, flat ground) + const dirtPatchFactor = smoothstep( + float(TERRAIN_CONSTANTS.DIRT_THRESHOLD - 0.05), + float(TERRAIN_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 dirtColor = mix(DIRT_BROWN, DIRT_DARK, dirtVariation); + c = mix(c, dirtColor, mul(dirtPatchFactor, flatnessFactor)); + + // Slope-based dirt + c = mix( + c, + dirtColor, + mul(smoothstep(float(0.15), float(0.5), slope), float(0.6)), + ); + + // Rock on steep slopes + const rockVariation = smoothstep(float(0.3), float(0.7), noiseVal); + const rockColor = mix(ROCK_GRAY, ROCK_DARK, rockVariation); + c = mix(c, rockColor, smoothstep(float(0.45), float(0.75), slope)); + + // Snow at high elevation + c = mix( + c, + SNOW_WHITE, + smoothstep(float(TERRAIN_CONSTANTS.SNOW_HEIGHT - 5.0), float(60.0), height), + ); + + // Sand near water (flat areas) + const sandBlend = mul( + smoothstep(float(10.0), float(6.0), height), + smoothstep(float(0.25), float(0.0), slope), + ); + c = mix(c, SAND_YELLOW, mul(sandBlend, float(0.6))); + + // Shoreline transitions + c = mix( + c, + DIRT_DARK, + mul(smoothstep(float(14.0), float(8.0), height), float(0.4)), + ); + c = mix( + c, + MUD_BROWN, + mul(smoothstep(float(9.0), float(6.0), height), float(0.7)), + ); + c = mix( + c, + WATER_EDGE, + mul(smoothstep(float(6.5), float(5.0), height), float(0.9)), + ); + + return c; +} + // ============================================================================ // PERLIN NOISE TEXTURE GENERATION // ============================================================================ @@ -399,6 +493,8 @@ 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.) @@ -455,6 +551,8 @@ 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 timeUniform = uniform(float(0)); const noiseScale = uniform(float(TERRAIN_CONSTANTS.NOISE_SCALE)); @@ -482,122 +580,41 @@ export function createTerrainMaterial(): THREE.Material & { const slope = sub(float(1.0), abs(worldNormal.y)); // ============================================================================ - // OSRS-STYLE VERTEX COLORS - // Flat, distinct colors - no gradients, no textures + // TERRAIN BASE COLOR (shared function + anti-dithering noise) // ============================================================================ - // Core terrain colors (OSRS palette) - const grassGreen = vec3(0.3, 0.55, 0.15); // Rich green grass - const grassDark = vec3(0.22, 0.42, 0.1); // Darker grass variation - const dirtBrown = vec3(0.45, 0.32, 0.18); // Light brown dirt - const dirtDark = vec3(0.32, 0.22, 0.12); // Dark brown dirt - const rockGray = vec3(0.45, 0.42, 0.38); // Gray rock - const rockDark = vec3(0.3, 0.28, 0.25); // Dark rock - const sandYellow = vec3(0.7, 0.6, 0.38); // Sandy beach - const snowWhite = vec3(0.92, 0.94, 0.96); // Snow caps - const mudBrown = vec3(0.18, 0.12, 0.08); // Wet mud near water - const waterEdge = vec3(0.08, 0.06, 0.04); // Dark water's edge - - // ============================================================================ - // DISTANCE-BASED LOD FOR NOISE SAMPLING - // PERFORMANCE: Reduced from 4 to 2 texture samples (compute derived values instead) - // ============================================================================ const toCamera = sub(worldPos, cameraPosition); const distSq = dot(toCamera, toCamera); - // LOD threshold: 100m^2 = 10000 - closer threshold for faster falloff - const _lodDetailFactor = smoothstep(float(15000.0), float(8000.0), distSq); - // Sample Perlin noise - ONLY 1 base sample always needed + // Sample Perlin noise const noiseUV = mul(vec2(worldPos.x, worldPos.z), noiseScale); const noiseValue = texture(noiseTex, noiseUV).r; - - // PERFORMANCE: Derive secondary noise mathematically instead of texture sample - // Uses sin transform of base noise for variation (no extra texture fetch) const noiseValue2 = add( mul(sin(mul(noiseValue, float(6.28))), float(0.3)), float(0.5), ); - // CONDITIONAL fine detail sample - only when close - // Uses step function to completely skip sample at distance (cheaper than smoothstep mix) + // 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)); const fineNoiseSample = texture(noiseTex, noiseUV3).r; const fineNoise = mix(float(0.5), fineNoiseSample, closeEnough); - - // PERFORMANCE: Derive micro noise from base noise (no 4th texture sample) const microNoise = add( mul(cos(mul(noiseValue, float(12.56))), float(0.2)), float(0.5), ); - // === BASE: GRASS with light/dark variation === - const grassVariation = smoothstep(float(0.4), float(0.6), noiseValue2); - let baseColor = mix(grassGreen, grassDark, grassVariation); - - // === DIRT PATCHES (noise-based, flat ground only) === - // Wider transition for smoother blending - const dirtPatchFactor = smoothstep( - float(TERRAIN_CONSTANTS.DIRT_THRESHOLD - 0.05), - float(TERRAIN_CONSTANTS.DIRT_THRESHOLD + 0.15), + // Base color from shared procedural palette + const baseColor = computeTerrainBaseColor( + height, + slope, noiseValue, + noiseValue2, ); - const flatnessFactor = smoothstep(float(0.3), float(0.05), slope); - const dirtVariation = smoothstep(float(0.3), float(0.7), noiseValue2); - const dirtColor = mix(dirtBrown, dirtDark, dirtVariation); - baseColor = mix(baseColor, dirtColor, mul(dirtPatchFactor, flatnessFactor)); - // === SLOPE-BASED DIRT (steeper = more dirt) === - // Gradual transition - baseColor = mix( - baseColor, - dirtColor, - mul(smoothstep(float(0.15), float(0.5), slope), float(0.6)), - ); - - // === ROCK ON STEEP SLOPES === - const rockVariation = smoothstep(float(0.3), float(0.7), noiseValue); - const rockColorFinal = mix(rockGray, rockDark, rockVariation); - baseColor = mix( - baseColor, - rockColorFinal, - smoothstep(float(0.45), float(0.75), slope), - ); - - // === SNOW AT HIGH ELEVATION === - // Very gradual snow transition - baseColor = mix( - baseColor, - snowWhite, - smoothstep(float(TERRAIN_CONSTANTS.SNOW_HEIGHT - 5.0), float(60.0), height), - ); - - // === SAND NEAR WATER (flat areas only) === - const sandBlend = mul( - smoothstep(float(10.0), float(6.0), height), - smoothstep(float(0.25), float(0.0), slope), - ); - baseColor = mix(baseColor, sandYellow, mul(sandBlend, float(0.6))); - - // === SHORELINE TRANSITIONS (gradual) === - // Zone 1: Wet dirt (8-14m) - wider zone - const wetDirtZone = smoothstep(float(14.0), float(8.0), height); - baseColor = mix(baseColor, dirtDark, mul(wetDirtZone, float(0.4))); - - // Zone 2: Mud (6-9m) - const mudZone = smoothstep(float(9.0), float(6.0), height); - baseColor = mix(baseColor, mudBrown, mul(mudZone, float(0.7))); - - // Zone 3: Water's edge (5-6.5m) - const edgeZone = smoothstep(float(6.5), float(5.0), height); - baseColor = mix(baseColor, waterEdge, mul(edgeZone, float(0.9))); - - // === ANTI-DITHERING: Add fine noise variation to break up banding === - // Subtle brightness variation based on high-frequency noise - const brightnessVar = mul(sub(fineNoise, float(0.5)), float(0.08)); // ±4% brightness - const colorVar = mul(sub(microNoise, float(0.5)), float(0.04)); // ±2% color shift - - // Apply variations to break up vertex interpolation artifacts + // Anti-dithering noise variation (±4% brightness, ±2% color shift) + const brightnessVar = mul(sub(fineNoise, float(0.5)), float(0.08)); + const colorVar = mul(sub(microNoise, float(0.5)), float(0.04)); const variedColor = add( baseColor, vec3( @@ -636,12 +653,12 @@ export function createTerrainMaterial(): THREE.Material & { // Reuse existing dirt colors with natural noise variation const roadNoiseVar = mul(noiseValue2, float(0.5)); // Natural dirt variation - const roadBaseColor = mix(dirtBrown, dirtDark, roadNoiseVar); + 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(rockGray, rockDark, float(0.5)); + const stoneColor = mix(ROCK_GRAY, ROCK_DARK, float(0.5)); // Mix stones into dirt base - more stones in center of road const roadDetailColor = mix( @@ -812,14 +829,25 @@ export function createTerrainMaterial(): THREE.Material & { material.side = THREE.FrontSide; material.fog = false; - // Apply fog AFTER PBR lighting via outputNode so fog color isn't darkened + // Apply sun shade + fog AFTER PBR lighting via outputNode material.outputNode = Fn(() => { const litColor = output; - return vec4(mix(litColor.rgb, fogColor, fogFactor), litColor.a); + + // 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); })(); 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 ebe261122..a629cd894 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -956,7 +956,7 @@ export class TerrainSystem extends System { tileZ * this.CONFIG.TILE_SIZE, ); mesh.name = `Terrain_${key}`; - mesh.receiveShadow = true; + mesh.receiveShadow = false; mesh.castShadow = false; mesh.frustumCulled = true; mesh.userData = { @@ -2069,7 +2069,7 @@ export class TerrainSystem extends System { mesh.name = `Terrain_${key}`; // Enable shadow receiving for CSM - mesh.receiveShadow = true; + mesh.receiveShadow = false; mesh.castShadow = false; // Terrain doesn't cast shadows on itself // Enable frustum culling (Three.js built-in) @@ -4560,12 +4560,12 @@ export class TerrainSystem extends System { this.generateTreesForTile(tile, biomeData); // Re-enabled for ore LOD testing - this.generateOtherResourcesForTile(tile, biomeData); + // this.generateOtherResourcesForTile(tile, biomeData); // Generate decorative rocks (client only) const isClient = this.runtimeIsClient; if (isClient) { - this.generateDecorativeRocksForTile(tile, biomeData); + // this.generateDecorativeRocksForTile(tile, biomeData); // NOTE: Plants disabled - not working/looking good yet // this.generateDecorativePlantsForTile(tile, biomeData); } @@ -4731,11 +4731,8 @@ export class TerrainSystem extends System { // Generate other resources (herbs, fishing spots) from legacy config const otherResources = biomeData.resources.filter( (r) => - r !== "tree" && - r !== "trees" && - r !== "ore" && - r !== "ores" && - r !== "rock", + // r !== "tree" && + r !== "trees" && r !== "ore" && r !== "ores" && r !== "rock", ); for (const resourceType of otherResources) { @@ -5060,6 +5057,28 @@ export class TerrainSystem extends System { if (materialWithUniforms) { materialWithUniforms.terrainUniforms.time.value = this.terrainTime; + // Sync sun direction and shade color 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 // Update lamppost vertex lights (night-only) From eb76bff1ab3f8209f8d9e920dbbdef0b9198cd92 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 3 Mar 2026 23:50:55 +0800 Subject: [PATCH 07/48] perf(trees): refactor GLBTreeInstancer to BatchedMesh with texture-based variant matching Replace per-variant InstancedMesh with BatchedMesh to consolidate all model variants into a single draw call per tree type per material slot. Update GPUMaterials highlight detection to use vBatchColor, and TreeGLBVisualStrategy to pass treeType + variants + variantIndex. Add texture fingerprint matching to fix material-geometry mismatch when GLB files have meshes in different traversal order. Made-with: Cursor --- .../world/visuals/TreeGLBVisualStrategy.ts | 20 +- .../systems/shared/world/GLBTreeInstancer.ts | 717 +++++++++++------- .../src/systems/shared/world/GPUMaterials.ts | 9 +- 3 files changed, 453 insertions(+), 293 deletions(-) diff --git a/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts b/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts index d7d45cc8f..6258eb51a 100644 --- a/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts +++ b/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts @@ -1,7 +1,7 @@ /** * TreeGLBVisualStrategy — GLBTreeInstancer integration for woodcutting trees. * - * Thin wrapper: the instancer owns InstancedMeshes and LOD switching. + * Thin wrapper: the instancer owns BatchedMeshes and LOD switching. * This strategy just calls addInstance / removeInstance / setDepleted. */ @@ -49,13 +49,13 @@ export class TreeGLBVisualStrategy implements ResourceVisualStrategy { async createVisual(ctx: ResourceVisualContext): Promise { const { config, id, position } = ctx; - // Pick model: hash-select from variants or fall back to direct modelPath - let modelPath = config.model; - if (config.modelVariants?.length) { - const hash = ctx.hashString(id) >>> 0; - modelPath = config.modelVariants[hash % config.modelVariants.length]; - } - if (!modelPath) return; + const treeType = config.resourceId.replace(/^tree_/, ""); + const variants = + config.modelVariants ?? (config.model ? [config.model] : []); + if (variants.length === 0) return; + + const hash = ctx.hashString(id) >>> 0; + const variantIndex = hash % variants.length; const baseScale = config.modelScale ?? 3.0; const worldPos = new THREE.Vector3(); @@ -67,7 +67,9 @@ export class TreeGLBVisualStrategy implements ResourceVisualStrategy { const rotation = ((rotHash % 1000) / 1000) * Math.PI * 2; const success = await addGLBTreeInstance( - modelPath, + treeType, + variants, + variantIndex, id, worldPos, rotation, diff --git a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts index 58b879dc6..6bee2d5be 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts @@ -1,18 +1,14 @@ /** - * GLBTreeInstancer - InstancedMesh-based rendering for GLB-loaded trees. + * GLBTreeInstancer — BatchedMesh-based rendering for GLB-loaded trees. * - * Instead of cloning the full GLB scene per tree (which deep-copies all - * geometry buffers and causes FPS drops), this module loads each model - * once, extracts its geometry by reference, and renders all instances - * of that model via a single THREE.InstancedMesh per LOD level. + * Loads all model variants for each tree type once, registers their + * geometries in a shared BatchedMesh per material slot per LOD level. + * Each tree instance picks a variant via addInstance(geometryId). * - * LOD0, LOD1, and LOD2 each get their own InstancedMesh. The instancer - * performs distance-based LOD switching per-instance every frame by - * moving instances between pools (matrix swaps + count adjustment). + * One BatchedMesh per material slot (bark, leaves) per LOD = minimal + * draw calls regardless of how many variants a tree type has. * - * ResourceEntity calls addInstance/removeInstance/setDepleted. It does - * NOT need to track whether it's instanced — those calls are safe no-ops - * when the entity isn't registered. + * ResourceEntity calls addInstance/removeInstance/setDepleted/setHighlight. * * @module GLBTreeInstancer */ @@ -34,7 +30,9 @@ const _matrix = new THREE.Matrix4(); const _position = new THREE.Vector3(); const _quaternion = new THREE.Quaternion(); const _scale = new THREE.Vector3(); -const _swapMatrix = new THREE.Matrix4(); + +const _defaultColor = new THREE.Color(1, 1, 1); +const _hlColor = new THREE.Color(1.15, 1.15, 1.15); interface TreeSlot { entityId: string; @@ -45,32 +43,33 @@ interface TreeSlot { yOffset: number; currentLOD: 0 | 1 | 2; depleted: boolean; + variantIndex: number; } -interface LODPool { - /** One InstancedMesh per sub-mesh/primitive in the GLB */ - meshes: THREE.InstancedMesh[]; +interface BatchedLODPool { + /** One BatchedMesh per material slot (e.g. [bark, leaves]) */ + batches: THREE.BatchedMesh[]; materials: DissolveMaterial[]; - /** entityId → slot index (same across all meshes) */ - slots: Map; - activeCount: number; - dirty: boolean; - /** Shared backing array for per-instance highlight intensity (0 or 1) */ - highlightData: Float32Array; -} - -interface ModelPool { - modelPath: string; - lod0: LODPool | null; - lod1: LODPool | null; - lod2: LODPool | null; - depleted: LODPool | null; + /** + * geometryIds[materialSlot][variantIndex] = geometryId returned by + * BatchedMesh.addGeometry() for that variant's geometry. + */ + geometryIds: number[][]; + /** entityId → array of instanceIds (one per BatchedMesh/material slot) */ + instanceIds: Map; +} + +interface TreeTypePool { + treeType: string; + variantPaths: string[]; + lod0: BatchedLODPool | null; + lod1: BatchedLODPool | null; + lod2: BatchedLODPool | null; + depleted: BatchedLODPool | null; instances: Map; yOffset: number; depletedYOffset: number; - /** Unscaled model height from bounding box */ modelHeight: number; - /** Unscaled model horizontal radius from bounding box */ modelRadius: number; } @@ -79,8 +78,8 @@ const resourceLOD = getLODDistances("resource"); // ---- Module state ---- let scene: THREE.Scene | null = null; let world: World | null = null; -const pools = new Map(); -const entityToModel = new Map(); +const pools = new Map(); +const entityToTreeType = new Map(); function inferLOD1Path(lod0Path: string): string { return lod0Path.replace(/\.glb$/i, "_lod1.glb"); @@ -89,7 +88,7 @@ function inferLOD2Path(lod0Path: string): string { return lod0Path.replace(/\.glb$/i, "_lod2.glb"); } -// ---- Geometry extraction (portfolio pattern: reference, not clone) ---- +// ---- Geometry extraction ---- interface MeshPart { geometry: THREE.BufferGeometry; @@ -112,27 +111,67 @@ function extractAllMeshParts(root: THREE.Object3D): MeshPart[] { return parts; } -function createSharedGeometry( - source: THREE.BufferGeometry, -): THREE.BufferGeometry { - const geo = new THREE.BufferGeometry(); - for (const name in source.attributes) { - geo.setAttribute(name, source.attributes[name]); +/** + * Returns a string key that identifies a material's diffuse texture. + * Used to match the same material slot across different model variants. + */ +function getTextureFingerprint(mat: THREE.Material): string { + const std = mat as THREE.MeshStandardMaterial; + if (std.map?.image) { + const img = std.map.image as { + width?: number; + height?: number; + src?: string; + uuid?: string; + }; + return `tex:${img.width}x${img.height}:${img.src ?? img.uuid ?? ""}`; } - if (source.index) geo.setIndex(source.index); - if (source.morphAttributes) { - for (const name in source.morphAttributes) { - geo.morphAttributes[name] = source.morphAttributes[name]; + if (std.name) return `name:${std.name}`; + return `idx:${Math.random()}`; +} + +/** + * Reorder `parts` so that each part's texture fingerprint matches + * the corresponding `refFingerprints[slotIdx]`. + * Returns reordered array, or null if matching fails. + */ +function matchPartsToReference( + refFingerprints: string[], + parts: MeshPart[], +): MeshPart[] | null { + if (parts.length !== refFingerprints.length) return null; + const partFingerprints = parts.map((p) => getTextureFingerprint(p.material)); + const used = new Set(); + const reordered: MeshPart[] = []; + + for (let slot = 0; slot < refFingerprints.length; slot++) { + let matched = -1; + for (let pi = 0; pi < partFingerprints.length; pi++) { + if (!used.has(pi) && partFingerprints[pi] === refFingerprints[slot]) { + matched = pi; + break; + } } - } - if (source.groups.length > 0) { - for (const group of source.groups) { - geo.addGroup(group.start, group.count, group.materialIndex); + if (matched === -1) { + // Fallback: try matching by material name + const refName = refFingerprints[slot].startsWith("name:") + ? refFingerprints[slot].slice(5) + : ""; + for (let pi = 0; pi < parts.length; pi++) { + if ( + !used.has(pi) && + (parts[pi].material as THREE.MeshStandardMaterial).name === refName + ) { + matched = pi; + break; + } + } } + if (matched === -1) return null; + used.add(matched); + reordered.push(parts[matched]); } - if (source.boundingBox) geo.boundingBox = source.boundingBox.clone(); - if (source.boundingSphere) geo.boundingSphere = source.boundingSphere.clone(); - return geo; + return reordered; } function computeModelBounds( @@ -153,41 +192,6 @@ function computeModelBounds( return { yOffset: -bbox.min.y, height, radius: Math.max(dx, dz) }; } -// ---- LODPool creation ---- - -function createLODPool( - parts: { geometry: THREE.BufferGeometry; material: DissolveMaterial }[], -): LODPool { - const meshes: THREE.InstancedMesh[] = []; - const materials: DissolveMaterial[] = []; - const hlData = new Float32Array(MAX_INSTANCES); - for (const part of parts) { - const geo = createSharedGeometry(part.geometry); - - const hlAttr = new THREE.InstancedBufferAttribute(hlData, 1); - hlAttr.setUsage(THREE.DynamicDrawUsage); - geo.setAttribute("instanceHighlight", hlAttr); - - const im = new THREE.InstancedMesh(geo, part.material, MAX_INSTANCES); - im.count = 0; - im.frustumCulled = false; - im.castShadow = true; - im.receiveShadow = false; - im.layers.set(1); - scene!.add(im); - meshes.push(im); - materials.push(part.material); - } - return { - meshes, - materials, - slots: new Map(), - activeCount: 0, - dirty: false, - highlightData: hlData, - }; -} - async function loadLODParts(path: string): Promise { try { const { scene: lodScene } = await modelCache.loadModel(path, world!); @@ -198,9 +202,86 @@ async function loadLODParts(path: string): Promise { } } -// ---- Model pool lifecycle ---- +// ---- BatchedLODPool creation ---- + +function countGeometry(geo: THREE.BufferGeometry): { + vertexCount: number; + indexCount: number; +} { + const vertexCount = geo.getAttribute("position")?.count ?? 0; + const indexCount = geo.index?.count ?? 0; + return { vertexCount, indexCount }; +} + +/** + * Build a BatchedLODPool from multiple variants' parts. + * variantParts[variant][materialSlot] = { geometry, material } + * All variants must have the same number of material slots. + */ +function createBatchedLODPool( + variantParts: { + geometry: THREE.BufferGeometry; + material: DissolveMaterial; + }[][], +): BatchedLODPool { + const numSlots = variantParts[0].length; + const numVariants = variantParts.length; + + const batches: THREE.BatchedMesh[] = []; + const materials: DissolveMaterial[] = []; + const geometryIds: number[][] = []; + + for (let slot = 0; slot < numSlots; slot++) { + const mat = variantParts[0][slot].material; -const pendingEnsure = new Map>(); + let totalVerts = 0; + let totalIndices = 0; + for (let v = 0; v < numVariants; v++) { + const counts = countGeometry(variantParts[v][slot].geometry); + totalVerts += counts.vertexCount; + totalIndices += counts.indexCount; + } + + const bm = new THREE.BatchedMesh( + MAX_INSTANCES, + totalVerts, + totalIndices > 0 ? totalIndices : undefined, + mat, + ); + bm.frustumCulled = false; + bm.perObjectFrustumCulled = false; + bm.sortObjects = false; + bm.castShadow = true; + bm.receiveShadow = false; + bm.layers.set(1); + + const slotGeoIds: number[] = []; + for (let v = 0; v < numVariants; v++) { + const geoId = bm.addGeometry(variantParts[v][slot].geometry); + slotGeoIds.push(geoId); + } + + // Force-init colors texture so BatchNode sets up vBatchColor varying + // before the first shader compilation. + const initId = bm.addInstance(slotGeoIds[0]); + bm.setColorAt(initId, _defaultColor); + bm.deleteInstance(initId); + + scene!.add(bm); + batches.push(bm); + materials.push(mat); + geometryIds.push(slotGeoIds); + } + + return { + batches, + materials, + geometryIds, + instanceIds: new Map(), + }; +} + +// ---- Texture helpers ---- function enableTextureRepeat(mat: DissolveMaterial): void { const texProps = [ @@ -224,11 +305,16 @@ function enableTextureRepeat(mat: DissolveMaterial): void { } } -async function ensureModelPool( - modelPath: string, +// ---- Tree type pool lifecycle ---- + +const pendingEnsure = new Map>(); + +async function ensureTreeTypePool( + treeType: string, + variantPaths: string[], depletedModelPath?: string | null, -): Promise { - const existing = pools.get(modelPath); +): Promise { + const existing = pools.get(treeType); if (existing) { if (depletedModelPath && !existing.depleted) { await loadDepletedPool(existing, depletedModelPath); @@ -236,10 +322,10 @@ async function ensureModelPool( return existing; } - const pending = pendingEnsure.get(modelPath); + const pending = pendingEnsure.get(treeType); if (pending) return pending; - const promise = (async (): Promise => { + const promise = (async (): Promise => { const dissolveOpts = { fadeStart: GPU_VEG_CONFIG.FADE_START, fadeEnd: GPU_VEG_CONFIG.FADE_END, @@ -249,44 +335,139 @@ async function ensureModelPool( enableRimHighlight: true, }; - function buildTreeParts( - parts: MeshPart[], - ): { geometry: THREE.BufferGeometry; material: DissolveMaterial }[] { - return parts.map((p) => { - const dm = createTreeDissolveMaterial(p.material, dissolveOpts); - dm.side = THREE.DoubleSide; - enableTextureRepeat(dm); - world!.setupMaterial(dm); - return { geometry: p.geometry, material: dm }; - }); + function buildMaterialForPart(p: MeshPart): DissolveMaterial { + const dm = createTreeDissolveMaterial(p.material, dissolveOpts); + dm.side = THREE.DoubleSide; + enableTextureRepeat(dm); + world!.setupMaterial(dm); + return dm; } - // LOD0 - const { scene: lod0Scene } = await modelCache.loadModel(modelPath, world!); - const lod0Parts = extractAllMeshParts(lod0Scene); - if (lod0Parts.length === 0) - throw new Error(`No mesh found in ${modelPath}`); + // Load all variant LOD0s in parallel + const lod0Scenes = await Promise.all( + variantPaths.map(async (vp) => { + const { scene: s } = await modelCache.loadModel(vp, world!); + return s; + }), + ); + + // Extract parts per variant + const allLod0Parts = lod0Scenes.map((s) => extractAllMeshParts(s)); + if (allLod0Parts[0].length === 0) + throw new Error(`No mesh found in ${variantPaths[0]}`); - const bounds = computeModelBounds(lod0Scene, 1); + const numSlots = allLod0Parts[0].length; - const lod0Pool = createLODPool(buildTreeParts(lod0Parts)); + // Build a texture fingerprint for each part in variant 0 to define slot identity + const refFingerprints = allLod0Parts[0].map((p) => + getTextureFingerprint(p.material), + ); - // LOD1 - let lod1Pool: LODPool | null = null; - const lod1Parts = await loadLODParts(inferLOD1Path(modelPath)); - if (lod1Parts) { - lod1Pool = createLODPool(buildTreeParts(lod1Parts)); + // Reorder each subsequent variant's parts to match variant 0's slot order + for (let v = 1; v < allLod0Parts.length; v++) { + const parts = allLod0Parts[v]; + if (parts.length !== numSlots) { + console.warn( + `[GLBTreeInstancer] Variant ${variantPaths[v]} has ${parts.length} parts, expected ${numSlots}!`, + ); + continue; + } + const reordered = matchPartsToReference(refFingerprints, parts); + if (reordered) { + allLod0Parts[v] = reordered; + } else { + console.warn( + `[GLBTreeInstancer] Could not match parts for ${variantPaths[v]} — using original order`, + ); + } } - // LOD2 - let lod2Pool: LODPool | null = null; - const lod2Parts = await loadLODParts(inferLOD2Path(modelPath)); - if (lod2Parts) { - lod2Pool = createLODPool(buildTreeParts(lod2Parts)); + // Debug: log final part order + for (let vi = 0; vi < allLod0Parts.length; vi++) { + const stdMat = (m: THREE.Material) => m as THREE.MeshStandardMaterial; + const parts = allLod0Parts[vi]; + console.log( + `[GLBTreeInstancer] ${variantPaths[vi]}: ${parts.length} parts → ` + + parts + .map( + (p, i) => + `[${i}] verts=${p.geometry.getAttribute("position")?.count} mat="${stdMat(p.material).name || "unnamed"}" map=${(stdMat(p.material).map?.image as { width?: number })?.width ?? "none"}`, + ) + .join(", "), + ); } - const pool: ModelPool = { - modelPath, + // Build materials from first variant (shared for all variants) + const sharedMaterials = allLod0Parts[0].map((p) => buildMaterialForPart(p)); + + // Build variant parts for LOD0 + const lod0VariantParts = allLod0Parts.map((parts) => + parts.map((p, slotIdx) => ({ + geometry: p.geometry, + material: sharedMaterials[slotIdx % sharedMaterials.length], + })), + ); + + const lod0Pool = createBatchedLODPool(lod0VariantParts); + + // Compute bounds from first variant + const bounds = computeModelBounds(lod0Scenes[0], 1); + + // Load LOD1 variants in parallel + let lod1Pool: BatchedLODPool | null = null; + const lod1Results = await Promise.all( + variantPaths.map((vp) => loadLODParts(inferLOD1Path(vp))), + ); + const validLod1 = lod1Results.filter( + (r): r is MeshPart[] => r !== null && r.length === numSlots, + ); + if (validLod1.length > 0) { + const lod1Ref = validLod1[0].map((p) => + getTextureFingerprint(p.material), + ); + for (let v = 1; v < validLod1.length; v++) { + const matched = matchPartsToReference(lod1Ref, validLod1[v]); + if (matched) validLod1[v] = matched; + } + const lod1Materials = validLod1[0].map((p) => buildMaterialForPart(p)); + const lod1VariantParts = validLod1.map((parts) => + parts.map((p, slotIdx) => ({ + geometry: p.geometry, + material: lod1Materials[slotIdx], + })), + ); + lod1Pool = createBatchedLODPool(lod1VariantParts); + } + + // Load LOD2 variants in parallel + let lod2Pool: BatchedLODPool | null = null; + const lod2Results = await Promise.all( + variantPaths.map((vp) => loadLODParts(inferLOD2Path(vp))), + ); + const validLod2 = lod2Results.filter( + (r): r is MeshPart[] => r !== null && r.length === numSlots, + ); + if (validLod2.length > 0) { + const lod2Ref = validLod2[0].map((p) => + getTextureFingerprint(p.material), + ); + for (let v = 1; v < validLod2.length; v++) { + const matched = matchPartsToReference(lod2Ref, validLod2[v]); + if (matched) validLod2[v] = matched; + } + const lod2Materials = validLod2[0].map((p) => buildMaterialForPart(p)); + const lod2VariantParts = validLod2.map((parts) => + parts.map((p, slotIdx) => ({ + geometry: p.geometry, + material: lod2Materials[slotIdx], + })), + ); + lod2Pool = createBatchedLODPool(lod2VariantParts); + } + + const pool: TreeTypePool = { + treeType, + variantPaths, lod0: lod0Pool, lod1: lod1Pool, lod2: lod2Pool, @@ -297,7 +478,7 @@ async function ensureModelPool( modelHeight: bounds.height, modelRadius: bounds.radius, }; - pools.set(modelPath, pool); + pools.set(treeType, pool); if (depletedModelPath) { await loadDepletedPool(pool, depletedModelPath); @@ -306,16 +487,16 @@ async function ensureModelPool( return pool; })(); - pendingEnsure.set(modelPath, promise); + pendingEnsure.set(treeType, promise); try { return await promise; } finally { - pendingEnsure.delete(modelPath); + pendingEnsure.delete(treeType); } } async function loadDepletedPool( - pool: ModelPool, + pool: TreeTypePool, depletedModelPath: string, ): Promise { if (pool.depleted) return; @@ -341,14 +522,26 @@ async function loadDepletedPool( enableOcclusionDissolve: false, enableRimHighlight: true, }; + const depletedDissolveParts = depletedParts.map((p) => { const dm = createTreeDissolveMaterial(p.material, dissolveOpts); dm.side = THREE.DoubleSide; enableTextureRepeat(dm); world!.setupMaterial(dm); - return { geometry: p.geometry, material: dm }; + return [{ geometry: p.geometry, material: dm }]; }); - pool.depleted = createLODPool(depletedDissolveParts); + + // Depleted has 1 "variant" (the stump) + // Transpose: depletedDissolveParts is [slot][1 variant] but we need [1 variant][slot] + const numSlots = depletedParts.length; + const singleVariant: { + geometry: THREE.BufferGeometry; + material: DissolveMaterial; + }[] = []; + for (let s = 0; s < numSlots; s++) { + singleVariant.push(depletedDissolveParts[s][0]); + } + pool.depleted = createBatchedLODPool([singleVariant]); pool.depletedYOffset = depletedYOffset; } @@ -366,44 +559,62 @@ function composeInstanceMatrix( return _matrix.compose(_position, _quaternion, _scale); } -function addToPool(pool: LODPool, entityId: string, mat: THREE.Matrix4): void { - const idx = pool.activeCount; - for (const im of pool.meshes) { - im.setMatrixAt(idx, mat); - im.count = idx + 1; +// ---- Pool add/remove ---- + +function addToPool( + pool: BatchedLODPool, + entityId: string, + mat: THREE.Matrix4, + variantIndex: number, +): void { + 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) { + console.warn( + `[GLBTreeInstancer] geoId undefined: slot=${i} variant=${clampedIdx} available=${numVariants}`, + ); + continue; + } + const instId = pool.batches[i].addInstance(geoId); + pool.batches[i].setMatrixAt(instId, mat); + pool.batches[i].setColorAt(instId, _defaultColor); + ids.push(instId); } - pool.slots.set(entityId, idx); - pool.activeCount++; - pool.dirty = true; + pool.instanceIds.set(entityId, ids); } -function removeFromPool(pool: LODPool, entityId: string): void { - const idx = pool.slots.get(entityId); - if (idx === undefined) return; +function removeFromPool(pool: BatchedLODPool, entityId: string): void { + const ids = pool.instanceIds.get(entityId); + if (!ids) return; + for (let i = 0; i < pool.batches.length; i++) { + pool.batches[i].deleteInstance(ids[i]); + } + pool.instanceIds.delete(entityId); +} - const lastIdx = pool.activeCount - 1; - if (idx !== lastIdx) { - for (const im of pool.meshes) { - im.getMatrixAt(lastIdx, _swapMatrix); - im.setMatrixAt(idx, _swapMatrix); - } - pool.highlightData[idx] = pool.highlightData[lastIdx]; +const _tmpColor = new THREE.Color(); - for (const [eid, eidIdx] of pool.slots) { - if (eidIdx === lastIdx) { - pool.slots.set(eid, idx); - break; - } - } - } - pool.highlightData[lastIdx] = 0; +function isHighlighted(pool: BatchedLODPool, entityId: string): boolean { + const ids = pool.instanceIds.get(entityId); + if (!ids || ids.length === 0) return false; + pool.batches[0].getColorAt(ids[0], _tmpColor); + return _tmpColor.r > 1.01; +} - pool.slots.delete(entityId); - pool.activeCount--; - for (const im of pool.meshes) { - im.count = pool.activeCount; +function applyHighlightColor( + pool: BatchedLODPool, + entityId: string, + on: boolean, +): void { + const ids = pool.instanceIds.get(entityId); + if (!ids) return; + const color = on ? _hlColor : _defaultColor; + for (let i = 0; i < pool.batches.length; i++) { + pool.batches[i].setColorAt(ids[i], color); } - pool.dirty = true; } // ---- Public API ---- @@ -417,22 +628,24 @@ export function destroyGLBTreeInstancer(): void { for (const pool of pools.values()) { for (const lodPool of [pool.lod0, pool.lod1, pool.lod2, pool.depleted]) { if (!lodPool) continue; - for (const im of lodPool.meshes) { - scene?.remove(im); - im.geometry.dispose(); + for (const bm of lodPool.batches) { + scene?.remove(bm); + bm.dispose(); } for (const mat of lodPool.materials) mat.dispose(); } } pools.clear(); - entityToModel.clear(); + entityToTreeType.clear(); pendingEnsure.clear(); scene = null; world = null; } export async function addInstance( - modelPath: string, + treeType: string, + variantPaths: string[], + variantIndex: number, entityId: string, position: THREE.Vector3, rotation: number, @@ -443,14 +656,17 @@ export async function addInstance( if (!scene || !world) return false; try { - const pool = await ensureModelPool(modelPath, depletedModelPath); - - if (pool.lod0 && pool.lod0.activeCount >= MAX_INSTANCES) { - console.warn( - `[GLBTreeInstancer] LOD0 pool full for ${modelPath}, cannot add ${entityId}`, - ); - return false; - } + console.log( + `[GLBTreeInstancer] addInstance: type=${treeType} variants=${variantPaths.length} varIdx=${variantIndex} entity=${entityId}`, + ); + const pool = await ensureTreeTypePool( + treeType, + variantPaths, + depletedModelPath, + ); + console.log( + `[GLBTreeInstancer] pool ready: lod0 batches=${pool.lod0?.batches.length} geoIds=${JSON.stringify(pool.lod0?.geometryIds)}`, + ); const slot: TreeSlot = { entityId, @@ -461,13 +677,14 @@ export async function addInstance( yOffset: pool.yOffset, currentLOD: 0, depleted: false, + variantIndex, }; pool.instances.set(entityId, slot); - entityToModel.set(entityId, modelPath); + entityToTreeType.set(entityId, treeType); const mat = composeInstanceMatrix(position, rotation, scale, pool.yOffset); - addToPool(pool.lod0!, entityId, mat); + if (pool.lod0) addToPool(pool.lod0, entityId, mat, variantIndex); return true; } catch (error) { @@ -480,32 +697,36 @@ export async function addInstance( } export function removeInstance(entityId: string): void { - const modelPath = entityToModel.get(entityId); - if (!modelPath) return; + const treeType = entityToTreeType.get(entityId); + if (!treeType) return; - const pool = pools.get(modelPath); + const pool = pools.get(treeType); if (!pool) return; const slot = pool.instances.get(entityId); if (!slot) return; - const lodPool = - slot.currentLOD === 0 - ? pool.lod0 - : slot.currentLOD === 1 - ? pool.lod1 - : pool.lod2; + const lodPool = getLodPool(pool, slot); if (lodPool) removeFromPool(lodPool, entityId); pool.instances.delete(entityId); - entityToModel.delete(entityId); + entityToTreeType.delete(entityId); +} + +function getLodPool(pool: TreeTypePool, slot: TreeSlot): BatchedLODPool | null { + if (slot.depleted) return pool.depleted; + return slot.currentLOD === 0 + ? pool.lod0 + : slot.currentLOD === 1 + ? pool.lod1 + : pool.lod2; } export function setDepleted(entityId: string, depleted: boolean): void { - const modelPath = entityToModel.get(entityId); - if (!modelPath) return; + const treeType = entityToTreeType.get(entityId); + if (!treeType) return; - const pool = pools.get(modelPath); + const pool = pools.get(treeType); if (!pool) return; const slot = pool.instances.get(entityId); @@ -514,16 +735,9 @@ export function setDepleted(entityId: string, depleted: boolean): void { slot.depleted = depleted; if (depleted) { - // Remove from living LOD pool - const lodPool = - slot.currentLOD === 0 - ? pool.lod0 - : slot.currentLOD === 1 - ? pool.lod1 - : pool.lod2; + const lodPool = getLodPool(pool, slot); if (lodPool) removeFromPool(lodPool, entityId); - // Add to depleted pool (instanced stump) if (pool.depleted) { const mat = composeInstanceMatrix( slot.position, @@ -531,15 +745,13 @@ export function setDepleted(entityId: string, depleted: boolean): void { slot.depletedScale, pool.depletedYOffset, ); - addToPool(pool.depleted, entityId, mat); + addToPool(pool.depleted, entityId, mat, 0); } } else { - // Remove from depleted pool if (pool.depleted) { removeFromPool(pool.depleted, entityId); } - // Re-add to living LOD pool const mat = composeInstanceMatrix( slot.position, slot.rotation, @@ -552,87 +764,54 @@ export function setDepleted(entityId: string, depleted: boolean): void { : slot.currentLOD === 1 ? pool.lod1 : pool.lod2; - if (lodPool) addToPool(lodPool, entityId, mat); + if (lodPool) addToPool(lodPool, entityId, mat, slot.variantIndex); } } export function hasInstance(entityId: string): boolean { - return entityToModel.has(entityId); + return entityToTreeType.has(entityId); } -/** - * Returns the unscaled model dimensions for an instanced entity. - * Used to size collision proxies to match the actual model. - */ export function getModelDimensions( entityId: string, ): { height: number; radius: number } | null { - const modelPath = entityToModel.get(entityId); - if (!modelPath) return null; - const pool = pools.get(modelPath); + const treeType = entityToTreeType.get(entityId); + if (!treeType) return null; + const pool = pools.get(treeType); if (!pool) return null; return { height: pool.modelHeight, radius: pool.modelRadius }; } -/** - * Returns true if the instancer has a depleted pool for this entity's model. - * When true, ResourceEntity can skip loading an individual depleted model. - */ export function hasDepleted(entityId: string): boolean { - const modelPath = entityToModel.get(entityId); - if (!modelPath) return false; - const pool = pools.get(modelPath); + const treeType = entityToTreeType.get(entityId); + if (!treeType) return false; + const pool = pools.get(treeType); return !!pool?.depleted; } -/** Track which entity is currently highlighted so we can clear it */ let highlightedEntityId: string | null = null; -/** - * Set or clear shader-based rim highlight for an instanced tree entity. - * Sets the per-instance `instanceHighlight` attribute to 1 or 0. - */ export function setHighlight(entityId: string, on: boolean): void { if (on && highlightedEntityId && highlightedEntityId !== entityId) { setHighlight(highlightedEntityId, false); } - const modelPath = entityToModel.get(entityId); - if (!modelPath) return; + const treeType = entityToTreeType.get(entityId); + if (!treeType) return; - const pool = pools.get(modelPath); + const pool = pools.get(treeType); if (!pool) return; const slot = pool.instances.get(entityId); if (!slot) return; - const lodPool = slot.depleted - ? pool.depleted - : slot.currentLOD === 0 - ? pool.lod0 - : slot.currentLOD === 1 - ? pool.lod1 - : pool.lod2; + const lodPool = getLodPool(pool, slot); if (!lodPool) return; - const idx = lodPool.slots.get(entityId); - if (idx === undefined) return; - - const value = on ? 1.0 : 0.0; - lodPool.highlightData[idx] = value; - for (const im of lodPool.meshes) { - const attr = im.geometry.getAttribute("instanceHighlight"); - if (attr) { - (attr as THREE.InstancedBufferAttribute).needsUpdate = true; - } - } - + applyHighlightColor(lodPool, entityId, on); highlightedEntityId = on ? entityId : null; } -/** - * Clear any active shader highlight (e.g. when hover leaves all entities). - */ export function clearHighlight(): void { if (highlightedEntityId) { setHighlight(highlightedEntityId, false); @@ -652,7 +831,7 @@ export function updateGLBTreeInstancer(): void { const camPos = camera.position; const lod1DistSq = resourceLOD.lod1DistanceSq; const lod2DistSq = resourceLOD.lod2DistanceSq; - const hysteresisSq = 0.81; // 0.9^2 + const hysteresisSq = 0.81; for (const pool of pools.values()) { for (const slot of pool.instances.values()) { @@ -681,21 +860,13 @@ export function updateGLBTreeInstancer(): void { if (targetLOD === slot.currentLOD) continue; - // Move instance between LOD pools - const oldPool = - slot.currentLOD === 0 - ? pool.lod0 - : slot.currentLOD === 1 - ? pool.lod1 - : pool.lod2; - const newPool = - targetLOD === 0 ? pool.lod0 : targetLOD === 1 ? pool.lod1 : pool.lod2; - - const wasHighlighted = - oldPool && oldPool.slots.has(slot.entityId) - ? oldPool.highlightData[oldPool.slots.get(slot.entityId)!] - : 0; + const oldPool = getLodPool(pool, slot); + const wasHl = oldPool ? isHighlighted(oldPool, slot.entityId) : false; if (oldPool) removeFromPool(oldPool, slot.entityId); + + slot.currentLOD = targetLOD; + + const newPool = getLodPool(pool, slot); if (newPool) { const mat = composeInstanceMatrix( slot.position, @@ -703,30 +874,18 @@ export function updateGLBTreeInstancer(): void { slot.scale, slot.yOffset, ); - addToPool(newPool, slot.entityId, mat); - if (wasHighlighted > 0) { - const newIdx = newPool.slots.get(slot.entityId); - if (newIdx !== undefined) { - newPool.highlightData[newIdx] = wasHighlighted; - for (const im of newPool.meshes) { - const attr = im.geometry.getAttribute("instanceHighlight"); - if (attr) - (attr as THREE.InstancedBufferAttribute).needsUpdate = true; - } - } - } + addToPool(newPool, slot.entityId, mat, slot.variantIndex); + if (wasHl) applyHighlightColor(newPool, slot.entityId, true); } - slot.currentLOD = targetLOD; } } - // Flush dirty pools + update dissolve uniforms + // Update dissolve uniforms const camY = camPos.y; const players = world.getPlayers(); const localPlayer = players && players.length > 0 ? players[0] : null; const playerPos = localPlayer?.node?.position ?? camPos; - // Get sun direction from Environment system const env = world.getSystem("environment") as { sunLight?: { intensity: number }; lightDirection?: THREE.Vector3; @@ -737,13 +896,6 @@ export function updateGLBTreeInstancer(): void { for (const lodPool of [pool.lod0, pool.lod1, pool.lod2, pool.depleted]) { if (!lodPool) continue; - if (lodPool.dirty) { - for (const im of lodPool.meshes) { - im.instanceMatrix.needsUpdate = true; - } - lodPool.dirty = false; - } - for (const mat of lodPool.materials) { mat.dissolveUniforms.cameraPos.value.set(camPos.x, camY, camPos.z); mat.dissolveUniforms.playerPos.value.set( @@ -752,7 +904,6 @@ export function updateGLBTreeInstancer(): void { playerPos.z, ); - // Sync tree-specific uniforms (sun direction, intensity, shade color) const treeMat = mat as TreeDissolveMaterial; if (treeMat.treeUniforms) { if (env?.lightDirection) { diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index ef3d26f47..d96cd7d47 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -54,6 +54,7 @@ import { attribute, positionView, } from "../../../extras/three/three"; +import { varyingProperty } from "three/tsl"; import { FOG_NEAR_SQ, FOG_FAR_SQ, fogRenderTarget } from "./FogConfig"; import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants"; @@ -981,6 +982,7 @@ export function createTreeDissolveMaterial( }); const material = baseDm as unknown as THREE.MeshStandardNodeMaterial; + material.alphaTest = 0.95; // Prevent the standard pipeline from multiplying vertex colors into diffuse. // We read vertex color manually in the shader for AO only (G channel). @@ -1031,7 +1033,12 @@ export function createTreeDissolveMaterial( ); // ---- Instance rim highlight (hover) ---- - const hlIntensity = attribute("instanceHighlight", "float"); + // BatchedMesh encodes highlight in batch color: (1.15,1.15,1.15) = on, (1,1,1) = off + const batchColor = varyingProperty("vec3", "vBatchColor"); + const hlIntensity = step( + float(1.01), + max(batchColor.x, max(batchColor.y, batchColor.z)), + ); const NV = normalize(normalView); const Vv = normalize(sub(vec3(0, 0, 0), positionView.xyz)); const NdotV = clamp(dot(NV, Vv), float(0.0), float(1.0)); From 537a0a24b04eaaf1d021ccb8b69fcadeb7373d5b Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Wed, 4 Mar 2026 10:19:11 +0800 Subject: [PATCH 08/48] perf(trees): dual-instancer strategy with BatchedMesh for variants and InstancedMesh for single models Split tree rendering into two paths: GLBTreeBatchedInstancer (BatchedMesh) for multi-variant trees and GLBTreeInstancer (InstancedMesh) for single-model trees. Unified TreeGLBVisualStrategy dispatches to the correct instancer based on config.modelVariants. GPUMaterials supports both highlight methods via a batched option. Made-with: Cursor --- .../world/visuals/TreeGLBVisualStrategy.ts | 129 ++- .../shared/src/runtime/createClientWorld.ts | 11 +- .../shared/world/GLBTreeBatchedInstancer.ts | 921 ++++++++++++++++++ .../systems/shared/world/GLBTreeInstancer.ts | 717 ++++++-------- .../src/systems/shared/world/GPUMaterials.ts | 19 +- 5 files changed, 1319 insertions(+), 478 deletions(-) create mode 100644 packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts diff --git a/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts b/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts index 6258eb51a..0eebd3410 100644 --- a/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts +++ b/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts @@ -1,28 +1,50 @@ /** - * TreeGLBVisualStrategy — GLBTreeInstancer integration for woodcutting trees. + * TreeGLBVisualStrategy — Unified tree visual strategy. * - * Thin wrapper: the instancer owns BatchedMeshes and LOD switching. - * This strategy just calls addInstance / removeInstance / setDepleted. + * Delegates to one of two instancers based on the manifest config: + * - **BatchedMesh** (GLBTreeBatchedInstancer) for trees with `modelVariants` + * — fewer draw calls when many variants share the same material. + * - **InstancedMesh** (GLBTreeInstancer) for trees with a single `model` path. + * + * All other lifecycle methods (depleted, highlight, respawn, destroy) + * dispatch to whichever instancer owns the entity. */ import THREE from "../../../extras/three/three"; import { MeshBasicNodeMaterial } from "three/webgpu"; import { - addInstance as addGLBTreeInstance, - removeInstance as removeGLBTreeInstance, - setDepleted as setGLBTreeDepleted, - hasDepleted as hasGLBTreeDepleted, - setHighlight as setGLBTreeHighlight, - getModelDimensions as getGLBModelDimensions, + addInstance as addInstancedTree, + removeInstance as removeInstancedTree, + setDepleted as setInstancedDepleted, + hasDepleted as hasInstancedDepleted, + setHighlight as setInstancedHighlight, + getModelDimensions as getInstancedDimensions, + hasInstance as isInInstancedPool, updateGLBTreeInstancer, } from "../../../systems/shared/world/GLBTreeInstancer"; +import { + addInstance as addBatchedTree, + removeInstance as removeBatchedTree, + setDepleted as setBatchedDepleted, + hasDepleted as hasBatchedDepleted, + setHighlight as setBatchedHighlight, + getModelDimensions as getBatchedDimensions, + hasInstance as isInBatchedPool, + updateGLBTreeBatchedInstancer, +} from "../../../systems/shared/world/GLBTreeBatchedInstancer"; import type { ResourceVisualContext, ResourceVisualStrategy, } from "./ResourceVisualStrategy"; -function createCollisionProxy(ctx: ResourceVisualContext, scale: number): void { - const dims = getGLBModelDimensions(ctx.id); +function createCollisionProxy( + ctx: ResourceVisualContext, + scale: number, + batched: boolean, +): void { + const dims = batched + ? getBatchedDimensions(ctx.id) + : getInstancedDimensions(ctx.id); const height = (dims?.height ?? 8) * scale; const radius = (dims?.radius ?? 1) * scale; const geometry = new THREE.CylinderGeometry(radius, radius, height, 6); @@ -45,18 +67,14 @@ function createCollisionProxy(ctx: ResourceVisualContext, scale: number): void { ctx.setMesh(proxy); } +function isBatched(entityId: string): boolean { + return isInBatchedPool(entityId); +} + export class TreeGLBVisualStrategy implements ResourceVisualStrategy { async createVisual(ctx: ResourceVisualContext): Promise { const { config, id, position } = ctx; - const treeType = config.resourceId.replace(/^tree_/, ""); - const variants = - config.modelVariants ?? (config.model ? [config.model] : []); - if (variants.length === 0) return; - - const hash = ctx.hashString(id) >>> 0; - const variantIndex = hash % variants.length; - const baseScale = config.modelScale ?? 3.0; const worldPos = new THREE.Vector3(); ctx.node.getWorldPosition(worldPos); @@ -66,39 +84,73 @@ export class TreeGLBVisualStrategy implements ResourceVisualStrategy { ); const rotation = ((rotHash % 1000) / 1000) * Math.PI * 2; - const success = await addGLBTreeInstance( - treeType, - variants, - variantIndex, - id, - worldPos, - rotation, - baseScale, - config.depletedModelPath ?? null, - config.depletedModelScale ?? 0.3, - ); + let success = false; + + if (config.modelVariants?.length) { + const treeType = config.resourceId.replace(/^tree_/, ""); + const hash = ctx.hashString(id) >>> 0; + const variantIndex = hash % config.modelVariants.length; + + success = await addBatchedTree( + treeType, + config.modelVariants, + variantIndex, + id, + worldPos, + rotation, + baseScale, + config.depletedModelPath ?? null, + config.depletedModelScale ?? 0.3, + ); + } else { + let modelPath = config.model; + if (!modelPath) return; + + success = await addInstancedTree( + modelPath, + id, + worldPos, + rotation, + baseScale, + config.depletedModelPath ?? null, + config.depletedModelScale ?? 0.3, + ); + } if (success) { - createCollisionProxy(ctx, baseScale); + createCollisionProxy(ctx, baseScale, !!config.modelVariants?.length); } } async onDepleted(ctx: ResourceVisualContext): Promise { - setGLBTreeDepleted(ctx.id, true); + const b = isBatched(ctx.id); + if (b) { + setBatchedDepleted(ctx.id, true); + } else { + setInstancedDepleted(ctx.id, true); + } const proxy = ctx.getMesh(); if (proxy) { proxy.userData.depleted = true; proxy.userData.interactable = false; } - return hasGLBTreeDepleted(ctx.id); + return b ? hasBatchedDepleted(ctx.id) : hasInstancedDepleted(ctx.id); } setShaderHighlight(ctx: ResourceVisualContext, on: boolean): void { - setGLBTreeHighlight(ctx.id, on); + if (isBatched(ctx.id)) { + setBatchedHighlight(ctx.id, on); + } else { + setInstancedHighlight(ctx.id, on); + } } async onRespawn(ctx: ResourceVisualContext): Promise { - setGLBTreeDepleted(ctx.id, false); + if (isBatched(ctx.id)) { + setBatchedDepleted(ctx.id, false); + } else { + setInstancedDepleted(ctx.id, false); + } const proxy = ctx.getMesh(); if (proxy) { proxy.userData.depleted = false; @@ -108,9 +160,14 @@ export class TreeGLBVisualStrategy implements ResourceVisualStrategy { update(): void { updateGLBTreeInstancer(); + updateGLBTreeBatchedInstancer(); } destroy(ctx: ResourceVisualContext): void { - removeGLBTreeInstance(ctx.id); + if (isBatched(ctx.id)) { + removeBatchedTree(ctx.id); + } else { + removeInstancedTree(ctx.id); + } } } diff --git a/packages/shared/src/runtime/createClientWorld.ts b/packages/shared/src/runtime/createClientWorld.ts index 4dd1e1c52..866d5f57e 100644 --- a/packages/shared/src/runtime/createClientWorld.ts +++ b/packages/shared/src/runtime/createClientWorld.ts @@ -88,6 +88,10 @@ import { initGLBTreeInstancer, destroyGLBTreeInstancer, } from "../systems/shared/world/GLBTreeInstancer"; +import { + initGLBTreeBatchedInstancer, + destroyGLBTreeBatchedInstancer, +} from "../systems/shared/world/GLBTreeBatchedInstancer"; import { initPlaceholderInstancer, destroyPlaceholderInstancer, @@ -188,6 +192,7 @@ export function createClientWorld() { // Clean up any previous instancer state from prior world destroyGLBTreeInstancer(); + destroyGLBTreeBatchedInstancer(); destroyPlaceholderInstancer(); destroyGLBResourceInstancer(); @@ -284,7 +289,7 @@ export function createClientWorld() { // ============================================================================ // Procedural building mesh rendering for towns // Must be registered after towns system as it depends on town data - world.register("building-rendering", BuildingRenderingSystem); + // world.register("building-rendering", BuildingRenderingSystem); // ============================================================================ // TOWN LANDMARKS SYSTEM @@ -346,6 +351,10 @@ export function createClientWorld() { if (stageSystem && stageSystem.scene) { stageSystem.THREE = THREE as unknown as StageSystem["THREE"]; initGLBTreeInstancer(stageSystem.scene as unknown as THREE.Scene, world); + initGLBTreeBatchedInstancer( + stageSystem.scene as unknown as THREE.Scene, + world, + ); initPlaceholderInstancer(stageSystem.scene as unknown as THREE.Scene); initGLBResourceInstancer( stageSystem.scene as unknown as THREE.Scene, diff --git a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts new file mode 100644 index 000000000..78a407c0a --- /dev/null +++ b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts @@ -0,0 +1,921 @@ +/** + * GLBTreeBatchedInstancer — BatchedMesh-based rendering for multi-variant trees. + * + * Loads all model variants for each tree type once, registers their + * geometries in a shared BatchedMesh per material slot per LOD level. + * Each tree instance picks a variant via addInstance(geometryId). + * + * One BatchedMesh per material slot (bark, leaves) per LOD = minimal + * draw calls regardless of how many variants a tree type has. + * + * Used when a tree type has `modelVariants` in its manifest. + * For single-model resources, use GLBTreeInstancer (InstancedMesh-based). + * + * @module GLBTreeBatchedInstancer + */ + +import THREE from "../../../extras/three/three"; +import type { World } from "../../../core/World"; +import { modelCache } from "../../../utils/rendering/ModelCache"; +import { + createTreeDissolveMaterial, + GPU_VEG_CONFIG, + type DissolveMaterial, + type TreeDissolveMaterial, +} from "./GPUMaterials"; +import { getLODDistances } from "./LODConfig"; + +const MAX_INSTANCES = 512; + +const _matrix = new THREE.Matrix4(); +const _position = new THREE.Vector3(); +const _quaternion = new THREE.Quaternion(); +const _scale = new THREE.Vector3(); + +const _defaultColor = new THREE.Color(1, 1, 1); +const _hlColor = new THREE.Color(1.15, 1.15, 1.15); + +interface TreeSlot { + entityId: string; + position: THREE.Vector3; + rotation: number; + scale: number; + depletedScale: number; + yOffset: number; + currentLOD: 0 | 1 | 2; + depleted: boolean; + variantIndex: number; +} + +interface BatchedLODPool { + /** One BatchedMesh per material slot (e.g. [bark, leaves]) */ + batches: THREE.BatchedMesh[]; + materials: DissolveMaterial[]; + /** + * geometryIds[materialSlot][variantIndex] = geometryId returned by + * BatchedMesh.addGeometry() for that variant's geometry. + */ + geometryIds: number[][]; + /** entityId → array of instanceIds (one per BatchedMesh/material slot) */ + instanceIds: Map; +} + +interface TreeTypePool { + treeType: string; + variantPaths: string[]; + lod0: BatchedLODPool | null; + lod1: BatchedLODPool | null; + lod2: BatchedLODPool | null; + depleted: BatchedLODPool | null; + instances: Map; + yOffset: number; + depletedYOffset: number; + modelHeight: number; + modelRadius: number; +} + +const resourceLOD = getLODDistances("resource"); + +// ---- Module state ---- +let scene: THREE.Scene | null = null; +let world: World | null = null; +const pools = new Map(); +const entityToTreeType = new Map(); + +function inferLOD1Path(lod0Path: string): string { + return lod0Path.replace(/\.glb$/i, "_lod1.glb"); +} +function inferLOD2Path(lod0Path: string): string { + return lod0Path.replace(/\.glb$/i, "_lod2.glb"); +} + +// ---- Geometry extraction ---- + +interface MeshPart { + geometry: THREE.BufferGeometry; + material: THREE.Material; +} + +function extractAllMeshParts(root: THREE.Object3D): MeshPart[] { + const parts: MeshPart[] = []; + root.traverse((child) => { + if (child instanceof THREE.Mesh && child.geometry) { + if (Array.isArray(child.material)) { + for (const mat of child.material) { + parts.push({ geometry: child.geometry, material: mat }); + } + } else { + parts.push({ geometry: child.geometry, material: child.material }); + } + } + }); + return parts; +} + +/** + * Returns a string key that identifies a material's diffuse texture. + * Used to match the same material slot across different model variants. + */ +function getTextureFingerprint(mat: THREE.Material): string { + const std = mat as THREE.MeshStandardMaterial; + if (std.map?.image) { + const img = std.map.image as { + width?: number; + height?: number; + src?: string; + uuid?: string; + }; + return `tex:${img.width}x${img.height}:${img.src ?? img.uuid ?? ""}`; + } + if (std.name) return `name:${std.name}`; + return `idx:${Math.random()}`; +} + +/** + * Reorder `parts` so that each part's texture fingerprint matches + * the corresponding `refFingerprints[slotIdx]`. + * Returns reordered array, or null if matching fails. + */ +function matchPartsToReference( + refFingerprints: string[], + parts: MeshPart[], +): MeshPart[] | null { + if (parts.length !== refFingerprints.length) return null; + const partFingerprints = parts.map((p) => getTextureFingerprint(p.material)); + const used = new Set(); + const reordered: MeshPart[] = []; + + for (let slot = 0; slot < refFingerprints.length; slot++) { + let matched = -1; + for (let pi = 0; pi < partFingerprints.length; pi++) { + if (!used.has(pi) && partFingerprints[pi] === refFingerprints[slot]) { + matched = pi; + break; + } + } + if (matched === -1) { + // Fallback: try matching by material name + const refName = refFingerprints[slot].startsWith("name:") + ? refFingerprints[slot].slice(5) + : ""; + for (let pi = 0; pi < parts.length; pi++) { + if ( + !used.has(pi) && + (parts[pi].material as THREE.MeshStandardMaterial).name === refName + ) { + matched = pi; + break; + } + } + } + if (matched === -1) return null; + used.add(matched); + reordered.push(parts[matched]); + } + return reordered; +} + +function computeModelBounds( + root: THREE.Object3D, + scale: number, +): { + yOffset: number; + height: number; + radius: number; +} { + const saved = root.scale.clone(); + root.scale.set(scale, scale, scale); + const bbox = new THREE.Box3().setFromObject(root); + root.scale.copy(saved); + const height = bbox.max.y - bbox.min.y; + const dx = Math.max(Math.abs(bbox.min.x), Math.abs(bbox.max.x)); + const dz = Math.max(Math.abs(bbox.min.z), Math.abs(bbox.max.z)); + return { yOffset: -bbox.min.y, height, radius: Math.max(dx, dz) }; +} + +async function loadLODParts(path: string): Promise { + try { + const { scene: lodScene } = await modelCache.loadModel(path, world!); + const parts = extractAllMeshParts(lodScene); + return parts.length > 0 ? parts : null; + } catch { + return null; + } +} + +// ---- BatchedLODPool creation ---- + +function countGeometry(geo: THREE.BufferGeometry): { + vertexCount: number; + indexCount: number; +} { + const vertexCount = geo.getAttribute("position")?.count ?? 0; + const indexCount = geo.index?.count ?? 0; + return { vertexCount, indexCount }; +} + +/** + * Build a BatchedLODPool from multiple variants' parts. + * variantParts[variant][materialSlot] = { geometry, material } + * All variants must have the same number of material slots. + */ +function createBatchedLODPool( + variantParts: { + geometry: THREE.BufferGeometry; + material: DissolveMaterial; + }[][], +): BatchedLODPool { + const numSlots = variantParts[0].length; + const numVariants = variantParts.length; + + const batches: THREE.BatchedMesh[] = []; + const materials: DissolveMaterial[] = []; + const geometryIds: number[][] = []; + + for (let slot = 0; slot < numSlots; slot++) { + const mat = variantParts[0][slot].material; + + let totalVerts = 0; + let totalIndices = 0; + for (let v = 0; v < numVariants; v++) { + const counts = countGeometry(variantParts[v][slot].geometry); + totalVerts += counts.vertexCount; + totalIndices += counts.indexCount; + } + + const bm = new THREE.BatchedMesh( + MAX_INSTANCES, + totalVerts, + totalIndices > 0 ? totalIndices : undefined, + mat, + ); + bm.frustumCulled = false; + bm.perObjectFrustumCulled = false; + bm.sortObjects = false; + bm.castShadow = true; + bm.receiveShadow = false; + bm.layers.set(1); + + const slotGeoIds: number[] = []; + for (let v = 0; v < numVariants; v++) { + const geoId = bm.addGeometry(variantParts[v][slot].geometry); + slotGeoIds.push(geoId); + } + + // Force-init colors texture so BatchNode sets up vBatchColor varying + // before the first shader compilation. + const initId = bm.addInstance(slotGeoIds[0]); + bm.setColorAt(initId, _defaultColor); + bm.deleteInstance(initId); + + scene!.add(bm); + batches.push(bm); + materials.push(mat); + geometryIds.push(slotGeoIds); + } + + return { + batches, + materials, + geometryIds, + instanceIds: new Map(), + }; +} + +// ---- Texture helpers ---- + +function enableTextureRepeat(mat: DissolveMaterial): void { + const texProps = [ + "map", + "normalMap", + "roughnessMap", + "metalnessMap", + "aoMap", + "emissiveMap", + "alphaMap", + ] as const; + for (const key of texProps) { + const tex = (mat as unknown as Record)[key] as + | THREE.Texture + | undefined; + if (tex) { + tex.wrapS = THREE.RepeatWrapping; + tex.wrapT = THREE.RepeatWrapping; + tex.needsUpdate = true; + } + } +} + +// ---- Tree type pool lifecycle ---- + +const pendingEnsure = new Map>(); + +async function ensureTreeTypePool( + treeType: string, + variantPaths: string[], + depletedModelPath?: string | null, +): Promise { + const existing = pools.get(treeType); + if (existing) { + if (depletedModelPath && !existing.depleted) { + await loadDepletedPool(existing, depletedModelPath); + } + return existing; + } + + const pending = pendingEnsure.get(treeType); + if (pending) return pending; + + const promise = (async (): Promise => { + const dissolveOpts = { + fadeStart: GPU_VEG_CONFIG.FADE_START, + fadeEnd: GPU_VEG_CONFIG.FADE_END, + enableNearFade: false, + enableWaterCulling: false, + enableOcclusionDissolve: false, + enableRimHighlight: true, + }; + + function buildMaterialForPart(p: MeshPart): DissolveMaterial { + const dm = createTreeDissolveMaterial(p.material, { + ...dissolveOpts, + batched: true, + }); + dm.side = THREE.DoubleSide; + enableTextureRepeat(dm); + world!.setupMaterial(dm); + return dm; + } + + // Load all variant LOD0s in parallel + const lod0Scenes = await Promise.all( + variantPaths.map(async (vp) => { + const { scene: s } = await modelCache.loadModel(vp, world!); + return s; + }), + ); + + // Extract parts per variant + const allLod0Parts = lod0Scenes.map((s) => extractAllMeshParts(s)); + if (allLod0Parts[0].length === 0) + throw new Error(`No mesh found in ${variantPaths[0]}`); + + const numSlots = allLod0Parts[0].length; + + // Build a texture fingerprint for each part in variant 0 to define slot identity + const refFingerprints = allLod0Parts[0].map((p) => + getTextureFingerprint(p.material), + ); + + // Reorder each subsequent variant's parts to match variant 0's slot order + for (let v = 1; v < allLod0Parts.length; v++) { + const parts = allLod0Parts[v]; + if (parts.length !== numSlots) { + console.warn( + `[GLBTreeBatchedInstancer] Variant ${variantPaths[v]} has ${parts.length} parts, expected ${numSlots}!`, + ); + continue; + } + const reordered = matchPartsToReference(refFingerprints, parts); + if (reordered) { + allLod0Parts[v] = reordered; + } else { + console.warn( + `[GLBTreeBatchedInstancer] Could not match parts for ${variantPaths[v]} — using original order`, + ); + } + } + + // Build materials from first variant (shared for all variants) + const sharedMaterials = allLod0Parts[0].map((p) => buildMaterialForPart(p)); + + // Build variant parts for LOD0 + const lod0VariantParts = allLod0Parts.map((parts) => + parts.map((p, slotIdx) => ({ + geometry: p.geometry, + material: sharedMaterials[slotIdx % sharedMaterials.length], + })), + ); + + const lod0Pool = createBatchedLODPool(lod0VariantParts); + + // Compute bounds from first variant + const bounds = computeModelBounds(lod0Scenes[0], 1); + + // Load LOD1 variants in parallel + let lod1Pool: BatchedLODPool | null = null; + const lod1Results = await Promise.all( + variantPaths.map((vp) => loadLODParts(inferLOD1Path(vp))), + ); + const validLod1 = lod1Results.filter( + (r): r is MeshPart[] => r !== null && r.length === numSlots, + ); + if (validLod1.length > 0) { + const lod1Ref = validLod1[0].map((p) => + getTextureFingerprint(p.material), + ); + for (let v = 1; v < validLod1.length; v++) { + const matched = matchPartsToReference(lod1Ref, validLod1[v]); + if (matched) validLod1[v] = matched; + } + const lod1Materials = validLod1[0].map((p) => buildMaterialForPart(p)); + const lod1VariantParts = validLod1.map((parts) => + parts.map((p, slotIdx) => ({ + geometry: p.geometry, + material: lod1Materials[slotIdx], + })), + ); + lod1Pool = createBatchedLODPool(lod1VariantParts); + } + + // Load LOD2 variants in parallel + let lod2Pool: BatchedLODPool | null = null; + const lod2Results = await Promise.all( + variantPaths.map((vp) => loadLODParts(inferLOD2Path(vp))), + ); + const validLod2 = lod2Results.filter( + (r): r is MeshPart[] => r !== null && r.length === numSlots, + ); + if (validLod2.length > 0) { + const lod2Ref = validLod2[0].map((p) => + getTextureFingerprint(p.material), + ); + for (let v = 1; v < validLod2.length; v++) { + const matched = matchPartsToReference(lod2Ref, validLod2[v]); + if (matched) validLod2[v] = matched; + } + const lod2Materials = validLod2[0].map((p) => buildMaterialForPart(p)); + const lod2VariantParts = validLod2.map((parts) => + parts.map((p, slotIdx) => ({ + geometry: p.geometry, + material: lod2Materials[slotIdx], + })), + ); + lod2Pool = createBatchedLODPool(lod2VariantParts); + } + + const pool: TreeTypePool = { + treeType, + variantPaths, + lod0: lod0Pool, + lod1: lod1Pool, + lod2: lod2Pool, + depleted: null, + instances: new Map(), + yOffset: bounds.yOffset, + depletedYOffset: 0, + modelHeight: bounds.height, + modelRadius: bounds.radius, + }; + pools.set(treeType, pool); + + if (depletedModelPath) { + await loadDepletedPool(pool, depletedModelPath); + } + + return pool; + })(); + + pendingEnsure.set(treeType, promise); + try { + return await promise; + } finally { + pendingEnsure.delete(treeType); + } +} + +async function loadDepletedPool( + pool: TreeTypePool, + depletedModelPath: string, +): Promise { + if (pool.depleted) return; + const depletedParts = await loadLODParts(depletedModelPath); + if (!depletedParts) return; + + let depletedYOffset = 0; + try { + const { scene: depScene } = await modelCache.loadModel( + depletedModelPath, + world!, + ); + depletedYOffset = computeModelBounds(depScene, 1).yOffset; + } catch { + /* use 0 */ + } + + const dissolveOpts = { + fadeStart: GPU_VEG_CONFIG.FADE_START, + fadeEnd: GPU_VEG_CONFIG.FADE_END, + enableNearFade: false, + enableWaterCulling: false, + enableOcclusionDissolve: false, + enableRimHighlight: true, + }; + + const depletedDissolveParts = depletedParts.map((p) => { + const dm = createTreeDissolveMaterial(p.material, { + ...dissolveOpts, + batched: true, + }); + dm.side = THREE.DoubleSide; + enableTextureRepeat(dm); + world!.setupMaterial(dm); + return [{ geometry: p.geometry, material: dm }]; + }); + + // Depleted has 1 "variant" (the stump) + // Transpose: depletedDissolveParts is [slot][1 variant] but we need [1 variant][slot] + const numSlots = depletedParts.length; + const singleVariant: { + geometry: THREE.BufferGeometry; + material: DissolveMaterial; + }[] = []; + for (let s = 0; s < numSlots; s++) { + singleVariant.push(depletedDissolveParts[s][0]); + } + pool.depleted = createBatchedLODPool([singleVariant]); + pool.depletedYOffset = depletedYOffset; +} + +// ---- Instance matrix helper ---- + +function composeInstanceMatrix( + position: THREE.Vector3, + rotation: number, + scale: number, + yOffset: number, +): THREE.Matrix4 { + _position.set(position.x, position.y + yOffset * scale, position.z); + _quaternion.setFromAxisAngle(THREE.Object3D.DEFAULT_UP, rotation); + _scale.set(scale, scale, scale); + return _matrix.compose(_position, _quaternion, _scale); +} + +// ---- Pool add/remove ---- + +function addToPool( + pool: BatchedLODPool, + entityId: string, + mat: THREE.Matrix4, + variantIndex: number, +): void { + 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) { + console.warn( + `[GLBTreeBatchedInstancer] geoId undefined: slot=${i} variant=${clampedIdx} available=${numVariants}`, + ); + continue; + } + const instId = pool.batches[i].addInstance(geoId); + pool.batches[i].setMatrixAt(instId, mat); + pool.batches[i].setColorAt(instId, _defaultColor); + ids.push(instId); + } + pool.instanceIds.set(entityId, ids); +} + +function removeFromPool(pool: BatchedLODPool, entityId: string): void { + const ids = pool.instanceIds.get(entityId); + if (!ids) return; + for (let i = 0; i < pool.batches.length; i++) { + pool.batches[i].deleteInstance(ids[i]); + } + pool.instanceIds.delete(entityId); +} + +const _tmpColor = new THREE.Color(); + +function isHighlighted(pool: BatchedLODPool, entityId: string): boolean { + const ids = pool.instanceIds.get(entityId); + if (!ids || ids.length === 0) return false; + pool.batches[0].getColorAt(ids[0], _tmpColor); + return _tmpColor.r > 1.01; +} + +function applyHighlightColor( + pool: BatchedLODPool, + entityId: string, + on: boolean, +): void { + const ids = pool.instanceIds.get(entityId); + if (!ids) return; + const color = on ? _hlColor : _defaultColor; + for (let i = 0; i < pool.batches.length; i++) { + pool.batches[i].setColorAt(ids[i], color); + } +} + +// ---- Public API ---- + +export function initGLBTreeBatchedInstancer(s: THREE.Scene, w: World): void { + scene = s; + world = w; +} + +export function destroyGLBTreeBatchedInstancer(): void { + for (const pool of pools.values()) { + for (const lodPool of [pool.lod0, pool.lod1, pool.lod2, pool.depleted]) { + if (!lodPool) continue; + for (const bm of lodPool.batches) { + scene?.remove(bm); + bm.dispose(); + } + for (const mat of lodPool.materials) mat.dispose(); + } + } + pools.clear(); + entityToTreeType.clear(); + pendingEnsure.clear(); + scene = null; + world = null; +} + +export async function addInstance( + treeType: string, + variantPaths: string[], + variantIndex: number, + entityId: string, + position: THREE.Vector3, + rotation: number, + scale: number, + depletedModelPath?: string | null, + depletedScale?: number, +): Promise { + if (!scene || !world) return false; + + try { + const pool = await ensureTreeTypePool( + treeType, + variantPaths, + depletedModelPath, + ); + + const slot: TreeSlot = { + entityId, + position: position.clone(), + rotation, + scale, + depletedScale: depletedScale ?? scale, + yOffset: pool.yOffset, + currentLOD: 0, + depleted: false, + variantIndex, + }; + + pool.instances.set(entityId, slot); + entityToTreeType.set(entityId, treeType); + + const mat = composeInstanceMatrix(position, rotation, scale, pool.yOffset); + if (pool.lod0) addToPool(pool.lod0, entityId, mat, variantIndex); + + return true; + } catch (error) { + console.warn( + `[GLBTreeBatchedInstancer] Failed to add instance ${entityId}:`, + error, + ); + return false; + } +} + +export function removeInstance(entityId: string): void { + const treeType = entityToTreeType.get(entityId); + if (!treeType) return; + + const pool = pools.get(treeType); + if (!pool) return; + + const slot = pool.instances.get(entityId); + if (!slot) return; + + const lodPool = getLodPool(pool, slot); + if (lodPool) removeFromPool(lodPool, entityId); + + pool.instances.delete(entityId); + entityToTreeType.delete(entityId); +} + +function getLodPool(pool: TreeTypePool, slot: TreeSlot): BatchedLODPool | null { + if (slot.depleted) return pool.depleted; + return slot.currentLOD === 0 + ? pool.lod0 + : slot.currentLOD === 1 + ? pool.lod1 + : pool.lod2; +} + +export function setDepleted(entityId: string, depleted: boolean): void { + const treeType = entityToTreeType.get(entityId); + if (!treeType) return; + + const pool = pools.get(treeType); + if (!pool) return; + + const slot = pool.instances.get(entityId); + if (!slot || slot.depleted === depleted) return; + + slot.depleted = depleted; + + if (depleted) { + const lodPool = getLodPool(pool, slot); + if (lodPool) removeFromPool(lodPool, entityId); + + if (pool.depleted) { + const mat = composeInstanceMatrix( + slot.position, + slot.rotation, + slot.depletedScale, + pool.depletedYOffset, + ); + addToPool(pool.depleted, entityId, mat, 0); + } + } else { + if (pool.depleted) { + removeFromPool(pool.depleted, entityId); + } + + const mat = composeInstanceMatrix( + slot.position, + slot.rotation, + slot.scale, + slot.yOffset, + ); + const lodPool = + slot.currentLOD === 0 + ? pool.lod0 + : slot.currentLOD === 1 + ? pool.lod1 + : pool.lod2; + if (lodPool) addToPool(lodPool, entityId, mat, slot.variantIndex); + } +} + +export function hasInstance(entityId: string): boolean { + return entityToTreeType.has(entityId); +} + +export function getModelDimensions( + entityId: string, +): { height: number; radius: number } | null { + const treeType = entityToTreeType.get(entityId); + if (!treeType) return null; + const pool = pools.get(treeType); + if (!pool) return null; + return { height: pool.modelHeight, radius: pool.modelRadius }; +} + +export function hasDepleted(entityId: string): boolean { + const treeType = entityToTreeType.get(entityId); + if (!treeType) return false; + const pool = pools.get(treeType); + return !!pool?.depleted; +} + +let highlightedEntityId: string | null = null; + +export function setHighlight(entityId: string, on: boolean): void { + if (on && highlightedEntityId && highlightedEntityId !== entityId) { + setHighlight(highlightedEntityId, false); + } + + const treeType = entityToTreeType.get(entityId); + if (!treeType) return; + + const pool = pools.get(treeType); + if (!pool) return; + + const slot = pool.instances.get(entityId); + if (!slot) return; + + const lodPool = getLodPool(pool, slot); + if (!lodPool) return; + + applyHighlightColor(lodPool, entityId, on); + highlightedEntityId = on ? entityId : null; +} + +export function clearHighlight(): void { + if (highlightedEntityId) { + setHighlight(highlightedEntityId, false); + } +} + +let lastUpdateFrame = -1; + +export function updateGLBTreeBatchedInstancer(): void { + if (!world) return; + if (world.frame === lastUpdateFrame) return; + lastUpdateFrame = world.frame; + + const camera = world.camera; + if (!camera) return; + + const camPos = camera.position; + const lod1DistSq = resourceLOD.lod1DistanceSq; + const lod2DistSq = resourceLOD.lod2DistanceSq; + const hysteresisSq = 0.81; + + for (const pool of pools.values()) { + for (const slot of pool.instances.values()) { + if (slot.depleted) continue; + + const dx = camPos.x - slot.position.x; + const dz = camPos.z - slot.position.z; + const distSq = dx * dx + dz * dz; + + let targetLOD: 0 | 1 | 2; + if (distSq < lod1DistSq * hysteresisSq) { + targetLOD = 0; + } else if (distSq < lod1DistSq) { + targetLOD = slot.currentLOD === 0 ? 0 : pool.lod1 ? 1 : 0; + } else if (distSq < lod2DistSq * hysteresisSq) { + targetLOD = pool.lod1 ? 1 : 0; + } else if (distSq < lod2DistSq) { + if (slot.currentLOD <= 1) { + targetLOD = pool.lod1 ? 1 : 0; + } else { + targetLOD = pool.lod2 ? 2 : pool.lod1 ? 1 : 0; + } + } else { + targetLOD = pool.lod2 ? 2 : pool.lod1 ? 1 : 0; + } + + if (targetLOD === slot.currentLOD) continue; + + const oldPool = getLodPool(pool, slot); + const wasHl = oldPool ? isHighlighted(oldPool, slot.entityId) : false; + if (oldPool) removeFromPool(oldPool, slot.entityId); + + slot.currentLOD = targetLOD; + + const newPool = getLodPool(pool, slot); + if (newPool) { + const mat = composeInstanceMatrix( + slot.position, + slot.rotation, + slot.scale, + slot.yOffset, + ); + addToPool(newPool, slot.entityId, mat, slot.variantIndex); + if (wasHl) applyHighlightColor(newPool, slot.entityId, true); + } + } + } + + // Update dissolve uniforms + const camY = camPos.y; + const players = world.getPlayers(); + const localPlayer = players && players.length > 0 ? players[0] : null; + const playerPos = localPlayer?.node?.position ?? camPos; + + const env = world.getSystem("environment") as { + sunLight?: { intensity: number }; + lightDirection?: THREE.Vector3; + hemisphereLight?: { color: THREE.Color }; + } | null; + + for (const pool of pools.values()) { + for (const lodPool of [pool.lod0, pool.lod1, pool.lod2, pool.depleted]) { + if (!lodPool) continue; + + for (const mat of lodPool.materials) { + mat.dissolveUniforms.cameraPos.value.set(camPos.x, camY, camPos.z); + mat.dissolveUniforms.playerPos.value.set( + playerPos.x, + playerPos.y, + playerPos.z, + ); + + const treeMat = mat as TreeDissolveMaterial; + if (treeMat.treeUniforms) { + if (env?.lightDirection) { + treeMat.treeUniforms.sunDirection.value + .copy(env.lightDirection) + .negate(); + } + if (env?.sunLight) { + treeMat.treeUniforms.sunIntensity.value = Math.min( + env.sunLight.intensity, + 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, + ); + } + } + } + } + } + } +} diff --git a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts index 6bee2d5be..58b879dc6 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts @@ -1,14 +1,18 @@ /** - * GLBTreeInstancer — BatchedMesh-based rendering for GLB-loaded trees. + * GLBTreeInstancer - InstancedMesh-based rendering for GLB-loaded trees. * - * Loads all model variants for each tree type once, registers their - * geometries in a shared BatchedMesh per material slot per LOD level. - * Each tree instance picks a variant via addInstance(geometryId). + * Instead of cloning the full GLB scene per tree (which deep-copies all + * geometry buffers and causes FPS drops), this module loads each model + * once, extracts its geometry by reference, and renders all instances + * of that model via a single THREE.InstancedMesh per LOD level. * - * One BatchedMesh per material slot (bark, leaves) per LOD = minimal - * draw calls regardless of how many variants a tree type has. + * LOD0, LOD1, and LOD2 each get their own InstancedMesh. The instancer + * performs distance-based LOD switching per-instance every frame by + * moving instances between pools (matrix swaps + count adjustment). * - * ResourceEntity calls addInstance/removeInstance/setDepleted/setHighlight. + * ResourceEntity calls addInstance/removeInstance/setDepleted. It does + * NOT need to track whether it's instanced — those calls are safe no-ops + * when the entity isn't registered. * * @module GLBTreeInstancer */ @@ -30,9 +34,7 @@ const _matrix = new THREE.Matrix4(); const _position = new THREE.Vector3(); const _quaternion = new THREE.Quaternion(); 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 _swapMatrix = new THREE.Matrix4(); interface TreeSlot { entityId: string; @@ -43,33 +45,32 @@ interface TreeSlot { yOffset: number; currentLOD: 0 | 1 | 2; depleted: boolean; - variantIndex: number; } -interface BatchedLODPool { - /** One BatchedMesh per material slot (e.g. [bark, leaves]) */ - batches: THREE.BatchedMesh[]; +interface LODPool { + /** One InstancedMesh per sub-mesh/primitive in the GLB */ + meshes: THREE.InstancedMesh[]; materials: DissolveMaterial[]; - /** - * geometryIds[materialSlot][variantIndex] = geometryId returned by - * BatchedMesh.addGeometry() for that variant's geometry. - */ - geometryIds: number[][]; - /** entityId → array of instanceIds (one per BatchedMesh/material slot) */ - instanceIds: Map; -} - -interface TreeTypePool { - treeType: string; - variantPaths: string[]; - lod0: BatchedLODPool | null; - lod1: BatchedLODPool | null; - lod2: BatchedLODPool | null; - depleted: BatchedLODPool | null; + /** entityId → slot index (same across all meshes) */ + slots: Map; + activeCount: number; + dirty: boolean; + /** Shared backing array for per-instance highlight intensity (0 or 1) */ + highlightData: Float32Array; +} + +interface ModelPool { + modelPath: string; + lod0: LODPool | null; + lod1: LODPool | null; + lod2: LODPool | null; + depleted: LODPool | null; instances: Map; yOffset: number; depletedYOffset: number; + /** Unscaled model height from bounding box */ modelHeight: number; + /** Unscaled model horizontal radius from bounding box */ modelRadius: number; } @@ -78,8 +79,8 @@ const resourceLOD = getLODDistances("resource"); // ---- Module state ---- let scene: THREE.Scene | null = null; let world: World | null = null; -const pools = new Map(); -const entityToTreeType = new Map(); +const pools = new Map(); +const entityToModel = new Map(); function inferLOD1Path(lod0Path: string): string { return lod0Path.replace(/\.glb$/i, "_lod1.glb"); @@ -88,7 +89,7 @@ function inferLOD2Path(lod0Path: string): string { return lod0Path.replace(/\.glb$/i, "_lod2.glb"); } -// ---- Geometry extraction ---- +// ---- Geometry extraction (portfolio pattern: reference, not clone) ---- interface MeshPart { geometry: THREE.BufferGeometry; @@ -111,67 +112,27 @@ function extractAllMeshParts(root: THREE.Object3D): MeshPart[] { return parts; } -/** - * Returns a string key that identifies a material's diffuse texture. - * Used to match the same material slot across different model variants. - */ -function getTextureFingerprint(mat: THREE.Material): string { - const std = mat as THREE.MeshStandardMaterial; - if (std.map?.image) { - const img = std.map.image as { - width?: number; - height?: number; - src?: string; - uuid?: string; - }; - return `tex:${img.width}x${img.height}:${img.src ?? img.uuid ?? ""}`; +function createSharedGeometry( + source: THREE.BufferGeometry, +): THREE.BufferGeometry { + const geo = new THREE.BufferGeometry(); + for (const name in source.attributes) { + geo.setAttribute(name, source.attributes[name]); } - if (std.name) return `name:${std.name}`; - return `idx:${Math.random()}`; -} - -/** - * Reorder `parts` so that each part's texture fingerprint matches - * the corresponding `refFingerprints[slotIdx]`. - * Returns reordered array, or null if matching fails. - */ -function matchPartsToReference( - refFingerprints: string[], - parts: MeshPart[], -): MeshPart[] | null { - if (parts.length !== refFingerprints.length) return null; - const partFingerprints = parts.map((p) => getTextureFingerprint(p.material)); - const used = new Set(); - const reordered: MeshPart[] = []; - - for (let slot = 0; slot < refFingerprints.length; slot++) { - let matched = -1; - for (let pi = 0; pi < partFingerprints.length; pi++) { - if (!used.has(pi) && partFingerprints[pi] === refFingerprints[slot]) { - matched = pi; - break; - } + if (source.index) geo.setIndex(source.index); + if (source.morphAttributes) { + for (const name in source.morphAttributes) { + geo.morphAttributes[name] = source.morphAttributes[name]; } - if (matched === -1) { - // Fallback: try matching by material name - const refName = refFingerprints[slot].startsWith("name:") - ? refFingerprints[slot].slice(5) - : ""; - for (let pi = 0; pi < parts.length; pi++) { - if ( - !used.has(pi) && - (parts[pi].material as THREE.MeshStandardMaterial).name === refName - ) { - matched = pi; - break; - } - } + } + if (source.groups.length > 0) { + for (const group of source.groups) { + geo.addGroup(group.start, group.count, group.materialIndex); } - if (matched === -1) return null; - used.add(matched); - reordered.push(parts[matched]); } - return reordered; + if (source.boundingBox) geo.boundingBox = source.boundingBox.clone(); + if (source.boundingSphere) geo.boundingSphere = source.boundingSphere.clone(); + return geo; } function computeModelBounds( @@ -192,6 +153,41 @@ function computeModelBounds( return { yOffset: -bbox.min.y, height, radius: Math.max(dx, dz) }; } +// ---- LODPool creation ---- + +function createLODPool( + parts: { geometry: THREE.BufferGeometry; material: DissolveMaterial }[], +): LODPool { + const meshes: THREE.InstancedMesh[] = []; + const materials: DissolveMaterial[] = []; + const hlData = new Float32Array(MAX_INSTANCES); + for (const part of parts) { + const geo = createSharedGeometry(part.geometry); + + const hlAttr = new THREE.InstancedBufferAttribute(hlData, 1); + hlAttr.setUsage(THREE.DynamicDrawUsage); + geo.setAttribute("instanceHighlight", hlAttr); + + const im = new THREE.InstancedMesh(geo, part.material, MAX_INSTANCES); + im.count = 0; + im.frustumCulled = false; + im.castShadow = true; + im.receiveShadow = false; + im.layers.set(1); + scene!.add(im); + meshes.push(im); + materials.push(part.material); + } + return { + meshes, + materials, + slots: new Map(), + activeCount: 0, + dirty: false, + highlightData: hlData, + }; +} + async function loadLODParts(path: string): Promise { try { const { scene: lodScene } = await modelCache.loadModel(path, world!); @@ -202,86 +198,9 @@ async function loadLODParts(path: string): Promise { } } -// ---- BatchedLODPool creation ---- - -function countGeometry(geo: THREE.BufferGeometry): { - vertexCount: number; - indexCount: number; -} { - const vertexCount = geo.getAttribute("position")?.count ?? 0; - const indexCount = geo.index?.count ?? 0; - return { vertexCount, indexCount }; -} - -/** - * Build a BatchedLODPool from multiple variants' parts. - * variantParts[variant][materialSlot] = { geometry, material } - * All variants must have the same number of material slots. - */ -function createBatchedLODPool( - variantParts: { - geometry: THREE.BufferGeometry; - material: DissolveMaterial; - }[][], -): BatchedLODPool { - const numSlots = variantParts[0].length; - const numVariants = variantParts.length; - - const batches: THREE.BatchedMesh[] = []; - const materials: DissolveMaterial[] = []; - const geometryIds: number[][] = []; - - for (let slot = 0; slot < numSlots; slot++) { - const mat = variantParts[0][slot].material; +// ---- Model pool lifecycle ---- - let totalVerts = 0; - let totalIndices = 0; - for (let v = 0; v < numVariants; v++) { - const counts = countGeometry(variantParts[v][slot].geometry); - totalVerts += counts.vertexCount; - totalIndices += counts.indexCount; - } - - const bm = new THREE.BatchedMesh( - MAX_INSTANCES, - totalVerts, - totalIndices > 0 ? totalIndices : undefined, - mat, - ); - bm.frustumCulled = false; - bm.perObjectFrustumCulled = false; - bm.sortObjects = false; - bm.castShadow = true; - bm.receiveShadow = false; - bm.layers.set(1); - - const slotGeoIds: number[] = []; - for (let v = 0; v < numVariants; v++) { - const geoId = bm.addGeometry(variantParts[v][slot].geometry); - slotGeoIds.push(geoId); - } - - // Force-init colors texture so BatchNode sets up vBatchColor varying - // before the first shader compilation. - const initId = bm.addInstance(slotGeoIds[0]); - bm.setColorAt(initId, _defaultColor); - bm.deleteInstance(initId); - - scene!.add(bm); - batches.push(bm); - materials.push(mat); - geometryIds.push(slotGeoIds); - } - - return { - batches, - materials, - geometryIds, - instanceIds: new Map(), - }; -} - -// ---- Texture helpers ---- +const pendingEnsure = new Map>(); function enableTextureRepeat(mat: DissolveMaterial): void { const texProps = [ @@ -305,16 +224,11 @@ function enableTextureRepeat(mat: DissolveMaterial): void { } } -// ---- Tree type pool lifecycle ---- - -const pendingEnsure = new Map>(); - -async function ensureTreeTypePool( - treeType: string, - variantPaths: string[], +async function ensureModelPool( + modelPath: string, depletedModelPath?: string | null, -): Promise { - const existing = pools.get(treeType); +): Promise { + const existing = pools.get(modelPath); if (existing) { if (depletedModelPath && !existing.depleted) { await loadDepletedPool(existing, depletedModelPath); @@ -322,10 +236,10 @@ async function ensureTreeTypePool( return existing; } - const pending = pendingEnsure.get(treeType); + const pending = pendingEnsure.get(modelPath); if (pending) return pending; - const promise = (async (): Promise => { + const promise = (async (): Promise => { const dissolveOpts = { fadeStart: GPU_VEG_CONFIG.FADE_START, fadeEnd: GPU_VEG_CONFIG.FADE_END, @@ -335,139 +249,44 @@ async function ensureTreeTypePool( enableRimHighlight: true, }; - function buildMaterialForPart(p: MeshPart): DissolveMaterial { - const dm = createTreeDissolveMaterial(p.material, dissolveOpts); - dm.side = THREE.DoubleSide; - enableTextureRepeat(dm); - world!.setupMaterial(dm); - return dm; + function buildTreeParts( + parts: MeshPart[], + ): { geometry: THREE.BufferGeometry; material: DissolveMaterial }[] { + return parts.map((p) => { + const dm = createTreeDissolveMaterial(p.material, dissolveOpts); + dm.side = THREE.DoubleSide; + enableTextureRepeat(dm); + world!.setupMaterial(dm); + return { geometry: p.geometry, material: dm }; + }); } - // Load all variant LOD0s in parallel - const lod0Scenes = await Promise.all( - variantPaths.map(async (vp) => { - const { scene: s } = await modelCache.loadModel(vp, world!); - return s; - }), - ); - - // Extract parts per variant - const allLod0Parts = lod0Scenes.map((s) => extractAllMeshParts(s)); - if (allLod0Parts[0].length === 0) - throw new Error(`No mesh found in ${variantPaths[0]}`); + // LOD0 + const { scene: lod0Scene } = await modelCache.loadModel(modelPath, world!); + const lod0Parts = extractAllMeshParts(lod0Scene); + if (lod0Parts.length === 0) + throw new Error(`No mesh found in ${modelPath}`); - const numSlots = allLod0Parts[0].length; + const bounds = computeModelBounds(lod0Scene, 1); - // Build a texture fingerprint for each part in variant 0 to define slot identity - const refFingerprints = allLod0Parts[0].map((p) => - getTextureFingerprint(p.material), - ); + const lod0Pool = createLODPool(buildTreeParts(lod0Parts)); - // Reorder each subsequent variant's parts to match variant 0's slot order - for (let v = 1; v < allLod0Parts.length; v++) { - const parts = allLod0Parts[v]; - if (parts.length !== numSlots) { - console.warn( - `[GLBTreeInstancer] Variant ${variantPaths[v]} has ${parts.length} parts, expected ${numSlots}!`, - ); - continue; - } - const reordered = matchPartsToReference(refFingerprints, parts); - if (reordered) { - allLod0Parts[v] = reordered; - } else { - console.warn( - `[GLBTreeInstancer] Could not match parts for ${variantPaths[v]} — using original order`, - ); - } + // LOD1 + let lod1Pool: LODPool | null = null; + const lod1Parts = await loadLODParts(inferLOD1Path(modelPath)); + if (lod1Parts) { + lod1Pool = createLODPool(buildTreeParts(lod1Parts)); } - // Debug: log final part order - for (let vi = 0; vi < allLod0Parts.length; vi++) { - const stdMat = (m: THREE.Material) => m as THREE.MeshStandardMaterial; - const parts = allLod0Parts[vi]; - console.log( - `[GLBTreeInstancer] ${variantPaths[vi]}: ${parts.length} parts → ` + - parts - .map( - (p, i) => - `[${i}] verts=${p.geometry.getAttribute("position")?.count} mat="${stdMat(p.material).name || "unnamed"}" map=${(stdMat(p.material).map?.image as { width?: number })?.width ?? "none"}`, - ) - .join(", "), - ); + // LOD2 + let lod2Pool: LODPool | null = null; + const lod2Parts = await loadLODParts(inferLOD2Path(modelPath)); + if (lod2Parts) { + lod2Pool = createLODPool(buildTreeParts(lod2Parts)); } - // Build materials from first variant (shared for all variants) - const sharedMaterials = allLod0Parts[0].map((p) => buildMaterialForPart(p)); - - // Build variant parts for LOD0 - const lod0VariantParts = allLod0Parts.map((parts) => - parts.map((p, slotIdx) => ({ - geometry: p.geometry, - material: sharedMaterials[slotIdx % sharedMaterials.length], - })), - ); - - const lod0Pool = createBatchedLODPool(lod0VariantParts); - - // Compute bounds from first variant - const bounds = computeModelBounds(lod0Scenes[0], 1); - - // Load LOD1 variants in parallel - let lod1Pool: BatchedLODPool | null = null; - const lod1Results = await Promise.all( - variantPaths.map((vp) => loadLODParts(inferLOD1Path(vp))), - ); - const validLod1 = lod1Results.filter( - (r): r is MeshPart[] => r !== null && r.length === numSlots, - ); - if (validLod1.length > 0) { - const lod1Ref = validLod1[0].map((p) => - getTextureFingerprint(p.material), - ); - for (let v = 1; v < validLod1.length; v++) { - const matched = matchPartsToReference(lod1Ref, validLod1[v]); - if (matched) validLod1[v] = matched; - } - const lod1Materials = validLod1[0].map((p) => buildMaterialForPart(p)); - const lod1VariantParts = validLod1.map((parts) => - parts.map((p, slotIdx) => ({ - geometry: p.geometry, - material: lod1Materials[slotIdx], - })), - ); - lod1Pool = createBatchedLODPool(lod1VariantParts); - } - - // Load LOD2 variants in parallel - let lod2Pool: BatchedLODPool | null = null; - const lod2Results = await Promise.all( - variantPaths.map((vp) => loadLODParts(inferLOD2Path(vp))), - ); - const validLod2 = lod2Results.filter( - (r): r is MeshPart[] => r !== null && r.length === numSlots, - ); - if (validLod2.length > 0) { - const lod2Ref = validLod2[0].map((p) => - getTextureFingerprint(p.material), - ); - for (let v = 1; v < validLod2.length; v++) { - const matched = matchPartsToReference(lod2Ref, validLod2[v]); - if (matched) validLod2[v] = matched; - } - const lod2Materials = validLod2[0].map((p) => buildMaterialForPart(p)); - const lod2VariantParts = validLod2.map((parts) => - parts.map((p, slotIdx) => ({ - geometry: p.geometry, - material: lod2Materials[slotIdx], - })), - ); - lod2Pool = createBatchedLODPool(lod2VariantParts); - } - - const pool: TreeTypePool = { - treeType, - variantPaths, + const pool: ModelPool = { + modelPath, lod0: lod0Pool, lod1: lod1Pool, lod2: lod2Pool, @@ -478,7 +297,7 @@ async function ensureTreeTypePool( modelHeight: bounds.height, modelRadius: bounds.radius, }; - pools.set(treeType, pool); + pools.set(modelPath, pool); if (depletedModelPath) { await loadDepletedPool(pool, depletedModelPath); @@ -487,16 +306,16 @@ async function ensureTreeTypePool( return pool; })(); - pendingEnsure.set(treeType, promise); + pendingEnsure.set(modelPath, promise); try { return await promise; } finally { - pendingEnsure.delete(treeType); + pendingEnsure.delete(modelPath); } } async function loadDepletedPool( - pool: TreeTypePool, + pool: ModelPool, depletedModelPath: string, ): Promise { if (pool.depleted) return; @@ -522,26 +341,14 @@ async function loadDepletedPool( enableOcclusionDissolve: false, enableRimHighlight: true, }; - const depletedDissolveParts = depletedParts.map((p) => { const dm = createTreeDissolveMaterial(p.material, dissolveOpts); dm.side = THREE.DoubleSide; enableTextureRepeat(dm); world!.setupMaterial(dm); - return [{ geometry: p.geometry, material: dm }]; + return { geometry: p.geometry, material: dm }; }); - - // Depleted has 1 "variant" (the stump) - // Transpose: depletedDissolveParts is [slot][1 variant] but we need [1 variant][slot] - const numSlots = depletedParts.length; - const singleVariant: { - geometry: THREE.BufferGeometry; - material: DissolveMaterial; - }[] = []; - for (let s = 0; s < numSlots; s++) { - singleVariant.push(depletedDissolveParts[s][0]); - } - pool.depleted = createBatchedLODPool([singleVariant]); + pool.depleted = createLODPool(depletedDissolveParts); pool.depletedYOffset = depletedYOffset; } @@ -559,62 +366,44 @@ function composeInstanceMatrix( return _matrix.compose(_position, _quaternion, _scale); } -// ---- Pool add/remove ---- - -function addToPool( - pool: BatchedLODPool, - entityId: string, - mat: THREE.Matrix4, - variantIndex: number, -): void { - 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) { - console.warn( - `[GLBTreeInstancer] geoId undefined: slot=${i} variant=${clampedIdx} available=${numVariants}`, - ); - continue; - } - const instId = pool.batches[i].addInstance(geoId); - pool.batches[i].setMatrixAt(instId, mat); - pool.batches[i].setColorAt(instId, _defaultColor); - ids.push(instId); +function addToPool(pool: LODPool, entityId: string, mat: THREE.Matrix4): void { + const idx = pool.activeCount; + for (const im of pool.meshes) { + im.setMatrixAt(idx, mat); + im.count = idx + 1; } - pool.instanceIds.set(entityId, ids); + pool.slots.set(entityId, idx); + pool.activeCount++; + pool.dirty = true; } -function removeFromPool(pool: BatchedLODPool, entityId: string): void { - const ids = pool.instanceIds.get(entityId); - if (!ids) return; - for (let i = 0; i < pool.batches.length; i++) { - pool.batches[i].deleteInstance(ids[i]); - } - pool.instanceIds.delete(entityId); -} +function removeFromPool(pool: LODPool, entityId: string): void { + const idx = pool.slots.get(entityId); + if (idx === undefined) return; -const _tmpColor = new THREE.Color(); + const lastIdx = pool.activeCount - 1; + if (idx !== lastIdx) { + for (const im of pool.meshes) { + im.getMatrixAt(lastIdx, _swapMatrix); + im.setMatrixAt(idx, _swapMatrix); + } + pool.highlightData[idx] = pool.highlightData[lastIdx]; -function isHighlighted(pool: BatchedLODPool, entityId: string): boolean { - const ids = pool.instanceIds.get(entityId); - if (!ids || ids.length === 0) return false; - pool.batches[0].getColorAt(ids[0], _tmpColor); - return _tmpColor.r > 1.01; -} + for (const [eid, eidIdx] of pool.slots) { + if (eidIdx === lastIdx) { + pool.slots.set(eid, idx); + break; + } + } + } + pool.highlightData[lastIdx] = 0; -function applyHighlightColor( - pool: BatchedLODPool, - entityId: string, - on: boolean, -): void { - const ids = pool.instanceIds.get(entityId); - if (!ids) return; - const color = on ? _hlColor : _defaultColor; - for (let i = 0; i < pool.batches.length; i++) { - pool.batches[i].setColorAt(ids[i], color); + pool.slots.delete(entityId); + pool.activeCount--; + for (const im of pool.meshes) { + im.count = pool.activeCount; } + pool.dirty = true; } // ---- Public API ---- @@ -628,24 +417,22 @@ export function destroyGLBTreeInstancer(): void { for (const pool of pools.values()) { for (const lodPool of [pool.lod0, pool.lod1, pool.lod2, pool.depleted]) { if (!lodPool) continue; - for (const bm of lodPool.batches) { - scene?.remove(bm); - bm.dispose(); + for (const im of lodPool.meshes) { + scene?.remove(im); + im.geometry.dispose(); } for (const mat of lodPool.materials) mat.dispose(); } } pools.clear(); - entityToTreeType.clear(); + entityToModel.clear(); pendingEnsure.clear(); scene = null; world = null; } export async function addInstance( - treeType: string, - variantPaths: string[], - variantIndex: number, + modelPath: string, entityId: string, position: THREE.Vector3, rotation: number, @@ -656,17 +443,14 @@ export async function addInstance( if (!scene || !world) return false; try { - console.log( - `[GLBTreeInstancer] addInstance: type=${treeType} variants=${variantPaths.length} varIdx=${variantIndex} entity=${entityId}`, - ); - const pool = await ensureTreeTypePool( - treeType, - variantPaths, - depletedModelPath, - ); - console.log( - `[GLBTreeInstancer] pool ready: lod0 batches=${pool.lod0?.batches.length} geoIds=${JSON.stringify(pool.lod0?.geometryIds)}`, - ); + const pool = await ensureModelPool(modelPath, depletedModelPath); + + if (pool.lod0 && pool.lod0.activeCount >= MAX_INSTANCES) { + console.warn( + `[GLBTreeInstancer] LOD0 pool full for ${modelPath}, cannot add ${entityId}`, + ); + return false; + } const slot: TreeSlot = { entityId, @@ -677,14 +461,13 @@ export async function addInstance( yOffset: pool.yOffset, currentLOD: 0, depleted: false, - variantIndex, }; pool.instances.set(entityId, slot); - entityToTreeType.set(entityId, treeType); + entityToModel.set(entityId, modelPath); const mat = composeInstanceMatrix(position, rotation, scale, pool.yOffset); - if (pool.lod0) addToPool(pool.lod0, entityId, mat, variantIndex); + addToPool(pool.lod0!, entityId, mat); return true; } catch (error) { @@ -697,36 +480,32 @@ export async function addInstance( } export function removeInstance(entityId: string): void { - const treeType = entityToTreeType.get(entityId); - if (!treeType) return; + const modelPath = entityToModel.get(entityId); + if (!modelPath) return; - const pool = pools.get(treeType); + const pool = pools.get(modelPath); if (!pool) return; const slot = pool.instances.get(entityId); if (!slot) return; - const lodPool = getLodPool(pool, slot); + const lodPool = + slot.currentLOD === 0 + ? pool.lod0 + : slot.currentLOD === 1 + ? pool.lod1 + : pool.lod2; if (lodPool) removeFromPool(lodPool, entityId); pool.instances.delete(entityId); - entityToTreeType.delete(entityId); -} - -function getLodPool(pool: TreeTypePool, slot: TreeSlot): BatchedLODPool | null { - if (slot.depleted) return pool.depleted; - return slot.currentLOD === 0 - ? pool.lod0 - : slot.currentLOD === 1 - ? pool.lod1 - : pool.lod2; + entityToModel.delete(entityId); } export function setDepleted(entityId: string, depleted: boolean): void { - const treeType = entityToTreeType.get(entityId); - if (!treeType) return; + const modelPath = entityToModel.get(entityId); + if (!modelPath) return; - const pool = pools.get(treeType); + const pool = pools.get(modelPath); if (!pool) return; const slot = pool.instances.get(entityId); @@ -735,9 +514,16 @@ export function setDepleted(entityId: string, depleted: boolean): void { slot.depleted = depleted; if (depleted) { - const lodPool = getLodPool(pool, slot); + // Remove from living LOD pool + const lodPool = + slot.currentLOD === 0 + ? pool.lod0 + : slot.currentLOD === 1 + ? pool.lod1 + : pool.lod2; if (lodPool) removeFromPool(lodPool, entityId); + // Add to depleted pool (instanced stump) if (pool.depleted) { const mat = composeInstanceMatrix( slot.position, @@ -745,13 +531,15 @@ export function setDepleted(entityId: string, depleted: boolean): void { slot.depletedScale, pool.depletedYOffset, ); - addToPool(pool.depleted, entityId, mat, 0); + addToPool(pool.depleted, entityId, mat); } } else { + // Remove from depleted pool if (pool.depleted) { removeFromPool(pool.depleted, entityId); } + // Re-add to living LOD pool const mat = composeInstanceMatrix( slot.position, slot.rotation, @@ -764,54 +552,87 @@ 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); } } export function hasInstance(entityId: string): boolean { - return entityToTreeType.has(entityId); + return entityToModel.has(entityId); } +/** + * Returns the unscaled model dimensions for an instanced entity. + * Used to size collision proxies to match the actual model. + */ export function getModelDimensions( entityId: string, ): { height: number; radius: number } | null { - const treeType = entityToTreeType.get(entityId); - if (!treeType) return null; - const pool = pools.get(treeType); + const modelPath = entityToModel.get(entityId); + if (!modelPath) return null; + const pool = pools.get(modelPath); if (!pool) return null; return { height: pool.modelHeight, radius: pool.modelRadius }; } +/** + * Returns true if the instancer has a depleted pool for this entity's model. + * When true, ResourceEntity can skip loading an individual depleted model. + */ export function hasDepleted(entityId: string): boolean { - const treeType = entityToTreeType.get(entityId); - if (!treeType) return false; - const pool = pools.get(treeType); + const modelPath = entityToModel.get(entityId); + if (!modelPath) return false; + const pool = pools.get(modelPath); return !!pool?.depleted; } +/** Track which entity is currently highlighted so we can clear it */ let highlightedEntityId: string | null = null; +/** + * Set or clear shader-based rim highlight for an instanced tree entity. + * Sets the per-instance `instanceHighlight` attribute to 1 or 0. + */ export function setHighlight(entityId: string, on: boolean): void { if (on && highlightedEntityId && highlightedEntityId !== entityId) { setHighlight(highlightedEntityId, false); } - const treeType = entityToTreeType.get(entityId); - if (!treeType) return; + const modelPath = entityToModel.get(entityId); + if (!modelPath) return; - const pool = pools.get(treeType); + const pool = pools.get(modelPath); if (!pool) return; const slot = pool.instances.get(entityId); if (!slot) return; - const lodPool = getLodPool(pool, slot); + const lodPool = slot.depleted + ? pool.depleted + : slot.currentLOD === 0 + ? pool.lod0 + : slot.currentLOD === 1 + ? pool.lod1 + : pool.lod2; if (!lodPool) return; - applyHighlightColor(lodPool, entityId, on); + const idx = lodPool.slots.get(entityId); + if (idx === undefined) return; + + const value = on ? 1.0 : 0.0; + lodPool.highlightData[idx] = value; + for (const im of lodPool.meshes) { + const attr = im.geometry.getAttribute("instanceHighlight"); + if (attr) { + (attr as THREE.InstancedBufferAttribute).needsUpdate = true; + } + } + highlightedEntityId = on ? entityId : null; } +/** + * Clear any active shader highlight (e.g. when hover leaves all entities). + */ export function clearHighlight(): void { if (highlightedEntityId) { setHighlight(highlightedEntityId, false); @@ -831,7 +652,7 @@ export function updateGLBTreeInstancer(): void { const camPos = camera.position; const lod1DistSq = resourceLOD.lod1DistanceSq; const lod2DistSq = resourceLOD.lod2DistanceSq; - const hysteresisSq = 0.81; + const hysteresisSq = 0.81; // 0.9^2 for (const pool of pools.values()) { for (const slot of pool.instances.values()) { @@ -860,13 +681,21 @@ export function updateGLBTreeInstancer(): void { if (targetLOD === slot.currentLOD) continue; - const oldPool = getLodPool(pool, slot); - const wasHl = oldPool ? isHighlighted(oldPool, slot.entityId) : false; + // Move instance between LOD pools + const oldPool = + slot.currentLOD === 0 + ? pool.lod0 + : slot.currentLOD === 1 + ? pool.lod1 + : pool.lod2; + const newPool = + targetLOD === 0 ? pool.lod0 : targetLOD === 1 ? pool.lod1 : pool.lod2; + + const wasHighlighted = + oldPool && oldPool.slots.has(slot.entityId) + ? oldPool.highlightData[oldPool.slots.get(slot.entityId)!] + : 0; if (oldPool) removeFromPool(oldPool, slot.entityId); - - slot.currentLOD = targetLOD; - - const newPool = getLodPool(pool, slot); if (newPool) { const mat = composeInstanceMatrix( slot.position, @@ -874,18 +703,30 @@ export function updateGLBTreeInstancer(): void { slot.scale, slot.yOffset, ); - addToPool(newPool, slot.entityId, mat, slot.variantIndex); - if (wasHl) applyHighlightColor(newPool, slot.entityId, true); + addToPool(newPool, slot.entityId, mat); + if (wasHighlighted > 0) { + const newIdx = newPool.slots.get(slot.entityId); + if (newIdx !== undefined) { + newPool.highlightData[newIdx] = wasHighlighted; + for (const im of newPool.meshes) { + const attr = im.geometry.getAttribute("instanceHighlight"); + if (attr) + (attr as THREE.InstancedBufferAttribute).needsUpdate = true; + } + } + } } + slot.currentLOD = targetLOD; } } - // Update dissolve uniforms + // Flush dirty pools + update dissolve uniforms const camY = camPos.y; const players = world.getPlayers(); const localPlayer = players && players.length > 0 ? players[0] : null; const playerPos = localPlayer?.node?.position ?? camPos; + // Get sun direction from Environment system const env = world.getSystem("environment") as { sunLight?: { intensity: number }; lightDirection?: THREE.Vector3; @@ -896,6 +737,13 @@ export function updateGLBTreeInstancer(): void { for (const lodPool of [pool.lod0, pool.lod1, pool.lod2, pool.depleted]) { if (!lodPool) continue; + if (lodPool.dirty) { + for (const im of lodPool.meshes) { + im.instanceMatrix.needsUpdate = true; + } + lodPool.dirty = false; + } + for (const mat of lodPool.materials) { mat.dissolveUniforms.cameraPos.value.set(camPos.x, camY, camPos.z); mat.dissolveUniforms.playerPos.value.set( @@ -904,6 +752,7 @@ export function updateGLBTreeInstancer(): void { playerPos.z, ); + // Sync tree-specific uniforms (sun direction, intensity, shade color) const treeMat = mat as TreeDissolveMaterial; if (treeMat.treeUniforms) { if (env?.lightDirection) { diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index d96cd7d47..44bb4219d 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -209,6 +209,8 @@ export type DissolveMaterialOptions = { enableOcclusionDissolve?: boolean; /** Enable per-instance rim highlight driven by an instanced attribute */ enableRimHighlight?: boolean; + /** Use BatchedMesh highlight (vBatchColor varying) instead of InstancedMesh attribute */ + batched?: boolean; }; /** @@ -982,7 +984,6 @@ export function createTreeDissolveMaterial( }); const material = baseDm as unknown as THREE.MeshStandardNodeMaterial; - material.alphaTest = 0.95; // Prevent the standard pipeline from multiplying vertex colors into diffuse. // We read vertex color manually in the shader for AO only (G channel). @@ -1033,12 +1034,16 @@ export function createTreeDissolveMaterial( ); // ---- Instance rim highlight (hover) ---- - // BatchedMesh encodes highlight in batch color: (1.15,1.15,1.15) = on, (1,1,1) = off - const batchColor = varyingProperty("vec3", "vBatchColor"); - const hlIntensity = step( - float(1.01), - max(batchColor.x, max(batchColor.y, batchColor.z)), - ); + let hlIntensity; + if (options.batched) { + const batchColor = varyingProperty("vec3", "vBatchColor"); + hlIntensity = step( + float(1.01), + max(batchColor.x, max(batchColor.y, batchColor.z)), + ); + } else { + hlIntensity = attribute("instanceHighlight", "float"); + } const NV = normalize(normalView); const Vv = normalize(sub(vec3(0, 0, 0), positionView.xyz)); const NdotV = clamp(dot(NV, Vv), float(0.0), float(1.0)); From b3eb0e493eef79917e3f55de4a44024117370960 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Wed, 4 Mar 2026 11:12:23 +0800 Subject: [PATCH 09/48] fix(trees): fallback for models without vertex colors in tree shader Models without COLOR_0 attribute (e.g. willow) were rendered at 35% brightness because the AO shader read zeroes from missing vertex data. Now checks source material's vertexColors flag and skips AO darkening when vertex colors are absent. Made-with: Cursor --- .../src/systems/shared/world/GPUMaterials.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index 44bb4219d..42575f248 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -985,8 +985,9 @@ export function createTreeDissolveMaterial( const material = baseDm as unknown as THREE.MeshStandardNodeMaterial; - // Prevent the standard pipeline from multiplying vertex colors into diffuse. + // Check if source has vertex colors before disabling them. // We read vertex color manually in the shader for AO only (G channel). + const hasVertexColors = !!(source as any).vertexColors; material.vertexColors = false; // Boost normal map effect when present for more realistic surface detail @@ -1012,11 +1013,15 @@ export function createTreeDissolveMaterial( material.outputNode = Fn(() => { const litColor = output; - // ---- Vertex-color AO ---- - const aoRaw = attribute("color", "vec3").y; - const aoFactor = pow(aoRaw, float(AO_POWER)); - const aoMul = mix(float(AO_DARK), float(1.0), aoFactor); - const aoResult = mul(litColor.rgb, aoMul); + // ---- Vertex-color AO (skip if model has no vertex colors) ---- + const aoResult = hasVertexColors + ? (() => { + const aoRaw = attribute("color", "vec3").y; + const aoFactor = pow(aoRaw, float(AO_POWER)); + const aoMul = mix(float(AO_DARK), float(1.0), aoFactor); + return mul(litColor.rgb, aoMul); + })() + : litColor.rgb; // ---- Sun shade ---- const shadeResult = applyTerrainSunShade( From 791a5614607aec0306fe9109709f7b79aaa547fb Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Thu, 5 Mar 2026 15:09:09 +0800 Subject: [PATCH 10/48] feat(terrain): quadtree LOD system with uniform resolution chunks Add a client-side quadtree LOD terrain rendering system that runs alongside the existing flat-grid for game logic. Key changes: - TerrainQuadTree: hierarchical quad-tree with configurable depth, split ratio, uniform resolution per chunk, and neighbor tracking - TerrainVisualManager: bridges quad-tree events to Three.js scene with async web worker dispatch and sync fallback - QuadChunkWorker: inline web worker for off-thread heightmap, normal, color, and biome computation with Transferable buffers - TerrainQuadChunkGenerator: main-thread assembly from worker output with road influence, flat zone overrides, and skirt generation - Centralized shadow config (TERRAIN_RECEIVE_SHADOW / TERRAIN_CAST_SHADOW) as single source of truth across flat-grid and quad-tree meshes Made-with: Cursor --- .../shared/world/TerrainQuadChunkGenerator.ts | 557 +++++++++++++++ .../systems/shared/world/TerrainQuadTree.ts | 632 ++++++++++++++++++ .../src/systems/shared/world/TerrainSystem.ts | 232 ++++++- .../shared/world/TerrainVisualManager.ts | 394 +++++++++++ packages/shared/src/types/world/terrain.ts | 25 + .../src/utils/workers/QuadChunkWorker.ts | 522 +++++++++++++++ packages/shared/src/utils/workers/index.ts | 11 + 7 files changed, 2362 insertions(+), 11 deletions(-) create mode 100644 packages/shared/src/systems/shared/world/TerrainQuadChunkGenerator.ts create mode 100644 packages/shared/src/systems/shared/world/TerrainQuadTree.ts create mode 100644 packages/shared/src/systems/shared/world/TerrainVisualManager.ts create mode 100644 packages/shared/src/utils/workers/QuadChunkWorker.ts diff --git a/packages/shared/src/systems/shared/world/TerrainQuadChunkGenerator.ts b/packages/shared/src/systems/shared/world/TerrainQuadChunkGenerator.ts new file mode 100644 index 000000000..0a4be32b6 --- /dev/null +++ b/packages/shared/src/systems/shared/world/TerrainQuadChunkGenerator.ts @@ -0,0 +1,557 @@ +/** + * TerrainQuadChunkGenerator — Assembles terrain geometry for quad-tree chunks + * from pre-computed worker output. + * + * The heavy lifting (noise, heights, normals, biome blending) is done by + * QuadChunkWorker on a background thread. This module handles: + * - Road influence sampling (needs live road network) + * - Flat-zone height overrides (needs live building data) + * - Skirt geometry generation + * - Index buffer generation + * - THREE.BufferGeometry assembly + * + * CLIENT-ONLY: Server terrain uses the flat tile grid. + */ + +import THREE from "../../../extras/three/three"; +import type { QuadChunkWorkerOutput } from "../../../utils/workers/QuadChunkWorker"; + +/** + * Main-thread callbacks for game-state queries that can't run in a worker. + */ +export interface ChunkTerrainProvider { + calculateRoadInfluenceAtVertex( + worldX: number, + worldZ: number, + tileX: number, + tileZ: number, + ): number; + getFlatZoneAt( + worldX: number, + worldZ: number, + ): { height: number; blendRadius: number } | null; + getHeightAtComputed(worldX: number, worldZ: number): number; + readonly TILE_SIZE: number; +} + +export interface ChunkGeometryResult { + geometry: THREE.BufferGeometry; + heightData: Float32Array; +} + +/** + * Assemble a THREE.BufferGeometry from worker-computed height/normal/color/biome + * data, adding road influence and skirts on the main thread. + */ +export function assembleQuadChunkGeometry( + workerData: QuadChunkWorkerOutput, + provider: ChunkTerrainProvider, + skirtDrop: number, +): ChunkGeometryResult { + const { + centerX, + centerZ, + size, + resolution, + heightData, + normalData, + colorData, + biomeData, + } = workerData; + const segments = resolution; + const halfSize = size * 0.5; + const gridStep = size / (segments - 1); + + const skirtCount = segments * 4; + const totalVertices = segments * segments + skirtCount; + const positions = new Float32Array(totalVertices * 3); + const normals = new Float32Array(totalVertices * 3); + const colors = new Float32Array(totalVertices * 3); + const biomeIds = new Float32Array(totalVertices); + const roadInfluences = new Float32Array(totalVertices); + + let flatZoneModified = false; + + for (let iz = 0; iz < segments; iz++) { + const localZ = -halfSize + iz * gridStep; + const worldZ = centerZ + localZ; + + for (let ix = 0; ix < segments; ix++) { + const localX = -halfSize + ix * gridStep; + const worldX = centerX + localX; + const idx = iz * segments + ix; + const i3 = idx * 3; + + let height = heightData[idx]; + + const flatZone = provider.getFlatZoneAt(worldX, worldZ); + if (flatZone) { + const distFactor = 1.0; + height = height + (flatZone.height - height) * distFactor; + flatZoneModified = true; + } + + positions[i3] = localX; + positions[i3 + 1] = height; + positions[i3 + 2] = localZ; + + normals[i3] = normalData[i3]; + normals[i3 + 1] = normalData[i3 + 1]; + normals[i3 + 2] = normalData[i3 + 2]; + + colors[i3] = colorData[i3]; + colors[i3 + 1] = colorData[i3 + 1]; + colors[i3 + 2] = colorData[i3 + 2]; + + biomeIds[idx] = biomeData[idx]; + + const roadTileX = Math.floor(worldX / provider.TILE_SIZE); + const roadTileZ = Math.floor(worldZ / provider.TILE_SIZE); + roadInfluences[idx] = provider.calculateRoadInfluenceAtVertex( + worldX, + worldZ, + roadTileX, + roadTileZ, + ); + } + } + + if (flatZoneModified) { + recomputeNormals(positions, normals, segments, gridStep); + } + + // ========================================================================= + // Skirt geometry + // ========================================================================= + let skirtIdx = segments * segments; + + const copyEdgeVertex = (mainIdx: number) => { + const si3 = skirtIdx * 3; + const mi3 = mainIdx * 3; + positions[si3] = positions[mi3]; + positions[si3 + 1] = positions[mi3 + 1] - skirtDrop; + positions[si3 + 2] = positions[mi3 + 2]; + normals[si3] = normals[mi3]; + normals[si3 + 1] = normals[mi3 + 1]; + normals[si3 + 2] = normals[mi3 + 2]; + colors[si3] = colors[mi3]; + colors[si3 + 1] = colors[mi3 + 1]; + colors[si3 + 2] = colors[mi3 + 2]; + biomeIds[skirtIdx] = biomeIds[mainIdx]; + roadInfluences[skirtIdx] = roadInfluences[mainIdx]; + skirtIdx++; + }; + + for (let ix = 0; ix < segments; ix++) copyEdgeVertex(ix); + for (let ix = 0; ix < segments; ix++) + copyEdgeVertex((segments - 1) * segments + ix); + for (let iz = 0; iz < segments; iz++) copyEdgeVertex(iz * segments); + for (let iz = 0; iz < segments; iz++) + copyEdgeVertex(iz * segments + (segments - 1)); + + // ========================================================================= + // Indices + // ========================================================================= + const subs = segments - 1; + const mainFaceCount = subs * subs; + const skirtFaceCount = subs * 4; + const totalIndices = (mainFaceCount + skirtFaceCount) * 6; + const indices = new Uint32Array(totalIndices); + let ii = 0; + + for (let iz = 0; iz < subs; iz++) { + for (let ix = 0; ix < subs; ix++) { + const a = iz * segments + ix; + const b = a + 1; + const c = a + segments; + const d = c + 1; + indices[ii++] = a; + indices[ii++] = c; + indices[ii++] = b; + indices[ii++] = b; + indices[ii++] = c; + indices[ii++] = d; + } + } + + const skirtBase = segments * segments; + const northSkirtBase = skirtBase; + const southSkirtBase = skirtBase + segments; + const westSkirtBase = skirtBase + segments * 2; + const eastSkirtBase = skirtBase + segments * 3; + + for (let ix = 0; ix < subs; ix++) { + const mainA = ix; + const mainB = ix + 1; + const skirtA = northSkirtBase + ix; + const skirtB = northSkirtBase + ix + 1; + indices[ii++] = skirtA; + indices[ii++] = mainA; + indices[ii++] = skirtB; + indices[ii++] = skirtB; + indices[ii++] = mainA; + indices[ii++] = mainB; + } + + for (let ix = 0; ix < subs; ix++) { + const mainA = (segments - 1) * segments + ix; + const mainB = mainA + 1; + const skirtA = southSkirtBase + ix; + const skirtB = southSkirtBase + ix + 1; + indices[ii++] = mainA; + indices[ii++] = skirtA; + indices[ii++] = mainB; + indices[ii++] = mainB; + indices[ii++] = skirtA; + indices[ii++] = skirtB; + } + + for (let iz = 0; iz < subs; iz++) { + const mainA = iz * segments; + const mainB = (iz + 1) * segments; + const skirtA = westSkirtBase + iz; + const skirtB = westSkirtBase + iz + 1; + indices[ii++] = mainA; + indices[ii++] = skirtA; + indices[ii++] = mainB; + indices[ii++] = mainB; + indices[ii++] = skirtA; + indices[ii++] = skirtB; + } + + for (let iz = 0; iz < subs; iz++) { + const mainA = iz * segments + (segments - 1); + const mainB = (iz + 1) * segments + (segments - 1); + const skirtA = eastSkirtBase + iz; + const skirtB = eastSkirtBase + iz + 1; + indices[ii++] = skirtA; + indices[ii++] = mainA; + indices[ii++] = skirtB; + indices[ii++] = skirtB; + indices[ii++] = mainA; + indices[ii++] = mainB; + } + + // ========================================================================= + // Build BufferGeometry + // ========================================================================= + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute("normal", new THREE.BufferAttribute(normals, 3)); + geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); + geometry.setAttribute("biomeId", new THREE.BufferAttribute(biomeIds, 1)); + geometry.setAttribute( + "roadInfluence", + new THREE.BufferAttribute(roadInfluences, 1), + ); + geometry.setIndex(new THREE.BufferAttribute(indices, 1)); + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + + return { geometry, heightData }; +} + +/** + * Recompute normals for the main grid after flat-zone height overrides. + * Uses simple centered finite differences from the position buffer. + */ +function recomputeNormals( + positions: Float32Array, + normals: Float32Array, + segments: number, + gridStep: number, +): void { + const invTwoStep = 1 / (2 * gridStep); + + for (let iz = 0; iz < segments; iz++) { + for (let ix = 0; ix < segments; ix++) { + const idx = iz * segments + ix; + const i3 = idx * 3; + + const izN = Math.max(0, iz - 1); + const izS = Math.min(segments - 1, iz + 1); + const ixW = Math.max(0, ix - 1); + const ixE = Math.min(segments - 1, ix + 1); + + const hL = positions[(iz * segments + ixW) * 3 + 1]; + const hR = positions[(iz * segments + ixE) * 3 + 1]; + const hD = positions[(izN * segments + ix) * 3 + 1]; + const hU = positions[(izS * segments + ix) * 3 + 1]; + + const dhdx = (hR - hL) * invTwoStep; + const dhdz = (hU - hD) * invTwoStep; + const nx = -dhdx; + const ny = 1; + const nz = -dhdz; + const len = Math.sqrt(nx * nx + ny * ny + nz * nz); + + normals[i3] = nx / len; + normals[i3 + 1] = ny / len; + normals[i3 + 2] = nz / len; + } + } +} + +/** + * Synchronous fallback: generates geometry without a worker. + * Used when workers are unavailable (server-side or fallback). + */ +export function generateQuadChunkGeometrySync( + centerX: number, + centerZ: number, + size: number, + resolution: number, + provider: ChunkTerrainProvider & { + computeBiomeWeightsAtPosition( + worldX: number, + worldZ: number, + ): { biomeWeightMap: Map; totalWeight: number }; + getBiomeId(biomeName: string): number; + getBiomeColor(biomeName: string): { r: number; g: number; b: number }; + readonly WATER_LEVEL_NORMALIZED: number; + readonly SHORELINE_THRESHOLD: number; + readonly SHORELINE_STRENGTH: number; + readonly MAX_HEIGHT: number; + }, + skirtDrop: number, +): ChunkGeometryResult { + const segments = resolution; + const halfSize = size * 0.5; + const gridStep = size / (segments - 1); + + const gRes = segments + 2; + const overflowGrid = new Float32Array(gRes * gRes); + const heightData = new Float32Array(segments * segments); + + for (let gz = 0; gz < gRes; gz++) { + const localZ = -halfSize + (gz - 1) * gridStep; + const worldZ = centerZ + localZ; + for (let gx = 0; gx < gRes; gx++) { + const localX = -halfSize + (gx - 1) * gridStep; + const worldX = centerX + localX; + const h = provider.getHeightAtComputed(worldX, worldZ); + overflowGrid[gz * gRes + gx] = h; + if (gx >= 1 && gx <= segments && gz >= 1 && gz <= segments) { + heightData[(gz - 1) * segments + (gx - 1)] = h; + } + } + } + + const skirtCount = segments * 4; + const totalVertices = segments * segments + skirtCount; + const positions = new Float32Array(totalVertices * 3); + const normalArr = new Float32Array(totalVertices * 3); + const colors = new Float32Array(totalVertices * 3); + const biomeIds = new Float32Array(totalVertices); + const roadInfluences = new Float32Array(totalVertices); + + const invTwoStep = 1 / (2 * gridStep); + const normalBuf = new Float32Array(segments * segments * 3); + + for (let iz = 0; iz < segments; iz++) { + const gz = iz + 1; + for (let ix = 0; ix < segments; ix++) { + const gx = ix + 1; + const hL = overflowGrid[gz * gRes + (gx - 1)]; + const hR = overflowGrid[gz * gRes + (gx + 1)]; + const hD = overflowGrid[(gz - 1) * gRes + gx]; + const hU = overflowGrid[(gz + 1) * gRes + gx]; + const dhdx = (hR - hL) * invTwoStep; + const dhdz = (hU - hD) * invTwoStep; + const nx = -dhdx; + const ny = 1; + const nz = -dhdz; + const len = Math.sqrt(nx * nx + ny * ny + nz * nz); + const i3 = (iz * segments + ix) * 3; + normalBuf[i3] = nx / len; + normalBuf[i3 + 1] = ny / len; + normalBuf[i3 + 2] = nz / len; + } + } + + for (let iz = 0; iz < segments; iz++) { + const localZ = -halfSize + iz * gridStep; + const worldZ = centerZ + localZ; + for (let ix = 0; ix < segments; ix++) { + const localX = -halfSize + ix * gridStep; + const worldX = centerX + localX; + const idx = iz * segments + ix; + const i3 = idx * 3; + let height = heightData[idx]; + + const flatZone = provider.getFlatZoneAt(worldX, worldZ); + if (flatZone) { + height = flatZone.height; + } + + positions[i3] = localX; + positions[i3 + 1] = height; + positions[i3 + 2] = localZ; + normalArr[i3] = normalBuf[i3]; + normalArr[i3 + 1] = normalBuf[i3 + 1]; + normalArr[i3 + 2] = normalBuf[i3 + 2]; + + const { biomeWeightMap, totalWeight } = + provider.computeBiomeWeightsAtPosition(worldX, worldZ); + const normalizedHeight = height / provider.MAX_HEIGHT; + + let dominantBiome = "plains"; + let dominantWeight = -Infinity; + let cr = 0, + cg = 0, + cb = 0; + + if (totalWeight > 0) { + const invTotal = 1 / totalWeight; + for (const [type, rawWeight] of biomeWeightMap) { + const weight = rawWeight * invTotal; + if (weight > dominantWeight) { + dominantWeight = weight; + dominantBiome = type; + } + const bc = provider.getBiomeColor(type); + cr += bc.r * weight; + cg += bc.g * weight; + cb += bc.b * weight; + } + } else { + const bc = provider.getBiomeColor("plains"); + cr = bc.r; + cg = bc.g; + cb = bc.b; + } + + biomeIds[idx] = provider.getBiomeId(dominantBiome); + + const waterLevel = provider.WATER_LEVEL_NORMALIZED; + const shoreThreshold = provider.SHORELINE_THRESHOLD; + if (normalizedHeight > waterLevel && normalizedHeight < shoreThreshold) { + const shoreFactor = + (1.0 - + (normalizedHeight - waterLevel) / (shoreThreshold - waterLevel)) * + provider.SHORELINE_STRENGTH; + cr += (0.545 - cr) * shoreFactor; + cg += (0.451 - cg) * shoreFactor; + cb += (0.333 - cb) * shoreFactor; + } + + colors[i3] = cr; + colors[i3 + 1] = cg; + colors[i3 + 2] = cb; + + const roadTileX = Math.floor(worldX / provider.TILE_SIZE); + const roadTileZ = Math.floor(worldZ / provider.TILE_SIZE); + roadInfluences[idx] = provider.calculateRoadInfluenceAtVertex( + worldX, + worldZ, + roadTileX, + roadTileZ, + ); + } + } + + // Skirt and index generation (reuse the same logic) + let skirtIdx = segments * segments; + const copyEdgeVertex = (mainIdx: number) => { + const si3 = skirtIdx * 3; + const mi3 = mainIdx * 3; + positions[si3] = positions[mi3]; + positions[si3 + 1] = positions[mi3 + 1] - skirtDrop; + positions[si3 + 2] = positions[mi3 + 2]; + normalArr[si3] = normalArr[mi3]; + normalArr[si3 + 1] = normalArr[mi3 + 1]; + normalArr[si3 + 2] = normalArr[mi3 + 2]; + colors[si3] = colors[mi3]; + colors[si3 + 1] = colors[mi3 + 1]; + colors[si3 + 2] = colors[mi3 + 2]; + biomeIds[skirtIdx] = biomeIds[mainIdx]; + roadInfluences[skirtIdx] = roadInfluences[mainIdx]; + skirtIdx++; + }; + + for (let ix = 0; ix < segments; ix++) copyEdgeVertex(ix); + for (let ix = 0; ix < segments; ix++) + copyEdgeVertex((segments - 1) * segments + ix); + for (let iz = 0; iz < segments; iz++) copyEdgeVertex(iz * segments); + for (let iz = 0; iz < segments; iz++) + copyEdgeVertex(iz * segments + (segments - 1)); + + const subs = segments - 1; + const totalIndices = (subs * subs + subs * 4) * 6; + const indices = new Uint32Array(totalIndices); + let ii = 0; + + for (let iz = 0; iz < subs; iz++) { + for (let ix = 0; ix < subs; ix++) { + const a = iz * segments + ix; + const b = a + 1; + const c = a + segments; + const d = c + 1; + indices[ii++] = a; + indices[ii++] = c; + indices[ii++] = b; + indices[ii++] = b; + indices[ii++] = c; + indices[ii++] = d; + } + } + + const skirtBase = segments * segments; + const northSkirtBase = skirtBase; + const southSkirtBase = skirtBase + segments; + const westSkirtBase = skirtBase + segments * 2; + const eastSkirtBase = skirtBase + segments * 3; + + for (let ix = 0; ix < subs; ix++) { + indices[ii++] = northSkirtBase + ix; + indices[ii++] = ix; + indices[ii++] = northSkirtBase + ix + 1; + indices[ii++] = northSkirtBase + ix + 1; + indices[ii++] = ix; + indices[ii++] = ix + 1; + } + for (let ix = 0; ix < subs; ix++) { + const mainA = (segments - 1) * segments + ix; + indices[ii++] = mainA; + indices[ii++] = southSkirtBase + ix; + indices[ii++] = mainA + 1; + indices[ii++] = mainA + 1; + indices[ii++] = southSkirtBase + ix; + indices[ii++] = southSkirtBase + ix + 1; + } + for (let iz = 0; iz < subs; iz++) { + const mainA = iz * segments; + const mainB = (iz + 1) * segments; + indices[ii++] = mainA; + indices[ii++] = westSkirtBase + iz; + indices[ii++] = mainB; + indices[ii++] = mainB; + indices[ii++] = westSkirtBase + iz; + indices[ii++] = westSkirtBase + iz + 1; + } + for (let iz = 0; iz < subs; iz++) { + const mainA = iz * segments + (segments - 1); + const mainB = (iz + 1) * segments + (segments - 1); + indices[ii++] = eastSkirtBase + iz; + indices[ii++] = mainA; + indices[ii++] = eastSkirtBase + iz + 1; + indices[ii++] = eastSkirtBase + iz + 1; + indices[ii++] = mainA; + indices[ii++] = mainB; + } + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute("normal", new THREE.BufferAttribute(normalArr, 3)); + geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); + geometry.setAttribute("biomeId", new THREE.BufferAttribute(biomeIds, 1)); + geometry.setAttribute( + "roadInfluence", + new THREE.BufferAttribute(roadInfluences, 1), + ); + geometry.setIndex(new THREE.BufferAttribute(indices, 1)); + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + + return { geometry, heightData }; +} diff --git a/packages/shared/src/systems/shared/world/TerrainQuadTree.ts b/packages/shared/src/systems/shared/world/TerrainQuadTree.ts new file mode 100644 index 000000000..2ac75325f --- /dev/null +++ b/packages/shared/src/systems/shared/world/TerrainQuadTree.ts @@ -0,0 +1,632 @@ +/** + * TerrainQuadTree — Quad-tree LOD system for terrain visual chunks. + * + * Manages a hierarchical quad-tree of terrain chunks that split/unsplit + * based on distance to the player. Near chunks are small and high-resolution, + * far chunks are large and low-resolution. + * + * Inspired by infinite-world-master's Chunk/Chunks system, adapted for + * Hyperscape's terrain pipeline. + * + * This is a CLIENT-ONLY visual system. Server and gameplay logic still use + * the flat 100m tile grid (TerrainTile). getHeightAt() is unaffected. + */ + +export interface QuadTreeConfig { + /** Smallest chunk size in meters (leaf nodes). Should match TILE_SIZE for grid alignment. */ + minSize: number; + /** Maximum depth of quad-tree subdivision */ + maxDepth: number; + /** Split when distance < size * splitRatio */ + splitRatio: number; + /** Uniform vertex resolution (segments per axis) for ALL depth levels */ + resolution: number; + /** Skirt drop distance in meters to hide LOD seams */ + skirtDrop: number; +} + +export const DEFAULT_QUAD_TREE_CONFIG: QuadTreeConfig = { + minSize: 100, + maxDepth: 4, + splitRatio: 1.5, + resolution: 32, + skirtDrop: 15, +}; + +export type QuadPosition = "ne" | "nw" | "sw" | "se"; +type CardinalDirection = "n" | "e" | "s" | "w"; + +/** + * A single node in the terrain quad-tree. + * Each node represents a square region of terrain that may be subdivided. + */ +export class TerrainQuadNode { + readonly id: number; + readonly tree: TerrainQuadTree; + readonly parent: TerrainQuadNode | null; + readonly quadPosition: QuadPosition | null; + readonly size: number; + readonly halfSize: number; + readonly quarterSize: number; + readonly centerX: number; + readonly centerZ: number; + readonly depth: number; + + /** Normalized LOD precision: 0 at root → 1 at max depth */ + readonly precision: number; + /** True when at maximum subdivision depth */ + readonly isMaxDepth: boolean; + + children = new Map(); + neighbours = new Map(); + splitted = false; + splitting = false; + unsplitting = false; + ready = false; + needsCheck = true; + + /** True when this is a leaf node that should have terrain geometry */ + isFinal = false; + terrainNeedsUpdate = true; + + /** Assigned by TerrainVisualManager when geometry is created */ + visualChunkKey: string | null = null; + + readonly boundingBox: { + xMin: number; + xMax: number; + zMin: number; + zMax: number; + }; + + constructor( + tree: TerrainQuadTree, + parent: TerrainQuadNode | null, + quadPosition: QuadPosition | null, + size: number, + centerX: number, + centerZ: number, + depth: number, + id: number, + ) { + this.id = id; + this.tree = tree; + this.parent = parent; + this.quadPosition = quadPosition; + this.size = size; + this.halfSize = size * 0.5; + this.quarterSize = this.halfSize * 0.5; + this.centerX = centerX; + this.centerZ = centerZ; + this.depth = depth; + this.precision = depth / tree.config.maxDepth; + this.isMaxDepth = depth === tree.config.maxDepth; + + this.boundingBox = { + xMin: centerX - this.halfSize, + xMax: centerX + this.halfSize, + zMin: centerZ - this.halfSize, + zMax: centerZ + this.halfSize, + }; + + this.check(); + + if (!this.splitted) { + this.createFinal(); + } + + this.testReady(); + } + + /** Resolution (segments per axis) — uniform for all depth levels */ + get resolution(): number { + return this.tree.config.resolution; + } + + check(): void { + if (!this.needsCheck) return; + this.needsCheck = false; + + const underSplit = this.tree.isUnderSplitDistance( + this.size, + this.centerX, + this.centerZ, + ); + + if (underSplit) { + if (!this.isMaxDepth && !this.splitted) { + this.split(); + } + } else { + if (this.splitted) { + this.unsplit(); + } + } + + for (const child of this.children.values()) { + child.check(); + } + } + + update(): void { + if (this.isFinal && this.terrainNeedsUpdate && this.neighbours.size === 4) { + this.tree.requestTerrainGeneration(this); + this.terrainNeedsUpdate = false; + } + + for (const child of this.children.values()) { + child.update(); + } + } + + setNeighbours( + n: TerrainQuadNode | null, + e: TerrainQuadNode | null, + s: TerrainQuadNode | null, + w: TerrainQuadNode | null, + ): void { + this.neighbours.set("n", n); + this.neighbours.set("e", e); + this.neighbours.set("s", s); + this.neighbours.set("w", w); + } + + testReady(): void { + if (this.splitted) { + let readyCount = 0; + for (const child of this.children.values()) { + if (child.ready) readyCount++; + } + if (readyCount === 4) { + this.setReady(); + } + } else { + if (this.visualChunkKey !== null) { + this.setReady(); + } + } + } + + setReady(): void { + if (this.ready) return; + this.ready = true; + + if (this.splitting) { + this.splitting = false; + this.destroyFinal(); + } + + if (this.unsplitting) { + this.unsplitting = false; + for (const child of this.children.values()) { + child.destroy(); + } + this.children.clear(); + } + + if (this.parent) { + this.parent.testReady(); + } + } + + unsetReady(): void { + if (!this.ready) return; + this.ready = false; + } + + split(): void { + this.splitting = true; + this.splitted = true; + this.unsetReady(); + + const qSize = this.halfSize; + const q = this.quarterSize; + const d = this.depth + 1; + + const ne = this.tree.createNode( + this, + "ne", + qSize, + this.centerX + q, + this.centerZ - q, + d, + ); + this.children.set("ne", ne); + + const nw = this.tree.createNode( + this, + "nw", + qSize, + this.centerX - q, + this.centerZ - q, + d, + ); + this.children.set("nw", nw); + + const sw = this.tree.createNode( + this, + "sw", + qSize, + this.centerX - q, + this.centerZ + q, + d, + ); + this.children.set("sw", sw); + + const se = this.tree.createNode( + this, + "se", + qSize, + this.centerX + q, + this.centerZ + q, + d, + ); + this.children.set("se", se); + } + + unsplit(): void { + if (!this.splitted) return; + this.splitted = false; + this.unsplitting = true; + this.unsetReady(); + this.createFinal(); + } + + createFinal(): void { + if (this.isFinal) return; + this.isFinal = true; + this.terrainNeedsUpdate = true; + } + + destroyFinal(): void { + if (!this.isFinal) return; + this.isFinal = false; + this.terrainNeedsUpdate = false; + this.tree.requestTerrainDestruction(this); + } + + destroy(): void { + for (const child of this.children.values()) { + child.destroy(); + } + this.children.clear(); + this.splitted = false; + this.splitting = false; + this.unsplitting = false; + + this.destroyFinal(); + this.tree.removeNode(this); + } + + isInside(x: number, z: number): boolean { + return ( + x > this.boundingBox.xMin && + x < this.boundingBox.xMax && + z > this.boundingBox.zMin && + z < this.boundingBox.zMax + ); + } + + getDeepestNodeAt(x: number, z: number): TerrainQuadNode | null { + if (!this.splitted) return this; + + for (const child of this.children.values()) { + if (child.isInside(x, z)) { + return child.getDeepestNodeAt(x, z); + } + } + + return null; + } +} + +/** + * Callback interface for the visual manager to receive quad-tree events. + */ +export interface QuadTreeListener { + onNodeNeedsGeometry(node: TerrainQuadNode): void; + onNodeDestroyGeometry(node: TerrainQuadNode): void; +} + +/** + * Manages the top-level quad-tree: root chunk grid, split/unsplit, + * neighbor resolution, and player tracking. + */ +export class TerrainQuadTree { + readonly config: QuadTreeConfig; + readonly maxSize: number; + + private mainChunks = new Map(); + private allNodes = new Map(); + private playerX = 0; + private playerZ = 0; + private lastChunkKey: string | null = null; + private listener: QuadTreeListener | null = null; + private nextNodeId = 0; + /** Set true whenever the tree structure changes (split/unsplit). Cleared after neighbour resolution. */ + private structureDirty = false; + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_QUAD_TREE_CONFIG, ...config }; + this.maxSize = this.config.minSize * Math.pow(2, this.config.maxDepth); + } + + setListener(listener: QuadTreeListener): void { + this.listener = listener; + } + + createNode( + parent: TerrainQuadNode | null, + quadPosition: QuadPosition | null, + size: number, + centerX: number, + centerZ: number, + depth: number, + ): TerrainQuadNode { + const id = this.nextNodeId++; + const node = new TerrainQuadNode( + this, + parent, + quadPosition, + size, + centerX, + centerZ, + depth, + id, + ); + this.allNodes.set(node.id, node); + this.structureDirty = true; + return node; + } + + removeNode(node: TerrainQuadNode): void { + this.allNodes.delete(node.id); + this.structureDirty = true; + } + + requestTerrainGeneration(node: TerrainQuadNode): void { + this.listener?.onNodeNeedsGeometry(node); + } + + requestTerrainDestruction(node: TerrainQuadNode): void { + this.listener?.onNodeDestroyGeometry(node); + if (node.visualChunkKey !== null) { + node.visualChunkKey = null; + } + // DO NOT removeNode here — the node may still be alive as a structural + // parent after splitting. Removing it from allNodes breaks neighbor + // resolution for all descendants. Nodes are only removed from allNodes + // when truly destroyed via destroy() → explicit removal. + } + + isUnderSplitDistance(size: number, chunkX: number, chunkZ: number): boolean { + const dx = this.playerX - chunkX; + const dz = this.playerZ - chunkZ; + const distance = Math.sqrt(dx * dx + dz * dz); + return distance < size * this.config.splitRatio; + } + + /** + * Main update — call every frame or when player moves significantly. + * Returns true if the tree structure changed. + */ + update(playerX: number, playerZ: number): boolean { + this.playerX = playerX; + this.playerZ = playerZ; + + const chunkKey = `${Math.round((playerX / this.config.minSize) * 2 + 0.5)}_${Math.round((playerZ / this.config.minSize) * 2 + 0.5)}`; + if (chunkKey === this.lastChunkKey) { + if (this.structureDirty) { + this.updateAllNeighbours(); + this.structureDirty = false; + } + for (const chunk of this.mainChunks.values()) { + chunk.update(); + } + return false; + } + this.lastChunkKey = chunkKey; + + // Mark all nodes for re-check + for (const node of this.allNodes.values()) { + node.needsCheck = true; + } + + // Get main chunk coordinates (3x3 grid around player) + const mainCoords = this.getMainChunkCoordinates(); + + // Destroy main chunks no longer in proximity + for (const [key, chunk] of this.mainChunks) { + if (!mainCoords.find((c) => c.key === key)) { + chunk.destroy(); + this.mainChunks.delete(key); + } + } + + // Create new main chunks + for (const coord of mainCoords) { + if (!this.mainChunks.has(coord.key)) { + const chunk = this.createNode( + null, + null, + this.maxSize, + coord.worldX, + coord.worldZ, + 0, + ); + this.mainChunks.set(coord.key, chunk); + } + } + + // Check all main chunks (propagates to children) + for (const chunk of this.mainChunks.values()) { + chunk.check(); + } + + // Update neighbor relationships + this.updateAllNeighbours(); + this.structureDirty = false; + + // Run update on all main chunks (triggers terrain generation requests) + for (const chunk of this.mainChunks.values()) { + chunk.update(); + } + + return true; + } + + /** Destroy all chunks and reset state */ + dispose(): void { + for (const chunk of this.mainChunks.values()) { + chunk.destroy(); + } + this.mainChunks.clear(); + this.allNodes.clear(); + this.lastChunkKey = null; + } + + /** Get all leaf nodes that currently have (or need) visual geometry */ + getFinalNodes(): TerrainQuadNode[] { + const result: TerrainQuadNode[] = []; + for (const node of this.allNodes.values()) { + if (node.isFinal) result.push(node); + } + return result; + } + + /** Get total node count (for debug stats) */ + get totalNodeCount(): number { + return this.allNodes.size; + } + + /** Get active visual chunk count (for debug stats) */ + get visualChunkCount(): number { + let count = 0; + for (const node of this.allNodes.values()) { + if (node.visualChunkKey !== null) count++; + } + return count; + } + + // ========================================================================= + // Private methods + // ========================================================================= + + private getMainChunkCoordinates(): Array<{ + key: string; + gridX: number; + gridZ: number; + worldX: number; + worldZ: number; + }> { + const gx = Math.round(this.playerX / this.maxSize); + const gz = Math.round(this.playerZ / this.maxSize); + + const coords: Array<{ + key: string; + gridX: number; + gridZ: number; + worldX: number; + worldZ: number; + }> = []; + + for (let dx = -1; dx <= 1; dx++) { + for (let dz = -1; dz <= 1; dz++) { + const cx = gx + dx; + const cz = gz + dz; + coords.push({ + key: `${cx},${cz}`, + gridX: cx, + gridZ: cz, + worldX: cx * this.maxSize, + worldZ: cz * this.maxSize, + }); + } + } + + return coords; + } + + private updateAllNeighbours(): void { + // Main chunk neighbours (from the 3x3 grid) + for (const [key, chunk] of this.mainChunks) { + const [xStr, zStr] = key.split(","); + const x = parseFloat(xStr); + const z = parseFloat(zStr); + + chunk.setNeighbours( + this.mainChunks.get(`${x},${z - 1}`) ?? null, + this.mainChunks.get(`${x + 1},${z}`) ?? null, + this.mainChunks.get(`${x},${z + 1}`) ?? null, + this.mainChunks.get(`${x - 1},${z}`) ?? null, + ); + } + + // Child chunk neighbours (depth-sorted for correct propagation) + const childNodes = [...this.allNodes.values()] + .filter((n) => n.depth > 0) + .sort((a, b) => a.depth - b.depth); + + for (const node of childNodes) { + if (!node.parent) continue; + + let n: TerrainQuadNode | null = null; + let e: TerrainQuadNode | null = null; + let s: TerrainQuadNode | null = null; + let w: TerrainQuadNode | null = null; + + const qp = node.quadPosition!; + const parent = node.parent; + + // North + if (qp === "sw") n = parent.children.get("nw") ?? null; + else if (qp === "se") n = parent.children.get("ne") ?? null; + else { + const pn = parent.neighbours.get("n"); + if (pn) { + if (pn.splitted) + n = pn.children.get(qp === "nw" ? "sw" : "se") ?? null; + else n = pn; + } + } + + // East + if (qp === "nw") e = parent.children.get("ne") ?? null; + else if (qp === "sw") e = parent.children.get("se") ?? null; + else { + const pe = parent.neighbours.get("e"); + if (pe) { + if (pe.splitted) + e = pe.children.get(qp === "ne" ? "nw" : "sw") ?? null; + else e = pe; + } + } + + // South + if (qp === "nw") s = parent.children.get("sw") ?? null; + else if (qp === "ne") s = parent.children.get("se") ?? null; + else { + const ps = parent.neighbours.get("s"); + if (ps) { + if (ps.splitted) + s = ps.children.get(qp === "sw" ? "nw" : "ne") ?? null; + else s = ps; + } + } + + // West + if (qp === "ne") w = parent.children.get("nw") ?? null; + else if (qp === "se") w = parent.children.get("sw") ?? null; + else { + const pw = parent.neighbours.get("w"); + if (pw) { + if (pw.splitted) + w = pw.children.get(qp === "nw" ? "ne" : "se") ?? null; + else w = pw; + } + } + + node.setNeighbours(n, e, s, w); + } + } +} diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts index a629cd894..c6013e3df 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -119,6 +119,12 @@ import { isTerrainComputeAvailable, type GPURoadSegment, } from "../../../utils/compute"; +import { + TerrainVisualManager, + type VisualManagerTerrainProvider, +} from "./TerrainVisualManager"; +import type { QuadChunkWorkerConfig } from "../../../utils/workers/QuadChunkWorker"; +import { terminateQuadChunkWorkerPool } from "../../../utils/workers/QuadChunkWorker"; // Road influence blending - used for shader's roadInfluence attribute const ROAD_BLEND_WIDTH = 0.5; // Extra blend distance beyond road width (meters) @@ -250,6 +256,9 @@ export class TerrainSystem extends System { private terrainComputeContext: TerrainComputeContext | null = null; private gpuComputeAvailable = false; + // Quad-tree LOD visual manager (client-only, when CONFIG.USE_QUADTREE_LOD is true) + private quadTreeVisualManager: TerrainVisualManager | null = null; + // Unified terrain generator from @hyperscape/procgen // Provides deterministic height/biome calculation independent of rendering private terrainGenerator!: TerrainGenerator; @@ -956,8 +965,8 @@ export class TerrainSystem extends System { tileZ * this.CONFIG.TILE_SIZE, ); mesh.name = `Terrain_${key}`; - mesh.receiveShadow = false; - mesh.castShadow = false; + mesh.receiveShadow = this.CONFIG.TERRAIN_RECEIVE_SHADOW; + mesh.castShadow = this.CONFIG.TERRAIN_CAST_SHADOW; mesh.frustumCulled = true; mesh.userData = { type: "terrain", @@ -1067,8 +1076,8 @@ export class TerrainSystem extends System { tile.collider = physics.addActor(actor, handle); } - // Add to scene - if (this.terrainContainer) { + // Add to scene (skip when quad-tree LOD handles visual rendering) + if (this.terrainContainer && !this.CONFIG.USE_QUADTREE_LOD) { this.terrainContainer.add(mesh); } @@ -1427,6 +1436,22 @@ export class TerrainSystem extends System { // Web Worker settings for parallel terrain generation USE_WORKERS: true, // Enable web workers for heightmap generation (client-side only) WORKER_POOL_SIZE: 0, // Number of workers (0 = auto, based on CPU cores) + + // Quad-tree LOD system (client-only visual terrain) + // When enabled, terrain rendering uses a quad-tree with uniform resolution + // chunks instead of the flat 100m grid. Gameplay logic (resources, physics, + // biomes, server sync) still uses the flat grid. getHeightAt() is unaffected. + USE_QUADTREE_LOD: true, + QUADTREE_DEBUG_WIREFRAME: false, + QUADTREE_MIN_SIZE: 100, + QUADTREE_MAX_DEPTH: 4, + QUADTREE_SPLIT_RATIO: 1.5, + QUADTREE_SKIRT_DROP: 15, + QUADTREE_RESOLUTION: 32, + + // Shadow settings for terrain meshes (applies to both flat-grid and quad-tree) + TERRAIN_RECEIVE_SHADOW: false, + TERRAIN_CAST_SHADOW: false, }; // Pre-computed terrain data from workers (awaiting geometry creation) @@ -1720,12 +1745,165 @@ export class TerrainSystem extends System { this.instancedMeshManager = new InstancedMeshManager(scene, this.world); this.registerInstancedMeshes(); + // Initialize quad-tree LOD visual manager if enabled + if (this.CONFIG.USE_QUADTREE_LOD) { + this.initQuadTreeVisualManager(); + } + // Setup initial camera only if no client camera system controls it // Leave control to ClientCameraSystem for third-person follow // Initial tiles will be loaded in start() method } + /** + * Build the VisualManagerTerrainProvider adapter that exposes TerrainSystem's + * height/biome/road/flat-zone queries to the quad-tree chunk generator. + */ + private buildChunkTerrainProvider(): VisualManagerTerrainProvider { + const biomeColorCache = new Map< + string, + { r: number; g: number; b: number } + >(); + + return { + getHeightAtComputed: (worldX: number, worldZ: number) => + this.getHeightAtComputed(worldX, worldZ), + + computeBiomeWeightsAtPosition: (worldX: number, worldZ: number) => + this.computeBiomeWeightsAtPosition(worldX, worldZ), + + calculateRoadInfluenceAtVertex: ( + worldX: number, + worldZ: number, + tileX: number, + tileZ: number, + ) => this.calculateRoadInfluenceAtVertex(worldX, worldZ, tileX, tileZ), + + getBiomeId: (biomeName: string) => this.getBiomeId(biomeName), + + getBiomeColor: (biomeName: string) => { + let cached = biomeColorCache.get(biomeName); + if (!cached) { + const biomeData = BIOMES[biomeName]; + if (biomeData) { + const c = new THREE.Color(biomeData.color); + cached = { r: c.r, g: c.g, b: c.b }; + } else { + cached = { r: 0.3, g: 0.55, b: 0.15 }; + } + biomeColorCache.set(biomeName, cached); + } + return cached; + }, + + getFlatZoneAt: (worldX: number, worldZ: number) => + this.getFlatZoneAt(worldX, worldZ), + + WATER_LEVEL_NORMALIZED: this.CONFIG.WATER_LEVEL_NORMALIZED, + SHORELINE_THRESHOLD: this.CONFIG.SHORELINE_THRESHOLD, + SHORELINE_STRENGTH: this.CONFIG.SHORELINE_STRENGTH, + MAX_HEIGHT: this.CONFIG.MAX_HEIGHT, + TILE_SIZE: this.CONFIG.TILE_SIZE, + }; + } + + /** + * Initialize the quad-tree LOD visual manager. + * Creates a separate THREE.Group for quad-tree terrain meshes. + */ + private initQuadTreeVisualManager(): void { + if (!this.terrainMaterial) { + console.warn( + "[TerrainSystem] Cannot init quad-tree: terrain material not ready", + ); + return; + } + + const qtContainer = new THREE.Group(); + qtContainer.name = "QuadTreeTerrainContainer"; + this.terrainContainer.parent!.add(qtContainer); + + const provider = this.buildChunkTerrainProvider(); + + const workerConfig: QuadChunkWorkerConfig = { + MAX_HEIGHT: this.CONFIG.MAX_HEIGHT, + BIOME_GAUSSIAN_COEFF: this.CONFIG.BIOME_GAUSSIAN_COEFF, + BIOME_BOUNDARY_NOISE_SCALE: this.CONFIG.BIOME_BOUNDARY_NOISE_SCALE, + BIOME_BOUNDARY_NOISE_AMOUNT: this.CONFIG.BIOME_BOUNDARY_NOISE_AMOUNT, + MOUNTAIN_HEIGHT_THRESHOLD: this.CONFIG.MOUNTAIN_HEIGHT_THRESHOLD, + MOUNTAIN_WEIGHT_BOOST: this.CONFIG.MOUNTAIN_WEIGHT_BOOST, + VALLEY_HEIGHT_THRESHOLD: this.CONFIG.VALLEY_HEIGHT_THRESHOLD, + VALLEY_WEIGHT_BOOST: this.CONFIG.VALLEY_WEIGHT_BOOST, + MOUNTAIN_HEIGHT_BOOST: this.CONFIG.MOUNTAIN_HEIGHT_BOOST, + WATER_THRESHOLD: this.CONFIG.WATER_THRESHOLD, + WATER_LEVEL_NORMALIZED: this.CONFIG.WATER_LEVEL_NORMALIZED, + SHORELINE_THRESHOLD: this.CONFIG.SHORELINE_THRESHOLD, + SHORELINE_STRENGTH: this.CONFIG.SHORELINE_STRENGTH, + SHORELINE_MIN_SLOPE: this.CONFIG.SHORELINE_MIN_SLOPE, + SHORELINE_SLOPE_SAMPLE_DISTANCE: + this.CONFIG.SHORELINE_SLOPE_SAMPLE_DISTANCE, + SHORELINE_LAND_BAND: this.CONFIG.SHORELINE_LAND_BAND, + SHORELINE_LAND_MAX_MULTIPLIER: this.CONFIG.SHORELINE_LAND_MAX_MULTIPLIER, + SHORELINE_UNDERWATER_BAND: this.CONFIG.SHORELINE_UNDERWATER_BAND, + UNDERWATER_DEPTH_MULTIPLIER: this.CONFIG.UNDERWATER_DEPTH_MULTIPLIER, + }; + + const biomeCenters = this.biomeCenters.map((c) => ({ + x: c.x, + z: c.z, + type: c.type, + influence: c.influence, + })); + + const biomeColorCache = new Map< + string, + { r: number; g: number; b: number } + >(); + const workerBiomes: Record< + string, + { heightModifier: number; color: { r: number; g: number; b: number } } + > = {}; + for (const [name, biomeData] of Object.entries(BIOMES)) { + let cached = biomeColorCache.get(name); + if (!cached) { + const c = new THREE.Color(biomeData.color); + cached = { r: c.r, g: c.g, b: c.b }; + biomeColorCache.set(name, cached); + } + workerBiomes[name] = { + heightModifier: biomeData.terrainMultiplier || 1, + color: cached, + }; + } + + this.quadTreeVisualManager = new TerrainVisualManager( + { + minSize: this.CONFIG.QUADTREE_MIN_SIZE, + maxDepth: this.CONFIG.QUADTREE_MAX_DEPTH, + splitRatio: this.CONFIG.QUADTREE_SPLIT_RATIO, + resolution: this.CONFIG.QUADTREE_RESOLUTION, + skirtDrop: this.CONFIG.QUADTREE_SKIRT_DROP, + }, + provider, + qtContainer, + this.terrainMaterial, + workerConfig, + this.computeSeedFromWorldId(), + biomeCenters, + workerBiomes, + this.CONFIG.QUADTREE_DEBUG_WIREFRAME, + this.CONFIG.TERRAIN_RECEIVE_SHADOW, + this.CONFIG.TERRAIN_CAST_SHADOW, + ); + + console.log( + "[TerrainSystem] Quad-tree LOD visual manager initialized " + + `(minSize=${this.CONFIG.QUADTREE_MIN_SIZE}, maxDepth=${this.CONFIG.QUADTREE_MAX_DEPTH}, ` + + `resolution=${this.CONFIG.QUADTREE_RESOLUTION}, splitRatio=${this.CONFIG.QUADTREE_SPLIT_RATIO})`, + ); + } + private registerInstancedMeshes(): void { // Register tree mesh - now with automatic pooling (1000 visible max) const treeSize = { x: 1.2, y: 3.0, z: 1.2 }; @@ -2068,9 +2246,8 @@ export class TerrainSystem extends System { ); mesh.name = `Terrain_${key}`; - // Enable shadow receiving for CSM - mesh.receiveShadow = false; - mesh.castShadow = false; // Terrain doesn't cast shadows on itself + mesh.receiveShadow = this.CONFIG.TERRAIN_RECEIVE_SHADOW; + mesh.castShadow = this.CONFIG.TERRAIN_CAST_SHADOW; // Enable frustum culling (Three.js built-in) mesh.frustumCulled = true; @@ -2213,8 +2390,8 @@ export class TerrainSystem extends System { tile.collider = physics.addActor(actor, handle); } - // Add to scene if client-side - if (this.terrainContainer) { + // Add to scene if client-side (skip when quad-tree LOD handles visual rendering) + if (this.terrainContainer && !this.CONFIG.USE_QUADTREE_LOD) { this.terrainContainer.add(mesh); } @@ -5022,6 +5199,15 @@ export class TerrainSystem extends System { this.processResourceInstanceQueue(); } + // Update quad-tree LOD visual manager (client only) + if (this.runtimeIsClient && this.quadTreeVisualManager) { + const centers = this.getTerrainCenters(); + if (centers.length > 0) { + const pos = centers[0].position; + this.quadTreeVisualManager.update(pos.x, pos.z); + } + } + // Update instance visibility on client based on player position if (this.runtimeIsClient && this.instancedMeshManager) { this.instancedMeshManager.updateAllInstanceVisibility(); @@ -5598,7 +5784,16 @@ export class TerrainSystem extends System { // waterMesh is null if no underwater areas exist if (waterMesh && tile.mesh) { - tile.mesh.add(waterMesh); + if (this.CONFIG.USE_QUADTREE_LOD && this.terrainContainer) { + // Quad-tree LOD doesn't add tile.mesh to the scene, so parent the + // water mesh directly to terrainContainer with the tile's world pos. + // Preserve Y (water level) already set by WaterSystem.generateWaterMesh. + waterMesh.position.x = tile.x * this.CONFIG.TILE_SIZE; + waterMesh.position.z = tile.z * this.CONFIG.TILE_SIZE; + this.terrainContainer.add(waterMesh); + } else { + tile.mesh.add(waterMesh); + } tile.waterMeshes.push(waterMesh); } } @@ -6227,8 +6422,15 @@ export class TerrainSystem extends System { } destroy(): void { - // Terminate terrain worker pool to free resources + // Dispose quad-tree visual manager + if (this.quadTreeVisualManager) { + this.quadTreeVisualManager.dispose(); + this.quadTreeVisualManager = null; + } + + // Terminate worker pools to free resources terminateTerrainWorkerPool(); + terminateQuadChunkWorkerPool(); // Clear pending worker results this.pendingWorkerResults.clear(); @@ -6829,6 +7031,13 @@ export class TerrainSystem extends System { lastSerializationTime: number; nextSerializationIn: number; worldStateVersion: number; + quadTreeLOD?: { + totalNodes: number; + visualChunks: number; + pendingWorkers: number; + settledQueue: number; + syncQueue: number; + }; } { return { totalChunks: this.terrainTiles.size, @@ -6839,6 +7048,7 @@ export class TerrainSystem extends System { nextSerializationIn: this.serializationInterval - (Date.now() - this.lastSerializationTime), worldStateVersion: this.worldStateVersion, + quadTreeLOD: this.quadTreeVisualManager?.getStats(), }; } diff --git a/packages/shared/src/systems/shared/world/TerrainVisualManager.ts b/packages/shared/src/systems/shared/world/TerrainVisualManager.ts new file mode 100644 index 000000000..d6f0d3b5a --- /dev/null +++ b/packages/shared/src/systems/shared/world/TerrainVisualManager.ts @@ -0,0 +1,394 @@ +/** + * TerrainVisualManager — Manages the lifecycle of quad-tree terrain visual chunks. + * + * Listens to TerrainQuadTree events, dispatches heavy computation to + * QuadChunkWorker, assembles geometry on the main thread via + * TerrainQuadChunkGenerator, and adds/removes meshes from the scene. + * + * CLIENT-ONLY: Server terrain uses the flat tile grid. + */ + +import THREE from "../../../extras/three/three"; +import { + TerrainQuadTree, + type TerrainQuadNode, + type QuadTreeConfig, + type QuadTreeListener, +} from "./TerrainQuadTree"; +import { + assembleQuadChunkGeometry, + generateQuadChunkGeometrySync, + type ChunkTerrainProvider, +} from "./TerrainQuadChunkGenerator"; +import { + generateQuadChunkAsync, + isQuadChunkWorkerAvailable, + type QuadChunkWorkerConfig, + type QuadChunkWorkerInput, + type QuadChunkWorkerOutput, +} from "../../../utils/workers/QuadChunkWorker"; + +export interface TerrainVisualChunk { + key: string; + node: TerrainQuadNode; + mesh: THREE.Mesh; + heightData: Float32Array; +} + +/** Extended provider that includes sync fallback methods + worker config builder */ +export interface VisualManagerTerrainProvider extends ChunkTerrainProvider { + computeBiomeWeightsAtPosition( + worldX: number, + worldZ: number, + ): { biomeWeightMap: Map; totalWeight: number }; + getBiomeId(biomeName: string): number; + getBiomeColor(biomeName: string): { r: number; g: number; b: number }; + readonly WATER_LEVEL_NORMALIZED: number; + readonly SHORELINE_THRESHOLD: number; + readonly SHORELINE_STRENGTH: number; + readonly MAX_HEIGHT: number; +} + +interface SettledWorkerResult { + nodeId: number; + node: TerrainQuadNode; + result: QuadChunkWorkerOutput | null; + error: unknown; +} + +/** + * Orchestrates quad-tree LOD terrain rendering with async worker dispatch. + */ +export class TerrainVisualManager implements QuadTreeListener { + private quadTree: TerrainQuadTree; + private provider: VisualManagerTerrainProvider; + private container: THREE.Group; + private material: THREE.Material; + private chunks = new Map(); + private playerX = 0; + private playerZ = 0; + private debugWireframe: boolean; + private receiveShadow: boolean; + private castShadow: boolean; + private workerConfig: QuadChunkWorkerConfig; + private workerSeed: number; + private workerBiomeCenters: QuadChunkWorkerInput["biomeCenters"]; + private workerBiomes: QuadChunkWorkerInput["biomes"]; + private useWorkers: boolean; + + /** Node IDs with in-flight worker promises */ + private pendingNodeIds = new Set(); + /** Settled worker results ready for main-thread assembly */ + private settledResults: SettledWorkerResult[] = []; + /** Sync fallback queue (used when workers unavailable) */ + private syncQueue: TerrainQuadNode[] = []; + /** Destroyed node IDs that should be ignored when results come back */ + private cancelledNodeIds = new Set(); + + private maxSyncChunksPerFrame = 4; + private maxAssembliesPerFrame = 6; + + constructor( + config: Partial, + provider: VisualManagerTerrainProvider, + container: THREE.Group, + material: THREE.Material, + workerConfig: QuadChunkWorkerConfig, + workerSeed: number, + workerBiomeCenters: QuadChunkWorkerInput["biomeCenters"], + workerBiomes: QuadChunkWorkerInput["biomes"], + debugWireframe = false, + receiveShadow = false, + castShadow = false, + ) { + this.provider = provider; + this.container = container; + this.material = material; + this.debugWireframe = debugWireframe; + this.receiveShadow = receiveShadow; + this.castShadow = castShadow; + this.workerConfig = workerConfig; + this.workerSeed = workerSeed; + this.workerBiomeCenters = workerBiomeCenters; + this.workerBiomes = workerBiomes; + this.useWorkers = isQuadChunkWorkerAvailable(); + + this.quadTree = new TerrainQuadTree(config); + this.quadTree.setListener(this); + } + + update(playerX: number, playerZ: number): void { + this.playerX = playerX; + this.playerZ = playerZ; + this.quadTree.update(playerX, playerZ); + this.processSettledResults(); + this.processSyncQueue(); + } + + dispose(): void { + this.quadTree.dispose(); + for (const chunk of this.chunks.values()) { + this.removeMeshFromScene(chunk); + } + this.chunks.clear(); + this.pendingNodeIds.clear(); + this.settledResults = []; + this.syncQueue = []; + this.cancelledNodeIds.clear(); + } + + getQuadTree(): TerrainQuadTree { + return this.quadTree; + } + + getChunks(): ReadonlyMap { + return this.chunks; + } + + getStats(): { + totalNodes: number; + visualChunks: number; + pendingWorkers: number; + settledQueue: number; + syncQueue: number; + } { + return { + totalNodes: this.quadTree.totalNodeCount, + visualChunks: this.chunks.size, + pendingWorkers: this.pendingNodeIds.size, + settledQueue: this.settledResults.length, + syncQueue: this.syncQueue.length, + }; + } + + updateBiomeData( + biomeCenters: QuadChunkWorkerInput["biomeCenters"], + biomes: QuadChunkWorkerInput["biomes"], + ): void { + this.workerBiomeCenters = biomeCenters; + this.workerBiomes = biomes; + } + + // ========================================================================= + // QuadTreeListener implementation + // ========================================================================= + + onNodeNeedsGeometry(node: TerrainQuadNode): void { + if (this.pendingNodeIds.has(node.id)) return; + + if (this.useWorkers) { + this.dispatchWorker(node); + } else { + if (!this.syncQueue.includes(node)) { + this.syncQueue.push(node); + } + } + } + + onNodeDestroyGeometry(node: TerrainQuadNode): void { + const key = node.visualChunkKey; + if (key !== null) { + const chunk = this.chunks.get(key); + if (chunk) { + this.removeMeshFromScene(chunk); + this.chunks.delete(key); + } + node.visualChunkKey = null; + } + if (this.pendingNodeIds.has(node.id)) { + this.cancelledNodeIds.add(node.id); + this.pendingNodeIds.delete(node.id); + } + const sqIdx = this.syncQueue.indexOf(node); + if (sqIdx !== -1) this.syncQueue.splice(sqIdx, 1); + } + + // ========================================================================= + // Worker dispatch — fire-and-forget, results arrive in settledResults + // ========================================================================= + + private dispatchWorker(node: TerrainQuadNode): void { + const input: QuadChunkWorkerInput = { + type: "generateQuadChunk", + centerX: node.centerX, + centerZ: node.centerZ, + size: node.size, + resolution: node.resolution, + config: this.workerConfig, + seed: this.workerSeed, + biomeCenters: this.workerBiomeCenters, + biomes: this.workerBiomes, + }; + + this.pendingNodeIds.add(node.id); + const nodeId = node.id; + + generateQuadChunkAsync(input).then( + (result) => { + this.settledResults.push({ nodeId, node, result, error: null }); + this.pendingNodeIds.delete(nodeId); + }, + (error) => { + this.settledResults.push({ nodeId, node, result: null, error }); + this.pendingNodeIds.delete(nodeId); + }, + ); + } + + private processSettledResults(): void { + if (this.settledResults.length === 0) return; + + const batch = this.settledResults.splice(0, this.maxAssembliesPerFrame); + + for (const entry of batch) { + if (this.cancelledNodeIds.has(entry.nodeId)) { + this.cancelledNodeIds.delete(entry.nodeId); + continue; + } + + if (!entry.node.isFinal || entry.node.visualChunkKey !== null) continue; + + if (entry.error || !entry.result) { + if (entry.error) { + console.error("[TerrainVisualManager] Worker error:", entry.error); + } + this.generateChunkSync(entry.node); + continue; + } + + this.assembleAndAddChunk(entry.node, entry.result); + } + } + + private processSyncQueue(): void { + if (this.syncQueue.length === 0) return; + + let generated = 0; + while ( + this.syncQueue.length > 0 && + generated < this.maxSyncChunksPerFrame + ) { + const node = this.syncQueue.shift()!; + if (!node.isFinal || node.visualChunkKey !== null) continue; + this.generateChunkSync(node); + generated++; + } + } + + // ========================================================================= + // Chunk assembly + // ========================================================================= + + private assembleAndAddChunk( + node: TerrainQuadNode, + workerData: QuadChunkWorkerOutput, + ): void { + const key = this.makeChunkKey(node); + + let result; + try { + result = assembleQuadChunkGeometry( + workerData, + this.provider, + this.quadTree.config.skirtDrop, + ); + } catch (err) { + console.error(`[TerrainVisualManager] Assembly failed for ${key}:`, err); + return; + } + + this.addMeshToScene(node, key, result); + } + + private generateChunkSync(node: TerrainQuadNode): void { + const key = this.makeChunkKey(node); + + let result; + try { + result = generateQuadChunkGeometrySync( + node.centerX, + node.centerZ, + node.size, + node.resolution, + this.provider, + this.quadTree.config.skirtDrop, + ); + } catch (err) { + console.error( + `[TerrainVisualManager] Sync generation failed for ${key}:`, + err, + ); + return; + } + + this.addMeshToScene(node, key, result); + } + + private makeChunkKey(node: TerrainQuadNode): string { + return `quad_${node.id}_d${node.depth}_${node.centerX.toFixed(0)}_${node.centerZ.toFixed(0)}`; + } + + private static DEBUG_DEPTH_COLORS = [ + 0xff0000, 0xff8800, 0xffff00, 0x00ccff, 0x00ff44, + ]; + + private addMeshToScene( + node: TerrainQuadNode, + key: string, + result: { geometry: THREE.BufferGeometry; heightData: Float32Array }, + ): void { + let meshMaterial: THREE.Material; + if (this.debugWireframe) { + const depthColor = + TerrainVisualManager.DEBUG_DEPTH_COLORS[ + Math.min( + node.depth, + TerrainVisualManager.DEBUG_DEPTH_COLORS.length - 1, + ) + ]; + meshMaterial = new THREE.MeshBasicMaterial({ + color: depthColor, + wireframe: true, + }); + } else { + meshMaterial = this.material; + } + + const mesh = new THREE.Mesh(result.geometry, meshMaterial); + mesh.position.set(node.centerX, 0, node.centerZ); + mesh.name = `QuadTerrain_${key}`; + mesh.receiveShadow = this.receiveShadow; + mesh.castShadow = this.castShadow; + mesh.frustumCulled = true; + + mesh.userData = { + type: "terrain", + walkable: true, + clickable: true, + quadNodeId: node.id, + depth: node.depth, + size: node.size, + resolution: node.resolution, + }; + + this.container.add(mesh); + + const chunk: TerrainVisualChunk = { + key, + node, + mesh, + heightData: result.heightData, + }; + + this.chunks.set(key, chunk); + node.visualChunkKey = key; + node.testReady(); + } + + private removeMeshFromScene(chunk: TerrainVisualChunk): void { + if (chunk.mesh.parent) { + this.container.remove(chunk.mesh); + } + chunk.mesh.geometry.dispose(); + } +} diff --git a/packages/shared/src/types/world/terrain.ts b/packages/shared/src/types/world/terrain.ts index ca4ff9f7c..3bc4eb876 100644 --- a/packages/shared/src/types/world/terrain.ts +++ b/packages/shared/src/types/world/terrain.ts @@ -212,3 +212,28 @@ export interface FlatZone { * Format: "tileX_tileZ" where tiles are terrain tiles (100m each). */ export type FlatZoneKey = `${number}_${number}`; + +// ============================================================================ +// QUAD-TREE LOD VISUAL CHUNKS +// ============================================================================ + +/** + * A visual terrain chunk generated by the quad-tree LOD system. + * These are client-only rendering primitives — gameplay logic uses TerrainTile. + */ +export interface TerrainVisualChunkData { + /** Unique key (e.g., "quad_42_3_100_200") */ + key: string; + /** Center X in world coordinates */ + centerX: number; + /** Center Z in world coordinates */ + centerZ: number; + /** World-space size of this chunk (varies by LOD depth) */ + size: number; + /** Vertex resolution per axis (uniform across all depths) */ + resolution: number; + /** Quad-tree depth (0 = root, maxDepth = leaf) */ + depth: number; + /** Height data for the main grid (resolution × resolution) */ + heightData: Float32Array; +} diff --git a/packages/shared/src/utils/workers/QuadChunkWorker.ts b/packages/shared/src/utils/workers/QuadChunkWorker.ts new file mode 100644 index 000000000..9ed6ba6c2 --- /dev/null +++ b/packages/shared/src/utils/workers/QuadChunkWorker.ts @@ -0,0 +1,522 @@ +/** + * QuadChunkWorker — Offloads quad-tree terrain chunk computation to Web Worker. + * + * Computes heights (with overflow grid for normals), per-vertex normals, + * biome-blended colors, and biome IDs for an arbitrary world-space region + * defined by (centerX, centerZ, size, resolution). + * + * Road influence and flat-zone application stay on the main thread because + * they depend on runtime game state (road network, building footprints). + * + * The inline worker code duplicates the same noise / height / biome logic + * used by TerrainWorker.ts, keeping both in sync with TerrainHeightParams.ts. + */ + +import { WorkerPool } from "./WorkerPool"; +import { + buildGetBaseHeightAtJS, + MOUNTAIN_BOOST_MAX_NORM_DIST, + MOUNTAIN_BOOST_GAUSSIAN_COEFF, +} from "../../systems/shared/world/TerrainHeightParams"; + +export interface QuadChunkWorkerConfig { + MAX_HEIGHT: number; + BIOME_GAUSSIAN_COEFF: number; + BIOME_BOUNDARY_NOISE_SCALE: number; + BIOME_BOUNDARY_NOISE_AMOUNT: number; + MOUNTAIN_HEIGHT_THRESHOLD: number; + MOUNTAIN_WEIGHT_BOOST: number; + VALLEY_HEIGHT_THRESHOLD: number; + VALLEY_WEIGHT_BOOST: number; + MOUNTAIN_HEIGHT_BOOST: number; + WATER_THRESHOLD: number; + WATER_LEVEL_NORMALIZED: number; + SHORELINE_THRESHOLD: number; + SHORELINE_STRENGTH: number; + SHORELINE_MIN_SLOPE: number; + SHORELINE_SLOPE_SAMPLE_DISTANCE: number; + SHORELINE_LAND_BAND: number; + SHORELINE_LAND_MAX_MULTIPLIER: number; + SHORELINE_UNDERWATER_BAND: number; + UNDERWATER_DEPTH_MULTIPLIER: number; +} + +export interface QuadChunkWorkerInput { + type: "generateQuadChunk"; + centerX: number; + centerZ: number; + size: number; + resolution: number; + config: QuadChunkWorkerConfig; + seed: number; + biomeCenters: Array<{ + x: number; + z: number; + type: string; + influence: number; + }>; + biomes: Record< + string, + { + heightModifier: number; + color: { r: number; g: number; b: number }; + } + >; +} + +export interface QuadChunkWorkerOutput { + type: "quadChunkResult"; + centerX: number; + centerZ: number; + size: number; + resolution: number; + heightData: Float32Array; + normalData: Float32Array; + colorData: Float32Array; + biomeData: Uint8Array; +} + +const QUAD_CHUNK_WORKER_CODE = ` +// NoiseGenerator — same as TerrainWorker (from NoiseGenerator.ts) +class NoiseGenerator { + constructor(seed = 12345) { + this.permutation = []; + this.p = []; + this.initializePermutation(seed); + } + + initializePermutation(seed) { + const perm = Array.from({ length: 256 }, (_, i) => i); + let random = seed; + for (let i = perm.length - 1; i > 0; i--) { + random = (random * 1664525 + 1013904223) % 4294967296; + const j = Math.floor((random / 4294967296) * (i + 1)); + [perm[i], perm[j]] = [perm[j], perm[i]]; + } + this.permutation = perm; + this.p = [...perm, ...perm]; + } + + fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); } + lerp(t, a, b) { return a + t * (b - a); } + grad2D(hash, x, y) { + const h = hash & 3; + const u = h < 2 ? x : y; + const v = h < 2 ? y : x; + return (h & 1 ? -u : u) + (h & 2 ? -v : v); + } + + perlin2D(x, y) { + const X = Math.floor(x) & 255; + const Y = Math.floor(y) & 255; + x -= Math.floor(x); + y -= Math.floor(y); + const u = this.fade(x); + const v = this.fade(y); + const A = this.p[X] + Y; + const AA = this.p[A]; + const AB = this.p[A + 1]; + const B = this.p[X + 1] + Y; + const BA = this.p[B]; + const BB = this.p[B + 1]; + const result = this.lerp(v, + this.lerp(u, this.grad2D(this.p[AA], x, y), this.grad2D(this.p[BA], x - 1, y)), + this.lerp(u, this.grad2D(this.p[AB], x, y - 1), this.grad2D(this.p[BB], x - 1, y - 1)) + ); + return Math.max(-1, Math.min(1, result)); + } + + gradSimplex2D(hash, x, y) { + const grad3 = [[1,1,0],[-1,1,0],[1,-1,0],[-1,-1,0],[1,0,1],[-1,0,1],[1,0,-1],[-1,0,-1],[0,1,1],[0,-1,1],[0,1,-1],[0,-1,-1]]; + return grad3[hash % 12][0] * x + grad3[hash % 12][1] * y; + } + + simplex2D(x, y) { + const F2 = 0.5 * (Math.sqrt(3.0) - 1.0); + const G2 = (3.0 - Math.sqrt(3.0)) / 6.0; + const s = (x + y) * F2; + const i = Math.floor(x + s); + const j = Math.floor(y + s); + const t = (i + j) * G2; + const X0 = i - t; + const Y0 = j - t; + const x0 = x - X0; + const y0 = y - Y0; + let i1, j1; + if (x0 > y0) { i1 = 1; j1 = 0; } else { i1 = 0; j1 = 1; } + const x1 = x0 - i1 + G2; + const y1 = y0 - j1 + G2; + const x2 = x0 - 1.0 + 2.0 * G2; + const y2 = y0 - 1.0 + 2.0 * G2; + const ii = i & 255; + const jj = j & 255; + const gi0 = this.p[ii + this.p[jj]] % 12; + const gi1 = this.p[ii + i1 + this.p[jj + j1]] % 12; + const gi2 = this.p[ii + 1 + this.p[jj + 1]] % 12; + let n0, n1, n2; + let t0 = 0.5 - x0 * x0 - y0 * y0; + if (t0 < 0) n0 = 0.0; + else { t0 *= t0; n0 = t0 * t0 * this.gradSimplex2D(gi0, x0, y0); } + let t1 = 0.5 - x1 * x1 - y1 * y1; + if (t1 < 0) n1 = 0.0; + else { t1 *= t1; n1 = t1 * t1 * this.gradSimplex2D(gi1, x1, y1); } + let t2 = 0.5 - x2 * x2 - y2 * y2; + if (t2 < 0) n2 = 0.0; + else { t2 *= t2; n2 = t2 * t2 * this.gradSimplex2D(gi2, x2, y2); } + return 70.0 * (n0 + n1 + n2); + } + + ridgeNoise2D(x, y) { + const perlinValue = this.perlin2D(x, y); + return 1.0 - Math.abs(Math.max(-1, Math.min(1, perlinValue))); + } + + fractal2D(x, y, octaves = 4, persistence = 0.5, lacunarity = 2.0) { + let value = 0; + let amplitude = 1; + let frequency = 1; + let maxValue = 0; + for (let i = 0; i < octaves; i++) { + value += this.perlin2D(x * frequency, y * frequency) * amplitude; + maxValue += amplitude; + amplitude *= persistence; + frequency *= lacunarity; + } + return value / maxValue; + } + + erosionNoise2D(x, y, iterations = 3) { + let height = this.fractal2D(x, y, 6); + for (let i = 0; i < iterations; i++) { + const delta = 0.01; + const hC = this.perlin2D(x, y); + const hX = this.perlin2D(x + delta, y); + const hY = this.perlin2D(x, y + delta); + const gradX = (hX - hC) / delta; + const gradY = (hY - hC) / delta; + const magnitude = Math.sqrt(gradX * gradX + gradY * gradY); + const erosionFactor = Math.min(1.0, magnitude * 2.0); + height *= 1.0 - erosionFactor * 0.1; + } + return height; + } +} + +const BIOME_IDS = { plains: 0, forest: 1, valley: 2, mountains: 3, tundra: 4, desert: 5, lakes: 6, swamp: 7 }; + +function generateQuadChunk(input) { + const { centerX, centerZ, size, resolution, config, seed, biomeCenters, biomes } = input; + const { + MAX_HEIGHT, + BIOME_GAUSSIAN_COEFF, + BIOME_BOUNDARY_NOISE_SCALE, + BIOME_BOUNDARY_NOISE_AMOUNT, + MOUNTAIN_HEIGHT_THRESHOLD, + MOUNTAIN_WEIGHT_BOOST, + VALLEY_HEIGHT_THRESHOLD, + VALLEY_WEIGHT_BOOST, + MOUNTAIN_HEIGHT_BOOST, + WATER_THRESHOLD, + WATER_LEVEL_NORMALIZED, + SHORELINE_THRESHOLD, + SHORELINE_STRENGTH, + SHORELINE_MIN_SLOPE, + SHORELINE_SLOPE_SAMPLE_DISTANCE, + SHORELINE_LAND_BAND, + SHORELINE_LAND_MAX_MULTIPLIER, + SHORELINE_UNDERWATER_BAND, + UNDERWATER_DEPTH_MULTIPLIER + } = config; + + const noise = new NoiseGenerator(seed); + const segments = resolution; + const vertexCount = segments * segments; + const halfSize = size * 0.5; + const gridStep = size / (segments - 1); + + ${buildGetBaseHeightAtJS()} + + function getHeightAtWithoutShore(worldX, worldZ) { + var baseHeight = getBaseHeightAt(worldX, worldZ); + var height = baseHeight / MAX_HEIGHT; + var mountainBoost = 0; + for (var _i = 0; _i < biomeCenters.length; _i++) { + var center = biomeCenters[_i]; + if (center.type === 'mountains') { + var dx = worldX - center.x; + var dz = worldZ - center.z; + var distance = Math.sqrt(dx * dx + dz * dz); + var normalizedDist = distance / center.influence; + if (normalizedDist < ${MOUNTAIN_BOOST_MAX_NORM_DIST}) { + var boost = Math.exp(-normalizedDist * normalizedDist * ${MOUNTAIN_BOOST_GAUSSIAN_COEFF}); + mountainBoost = Math.max(mountainBoost, boost); + } + } + } + height = height * (1 + mountainBoost * MOUNTAIN_HEIGHT_BOOST); + height = Math.min(1, height); + return height * MAX_HEIGHT; + } + + function calculateBaseSlopeAt(worldX, worldZ, centerHeight) { + const d = SHORELINE_SLOPE_SAMPLE_DISTANCE; + const hN = getHeightAtWithoutShore(worldX, worldZ + d); + const hS = getHeightAtWithoutShore(worldX, worldZ - d); + const hE = getHeightAtWithoutShore(worldX + d, worldZ); + const hW = getHeightAtWithoutShore(worldX - d, worldZ); + return Math.max( + Math.abs(hN - centerHeight) / d, + Math.abs(hS - centerHeight) / d, + Math.abs(hE - centerHeight) / d, + Math.abs(hW - centerHeight) / d + ); + } + + function adjustHeightForShoreline(baseHeight, slope) { + if (baseHeight === WATER_THRESHOLD) return baseHeight; + const isLand = baseHeight > WATER_THRESHOLD; + const band = isLand ? SHORELINE_LAND_BAND : SHORELINE_UNDERWATER_BAND; + if (band <= 0) return baseHeight; + const delta = Math.abs(baseHeight - WATER_THRESHOLD); + if (delta >= band) return baseHeight; + if (SHORELINE_MIN_SLOPE <= 0) return baseHeight; + const maxMul = isLand ? SHORELINE_LAND_MAX_MULTIPLIER : UNDERWATER_DEPTH_MULTIPLIER; + if (maxMul <= 1) return baseHeight; + const slopeSafe = Math.max(0.0001, slope); + const targetMul = Math.min(maxMul, Math.max(1, SHORELINE_MIN_SLOPE / slopeSafe)); + const falloff = 1 - delta / band; + const mul = 1 + (targetMul - 1) * falloff; + const adjustedDelta = delta * mul; + return isLand ? WATER_THRESHOLD + adjustedDelta : WATER_THRESHOLD - adjustedDelta; + } + + function getHeightComputed(worldX, worldZ) { + const h = getHeightAtWithoutShore(worldX, worldZ); + if (h >= WATER_THRESHOLD + SHORELINE_LAND_BAND || h <= WATER_THRESHOLD - SHORELINE_UNDERWATER_BAND) { + return h; + } + const slope = calculateBaseSlopeAt(worldX, worldZ, h); + return adjustHeightForShoreline(h, slope); + } + + function getBiomeInfluences(worldX, worldZ, normalizedHeight) { + if (!biomeCenters || biomeCenters.length === 0) { + return [{ type: 'plains', weight: 1.0 }]; + } + const boundaryNoise = noise.simplex2D( + worldX * BIOME_BOUNDARY_NOISE_SCALE, + worldZ * BIOME_BOUNDARY_NOISE_SCALE + ); + const biomeWeightMap = {}; + for (const center of biomeCenters) { + const dx = worldX - center.x; + const dz = worldZ - center.z; + const distance = Math.sqrt(dx * dx + dz * dz); + const noisyDistance = distance * (1 + boundaryNoise * BIOME_BOUNDARY_NOISE_AMOUNT); + const normalizedDistance = noisyDistance / center.influence; + let weight = Math.exp(-normalizedDistance * normalizedDistance * BIOME_GAUSSIAN_COEFF); + if (center.type === 'mountains' && normalizedHeight > MOUNTAIN_HEIGHT_THRESHOLD) { + weight *= 1.0 + (normalizedHeight - MOUNTAIN_HEIGHT_THRESHOLD) * MOUNTAIN_WEIGHT_BOOST; + } + if ((center.type === 'valley' || center.type === 'plains') && normalizedHeight < VALLEY_HEIGHT_THRESHOLD) { + weight *= 1.0 + (VALLEY_HEIGHT_THRESHOLD - normalizedHeight) * VALLEY_WEIGHT_BOOST; + } + biomeWeightMap[center.type] = (biomeWeightMap[center.type] || 0) + weight; + } + const biomeInfluences = []; + for (const type in biomeWeightMap) { + biomeInfluences.push({ type, weight: biomeWeightMap[type] }); + } + const totalWeight = biomeInfluences.reduce((sum, b) => sum + b.weight, 0); + if (totalWeight > 0) { + for (const inf of biomeInfluences) { inf.weight /= totalWeight; } + } else { + biomeInfluences.push({ type: 'plains', weight: 1.0 }); + } + biomeInfluences.sort((a, b) => b.weight - a.weight); + return biomeInfluences.slice(0, 3); + } + + // Overflow grid for normals: (segments+2)^2 + const gRes = segments + 2; + const overflowGrid = new Float32Array(gRes * gRes); + + for (let gz = 0; gz < gRes; gz++) { + const localZ = -halfSize + (gz - 1) * gridStep; + const worldZ = centerZ + localZ; + for (let gx = 0; gx < gRes; gx++) { + const localX = -halfSize + (gx - 1) * gridStep; + const worldX = centerX + localX; + overflowGrid[gz * gRes + gx] = getHeightComputed(worldX, worldZ); + } + } + + const heightData = new Float32Array(vertexCount); + for (let iz = 0; iz < segments; iz++) { + const srcRow = (iz + 1) * gRes + 1; + const dstRow = iz * segments; + for (let ix = 0; ix < segments; ix++) { + heightData[dstRow + ix] = overflowGrid[srcRow + ix]; + } + } + + // Normals via centered finite differences + const normalData = new Float32Array(vertexCount * 3); + const invTwoStep = 1 / (2 * gridStep); + for (let iz = 0; iz < segments; iz++) { + const gz = iz + 1; + for (let ix = 0; ix < segments; ix++) { + const gx = ix + 1; + const hL = overflowGrid[gz * gRes + (gx - 1)]; + const hR = overflowGrid[gz * gRes + (gx + 1)]; + const hD = overflowGrid[(gz - 1) * gRes + gx]; + const hU = overflowGrid[(gz + 1) * gRes + gx]; + const dhdx = (hR - hL) * invTwoStep; + const dhdz = (hU - hD) * invTwoStep; + const nx = -dhdx; + const ny = 1; + const nz = -dhdz; + const len = Math.sqrt(nx * nx + ny * ny + nz * nz); + const i3 = (iz * segments + ix) * 3; + normalData[i3] = nx / len; + normalData[i3 + 1] = ny / len; + normalData[i3 + 2] = nz / len; + } + } + + // Colors and biome IDs + const colorData = new Float32Array(vertexCount * 3); + const biomeData = new Uint8Array(vertexCount); + + for (let iz = 0; iz < segments; iz++) { + for (let ix = 0; ix < segments; ix++) { + const idx = iz * segments + ix; + const height = heightData[idx]; + const normalizedHeight = height / MAX_HEIGHT; + + const localX = -halfSize + ix * gridStep; + const localZ = -halfSize + iz * gridStep; + const worldX = centerX + localX; + const worldZ = centerZ + localZ; + + const biomeInfluences = getBiomeInfluences(worldX, worldZ, normalizedHeight); + biomeData[idx] = BIOME_IDS[biomeInfluences[0].type] || 0; + + let colorR = 0, colorG = 0, colorB = 0; + for (const influence of biomeInfluences) { + const biomeConfig = biomes[influence.type] || { color: { r: 0.4, g: 0.6, b: 0.3 } }; + const color = biomeConfig.color || { r: 0.4, g: 0.6, b: 0.3 }; + colorR += color.r * influence.weight; + colorG += color.g * influence.weight; + colorB += color.b * influence.weight; + } + + if (normalizedHeight > WATER_LEVEL_NORMALIZED && normalizedHeight < SHORELINE_THRESHOLD) { + const shoreFactor = (1.0 - (normalizedHeight - WATER_LEVEL_NORMALIZED) / + (SHORELINE_THRESHOLD - WATER_LEVEL_NORMALIZED)) * SHORELINE_STRENGTH; + colorR = colorR + (0.545 - colorR) * shoreFactor; + colorG = colorG + (0.451 - colorG) * shoreFactor; + colorB = colorB + (0.333 - colorB) * shoreFactor; + } + + colorData[idx * 3] = colorR; + colorData[idx * 3 + 1] = colorG; + colorData[idx * 3 + 2] = colorB; + } + } + + return { + type: 'quadChunkResult', + centerX, + centerZ, + size, + resolution, + heightData, + normalData, + colorData, + biomeData + }; +} + +self.onmessage = function(e) { + const input = e.data; + if (input.type === 'generateQuadChunk') { + try { + const result = generateQuadChunk(input); + self.postMessage({ result }, [ + result.heightData.buffer, + result.normalData.buffer, + result.colorData.buffer, + result.biomeData.buffer + ]); + } catch (error) { + self.postMessage({ error: error.message || 'Unknown error' }); + } + } +}; +`; + +let quadChunkWorkerPool: WorkerPool< + QuadChunkWorkerInput, + QuadChunkWorkerOutput +> | null = null; + +let workersChecked = false; +let workersAvailable = false; + +export function isQuadChunkWorkerAvailable(): boolean { + if (!workersChecked) { + workersChecked = true; + if (typeof Worker === "undefined" || typeof Blob === "undefined") { + workersAvailable = false; + return workersAvailable; + } + if ( + typeof process !== "undefined" && + process.versions && + "bun" in process.versions + ) { + workersAvailable = false; + return workersAvailable; + } + if (typeof window === "undefined") { + workersAvailable = false; + return workersAvailable; + } + workersAvailable = true; + } + return workersAvailable; +} + +export function getQuadChunkWorkerPool( + poolSize?: number, +): WorkerPool | null { + if (!isQuadChunkWorkerAvailable()) { + return null; + } + + if (!quadChunkWorkerPool) { + quadChunkWorkerPool = new WorkerPool< + QuadChunkWorkerInput, + QuadChunkWorkerOutput + >(QUAD_CHUNK_WORKER_CODE, poolSize); + } + return quadChunkWorkerPool; +} + +export async function generateQuadChunkAsync( + input: QuadChunkWorkerInput, +): Promise { + const pool = getQuadChunkWorkerPool(); + if (!pool) { + return null; + } + return pool.execute(input); +} + +export function terminateQuadChunkWorkerPool(): void { + if (quadChunkWorkerPool) { + quadChunkWorkerPool.terminate(); + quadChunkWorkerPool = null; + } +} diff --git a/packages/shared/src/utils/workers/index.ts b/packages/shared/src/utils/workers/index.ts index 37bc893f2..81bb647f5 100644 --- a/packages/shared/src/utils/workers/index.ts +++ b/packages/shared/src/utils/workers/index.ts @@ -110,6 +110,17 @@ export { type PhysicsWorkerOutput, } from "./PhysicsWorker"; +// QuadChunk Worker - Offloads quad-tree terrain chunk computation to web worker +export { + getQuadChunkWorkerPool, + generateQuadChunkAsync, + terminateQuadChunkWorkerPool, + isQuadChunkWorkerAvailable, + type QuadChunkWorkerConfig, + type QuadChunkWorkerInput, + type QuadChunkWorkerOutput, +} from "./QuadChunkWorker"; + // Minimap Worker - 2D canvas-based minimap rendering in web worker export { MinimapWorkerManager, From c6d68a8da3836c32cd8e542c4cb744b3607ee9f1 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Thu, 5 Mar 2026 15:47:26 +0800 Subject: [PATCH 11/48] =?UTF-8?q?fix(terrain):=20quadtree=20LOD=20improvem?= =?UTF-8?q?ents=20=E2=80=94=20blending,=20hysteresis,=20cleanup,=20retry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix flat zone blending: use getFlatZoneHeight (smoothstep) instead of hard cutoff getFlatZoneAt - Add split/unsplit hysteresis (unsplitMultiplier: 1.2) to prevent thrashing at LOD boundaries - Fix qtContainer leak: remove from scene graph on dispose - Guard terrainContainer.parent null access in init - Add bounded retry (max 3 attempts) for failed chunk generation - Remove unused heightModifier from worker biomes type - Deduplicate sync fallback: generateQuadChunkDataSync produces QuadChunkWorkerOutput, then feeds through assembleQuadChunkGeometry - Make per-frame limits configurable via CONFIG Made-with: Cursor --- .../shared/world/TerrainQuadChunkGenerator.ts | 264 ++++++------------ .../systems/shared/world/TerrainQuadTree.ts | 18 +- .../src/systems/shared/world/TerrainSystem.ts | 26 +- .../shared/world/TerrainVisualManager.ts | 63 +++-- .../src/utils/workers/QuadChunkWorker.ts | 8 +- 5 files changed, 160 insertions(+), 219 deletions(-) diff --git a/packages/shared/src/systems/shared/world/TerrainQuadChunkGenerator.ts b/packages/shared/src/systems/shared/world/TerrainQuadChunkGenerator.ts index 0a4be32b6..1ef8f2810 100644 --- a/packages/shared/src/systems/shared/world/TerrainQuadChunkGenerator.ts +++ b/packages/shared/src/systems/shared/world/TerrainQuadChunkGenerator.ts @@ -5,11 +5,16 @@ * The heavy lifting (noise, heights, normals, biome blending) is done by * QuadChunkWorker on a background thread. This module handles: * - Road influence sampling (needs live road network) - * - Flat-zone height overrides (needs live building data) + * - Flat-zone height overrides via getFlatZoneHeight (needs live building data) * - Skirt geometry generation * - Index buffer generation * - THREE.BufferGeometry assembly * + * A synchronous data generator (generateQuadChunkDataSync) is also provided + * as a fallback when workers are unavailable. It produces the same + * QuadChunkWorkerOutput format and feeds it through assembleQuadChunkGeometry, + * keeping all assembly logic in one place. + * * CLIENT-ONLY: Server terrain uses the flat tile grid. */ @@ -18,6 +23,7 @@ import type { QuadChunkWorkerOutput } from "../../../utils/workers/QuadChunkWork /** * Main-thread callbacks for game-state queries that can't run in a worker. + * Used by assembleQuadChunkGeometry for road influence and flat-zone overrides. */ export interface ChunkTerrainProvider { calculateRoadInfluenceAtVertex( @@ -26,14 +32,35 @@ export interface ChunkTerrainProvider { tileX: number, tileZ: number, ): number; - getFlatZoneAt( - worldX: number, - worldZ: number, - ): { height: number; blendRadius: number } | null; + /** + * Returns the final terrain height at this position if a flat zone applies, + * including smooth blend transitions. Returns null if no flat zone affects + * this point. This delegates to TerrainSystem.getFlatZoneHeight() which + * handles core/blend classification and smoothstep interpolation. + */ + getFlatZoneHeight(worldX: number, worldZ: number): number | null; getHeightAtComputed(worldX: number, worldZ: number): number; readonly TILE_SIZE: number; } +/** + * Extended provider that includes sync fallback methods for the data generator. + * Used by generateQuadChunkDataSync (main-thread fallback) and by the + * TerrainVisualManager for worker dispatch. + */ +export interface FullTerrainProvider extends ChunkTerrainProvider { + computeBiomeWeightsAtPosition( + worldX: number, + worldZ: number, + ): { biomeWeightMap: Map; totalWeight: number }; + getBiomeId(biomeName: string): number; + getBiomeColor(biomeName: string): { r: number; g: number; b: number }; + readonly WATER_LEVEL_NORMALIZED: number; + readonly SHORELINE_THRESHOLD: number; + readonly SHORELINE_STRENGTH: number; + readonly MAX_HEIGHT: number; +} + export interface ChunkGeometryResult { geometry: THREE.BufferGeometry; heightData: Float32Array; @@ -41,7 +68,7 @@ export interface ChunkGeometryResult { /** * Assemble a THREE.BufferGeometry from worker-computed height/normal/color/biome - * data, adding road influence and skirts on the main thread. + * data, adding flat-zone overrides, road influence, and skirts on the main thread. */ export function assembleQuadChunkGeometry( workerData: QuadChunkWorkerOutput, @@ -84,10 +111,9 @@ export function assembleQuadChunkGeometry( let height = heightData[idx]; - const flatZone = provider.getFlatZoneAt(worldX, worldZ); - if (flatZone) { - const distFactor = 1.0; - height = height + (flatZone.height - height) * distFactor; + const flatHeight = provider.getFlatZoneHeight(worldX, worldZ); + if (flatHeight !== null) { + height = flatHeight; flatZoneModified = true; } @@ -293,35 +319,25 @@ function recomputeNormals( } /** - * Synchronous fallback: generates geometry without a worker. - * Used when workers are unavailable (server-side or fallback). + * Synchronous data generator — produces QuadChunkWorkerOutput on the main thread. + * Used as fallback when workers are unavailable. The output feeds directly into + * assembleQuadChunkGeometry, keeping all assembly logic in one place. */ -export function generateQuadChunkGeometrySync( +export function generateQuadChunkDataSync( centerX: number, centerZ: number, size: number, resolution: number, - provider: ChunkTerrainProvider & { - computeBiomeWeightsAtPosition( - worldX: number, - worldZ: number, - ): { biomeWeightMap: Map; totalWeight: number }; - getBiomeId(biomeName: string): number; - getBiomeColor(biomeName: string): { r: number; g: number; b: number }; - readonly WATER_LEVEL_NORMALIZED: number; - readonly SHORELINE_THRESHOLD: number; - readonly SHORELINE_STRENGTH: number; - readonly MAX_HEIGHT: number; - }, - skirtDrop: number, -): ChunkGeometryResult { + provider: FullTerrainProvider, +): QuadChunkWorkerOutput { const segments = resolution; const halfSize = size * 0.5; const gridStep = size / (segments - 1); + const vertexCount = segments * segments; + // Overflow grid for normals: (segments+2)^2 const gRes = segments + 2; const overflowGrid = new Float32Array(gRes * gRes); - const heightData = new Float32Array(segments * segments); for (let gz = 0; gz < gRes; gz++) { const localZ = -halfSize + (gz - 1) * gridStep; @@ -329,25 +345,25 @@ export function generateQuadChunkGeometrySync( for (let gx = 0; gx < gRes; gx++) { const localX = -halfSize + (gx - 1) * gridStep; const worldX = centerX + localX; - const h = provider.getHeightAtComputed(worldX, worldZ); - overflowGrid[gz * gRes + gx] = h; - if (gx >= 1 && gx <= segments && gz >= 1 && gz <= segments) { - heightData[(gz - 1) * segments + (gx - 1)] = h; - } + overflowGrid[gz * gRes + gx] = provider.getHeightAtComputed( + worldX, + worldZ, + ); } } - const skirtCount = segments * 4; - const totalVertices = segments * segments + skirtCount; - const positions = new Float32Array(totalVertices * 3); - const normalArr = new Float32Array(totalVertices * 3); - const colors = new Float32Array(totalVertices * 3); - const biomeIds = new Float32Array(totalVertices); - const roadInfluences = new Float32Array(totalVertices); + const heightData = new Float32Array(vertexCount); + for (let iz = 0; iz < segments; iz++) { + const srcRow = (iz + 1) * gRes + 1; + const dstRow = iz * segments; + for (let ix = 0; ix < segments; ix++) { + heightData[dstRow + ix] = overflowGrid[srcRow + ix]; + } + } + // Normals via centered finite differences on the overflow grid + const normalData = new Float32Array(vertexCount * 3); const invTwoStep = 1 / (2 * gridStep); - const normalBuf = new Float32Array(segments * segments * 3); - for (let iz = 0; iz < segments; iz++) { const gz = iz + 1; for (let ix = 0; ix < segments; ix++) { @@ -363,37 +379,29 @@ export function generateQuadChunkGeometrySync( const nz = -dhdz; const len = Math.sqrt(nx * nx + ny * ny + nz * nz); const i3 = (iz * segments + ix) * 3; - normalBuf[i3] = nx / len; - normalBuf[i3 + 1] = ny / len; - normalBuf[i3 + 2] = nz / len; + normalData[i3] = nx / len; + normalData[i3 + 1] = ny / len; + normalData[i3 + 2] = nz / len; } } + // Colors and biome IDs + const colorData = new Float32Array(vertexCount * 3); + const biomeData = new Uint8Array(vertexCount); + for (let iz = 0; iz < segments; iz++) { - const localZ = -halfSize + iz * gridStep; - const worldZ = centerZ + localZ; for (let ix = 0; ix < segments; ix++) { - const localX = -halfSize + ix * gridStep; - const worldX = centerX + localX; const idx = iz * segments + ix; - const i3 = idx * 3; - let height = heightData[idx]; - - const flatZone = provider.getFlatZoneAt(worldX, worldZ); - if (flatZone) { - height = flatZone.height; - } + const height = heightData[idx]; + const normalizedHeight = height / provider.MAX_HEIGHT; - positions[i3] = localX; - positions[i3 + 1] = height; - positions[i3 + 2] = localZ; - normalArr[i3] = normalBuf[i3]; - normalArr[i3 + 1] = normalBuf[i3 + 1]; - normalArr[i3 + 2] = normalBuf[i3 + 2]; + const localX = -halfSize + ix * gridStep; + const localZ = -halfSize + iz * gridStep; + const worldX = centerX + localX; + const worldZ = centerZ + localZ; const { biomeWeightMap, totalWeight } = provider.computeBiomeWeightsAtPosition(worldX, worldZ); - const normalizedHeight = height / provider.MAX_HEIGHT; let dominantBiome = "plains"; let dominantWeight = -Infinity; @@ -421,7 +429,7 @@ export function generateQuadChunkGeometrySync( cb = bc.b; } - biomeIds[idx] = provider.getBiomeId(dominantBiome); + biomeData[idx] = provider.getBiomeId(dominantBiome); const waterLevel = provider.WATER_LEVEL_NORMALIZED; const shoreThreshold = provider.SHORELINE_THRESHOLD; @@ -435,123 +443,21 @@ export function generateQuadChunkGeometrySync( cb += (0.333 - cb) * shoreFactor; } - colors[i3] = cr; - colors[i3 + 1] = cg; - colors[i3 + 2] = cb; - - const roadTileX = Math.floor(worldX / provider.TILE_SIZE); - const roadTileZ = Math.floor(worldZ / provider.TILE_SIZE); - roadInfluences[idx] = provider.calculateRoadInfluenceAtVertex( - worldX, - worldZ, - roadTileX, - roadTileZ, - ); + colorData[idx * 3] = cr; + colorData[idx * 3 + 1] = cg; + colorData[idx * 3 + 2] = cb; } } - // Skirt and index generation (reuse the same logic) - let skirtIdx = segments * segments; - const copyEdgeVertex = (mainIdx: number) => { - const si3 = skirtIdx * 3; - const mi3 = mainIdx * 3; - positions[si3] = positions[mi3]; - positions[si3 + 1] = positions[mi3 + 1] - skirtDrop; - positions[si3 + 2] = positions[mi3 + 2]; - normalArr[si3] = normalArr[mi3]; - normalArr[si3 + 1] = normalArr[mi3 + 1]; - normalArr[si3 + 2] = normalArr[mi3 + 2]; - colors[si3] = colors[mi3]; - colors[si3 + 1] = colors[mi3 + 1]; - colors[si3 + 2] = colors[mi3 + 2]; - biomeIds[skirtIdx] = biomeIds[mainIdx]; - roadInfluences[skirtIdx] = roadInfluences[mainIdx]; - skirtIdx++; + return { + type: "quadChunkResult", + centerX, + centerZ, + size, + resolution, + heightData, + normalData, + colorData, + biomeData, }; - - for (let ix = 0; ix < segments; ix++) copyEdgeVertex(ix); - for (let ix = 0; ix < segments; ix++) - copyEdgeVertex((segments - 1) * segments + ix); - for (let iz = 0; iz < segments; iz++) copyEdgeVertex(iz * segments); - for (let iz = 0; iz < segments; iz++) - copyEdgeVertex(iz * segments + (segments - 1)); - - const subs = segments - 1; - const totalIndices = (subs * subs + subs * 4) * 6; - const indices = new Uint32Array(totalIndices); - let ii = 0; - - for (let iz = 0; iz < subs; iz++) { - for (let ix = 0; ix < subs; ix++) { - const a = iz * segments + ix; - const b = a + 1; - const c = a + segments; - const d = c + 1; - indices[ii++] = a; - indices[ii++] = c; - indices[ii++] = b; - indices[ii++] = b; - indices[ii++] = c; - indices[ii++] = d; - } - } - - const skirtBase = segments * segments; - const northSkirtBase = skirtBase; - const southSkirtBase = skirtBase + segments; - const westSkirtBase = skirtBase + segments * 2; - const eastSkirtBase = skirtBase + segments * 3; - - for (let ix = 0; ix < subs; ix++) { - indices[ii++] = northSkirtBase + ix; - indices[ii++] = ix; - indices[ii++] = northSkirtBase + ix + 1; - indices[ii++] = northSkirtBase + ix + 1; - indices[ii++] = ix; - indices[ii++] = ix + 1; - } - for (let ix = 0; ix < subs; ix++) { - const mainA = (segments - 1) * segments + ix; - indices[ii++] = mainA; - indices[ii++] = southSkirtBase + ix; - indices[ii++] = mainA + 1; - indices[ii++] = mainA + 1; - indices[ii++] = southSkirtBase + ix; - indices[ii++] = southSkirtBase + ix + 1; - } - for (let iz = 0; iz < subs; iz++) { - const mainA = iz * segments; - const mainB = (iz + 1) * segments; - indices[ii++] = mainA; - indices[ii++] = westSkirtBase + iz; - indices[ii++] = mainB; - indices[ii++] = mainB; - indices[ii++] = westSkirtBase + iz; - indices[ii++] = westSkirtBase + iz + 1; - } - for (let iz = 0; iz < subs; iz++) { - const mainA = iz * segments + (segments - 1); - const mainB = (iz + 1) * segments + (segments - 1); - indices[ii++] = eastSkirtBase + iz; - indices[ii++] = mainA; - indices[ii++] = eastSkirtBase + iz + 1; - indices[ii++] = eastSkirtBase + iz + 1; - indices[ii++] = mainA; - indices[ii++] = mainB; - } - - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); - geometry.setAttribute("normal", new THREE.BufferAttribute(normalArr, 3)); - geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); - geometry.setAttribute("biomeId", new THREE.BufferAttribute(biomeIds, 1)); - geometry.setAttribute( - "roadInfluence", - new THREE.BufferAttribute(roadInfluences, 1), - ); - geometry.setIndex(new THREE.BufferAttribute(indices, 1)); - geometry.computeBoundingBox(); - geometry.computeBoundingSphere(); - - return { geometry, heightData }; } diff --git a/packages/shared/src/systems/shared/world/TerrainQuadTree.ts b/packages/shared/src/systems/shared/world/TerrainQuadTree.ts index 2ac75325f..59f123a39 100644 --- a/packages/shared/src/systems/shared/world/TerrainQuadTree.ts +++ b/packages/shared/src/systems/shared/world/TerrainQuadTree.ts @@ -19,6 +19,8 @@ export interface QuadTreeConfig { maxDepth: number; /** Split when distance < size * splitRatio */ splitRatio: number; + /** Multiplier on splitRatio for unsplit threshold (prevents thrashing at boundary). Must be > 1. */ + unsplitMultiplier: number; /** Uniform vertex resolution (segments per axis) for ALL depth levels */ resolution: number; /** Skirt drop distance in meters to hide LOD seams */ @@ -29,6 +31,7 @@ export const DEFAULT_QUAD_TREE_CONFIG: QuadTreeConfig = { minSize: 100, maxDepth: 4, splitRatio: 1.5, + unsplitMultiplier: 1.2, resolution: 32, skirtDrop: 15, }; @@ -137,8 +140,10 @@ export class TerrainQuadNode { if (!this.isMaxDepth && !this.splitted) { this.split(); } - } else { - if (this.splitted) { + } else if (this.splitted) { + if ( + this.tree.isOverUnsplitDistance(this.size, this.centerX, this.centerZ) + ) { this.unsplit(); } } @@ -406,6 +411,15 @@ export class TerrainQuadTree { return distance < size * this.config.splitRatio; } + isOverUnsplitDistance(size: number, chunkX: number, chunkZ: number): boolean { + const dx = this.playerX - chunkX; + const dz = this.playerZ - chunkZ; + const distance = Math.sqrt(dx * dx + dz * dz); + return ( + distance > size * this.config.splitRatio * this.config.unsplitMultiplier + ); + } + /** * Main update — call every frame or when player moves significantly. * Returns true if the tree structure changed. diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts index c6013e3df..8336c8ad1 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -1447,7 +1447,10 @@ export class TerrainSystem extends System { QUADTREE_MAX_DEPTH: 4, QUADTREE_SPLIT_RATIO: 1.5, QUADTREE_SKIRT_DROP: 15, + QUADTREE_UNSPLIT_MULTIPLIER: 1.2, QUADTREE_RESOLUTION: 32, + QUADTREE_MAX_SYNC_PER_FRAME: 4, + QUADTREE_MAX_ASSEMBLIES_PER_FRAME: 6, // Shadow settings for terrain meshes (applies to both flat-grid and quad-tree) TERRAIN_RECEIVE_SHADOW: false, @@ -1797,8 +1800,8 @@ export class TerrainSystem extends System { return cached; }, - getFlatZoneAt: (worldX: number, worldZ: number) => - this.getFlatZoneAt(worldX, worldZ), + getFlatZoneHeight: (worldX: number, worldZ: number) => + this.getFlatZoneHeight(worldX, worldZ), WATER_LEVEL_NORMALIZED: this.CONFIG.WATER_LEVEL_NORMALIZED, SHORELINE_THRESHOLD: this.CONFIG.SHORELINE_THRESHOLD, @@ -1822,7 +1825,14 @@ export class TerrainSystem extends System { const qtContainer = new THREE.Group(); qtContainer.name = "QuadTreeTerrainContainer"; - this.terrainContainer.parent!.add(qtContainer); + const containerParent = this.terrainContainer?.parent; + if (!containerParent) { + console.warn( + "[TerrainSystem] Cannot init quad-tree: terrainContainer has no parent", + ); + return; + } + containerParent.add(qtContainer); const provider = this.buildChunkTerrainProvider(); @@ -1862,7 +1872,7 @@ export class TerrainSystem extends System { >(); const workerBiomes: Record< string, - { heightModifier: number; color: { r: number; g: number; b: number } } + { color: { r: number; g: number; b: number } } > = {}; for (const [name, biomeData] of Object.entries(BIOMES)) { let cached = biomeColorCache.get(name); @@ -1871,10 +1881,7 @@ export class TerrainSystem extends System { cached = { r: c.r, g: c.g, b: c.b }; biomeColorCache.set(name, cached); } - workerBiomes[name] = { - heightModifier: biomeData.terrainMultiplier || 1, - color: cached, - }; + workerBiomes[name] = { color: cached }; } this.quadTreeVisualManager = new TerrainVisualManager( @@ -1882,6 +1889,7 @@ export class TerrainSystem extends System { minSize: this.CONFIG.QUADTREE_MIN_SIZE, maxDepth: this.CONFIG.QUADTREE_MAX_DEPTH, splitRatio: this.CONFIG.QUADTREE_SPLIT_RATIO, + unsplitMultiplier: this.CONFIG.QUADTREE_UNSPLIT_MULTIPLIER, resolution: this.CONFIG.QUADTREE_RESOLUTION, skirtDrop: this.CONFIG.QUADTREE_SKIRT_DROP, }, @@ -1895,6 +1903,8 @@ export class TerrainSystem extends System { this.CONFIG.QUADTREE_DEBUG_WIREFRAME, this.CONFIG.TERRAIN_RECEIVE_SHADOW, this.CONFIG.TERRAIN_CAST_SHADOW, + this.CONFIG.QUADTREE_MAX_SYNC_PER_FRAME, + this.CONFIG.QUADTREE_MAX_ASSEMBLIES_PER_FRAME, ); console.log( diff --git a/packages/shared/src/systems/shared/world/TerrainVisualManager.ts b/packages/shared/src/systems/shared/world/TerrainVisualManager.ts index d6f0d3b5a..9f4391f3d 100644 --- a/packages/shared/src/systems/shared/world/TerrainVisualManager.ts +++ b/packages/shared/src/systems/shared/world/TerrainVisualManager.ts @@ -17,8 +17,8 @@ import { } from "./TerrainQuadTree"; import { assembleQuadChunkGeometry, - generateQuadChunkGeometrySync, - type ChunkTerrainProvider, + generateQuadChunkDataSync, + type FullTerrainProvider, } from "./TerrainQuadChunkGenerator"; import { generateQuadChunkAsync, @@ -28,6 +28,8 @@ import { type QuadChunkWorkerOutput, } from "../../../utils/workers/QuadChunkWorker"; +export type VisualManagerTerrainProvider = FullTerrainProvider; + export interface TerrainVisualChunk { key: string; node: TerrainQuadNode; @@ -35,20 +37,6 @@ export interface TerrainVisualChunk { heightData: Float32Array; } -/** Extended provider that includes sync fallback methods + worker config builder */ -export interface VisualManagerTerrainProvider extends ChunkTerrainProvider { - computeBiomeWeightsAtPosition( - worldX: number, - worldZ: number, - ): { biomeWeightMap: Map; totalWeight: number }; - getBiomeId(biomeName: string): number; - getBiomeColor(biomeName: string): { r: number; g: number; b: number }; - readonly WATER_LEVEL_NORMALIZED: number; - readonly SHORELINE_THRESHOLD: number; - readonly SHORELINE_STRENGTH: number; - readonly MAX_HEIGHT: number; -} - interface SettledWorkerResult { nodeId: number; node: TerrainQuadNode; @@ -84,9 +72,13 @@ export class TerrainVisualManager implements QuadTreeListener { private syncQueue: TerrainQuadNode[] = []; /** Destroyed node IDs that should be ignored when results come back */ private cancelledNodeIds = new Set(); + /** Tracks generation failure count per node ID for bounded retry */ + private failedAttempts = new Map(); + + private maxSyncChunksPerFrame: number; + private maxAssembliesPerFrame: number; - private maxSyncChunksPerFrame = 4; - private maxAssembliesPerFrame = 6; + private static MAX_GENERATION_RETRIES = 3; constructor( config: Partial, @@ -100,6 +92,8 @@ export class TerrainVisualManager implements QuadTreeListener { debugWireframe = false, receiveShadow = false, castShadow = false, + maxSyncChunksPerFrame = 4, + maxAssembliesPerFrame = 6, ) { this.provider = provider; this.container = container; @@ -107,6 +101,8 @@ export class TerrainVisualManager implements QuadTreeListener { this.debugWireframe = debugWireframe; this.receiveShadow = receiveShadow; this.castShadow = castShadow; + this.maxSyncChunksPerFrame = maxSyncChunksPerFrame; + this.maxAssembliesPerFrame = maxAssembliesPerFrame; this.workerConfig = workerConfig; this.workerSeed = workerSeed; this.workerBiomeCenters = workerBiomeCenters; @@ -135,6 +131,11 @@ export class TerrainVisualManager implements QuadTreeListener { this.settledResults = []; this.syncQueue = []; this.cancelledNodeIds.clear(); + this.failedAttempts.clear(); + + if (this.container.parent) { + this.container.parent.remove(this.container); + } } getQuadTree(): TerrainQuadTree { @@ -201,6 +202,7 @@ export class TerrainVisualManager implements QuadTreeListener { } const sqIdx = this.syncQueue.indexOf(node); if (sqIdx !== -1) this.syncQueue.splice(sqIdx, 1); + this.failedAttempts.delete(node.id); } // ========================================================================= @@ -294,6 +296,7 @@ export class TerrainVisualManager implements QuadTreeListener { ); } catch (err) { console.error(`[TerrainVisualManager] Assembly failed for ${key}:`, err); + this.handleGenerationFailure(node); return; } @@ -303,25 +306,38 @@ export class TerrainVisualManager implements QuadTreeListener { private generateChunkSync(node: TerrainQuadNode): void { const key = this.makeChunkKey(node); - let result; + let workerData: QuadChunkWorkerOutput; try { - result = generateQuadChunkGeometrySync( + workerData = generateQuadChunkDataSync( node.centerX, node.centerZ, node.size, node.resolution, this.provider, - this.quadTree.config.skirtDrop, ); } catch (err) { console.error( - `[TerrainVisualManager] Sync generation failed for ${key}:`, + `[TerrainVisualManager] Sync data generation failed for ${key}:`, err, ); + this.handleGenerationFailure(node); return; } - this.addMeshToScene(node, key, result); + this.assembleAndAddChunk(node, workerData); + } + + private handleGenerationFailure(node: TerrainQuadNode): void { + const attempts = (this.failedAttempts.get(node.id) ?? 0) + 1; + if (attempts < TerrainVisualManager.MAX_GENERATION_RETRIES) { + this.failedAttempts.set(node.id, attempts); + node.terrainNeedsUpdate = true; + } else { + console.error( + `[TerrainVisualManager] Giving up on node ${node.id} after ${attempts} attempts`, + ); + this.failedAttempts.delete(node.id); + } } private makeChunkKey(node: TerrainQuadNode): string { @@ -382,6 +398,7 @@ export class TerrainVisualManager implements QuadTreeListener { this.chunks.set(key, chunk); node.visualChunkKey = key; + this.failedAttempts.delete(node.id); node.testReady(); } diff --git a/packages/shared/src/utils/workers/QuadChunkWorker.ts b/packages/shared/src/utils/workers/QuadChunkWorker.ts index 9ed6ba6c2..86c318b4d 100644 --- a/packages/shared/src/utils/workers/QuadChunkWorker.ts +++ b/packages/shared/src/utils/workers/QuadChunkWorker.ts @@ -55,13 +55,7 @@ export interface QuadChunkWorkerInput { type: string; influence: number; }>; - biomes: Record< - string, - { - heightModifier: number; - color: { r: number; g: number; b: number }; - } - >; + biomes: Record; } export interface QuadChunkWorkerOutput { From b11400459ed9731470b746093a8bd0ea64644033 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Fri, 6 Mar 2026 12:43:13 +0800 Subject: [PATCH 12/48] feat(terrain): biome-blended terrain generation with centralized architecture - Add BiomeType enum (Tundra/Forest/Desert) as single source of truth - Create TerrainWorkerShared.ts to eliminate code duplication between workers - Implement per-biome noise profiles (terrain shape, terracing, power curves) - Add biome-blended GPU shader with distinct palettes per biome - Place exactly 3 biome centers (one per type) in equilateral triangle layout - Set Forest as default biome with sharp Gaussian boundaries - Clean up dead code (mountain boost, legacy biome types, duplicate worker logic) Made-with: Cursor --- .../shared/world/GrassExclusionGrid.ts | 7 +- .../systems/shared/world/TerrainBiomeTypes.ts | 30 + .../shared/world/TerrainHeightParams.ts | 481 ++++++++++++-- .../shared/world/TerrainQuadChunkGenerator.ts | 40 +- .../src/systems/shared/world/TerrainShader.ts | 79 ++- .../src/systems/shared/world/TerrainSystem.ts | 608 ++++++++---------- .../src/utils/workers/QuadChunkWorker.ts | 288 ++------- .../shared/src/utils/workers/TerrainWorker.ts | 260 +------- .../src/utils/workers/TerrainWorkerShared.ts | 250 +++++++ 9 files changed, 1191 insertions(+), 852 deletions(-) create mode 100644 packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts create mode 100644 packages/shared/src/utils/workers/TerrainWorkerShared.ts diff --git a/packages/shared/src/systems/shared/world/GrassExclusionGrid.ts b/packages/shared/src/systems/shared/world/GrassExclusionGrid.ts index e9830da6a..12bad901c 100644 --- a/packages/shared/src/systems/shared/world/GrassExclusionGrid.ts +++ b/packages/shared/src/systems/shared/world/GrassExclusionGrid.ts @@ -25,6 +25,7 @@ import { texture, uniform, Fn, float } from "three/tsl"; import type { World } from "../../../core/World"; import type { CollisionMatrix } from "../movement/CollisionMatrix"; import { CollisionFlag, CollisionMask } from "../movement/CollisionFlags"; +import { BiomeType } from "./TerrainBiomeTypes"; import { BIOMES } from "../../../data/world-structure"; import { setGridExclusionTexture } from "./ProceduralGrass"; import type { TownSystem } from "./TownSystem"; @@ -44,9 +45,9 @@ const GRID_CONFIG = { /** Distance player must move before re-centering texture */ RECENTER_THRESHOLD: 64, // Re-center when player moves 64m from texture center /** Biomes where grass never grows - matched from biomes.json terrain types */ - NON_GRASSY_TERRAINS: new Set([ - "desert", - "tundra", + NON_GRASSY_TERRAINS: new Set([ + BiomeType.Desert, + BiomeType.Tundra, "mountains", "lake", "frozen", diff --git a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts new file mode 100644 index 000000000..dabdaf0ac --- /dev/null +++ b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts @@ -0,0 +1,30 @@ +/** + * Single source of truth for biome type identifiers. + * + * All terrain files (TerrainSystem, TerrainHeightParams, TerrainShader, + * QuadChunkWorker, TerrainWorker, TerrainQuadChunkGenerator) MUST import + * from here instead of using string literals. + */ + +export enum BiomeType { + Tundra = "tundra", + Forest = "forest", + Desert = "desert", +} + +export const DEFAULT_BIOME = BiomeType.Forest; +export const BIOME_LIST: BiomeType[] = Object.values(BiomeType); + +/** + * Worker-injectable JS that defines BiomeType constants. + * Injected once at the top of inline worker code so the worker + * can reference BT_TUNDRA, BT_FOREST, BT_DESERT without magic strings. + */ +export function buildBiomeConstantsJS(): string { + return ` + var BT_TUNDRA = "${BiomeType.Tundra}"; + var BT_FOREST = "${BiomeType.Forest}"; + var BT_DESERT = "${BiomeType.Desert}"; + var BT_DEFAULT = BT_FOREST; + `; +} diff --git a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts index d406849c0..fcb0ec51c 100644 --- a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts +++ b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts @@ -9,6 +9,8 @@ * them directly as TypeScript constants. */ +import { BiomeType, DEFAULT_BIOME } from "./TerrainBiomeTypes"; + // --------------------------------------------------------------------------- // Noise layer definitions — drive getBaseHeightAt() // --------------------------------------------------------------------------- @@ -61,26 +63,110 @@ export const DETAIL_LAYER: NoiseLayerDef = { /** Power curve applied after blending noise layers and normalizing to [0,1] */ export const HEIGHT_POWER_CURVE = 1.1; +// --------------------------------------------------------------------------- +// Biome noise profiles — per-biome noise weight blending (Option A) +// --------------------------------------------------------------------------- + +export interface BiomeNoiseProfile { + continentWeight: number; + ridgeWeight: number; + hillWeight: number; + erosionWeight: number; + detailWeight: number; + powerCurve: number; + terraceStrength: number; + terraceCeiling: number; + terraceFloor: number; +} + +// Tundra: gentle rolling plains, smooth and open with minimal detail +export const TUNDRA_PROFILE: BiomeNoiseProfile = { + continentWeight: 0.4, + ridgeWeight: 0.05, + hillWeight: 0.12, + erosionWeight: 0.03, + detailWeight: 0.02, + powerCurve: 0.85, + terraceStrength: 0.0, + terraceCeiling: 0.8, + terraceFloor: -0.1, +}; + +// Forest: hilly terrain with moderate ridges and visible terracing +export const FOREST_PROFILE: BiomeNoiseProfile = { + continentWeight: 0.28, + ridgeWeight: 0.2, + hillWeight: 0.35, + erosionWeight: 0.12, + detailWeight: 0.15, + powerCurve: 1.15, + terraceStrength: 0.3, + terraceCeiling: 0.75, + terraceFloor: 0.05, +}; + +// Desert: dramatic mesas and canyons with strong terracing and erosion +export const DESERT_PROFILE: BiomeNoiseProfile = { + continentWeight: 0.32, + ridgeWeight: 0.25, + hillWeight: 0.18, + erosionWeight: 0.2, + detailWeight: 0.05, + powerCurve: 1.5, + terraceStrength: 0.55, + terraceCeiling: 0.85, + terraceFloor: 0.0, +}; + +export const BIOME_PROFILES: Record = { + [BiomeType.Tundra]: TUNDRA_PROFILE, + [BiomeType.Forest]: FOREST_PROFILE, + [BiomeType.Desert]: DESERT_PROFILE, +}; + +export const TERRACE_NOISE_SCALE = 0.005; + // --------------------------------------------------------------------------- // Island configuration // --------------------------------------------------------------------------- export const ISLAND_RADIUS = 350; -export const ISLAND_FALLOFF = 100; +export const ISLAND_FALLOFF = 200; export const ISLAND_DEEP_OCEAN_BUFFER = 50; 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 BEACH_PROFILE_POWER = 3.0; // --------------------------------------------------------------------------- -// Pond configuration +// Landscape features — mountains & ponds, independent of biomes // --------------------------------------------------------------------------- -export const POND_RADIUS = 50; -export const POND_DEPTH = 0.55; -export const POND_CENTER_X = -80; -export const POND_CENTER_Z = 60; +export interface LandscapeFeatureDef { + type: "mountain" | "pond"; + x: number; + z: number; + radius: number; + strength: number; + gaussianCoeff: number; +} + +export const MOUNTAIN_FEATURE_DEFAULTS = { + minRadius: 80, + maxRadius: 200, + minStrength: 0.3, + maxStrength: 0.6, + gaussianCoeff: 0.3, +}; + +export const POND_FEATURE_DEFAULTS = { + minRadius: 30, + maxRadius: 60, + minStrength: 0.4, + maxStrength: 0.6, + gaussianCoeff: 0.5, +}; // --------------------------------------------------------------------------- // Coastline noise — varies the island radius for irregular shoreline @@ -109,39 +195,371 @@ export const COAST_SMALL = { }; // --------------------------------------------------------------------------- -// Mountain boost +// Legacy exports — kept for backward compatibility with TerrainWorker.ts // --------------------------------------------------------------------------- -export const MOUNTAIN_BOOST_MAX_NORM_DIST = 2.5; -export const MOUNTAIN_BOOST_GAUSSIAN_COEFF = 0.3; +/** @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; -// --------------------------------------------------------------------------- -// Worker code generation helper -// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════════ +// SINGLE SOURCE OF TRUTH — pure TypeScript functions +// +// computeBaseHeight() → called directly by TerrainSystem (main thread) +// applyLandscapeFeaturesPure() → called by computeBaseHeight +// +// The buildXxxJS() functions below are worker-embeddable MIRRORS of the +// same logic. They MUST stay in sync — edit both together. +// ═══════════════════════════════════════════════════════════════════════════ + +export interface TerrainNoiseAdapter { + fractal2D( + x: number, + z: number, + octaves: number, + persistence: number, + lacunarity: number, + ): number; + ridgeNoise2D(x: number, z: number): number; + erosionNoise2D(x: number, z: number, iterations: number): number; + simplex2D(x: number, z: number): number; +} + +export function applyLandscapeFeaturesPure( + height: number, + worldX: number, + worldZ: number, + features: ReadonlyArray, +): number { + for (let i = 0; i < features.length; i++) { + const feat = features[i]; + const dx = worldX - feat.x; + const dz = worldZ - feat.z; + const dist = Math.sqrt(dx * dx + dz * dz); + if (dist > feat.radius * 3) continue; + const nd = dist / feat.radius; + const influence = Math.exp(-nd * nd * feat.gaussianCoeff); + if (feat.type === "mountain") { + height += influence * feat.strength; + } else if (feat.type === "pond") { + height -= influence * feat.strength; + } + } + return height; +} + +const TERRACE_STEPS = 6; + +/** + * THE height generation algorithm. Main thread calls this directly. + * Workers use the JS-string mirror (buildGetBaseHeightAtJS) which + * must produce identical results — keep them in sync. + */ +export function computeBaseHeight( + worldX: number, + worldZ: number, + noise: TerrainNoiseAdapter, + 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, + tC = 0, + tF = 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; + tC += p.terraceCeiling * w; + tF += p.terraceFloor * 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 (6-step quantization with noise-varied blend) ────── + if (tS > 0.001) { + const terraceNoise = noise.simplex2D( + worldX * TERRACE_NOISE_SCALE, + worldZ * TERRACE_NOISE_SCALE, + ); + const range = Math.max(0.01, tC - tF); + const normalized = Math.max(0, Math.min(1, (height - tF) / range)); + const quantized = + (Math.round(normalized * TERRACE_STEPS) / TERRACE_STEPS) * range + tF; + const terraceBlend = tS * (0.7 + 0.3 * (terraceNoise * 0.5 + 0.5)); + height = height + (quantized - height) * terraceBlend; + } + + // ── 5. 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( + cnx, + cnz, + COAST_LARGE.octaves, + COAST_LARGE.persistence, + COAST_LARGE.lacunarity, + ); + const cst2 = noise.fractal2D( + cnx * COAST_MEDIUM.freqMultiplier, + cnz * COAST_MEDIUM.freqMultiplier, + COAST_MEDIUM.octaves, + COAST_MEDIUM.persistence, + COAST_MEDIUM.lacunarity, + ); + const cst3 = noise.simplex2D( + cnx * COAST_SMALL.freqMultiplier, + cnz * COAST_SMALL.freqMultiplier, + ); + const coastVar = + cst1 * COAST_LARGE.weight + + cst2 * COAST_MEDIUM.weight + + cst3 * COAST_SMALL.weight; + const effectiveRadius = ISLAND_RADIUS * (1 + coastVar); + + let islandMask = 1.0; + if (distFromCenter > effectiveRadius - ISLAND_FALLOFF) { + const edgeDist = distFromCenter - (effectiveRadius - ISLAND_FALLOFF); + const t = Math.min(1.0, edgeDist / ISLAND_FALLOFF); + islandMask = 1.0 - Math.pow(t, BEACH_PROFILE_POWER); + } + if (distFromCenter > effectiveRadius + ISLAND_DEEP_OCEAN_BUFFER) { + islandMask = 0; + } + + // ── 6. Island mask + landscape features ───────────────────────────── + height = height * islandMask; + height = applyLandscapeFeaturesPure(height, worldX, worldZ, features); + + if (islandMask === 0) { + height = OCEAN_FLOOR_HEIGHT; + } + + return height * maxHeight; +} + +export interface ShorelineConfig { + waterThreshold: number; + shorelineLandBand: number; + shorelineUnderwaterBand: number; + shorelineMinSlope: number; + shorelineLandMaxMultiplier: number; + underwaterDepthMultiplier: number; +} /** - * Generate the JS source for `getBaseHeightAt()` to be embedded in the - * inline worker string. All numeric constants are baked in from the exports - * above, ensuring the worker always matches the main thread. + * Adjust height near shoreline to prevent flat beach zones. + * Both main thread and workers use the same algorithm. + */ +export function adjustShorelineHeight( + baseHeight: number, + slope: number, + config: ShorelineConfig, +): number { + if (baseHeight === config.waterThreshold) return baseHeight; + + const isLand = baseHeight > config.waterThreshold; + const band = isLand + ? config.shorelineLandBand + : config.shorelineUnderwaterBand; + if (band <= 0) return baseHeight; + + const delta = Math.abs(baseHeight - config.waterThreshold); + if (delta >= band) return baseHeight; + + if (config.shorelineMinSlope <= 0) return baseHeight; + + const maxMul = isLand + ? config.shorelineLandMaxMultiplier + : config.underwaterDepthMultiplier; + if (maxMul <= 1) return baseHeight; + + const slopeSafe = Math.max(0.0001, slope); + const targetMul = Math.min( + maxMul, + Math.max(1, config.shorelineMinSlope / slopeSafe), + ); + const falloff = 1 - delta / band; + const mul = 1 + (targetMul - 1) * falloff; + const adjustedDelta = delta * mul; + + return isLand + ? config.waterThreshold + adjustedDelta + : config.waterThreshold - adjustedDelta; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Worker JS string mirrors — MUST match the pure functions above. +// Workers can't import TS modules, so we generate equivalent JS strings +// with constants baked in. Keep these in lock-step with the TS functions. +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * JS source for position-only biome weight computation. + * Worker mirror — depends on: noise, biomeCenters, + * BIOME_BOUNDARY_NOISE_SCALE, BIOME_BOUNDARY_NOISE_AMOUNT, BIOME_GAUSSIAN_COEFF. + */ +export function buildComputeBiomeWeightsJS(): string { + return ` + function computeBiomeWeightsByPosition(worldX, worldZ) { + var boundaryNoise = noise.simplex2D( + worldX * BIOME_BOUNDARY_NOISE_SCALE, + worldZ * BIOME_BOUNDARY_NOISE_SCALE + ); + var weights = {}; + var totalWeight = 0; + for (var i = 0; i < biomeCenters.length; i++) { + var center = biomeCenters[i]; + var dx = worldX - center.x; + var dz = worldZ - center.z; + var dist = Math.sqrt(dx * dx + dz * dz); + var noisyDist = dist * (1 + boundaryNoise * BIOME_BOUNDARY_NOISE_AMOUNT); + var normDist = noisyDist / center.influence; + var w = Math.exp(-normDist * normDist * BIOME_GAUSSIAN_COEFF); + var type = center.type; + weights[type] = (weights[type] || 0) + w; + totalWeight += w; + } + if (totalWeight > 0) { + for (var key in weights) { weights[key] /= totalWeight; } + } else { + weights[BT_DEFAULT] = 1.0; + } + return weights; + }`; +} + +/** + * JS source — worker mirror of applyLandscapeFeaturesPure(). + * Depends on: landscapeFeatures array injected into worker scope. + */ +export function buildApplyLandscapeFeaturesJS(): string { + return ` + function applyLandscapeFeatures(height, worldX, worldZ) { + for (var i = 0; i < landscapeFeatures.length; i++) { + var feat = landscapeFeatures[i]; + var dx = worldX - feat.x; + var dz = worldZ - feat.z; + var dist = Math.sqrt(dx * dx + dz * dz); + if (dist > feat.radius * 3) continue; + var normDist = dist / feat.radius; + var influence = Math.exp(-normDist * normDist * feat.gaussianCoeff); + if (feat.type === 'mountain') { + height += influence * feat.strength; + } else if (feat.type === 'pond') { + height -= influence * feat.strength; + } + } + return height; + }`; +} + +// 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}, tC: ${TUNDRA_PROFILE.terraceCeiling}, tF: ${TUNDRA_PROFILE.terraceFloor} }; + 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}, tC: ${FOREST_PROFILE.terraceCeiling}, tF: ${FOREST_PROFILE.terraceFloor} }; + BIOME_PROFILES[BT_DESERT] = { cW: ${DESERT_PROFILE.continentWeight}, rW: ${DESERT_PROFILE.ridgeWeight}, hW: ${DESERT_PROFILE.hillWeight}, eW: ${DESERT_PROFILE.erosionWeight}, dW: ${DESERT_PROFILE.detailWeight}, pC: ${DESERT_PROFILE.powerCurve}, tS: ${DESERT_PROFILE.terraceStrength}, tC: ${DESERT_PROFILE.terraceCeiling}, tF: ${DESERT_PROFILE.terraceFloor} }; + var TERRACE_NOISE_SCALE = ${TERRACE_NOISE_SCALE}; + var TERRACE_STEPS = ${TERRACE_STEPS}; +`; + +/** + * JS source — worker mirror of computeBaseHeight(). + * Accepts biome weights and blends noise per-biome. + * MUST stay in sync with computeBaseHeight() above. */ export function buildGetBaseHeightAtJS(): string { return ` - function getBaseHeightAt(worldX, worldZ) { + ${PROFILES_JS} + + function getBaseHeightAt(worldX, worldZ, biomeWeights) { + var bw = biomeWeights || computeBiomeWeightsByPosition(worldX, worldZ); + 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}); - var height = 0; - height += cN * ${CONTINENT_LAYER.weight}; - height += rN * ${RIDGE_LAYER.weight}; - height += hN * ${HILL_LAYER.weight}; - height += eN * ${EROSION_LAYER.weight}; - height += dN * ${DETAIL_LAYER.weight}; + var cW = 0, rW = 0, hW = 0, eW = 0, dW = 0, pC = 0, tS = 0, tC = 0, tF = 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; tC += p.tC * w; tF += p.tF * w; + } + + 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, ${HEIGHT_POWER_CURVE}); + height = Math.pow(height, pC); + + if (tS > 0.001) { + var terraceNoise = noise.simplex2D(worldX * TERRACE_NOISE_SCALE, worldZ * TERRACE_NOISE_SCALE); + var range = Math.max(0.01, tC - tF); + var normalized = Math.max(0, Math.min(1, (height - tF) / range)); + var quantized = Math.round(normalized * TERRACE_STEPS) / TERRACE_STEPS * range + tF; + var terraceBlend = tS * (0.7 + 0.3 * (terraceNoise * 0.5 + 0.5)); + height = height + (quantized - height) * terraceBlend; + } var distFromCenter = Math.sqrt(worldX * worldX + worldZ * worldZ); var angle = Math.atan2(worldZ, worldX); @@ -157,26 +575,15 @@ export function buildGetBaseHeightAtJS(): string { if (distFromCenter > effectiveRadius - ${ISLAND_FALLOFF}) { var edgeDist = distFromCenter - (effectiveRadius - ${ISLAND_FALLOFF}); var t = Math.min(1.0, edgeDist / ${ISLAND_FALLOFF}); - var smoothstep = t * t * (3 - 2 * t); - islandMask = 1.0 - smoothstep; + islandMask = 1.0 - Math.pow(t, ${BEACH_PROFILE_POWER}); } if (distFromCenter > effectiveRadius + ${ISLAND_DEEP_OCEAN_BUFFER}) { islandMask = 0; } - var distFromPond = Math.sqrt( - (worldX - (${POND_CENTER_X})) * (worldX - (${POND_CENTER_X})) + - (worldZ - ${POND_CENTER_Z}) * (worldZ - ${POND_CENTER_Z}) - ); - var pondDepression = 0; - if (distFromPond < ${POND_RADIUS} * 2) { - var pondFactor = 1.0 - distFromPond / (${POND_RADIUS} * 2); - pondDepression = pondFactor * pondFactor * ${POND_DEPTH}; - } - height = height * islandMask; - height = height * ${HEIGHT_TERRAIN_MIX} + ${BASE_ELEVATION} * islandMask; - height -= pondDepression; + height = applyLandscapeFeatures(height, worldX, worldZ); + if (islandMask === 0) { height = ${OCEAN_FLOOR_HEIGHT}; } return height * MAX_HEIGHT; }`; diff --git a/packages/shared/src/systems/shared/world/TerrainQuadChunkGenerator.ts b/packages/shared/src/systems/shared/world/TerrainQuadChunkGenerator.ts index 1ef8f2810..8bab0fb55 100644 --- a/packages/shared/src/systems/shared/world/TerrainQuadChunkGenerator.ts +++ b/packages/shared/src/systems/shared/world/TerrainQuadChunkGenerator.ts @@ -20,6 +20,7 @@ import THREE from "../../../extras/three/three"; import type { QuadChunkWorkerOutput } from "../../../utils/workers/QuadChunkWorker"; +import { BiomeType, DEFAULT_BIOME } from "./TerrainBiomeTypes"; /** * Main-thread callbacks for game-state queries that can't run in a worker. @@ -53,6 +54,10 @@ export interface FullTerrainProvider extends ChunkTerrainProvider { worldX: number, worldZ: number, ): { biomeWeightMap: Map; totalWeight: number }; + computeBiomeWeightsByPosition( + worldX: number, + worldZ: number, + ): Record; getBiomeId(biomeName: string): number; getBiomeColor(biomeName: string): { r: number; g: number; b: number }; readonly WATER_LEVEL_NORMALIZED: number; @@ -84,6 +89,8 @@ export function assembleQuadChunkGeometry( normalData, colorData, biomeData, + biomeForestWeight, + biomeDesertWeight, } = workerData; const segments = resolution; const halfSize = size * 0.5; @@ -95,6 +102,8 @@ export function assembleQuadChunkGeometry( const normals = new Float32Array(totalVertices * 3); const colors = new Float32Array(totalVertices * 3); const biomeIds = new Float32Array(totalVertices); + const forestWeights = new Float32Array(totalVertices); + const desertWeights = new Float32Array(totalVertices); const roadInfluences = new Float32Array(totalVertices); let flatZoneModified = false; @@ -130,6 +139,8 @@ export function assembleQuadChunkGeometry( colors[i3 + 2] = colorData[i3 + 2]; biomeIds[idx] = biomeData[idx]; + forestWeights[idx] = biomeForestWeight[idx]; + desertWeights[idx] = biomeDesertWeight[idx]; const roadTileX = Math.floor(worldX / provider.TILE_SIZE); const roadTileZ = Math.floor(worldZ / provider.TILE_SIZE); @@ -164,6 +175,8 @@ export function assembleQuadChunkGeometry( colors[si3 + 1] = colors[mi3 + 1]; colors[si3 + 2] = colors[mi3 + 2]; biomeIds[skirtIdx] = biomeIds[mainIdx]; + forestWeights[skirtIdx] = forestWeights[mainIdx]; + desertWeights[skirtIdx] = desertWeights[mainIdx]; roadInfluences[skirtIdx] = roadInfluences[mainIdx]; skirtIdx++; }; @@ -266,6 +279,14 @@ export function assembleQuadChunkGeometry( geometry.setAttribute("normal", new THREE.BufferAttribute(normals, 3)); geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); geometry.setAttribute("biomeId", new THREE.BufferAttribute(biomeIds, 1)); + geometry.setAttribute( + "biomeForestWeight", + new THREE.BufferAttribute(forestWeights, 1), + ); + geometry.setAttribute( + "biomeDesertWeight", + new THREE.BufferAttribute(desertWeights, 1), + ); geometry.setAttribute( "roadInfluence", new THREE.BufferAttribute(roadInfluences, 1), @@ -388,6 +409,8 @@ export function generateQuadChunkDataSync( // Colors and biome IDs const colorData = new Float32Array(vertexCount * 3); const biomeData = new Uint8Array(vertexCount); + const biomeForestWeight = new Float32Array(vertexCount); + const biomeDesertWeight = new Float32Array(vertexCount); for (let iz = 0; iz < segments; iz++) { for (let ix = 0; ix < segments; ix++) { @@ -403,7 +426,7 @@ export function generateQuadChunkDataSync( const { biomeWeightMap, totalWeight } = provider.computeBiomeWeightsAtPosition(worldX, worldZ); - let dominantBiome = "plains"; + let dominantBiome: string = DEFAULT_BIOME; let dominantWeight = -Infinity; let cr = 0, cg = 0, @@ -423,7 +446,7 @@ export function generateQuadChunkDataSync( cb += bc.b * weight; } } else { - const bc = provider.getBiomeColor("plains"); + const bc = provider.getBiomeColor(DEFAULT_BIOME); cr = bc.r; cg = bc.g; cb = bc.b; @@ -431,6 +454,17 @@ export function generateQuadChunkDataSync( biomeData[idx] = provider.getBiomeId(dominantBiome); + const fwNorm = + totalWeight > 0 + ? (biomeWeightMap.get(BiomeType.Forest) || 0) / totalWeight + : 0; + const dwNorm = + totalWeight > 0 + ? (biomeWeightMap.get(BiomeType.Desert) || 0) / totalWeight + : 0; + biomeForestWeight[idx] = fwNorm; + biomeDesertWeight[idx] = dwNorm; + const waterLevel = provider.WATER_LEVEL_NORMALIZED; const shoreThreshold = provider.SHORELINE_THRESHOLD; if (normalizedHeight > waterLevel && normalizedHeight < shoreThreshold) { @@ -459,5 +493,7 @@ export function generateQuadChunkDataSync( normalData, colorData, biomeData, + biomeForestWeight, + biomeDesertWeight, }; } diff --git a/packages/shared/src/systems/shared/world/TerrainShader.ts b/packages/shared/src/systems/shared/world/TerrainShader.ts index e8d04c440..28aec6bbe 100644 --- a/packages/shared/src/systems/shared/world/TerrainShader.ts +++ b/packages/shared/src/systems/shared/world/TerrainShader.ts @@ -59,12 +59,29 @@ export const TERRAIN_CONSTANTS = { // SHARED TERRAIN BASE COLOR (used by terrain shader AND tree ground-blend) // ============================================================================ -// OSRS palette — duplicated as module-level so the shared function and the -// terrain shader itself both reference the same values. -const GRASS_GREEN = vec3(0.3, 0.55, 0.15); -const GRASS_DARK = vec3(0.22, 0.42, 0.1); -const DIRT_BROWN = vec3(0.45, 0.32, 0.18); -const DIRT_DARK = vec3(0.32, 0.22, 0.12); +// --- 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_DIRT = vec3(0.55, 0.55, 0.58); +const TUNDRA_DIRT_DARK = vec3(0.42, 0.42, 0.45); + +// --- 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_DIRT = vec3(0.35, 0.24, 0.12); +const FOREST_DIRT_DARK = vec3(0.22, 0.15, 0.08); + +// --- Desert palette: red-orange sand with deep crimson rock --- +const DESERT_SAND = vec3(0.82, 0.52, 0.28); +const DESERT_SAND_DARK = vec3(0.72, 0.42, 0.2); +const DESERT_ROCK = vec3(0.62, 0.28, 0.15); +const DESERT_ROCK_DARK = vec3(0.48, 0.2, 0.1); + +// Legacy aliases used by road overlay and other shader sections (default = forest) +const GRASS_GREEN = FOREST_GRASS; +const GRASS_DARK = FOREST_GRASS_DARK; +const DIRT_BROWN = FOREST_DIRT; +const DIRT_DARK = FOREST_DIRT_DARK; const ROCK_GRAY = vec3(0.45, 0.42, 0.38); const ROCK_DARK = vec3(0.3, 0.28, 0.25); const SAND_YELLOW = vec3(0.7, 0.6, 0.38); @@ -81,17 +98,32 @@ const WATER_EDGE = vec3(0.08, 0.06, 0.04); * @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 desertWeight - biome weight for desert [0..1] */ export function computeTerrainBaseColor( height: any, slope: any, noiseVal: any, noiseVal2: any, + forestWeight?: any, + desertWeight?: any, ) { + const fW = forestWeight ?? float(0.0); + const dW = desertWeight ?? float(0.0); + const tW = sub(float(1.0), add(fW, dW)); + + // Biome-blended grass const grassVariation = smoothstep(float(0.4), float(0.6), noiseVal2); - let c = mix(GRASS_GREEN, GRASS_DARK, grassVariation); + const tundraGrass = mix(TUNDRA_GRASS, TUNDRA_GRASS_DARK, grassVariation); + const forestGrass = mix(FOREST_GRASS, FOREST_GRASS_DARK, grassVariation); + const desertGrass = mix(DESERT_SAND, DESERT_SAND_DARK, grassVariation); + let c: any = add( + add(mul(tundraGrass, tW), mul(forestGrass, fW)), + mul(desertGrass, dW), + ); - // Dirt patches (noise-based, flat ground) + // Biome-blended dirt const dirtPatchFactor = smoothstep( float(TERRAIN_CONSTANTS.DIRT_THRESHOLD - 0.05), float(TERRAIN_CONSTANTS.DIRT_THRESHOLD + 0.15), @@ -99,7 +131,13 @@ export function computeTerrainBaseColor( ); const flatnessFactor = smoothstep(float(0.3), float(0.05), slope); const dirtVariation = smoothstep(float(0.3), float(0.7), noiseVal2); - const dirtColor = mix(DIRT_BROWN, DIRT_DARK, dirtVariation); + const tundraDirt = mix(TUNDRA_DIRT, TUNDRA_DIRT_DARK, dirtVariation); + const forestDirt = mix(FOREST_DIRT, FOREST_DIRT_DARK, dirtVariation); + const desertDirt = mix(DESERT_ROCK, DESERT_ROCK_DARK, dirtVariation); + const dirtColor = add( + add(mul(tundraDirt, tW), mul(forestDirt, fW)), + mul(desertDirt, dW), + ); c = mix(c, dirtColor, mul(dirtPatchFactor, flatnessFactor)); // Slope-based dirt @@ -114,19 +152,28 @@ export function computeTerrainBaseColor( const rockColor = mix(ROCK_GRAY, ROCK_DARK, rockVariation); c = mix(c, rockColor, smoothstep(float(0.45), float(0.75), slope)); - // Snow at high elevation + // Snow at high elevation (suppressed in desert) + const snowMask = sub(float(1.0), dW); c = mix( c, SNOW_WHITE, - smoothstep(float(TERRAIN_CONSTANTS.SNOW_HEIGHT - 5.0), float(60.0), height), + mul( + smoothstep( + float(TERRAIN_CONSTANTS.SNOW_HEIGHT - 5.0), + float(60.0), + height, + ), + snowMask, + ), ); - // Sand near water (flat areas) + // Sand near water (flat areas, stronger in desert) const sandBlend = mul( smoothstep(float(10.0), float(6.0), height), smoothstep(float(0.25), float(0.0), slope), ); - c = mix(c, SAND_YELLOW, mul(sandBlend, float(0.6))); + const sandStrength = mix(float(0.6), float(0.9), dW); + c = mix(c, SAND_YELLOW, mul(sandBlend, sandStrength)); // Shoreline transitions c = mix( @@ -604,12 +651,18 @@ export function createTerrainMaterial(): THREE.Material & { float(0.5), ); + // Biome weight attributes (computed per-vertex by QuadChunkWorker) + const biomeForestW = attribute("biomeForestWeight", "float"); + const biomeDesertW = attribute("biomeDesertWeight", "float"); + // Base color from shared procedural palette const baseColor = computeTerrainBaseColor( height, slope, noiseValue, noiseValue2, + biomeForestW, + biomeDesertW, ); // Anti-dithering noise variation (±4% brightness, ±2% color shift) diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts index 8336c8ad1..ae815fc84 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -18,29 +18,23 @@ import { type TerrainWorkerOutput, } from "../../../utils/workers"; import { - CONTINENT_LAYER, - RIDGE_LAYER, - HILL_LAYER, - EROSION_LAYER, - DETAIL_LAYER, - HEIGHT_POWER_CURVE, - ISLAND_RADIUS, - ISLAND_FALLOFF, - ISLAND_DEEP_OCEAN_BUFFER, - BASE_ELEVATION, - OCEAN_FLOOR_HEIGHT, - HEIGHT_TERRAIN_MIX, POND_RADIUS, POND_DEPTH, POND_CENTER_X, POND_CENTER_Z, - COASTLINE_CIRCLE_SAMPLE_RADIUS, - COAST_LARGE, - COAST_MEDIUM, - COAST_SMALL, - MOUNTAIN_BOOST_MAX_NORM_DIST, - MOUNTAIN_BOOST_GAUSSIAN_COEFF, + MOUNTAIN_FEATURE_DEFAULTS, + POND_FEATURE_DEFAULTS, + ISLAND_RADIUS, + computeBaseHeight, + adjustShorelineHeight, + buildComputeBiomeWeightsJS, + buildApplyLandscapeFeaturesJS, } from "./TerrainHeightParams"; +import type { + LandscapeFeatureDef, + ShorelineConfig, +} from "./TerrainHeightParams"; +import { BiomeType, DEFAULT_BIOME, BIOME_LIST } from "./TerrainBiomeTypes"; // Import terrain generator from procgen package import { @@ -188,7 +182,11 @@ export class TerrainSystem extends System { private terrainTime = 0; // For animated caustics private noise!: NoiseGenerator; private biomeCenters: BiomeCenter[] = []; + private landscapeFeatures: LandscapeFeatureDef[] = []; + private biomeWeightPositionalScratch = new Map(); private biomeWeightScratch = new Map(); + private _loggedWorkerTileBiome = 0; + private _loggedSyncTileBiome = 0; private databaseSystem!: { saveWorldChunk(chunkData: WorldChunkData): void; }; // DatabaseSystem reference @@ -783,6 +781,14 @@ export class TerrainSystem extends System { this.CONFIG.SHORELINE_LAND_MAX_MULTIPLIER, SHORELINE_UNDERWATER_BAND: this.CONFIG.SHORELINE_UNDERWATER_BAND, UNDERWATER_DEPTH_MULTIPLIER: this.CONFIG.UNDERWATER_DEPTH_MULTIPLIER, + landscapeFeatures: this.landscapeFeatures.map((f) => ({ + type: f.type, + x: f.x, + z: f.z, + radius: f.radius, + strength: f.strength, + gaussianCoeff: f.gaussianCoeff, + })), }; // Build simplified biome data for worker @@ -854,6 +860,8 @@ export class TerrainSystem extends System { const positions = geometry.attributes.position; const roadInfluences = new Float32Array(positions.count); + const forestWeights = new Float32Array(positions.count); + const desertWeights = new Float32Array(positions.count); const tileKey = `${tileX}_${tileZ}`; const hasFlatZones = (this.flatZonesByTile.get(tileKey)?.length ?? 0) > 0; @@ -883,7 +891,7 @@ export class TerrainSystem extends System { } } - // Road influence per vertex (cheap — no noise, only tile lookups) + // Road influence and biome weights per vertex for (let i = 0; i < positions.count; i++) { const localX = positions.getX(i); const localZ = positions.getZ(i); @@ -895,6 +903,14 @@ export class TerrainSystem extends System { tileX, tileZ, ); + + const { biomeWeightMap, totalWeight } = + this.computeBiomeWeightsAtPosition(worldX, worldZ); + if (totalWeight > 0) { + const invW = 1 / totalWeight; + forestWeights[i] = (biomeWeightMap.get(BiomeType.Forest) || 0) * invW; + desertWeights[i] = (biomeWeightMap.get(BiomeType.Desert) || 0) * invW; + } } // Set attributes @@ -907,6 +923,34 @@ export class TerrainSystem extends System { "roadInfluence", new THREE.BufferAttribute(roadInfluences, 1), ); + geometry.setAttribute( + "biomeForestWeight", + new THREE.BufferAttribute(forestWeights, 1), + ); + geometry.setAttribute( + "biomeDesertWeight", + new THREE.BufferAttribute(desertWeights, 1), + ); + + // DEBUG: log biome weights for first 5 tiles + if (this._loggedWorkerTileBiome < 5) { + this._loggedWorkerTileBiome++; + let maxF = 0, + maxD = 0, + sumF = 0, + sumD = 0; + for (let _d = 0; _d < forestWeights.length; _d++) { + sumF += forestWeights[_d]; + sumD += desertWeights[_d]; + maxF = Math.max(maxF, forestWeights[_d]); + maxD = Math.max(maxD, desertWeights[_d]); + } + const n = forestWeights.length; + const avgP = (1 - sumF / n - sumD / n).toFixed(3); + console.log( + `[BiomeDebug/WorkerTile] Tile(${tileX},${tileZ}) plains=${avgP} forest=${(sumF / n).toFixed(3)} desert=${(sumD / n).toFixed(3)}`, + ); + } if (TERRAIN_ROAD_INFLUENCE_DEBUG) { let nonZeroCount = 0; @@ -1177,66 +1221,90 @@ export class TerrainSystem extends System { } private initializeBiomeCenters(): void { - const worldSize = this.getActiveWorldSizeMeters(); - const gridSize = this.CONFIG.BIOME_GRID_SIZE; // 5x5 grid - const cellSize = worldSize / gridSize; + // Exactly one center per biome type, placed in a triangle within the island + const placementRadius = ISLAND_RADIUS * 0.45; - // Use deterministic PRNG for reproducible biome placement - const baseSeed = this.computeSeedFromWorldId(); - let randomState = baseSeed; + this.biomeCenters = []; + for (let i = 0; i < BIOME_LIST.length; i++) { + const angle = (i / BIOME_LIST.length) * Math.PI * 2 - Math.PI / 2; + this.biomeCenters.push({ + x: Math.cos(angle) * placementRadius, + z: Math.sin(angle) * placementRadius, + type: BIOME_LIST[i], + influence: ISLAND_RADIUS * 0.6, + }); + } - const nextRandom = () => { - // Linear congruential generator for deterministic random - randomState = (randomState * 1664525 + 1013904223) >>> 0; - return randomState / 0xffffffff; - }; + const typeCounts: Record = {}; + for (const c of this.biomeCenters) { + typeCounts[c.type] = (typeCounts[c.type] || 0) + 1; + } + console.log( + "[TerrainSystem] Biome centers:", + this.biomeCenters.length, + "types:", + JSON.stringify(typeCounts), + ); + } - // Weighted biome types - plains dominant, with variety - const biomeTypes = [ - "plains", - "plains", - "plains", - "forest", - "forest", - "valley", - "mountains", - "mountains", - "desert", - "swamp", - "tundra", // Added back - ]; + private initializeLandscapeFeatures(): void { + this.landscapeFeatures = []; - // Clear any existing centers - this.biomeCenters = []; + const baseSeed = this.computeSeedFromWorldId() + 77777; + let state = baseSeed; + const rand = () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 0xffffffff; + }; - // Grid-jitter placement for even distribution - for (let gx = 0; gx < gridSize; gx++) { - for (let gz = 0; gz < gridSize; gz++) { - // Base position at grid cell center - const baseX = (gx + 0.5) * cellSize - worldSize / 2; - const baseZ = (gz + 0.5) * cellSize - worldSize / 2; - - // Jitter within cell (controlled randomness) - const jitter = this.CONFIG.BIOME_JITTER; - const jitterX = (nextRandom() - 0.5) * 2 * jitter * cellSize; - const jitterZ = (nextRandom() - 0.5) * 2 * jitter * cellSize; - - const x = baseX + jitterX; - const z = baseZ + jitterZ; - - const typeIndex = Math.floor(nextRandom() * biomeTypes.length); - const influenceRange = - this.CONFIG.BIOME_MAX_INFLUENCE - this.CONFIG.BIOME_MIN_INFLUENCE; - const influence = - this.CONFIG.BIOME_MIN_INFLUENCE + nextRandom() * influenceRange; - - this.biomeCenters.push({ - x, - z, - type: biomeTypes[typeIndex], - influence, - }); - } + const worldSize = this.getActiveWorldSizeMeters(); + const halfWorld = worldSize / 2; + + const mountainCount = 4; + for (let i = 0; i < mountainCount; i++) { + const x = (rand() - 0.5) * worldSize * 0.7; + const z = (rand() - 0.5) * worldSize * 0.7; + const dist = Math.sqrt(x * x + z * z); + if (dist > halfWorld * 0.85) continue; + this.landscapeFeatures.push({ + type: "mountain", + x, + z, + radius: + MOUNTAIN_FEATURE_DEFAULTS.minRadius + + rand() * + (MOUNTAIN_FEATURE_DEFAULTS.maxRadius - + MOUNTAIN_FEATURE_DEFAULTS.minRadius), + strength: + MOUNTAIN_FEATURE_DEFAULTS.minStrength + + rand() * + (MOUNTAIN_FEATURE_DEFAULTS.maxStrength - + MOUNTAIN_FEATURE_DEFAULTS.minStrength), + gaussianCoeff: MOUNTAIN_FEATURE_DEFAULTS.gaussianCoeff, + }); + } + + const pondCount = 3; + for (let i = 0; i < pondCount; i++) { + const x = (rand() - 0.5) * worldSize * 0.6; + const z = (rand() - 0.5) * worldSize * 0.6; + const dist = Math.sqrt(x * x + z * z); + if (dist > halfWorld * 0.75) continue; + this.landscapeFeatures.push({ + type: "pond", + x, + z, + radius: + POND_FEATURE_DEFAULTS.minRadius + + rand() * + (POND_FEATURE_DEFAULTS.maxRadius - POND_FEATURE_DEFAULTS.minRadius), + strength: + POND_FEATURE_DEFAULTS.minStrength + + rand() * + (POND_FEATURE_DEFAULTS.maxStrength - + POND_FEATURE_DEFAULTS.minStrength), + gaussianCoeff: POND_FEATURE_DEFAULTS.gaussianCoeff, + }); } } @@ -1399,11 +1467,11 @@ export class TerrainSystem extends System { BOSS_MIN_LEVEL: 800, // Biome Generation - BIOME_GRID_SIZE: 3, // 3x3 grid = 9 very large biomes + BIOME_GRID_SIZE: 5, // 5x5 grid = 25 biomes, visible within camera range BIOME_JITTER: 0.35, // How much to randomize position within grid cell (0-0.5) - BIOME_MIN_INFLUENCE: 2000, // Biome influence radius in meters - BIOME_MAX_INFLUENCE: 3500, // Biome influence radius in meters - BIOME_GAUSSIAN_COEFF: 0.15, // Gaussian falloff (smooth natural decay) + BIOME_MIN_INFLUENCE: 120, // Biome influence radius — roughly 1 grid cell + BIOME_MAX_INFLUENCE: 200, // Biome influence radius — ~1.5 grid cells + BIOME_GAUSSIAN_COEFF: 12.0, // Gaussian falloff — higher = sharper biome boundaries BIOME_RANGE_MULT: 10, // Not used - no hard cutoff BIOME_BOUNDARY_NOISE_SCALE: 0.003, // Larger scale noise for organic boundaries BIOME_BOUNDARY_NOISE_AMOUNT: 0.15, // Subtle noise @@ -1481,6 +1549,7 @@ export class TerrainSystem extends System { // Initialize biome centers using deterministic random placement this.initializeBiomeCenters(); + this.initializeLandscapeFeatures(); // Initialize the unified terrain generator from @hyperscape/procgen // This provides a standalone, testable height generation system @@ -1591,6 +1660,7 @@ export class TerrainSystem extends System { if (!this.noise) { this.noise = new NoiseGenerator(this.computeSeedFromWorldId()); this.initializeBiomeCenters(); + this.initializeLandscapeFeatures(); } // CRITICAL: Wait for DataManager to initialize BIOMES data before generating terrain @@ -1776,6 +1846,9 @@ export class TerrainSystem extends System { computeBiomeWeightsAtPosition: (worldX: number, worldZ: number) => this.computeBiomeWeightsAtPosition(worldX, worldZ), + computeBiomeWeightsByPosition: (worldX: number, worldZ: number) => + this.computeBiomeWeightsByPosition(worldX, worldZ), + calculateRoadInfluenceAtVertex: ( worldX: number, worldZ: number, @@ -1857,6 +1930,7 @@ export class TerrainSystem extends System { SHORELINE_LAND_MAX_MULTIPLIER: this.CONFIG.SHORELINE_LAND_MAX_MULTIPLIER, SHORELINE_UNDERWATER_BAND: this.CONFIG.SHORELINE_UNDERWATER_BAND, UNDERWATER_DEPTH_MULTIPLIER: this.CONFIG.UNDERWATER_DEPTH_MULTIPLIER, + landscapeFeatures: this.landscapeFeatures, }; const biomeCenters = this.biomeCenters.map((c) => ({ @@ -1866,6 +1940,23 @@ export class TerrainSystem extends System { influence: c.influence, })); + // DEBUG: log what we're sending to the worker + const workerTypeCounts: Record = {}; + for (const c of biomeCenters) { + workerTypeCounts[c.type ?? "UNDEFINED"] = + (workerTypeCounts[c.type ?? "UNDEFINED"] || 0) + 1; + } + console.log( + "[TerrainSystem] Worker biomeCenters:", + biomeCenters.length, + "types:", + JSON.stringify(workerTypeCounts), + ); + console.log( + "[TerrainSystem] First 5 centers:", + JSON.stringify(biomeCenters.slice(0, 5)), + ); + const biomeColorCache = new Map< string, { r: number; g: number; b: number } @@ -2547,6 +2638,8 @@ export class TerrainSystem extends System { const heightData: number[] = []; const biomeIds = new Float32Array(positions.count); const roadInfluences = new Float32Array(positions.count); + const forestWeights = new Float32Array(positions.count); + const desertWeights = new Float32Array(positions.count); // Verify biome data is loaded - error if not if (Object.keys(BIOMES).length === 0) { @@ -2554,7 +2647,7 @@ export class TerrainSystem extends System { "[TerrainSystem] BIOMES data not loaded! DataManager must initialize before terrain generation.", ); } - const defaultBiomeData = BIOMES["plains"]; + const defaultBiomeData = BIOMES[DEFAULT_BIOME] || BIOMES["plains"]; if (!defaultBiomeData) { throw new Error("[TerrainSystem] Plains biome not found in BIOMES data!"); } @@ -2588,7 +2681,7 @@ export class TerrainSystem extends System { const normalizedHeight = height / this.CONFIG.MAX_HEIGHT; // Store dominant biome ID for shader - let dominantBiome = "plains"; + let dominantBiome = DEFAULT_BIOME as string; let dominantWeight = -Infinity; // PERFORMANCE: Reuse _tempColor to avoid GC pressure (no new THREE.Color per vertex) @@ -2612,22 +2705,19 @@ export class TerrainSystem extends System { `[TerrainSystem] Biome "${type}" not found in BIOMES data!`, ); } - // Use _tempColor to parse hex once, then extract RGB (no allocation) this._tempColor.set(biomeData.color); - // Accumulate weighted colors directly to numbers colorR += this._tempColor.r * weight; colorG += this._tempColor.g * weight; colorB += this._tempColor.b * weight; } } else { - const biomeData = BIOMES["plains"]; + const biomeData = BIOMES[DEFAULT_BIOME] || BIOMES["plains"]; if (!biomeData) { throw new Error( - `[TerrainSystem] Biome "plains" not found in BIOMES data!`, + `[TerrainSystem] Default biome not found in BIOMES data!`, ); } - // Use _tempColor to parse hex once, then extract RGB (no allocation) this._tempColor.set(biomeData.color); colorR = this._tempColor.r; colorG = this._tempColor.g; @@ -2635,6 +2725,12 @@ export class TerrainSystem extends System { } biomeIds[i] = this.getBiomeId(dominantBiome); + if (totalWeight > 0) { + const invW = 1 / totalWeight; + forestWeights[i] = (biomeWeightMap.get(BiomeType.Forest) || 0) * invW; + desertWeights[i] = (biomeWeightMap.get(BiomeType.Desert) || 0) * invW; + } + // Apply brownish shoreline tint near water level const waterLevel = this.CONFIG.WATER_LEVEL_NORMALIZED; const shorelineThreshold = this.CONFIG.SHORELINE_THRESHOLD; @@ -2674,6 +2770,30 @@ export class TerrainSystem extends System { "roadInfluence", new THREE.BufferAttribute(roadInfluences, 1), ); + geometry.setAttribute( + "biomeForestWeight", + new THREE.BufferAttribute(forestWeights, 1), + ); + geometry.setAttribute( + "biomeDesertWeight", + new THREE.BufferAttribute(desertWeights, 1), + ); + + // DEBUG: log biome weights for first 5 sync tiles + if (this._loggedSyncTileBiome < 5) { + this._loggedSyncTileBiome++; + let sumF = 0, + sumD = 0; + for (let _d = 0; _d < forestWeights.length; _d++) { + sumF += forestWeights[_d]; + sumD += desertWeights[_d]; + } + const n = forestWeights.length; + const avgP = (1 - sumF / n - sumD / n).toFixed(3); + console.log( + `[BiomeDebug/SyncTile] Tile(${tileX},${tileZ}) plains=${avgP} forest=${(sumF / n).toFixed(3)} desert=${(sumD / n).toFixed(3)}`, + ); + } if (TERRAIN_ROAD_INFLUENCE_DEBUG) { let nonZeroCount = 0; @@ -3153,14 +3273,9 @@ export class TerrainSystem extends System { const biomeIds: Record = { plains: 0, forest: 1, - valley: 2, - mountains: 3, - tundra: 4, - desert: 5, - lakes: 6, - swamp: 7, + desert: 2, }; - return biomeIds[biomeName] || 0; + return biomeIds[biomeName] ?? 0; } private getIslandMask(worldX: number, worldZ: number): number { @@ -3197,184 +3312,57 @@ export class TerrainSystem extends System { /** * Get base terrain height WITHOUT mountain biome boost. - * Used for biome influence calculation to avoid feedback loops. + * Delegates to the single source of truth in TerrainHeightParams. */ - private getBaseHeightAt(worldX: number, worldZ: number): number { + private getBaseHeightAt( + worldX: number, + worldZ: number, + biomeWeights?: Record, + ): number { if (!this.noise) { this.noise = new NoiseGenerator(this.computeSeedFromWorldId()); if (!this.biomeCenters || this.biomeCenters.length === 0) { this.initializeBiomeCenters(); + this.initializeLandscapeFeatures(); } } - // Multi-layered noise — constants from TerrainHeightParams (single source of truth) - const continentNoise = this.noise.fractal2D( - worldX * CONTINENT_LAYER.scale, - worldZ * CONTINENT_LAYER.scale, - CONTINENT_LAYER.octaves!, - CONTINENT_LAYER.persistence!, - CONTINENT_LAYER.lacunarity!, - ); - const ridgeNoise = this.noise.ridgeNoise2D( - worldX * RIDGE_LAYER.scale, - worldZ * RIDGE_LAYER.scale, - ); - const hillNoise = this.noise.fractal2D( - worldX * HILL_LAYER.scale, - worldZ * HILL_LAYER.scale, - HILL_LAYER.octaves!, - HILL_LAYER.persistence!, - HILL_LAYER.lacunarity!, - ); - const erosionNoise = this.noise.erosionNoise2D( - worldX * EROSION_LAYER.scale, - worldZ * EROSION_LAYER.scale, - EROSION_LAYER.iterations!, - ); - const detailNoise = this.noise.fractal2D( - worldX * DETAIL_LAYER.scale, - worldZ * DETAIL_LAYER.scale, - DETAIL_LAYER.octaves!, - DETAIL_LAYER.persistence!, - DETAIL_LAYER.lacunarity!, - ); - - let height = 0; - height += continentNoise * CONTINENT_LAYER.weight; - height += ridgeNoise * RIDGE_LAYER.weight; - height += hillNoise * HILL_LAYER.weight; - height += erosionNoise * EROSION_LAYER.weight; - height += detailNoise * DETAIL_LAYER.weight; - - height = (height + 1) * 0.5; - height = Math.max(0, Math.min(1, height)); - height = Math.pow(height, HEIGHT_POWER_CURVE); - - // Natural coastline — noise varies island radius for irregular shoreline - 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 coastNoise1 = this.noise.fractal2D( - cnx, - cnz, - COAST_LARGE.octaves, - COAST_LARGE.persistence, - COAST_LARGE.lacunarity, - ); - const coastNoise2 = this.noise.fractal2D( - cnx * COAST_MEDIUM.freqMultiplier, - cnz * COAST_MEDIUM.freqMultiplier, - COAST_MEDIUM.octaves, - COAST_MEDIUM.persistence, - COAST_MEDIUM.lacunarity, - ); - const coastNoise3 = this.noise.simplex2D( - cnx * COAST_SMALL.freqMultiplier, - cnz * COAST_SMALL.freqMultiplier, - ); - - const coastlineVariation = - coastNoise1 * COAST_LARGE.weight + - coastNoise2 * COAST_MEDIUM.weight + - coastNoise3 * COAST_SMALL.weight; - const effectiveRadius = ISLAND_RADIUS * (1 + coastlineVariation); - - // Island mask — smooth falloff at edges - let islandMask = 1.0; - if (distFromCenter > effectiveRadius - ISLAND_FALLOFF) { - const edgeDist = distFromCenter - (effectiveRadius - ISLAND_FALLOFF); - const t = Math.min(1.0, edgeDist / ISLAND_FALLOFF); - const smoothstep = t * t * (3 - 2 * t); - islandMask = 1.0 - smoothstep; - } - if (distFromCenter > effectiveRadius + ISLAND_DEEP_OCEAN_BUFFER) { - islandMask = 0; - } - - // Pond — offset from center near spawn - const distFromPond = Math.sqrt( - (worldX - POND_CENTER_X) * (worldX - POND_CENTER_X) + - (worldZ - POND_CENTER_Z) * (worldZ - POND_CENTER_Z), + const weights = + biomeWeights ?? this.computeBiomeWeightsByPosition(worldX, worldZ); + return computeBaseHeight( + worldX, + worldZ, + this.noise, + weights, + this.landscapeFeatures, + this.CONFIG.MAX_HEIGHT, ); - let pondDepression = 0; - if (distFromPond < POND_RADIUS * 2) { - const pondFactor = 1.0 - distFromPond / (POND_RADIUS * 2); - pondDepression = pondFactor * pondFactor * POND_DEPTH; - } - - height = height * islandMask; - height = height * HEIGHT_TERRAIN_MIX + BASE_ELEVATION * islandMask; - height -= pondDepression; - - if (islandMask === 0) { - height = OCEAN_FLOOR_HEIGHT; - } - - return height * this.CONFIG.MAX_HEIGHT; } private getHeightAtWithoutShore(worldX: number, worldZ: number): number { - // Ensure biome centers are initialized if (!this.biomeCenters || this.biomeCenters.length === 0) { if (!this.noise) { this.noise = new NoiseGenerator(this.computeSeedFromWorldId()); } this.initializeBiomeCenters(); + this.initializeLandscapeFeatures(); } - // Check flat zones first (for stations, etc.) const flatHeight = this.getFlatZoneHeight(worldX, worldZ); if (flatHeight !== null) { return flatHeight; } - // Get procedural height with mountain boost - return this.getProceduralHeightWithBoost(worldX, worldZ); + return this.getBaseHeightAt(worldX, worldZ); } /** - * Get procedural terrain height with mountain biome boost applied. + * Get procedural terrain height. * This bypasses flat zones and returns the raw procedural height. * Useful for positioning objects that should sit above the terrain mesh. */ getProceduralHeightAt(worldX: number, worldZ: number): number { - return this.getProceduralHeightWithBoost(worldX, worldZ); - } - - /** - * Get procedural terrain height with mountain biome boost applied. - * Extracted for reuse in flat zone blending. - */ - private getProceduralHeightWithBoost(worldX: number, worldZ: number): number { - // Get base height (without mountain boost) - const baseHeight = this.getBaseHeightAt(worldX, worldZ); - let height = baseHeight / this.CONFIG.MAX_HEIGHT; // Normalize for boost calc - - // Apply mountain biome height boost - let mountainBoost = 0; - for (const center of this.biomeCenters) { - if (center.type === "mountains") { - const dx = worldX - center.x; - const dz = worldZ - center.z; - const distance = Math.sqrt(dx * dx + dz * dz); - const normalizedDist = distance / center.influence; - - if (normalizedDist < MOUNTAIN_BOOST_MAX_NORM_DIST) { - const boost = Math.exp( - -normalizedDist * normalizedDist * MOUNTAIN_BOOST_GAUSSIAN_COEFF, - ); - mountainBoost = Math.max(mountainBoost, boost); - } - } - } - - // Apply mountain height boost from CONFIG - height = height * (1 + mountainBoost * this.CONFIG.MOUNTAIN_HEIGHT_BOOST); - height = Math.min(1, height); // Cap at max - - return height * this.CONFIG.MAX_HEIGHT; + return this.getBaseHeightAt(worldX, worldZ); } private calculateBaseSlopeAt( @@ -3410,39 +3398,24 @@ export class TerrainSystem extends System { return Math.max(...slopes); } - private adjustHeightForShoreline(baseHeight: number, slope: number): number { - const waterThreshold = this.CONFIG.WATER_THRESHOLD; - if (baseHeight === waterThreshold) return baseHeight; - - const isLand = baseHeight > waterThreshold; - const band = isLand - ? this.CONFIG.SHORELINE_LAND_BAND - : this.CONFIG.SHORELINE_UNDERWATER_BAND; - if (band <= 0) return baseHeight; + private _shorelineConfig: ShorelineConfig | null = null; - const delta = Math.abs(baseHeight - waterThreshold); - if (delta >= band) return baseHeight; - - const minSlope = this.CONFIG.SHORELINE_MIN_SLOPE; - if (minSlope <= 0) return baseHeight; - - const maxMultiplier = isLand - ? this.CONFIG.SHORELINE_LAND_MAX_MULTIPLIER - : this.CONFIG.UNDERWATER_DEPTH_MULTIPLIER; - if (maxMultiplier <= 1) return baseHeight; - - const slopeSafe = Math.max(0.0001, slope); - const targetMultiplier = Math.min( - maxMultiplier, - Math.max(1, minSlope / slopeSafe), - ); - const falloff = 1 - delta / band; - const multiplier = 1 + (targetMultiplier - 1) * falloff; - const adjustedDelta = delta * multiplier; + private getShorelineConfig(): ShorelineConfig { + if (!this._shorelineConfig) { + this._shorelineConfig = { + waterThreshold: this.CONFIG.WATER_THRESHOLD, + shorelineLandBand: this.CONFIG.SHORELINE_LAND_BAND, + shorelineUnderwaterBand: this.CONFIG.SHORELINE_UNDERWATER_BAND, + shorelineMinSlope: this.CONFIG.SHORELINE_MIN_SLOPE, + shorelineLandMaxMultiplier: this.CONFIG.SHORELINE_LAND_MAX_MULTIPLIER, + underwaterDepthMultiplier: this.CONFIG.UNDERWATER_DEPTH_MULTIPLIER, + }; + } + return this._shorelineConfig; + } - return isLand - ? waterThreshold + adjustedDelta - : waterThreshold - adjustedDelta; + private adjustHeightForShoreline(baseHeight: number, slope: number): number { + return adjustShorelineHeight(baseHeight, slope, this.getShorelineConfig()); } // ============================================================================ @@ -3807,10 +3780,7 @@ export class TerrainSystem extends System { // If in a blend area, smoothly interpolate if (bestBlendZone) { - const proceduralHeight = this.getProceduralHeightWithBoost( - worldX, - worldZ, - ); + const proceduralHeight = this.getProceduralHeightAt(worldX, worldZ); // Smoothstep: t² × (3 - 2t) for C1 continuous transition const t = bestBlendFactor * bestBlendFactor * (3 - 2 * bestBlendFactor); @@ -4239,7 +4209,7 @@ export class TerrainSystem extends System { const depth = size.z * MOVEMENT_TILE_SIZE + padding * 2; // Get procedural height at station center (what terrain would be without flattening) - const flatHeight = this.getProceduralHeightWithBoost( + const flatHeight = this.getProceduralHeightAt( station.position.x, station.position.z, ); @@ -4263,7 +4233,7 @@ export class TerrainSystem extends System { if (areaConfig.flatZones) { for (const zoneConfig of areaConfig.flatZones) { // Calculate base height from procedural terrain - const proceduralHeight = this.getProceduralHeightWithBoost( + const proceduralHeight = this.getProceduralHeightAt( zoneConfig.centerX, zoneConfig.centerZ, ); @@ -4585,7 +4555,7 @@ export class TerrainSystem extends System { for (const town of towns) { const distance = Math.sqrt((tileX - town.x) ** 2 + (tileZ - town.z) ** 2); - if (distance < 3) return "plains"; + if (distance < 3) return DEFAULT_BIOME; } return this.getBiomeAtWorldPosition(worldX, worldZ); @@ -4608,7 +4578,7 @@ export class TerrainSystem extends System { } } else { // Fallback to plains if no biome centers are nearby - biomeInfluences.push({ type: "plains", weight: 1.0 }); + biomeInfluences.push({ type: DEFAULT_BIOME, weight: 1.0 }); } return biomeInfluences; @@ -4618,61 +4588,33 @@ export class TerrainSystem extends System { worldX: number, worldZ: number, ): { biomeWeightMap: Map; totalWeight: number } { - // Get BASE height (without mountain boost) to avoid feedback loop - const baseHeight = this.getBaseHeightAt(worldX, worldZ); - const normalizedHeight = baseHeight / this.CONFIG.MAX_HEIGHT; - - // Add boundary noise for organic edges + // Biome weights are purely positional (gaussian distance from centres + + // boundary noise). No height lookup here — getBaseHeightAt depends on + // biome weights, so calling it would create infinite recursion. const boundaryNoise = this.noise.simplex2D( worldX * this.CONFIG.BIOME_BOUNDARY_NOISE_SCALE, worldZ * this.CONFIG.BIOME_BOUNDARY_NOISE_SCALE, ); - // Map to collect and merge same-type biomes. - // Reusing one map avoids per-vertex map allocations during tile generation. const biomeWeightMap = this.biomeWeightScratch; biomeWeightMap.clear(); - // Calculate influence from ALL biome centers (no hard cutoff!) for (const center of this.biomeCenters) { const dx = worldX - center.x; const dz = worldZ - center.z; const distance = Math.sqrt(dx * dx + dz * dz); - // Add subtle noise to distance for organic boundaries const noisyDistance = distance * (1 + boundaryNoise * this.CONFIG.BIOME_BOUNDARY_NOISE_AMOUNT); - // Pure gaussian falloff - NO hard distance cutoff - // The gaussian naturally approaches 0 at large distances const normalizedDistance = noisyDistance / center.influence; - let weight = Math.exp( + const weight = Math.exp( -normalizedDistance * normalizedDistance * this.CONFIG.BIOME_GAUSSIAN_COEFF, ); - // Height-based weight adjustments (using BASE height, not boosted) - if ( - center.type === "mountains" && - normalizedHeight > this.CONFIG.MOUNTAIN_HEIGHT_THRESHOLD - ) { - const heightFactor = - normalizedHeight - this.CONFIG.MOUNTAIN_HEIGHT_THRESHOLD; - weight *= 1.0 + heightFactor * this.CONFIG.MOUNTAIN_WEIGHT_BOOST; - } - - if ( - (center.type === "valley" || center.type === "plains") && - normalizedHeight < this.CONFIG.VALLEY_HEIGHT_THRESHOLD - ) { - const heightFactor = - this.CONFIG.VALLEY_HEIGHT_THRESHOLD - normalizedHeight; - weight *= 1.0 + heightFactor * this.CONFIG.VALLEY_WEIGHT_BOOST; - } - - // Merge same-type biomes (no threshold - let gaussian handle falloff) const existing = biomeWeightMap.get(center.type) || 0; biomeWeightMap.set(center.type, existing + weight); } @@ -4685,9 +4627,29 @@ export class TerrainSystem extends System { return { biomeWeightMap, totalWeight }; } + computeBiomeWeightsByPosition( + worldX: number, + worldZ: number, + ): Record { + const { biomeWeightMap, totalWeight } = this.computeBiomeWeightsAtPosition( + worldX, + worldZ, + ); + const result: Record = {}; + if (totalWeight > 0) { + const inv = 1 / totalWeight; + for (const [type, weight] of biomeWeightMap) { + result[type] = weight * inv; + } + } else { + result[DEFAULT_BIOME] = 1.0; + } + return result; + } + private getBiomeAtWorldPosition(worldX: number, worldZ: number): string { const influences = this.getBiomeInfluencesAtPosition(worldX, worldZ); - return influences.length > 0 ? influences[0].type : "plains"; + return influences.length > 0 ? influences[0].type : DEFAULT_BIOME; } private getBiomeNoise(x: number, z: number): number { @@ -4699,7 +4661,8 @@ export class TerrainSystem extends System { ); } - // Map internal biome keys to generic TerrainTileData biome set + // Map internal biome keys to generic TerrainTileData biome set. + // Only tundra/forest/desert biome centers exist (see initializeBiomeCenters). private mapBiomeToGeneric( internal: string, ): @@ -4711,24 +4674,13 @@ export class TerrainSystem extends System { | "tundra" | "jungle" { switch (internal) { - case "forest": + case BiomeType.Forest: return "forest"; - case "plains": - return "plains"; - case "valley": - return "plains"; - case "mountains": - return "mountains"; - case "tundra": - return "tundra"; - case "desert": + case BiomeType.Desert: return "desert"; - case "lakes": - return "swamp"; - case "swamp": - return "swamp"; + case BiomeType.Tundra: default: - return "plains"; + return "tundra"; } } @@ -6096,6 +6048,7 @@ export class TerrainSystem extends System { this.noise = new NoiseGenerator(this.computeSeedFromWorldId()); if (this.biomeCenters.length === 0) { this.initializeBiomeCenters(); + this.initializeLandscapeFeatures(); } } @@ -6263,6 +6216,7 @@ export class TerrainSystem extends System { this.noise = new NoiseGenerator(this.computeSeedFromWorldId()); if (this.biomeCenters.length === 0) { this.initializeBiomeCenters(); + this.initializeLandscapeFeatures(); } } diff --git a/packages/shared/src/utils/workers/QuadChunkWorker.ts b/packages/shared/src/utils/workers/QuadChunkWorker.ts index 86c318b4d..ae8c074f2 100644 --- a/packages/shared/src/utils/workers/QuadChunkWorker.ts +++ b/packages/shared/src/utils/workers/QuadChunkWorker.ts @@ -15,9 +15,16 @@ import { WorkerPool } from "./WorkerPool"; import { buildGetBaseHeightAtJS, - MOUNTAIN_BOOST_MAX_NORM_DIST, - MOUNTAIN_BOOST_GAUSSIAN_COEFF, + buildComputeBiomeWeightsJS, + buildApplyLandscapeFeaturesJS, } from "../../systems/shared/world/TerrainHeightParams"; +import type { LandscapeFeatureDef } from "../../systems/shared/world/TerrainHeightParams"; +import { buildBiomeConstantsJS } from "../../systems/shared/world/TerrainBiomeTypes"; +import { + buildNoiseGeneratorJS, + buildHeightHelpersJS, + buildBiomeInfluencesJS, +} from "./TerrainWorkerShared"; export interface QuadChunkWorkerConfig { MAX_HEIGHT: number; @@ -39,6 +46,7 @@ export interface QuadChunkWorkerConfig { SHORELINE_LAND_MAX_MULTIPLIER: number; SHORELINE_UNDERWATER_BAND: number; UNDERWATER_DEPTH_MULTIPLIER: number; + landscapeFeatures?: LandscapeFeatureDef[]; } export interface QuadChunkWorkerInput { @@ -68,135 +76,18 @@ export interface QuadChunkWorkerOutput { normalData: Float32Array; colorData: Float32Array; biomeData: Uint8Array; + biomeForestWeight: Float32Array; + biomeDesertWeight: Float32Array; } const QUAD_CHUNK_WORKER_CODE = ` -// NoiseGenerator — same as TerrainWorker (from NoiseGenerator.ts) -class NoiseGenerator { - constructor(seed = 12345) { - this.permutation = []; - this.p = []; - this.initializePermutation(seed); - } - - initializePermutation(seed) { - const perm = Array.from({ length: 256 }, (_, i) => i); - let random = seed; - for (let i = perm.length - 1; i > 0; i--) { - random = (random * 1664525 + 1013904223) % 4294967296; - const j = Math.floor((random / 4294967296) * (i + 1)); - [perm[i], perm[j]] = [perm[j], perm[i]]; - } - this.permutation = perm; - this.p = [...perm, ...perm]; - } - - fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); } - lerp(t, a, b) { return a + t * (b - a); } - grad2D(hash, x, y) { - const h = hash & 3; - const u = h < 2 ? x : y; - const v = h < 2 ? y : x; - return (h & 1 ? -u : u) + (h & 2 ? -v : v); - } - - perlin2D(x, y) { - const X = Math.floor(x) & 255; - const Y = Math.floor(y) & 255; - x -= Math.floor(x); - y -= Math.floor(y); - const u = this.fade(x); - const v = this.fade(y); - const A = this.p[X] + Y; - const AA = this.p[A]; - const AB = this.p[A + 1]; - const B = this.p[X + 1] + Y; - const BA = this.p[B]; - const BB = this.p[B + 1]; - const result = this.lerp(v, - this.lerp(u, this.grad2D(this.p[AA], x, y), this.grad2D(this.p[BA], x - 1, y)), - this.lerp(u, this.grad2D(this.p[AB], x, y - 1), this.grad2D(this.p[BB], x - 1, y - 1)) - ); - return Math.max(-1, Math.min(1, result)); - } - - gradSimplex2D(hash, x, y) { - const grad3 = [[1,1,0],[-1,1,0],[1,-1,0],[-1,-1,0],[1,0,1],[-1,0,1],[1,0,-1],[-1,0,-1],[0,1,1],[0,-1,1],[0,1,-1],[0,-1,-1]]; - return grad3[hash % 12][0] * x + grad3[hash % 12][1] * y; - } - - simplex2D(x, y) { - const F2 = 0.5 * (Math.sqrt(3.0) - 1.0); - const G2 = (3.0 - Math.sqrt(3.0)) / 6.0; - const s = (x + y) * F2; - const i = Math.floor(x + s); - const j = Math.floor(y + s); - const t = (i + j) * G2; - const X0 = i - t; - const Y0 = j - t; - const x0 = x - X0; - const y0 = y - Y0; - let i1, j1; - if (x0 > y0) { i1 = 1; j1 = 0; } else { i1 = 0; j1 = 1; } - const x1 = x0 - i1 + G2; - const y1 = y0 - j1 + G2; - const x2 = x0 - 1.0 + 2.0 * G2; - const y2 = y0 - 1.0 + 2.0 * G2; - const ii = i & 255; - const jj = j & 255; - const gi0 = this.p[ii + this.p[jj]] % 12; - const gi1 = this.p[ii + i1 + this.p[jj + j1]] % 12; - const gi2 = this.p[ii + 1 + this.p[jj + 1]] % 12; - let n0, n1, n2; - let t0 = 0.5 - x0 * x0 - y0 * y0; - if (t0 < 0) n0 = 0.0; - else { t0 *= t0; n0 = t0 * t0 * this.gradSimplex2D(gi0, x0, y0); } - let t1 = 0.5 - x1 * x1 - y1 * y1; - if (t1 < 0) n1 = 0.0; - else { t1 *= t1; n1 = t1 * t1 * this.gradSimplex2D(gi1, x1, y1); } - let t2 = 0.5 - x2 * x2 - y2 * y2; - if (t2 < 0) n2 = 0.0; - else { t2 *= t2; n2 = t2 * t2 * this.gradSimplex2D(gi2, x2, y2); } - return 70.0 * (n0 + n1 + n2); - } - - ridgeNoise2D(x, y) { - const perlinValue = this.perlin2D(x, y); - return 1.0 - Math.abs(Math.max(-1, Math.min(1, perlinValue))); - } - - fractal2D(x, y, octaves = 4, persistence = 0.5, lacunarity = 2.0) { - let value = 0; - let amplitude = 1; - let frequency = 1; - let maxValue = 0; - for (let i = 0; i < octaves; i++) { - value += this.perlin2D(x * frequency, y * frequency) * amplitude; - maxValue += amplitude; - amplitude *= persistence; - frequency *= lacunarity; - } - return value / maxValue; - } - - erosionNoise2D(x, y, iterations = 3) { - let height = this.fractal2D(x, y, 6); - for (let i = 0; i < iterations; i++) { - const delta = 0.01; - const hC = this.perlin2D(x, y); - const hX = this.perlin2D(x + delta, y); - const hY = this.perlin2D(x, y + delta); - const gradX = (hX - hC) / delta; - const gradY = (hY - hC) / delta; - const magnitude = Math.sqrt(gradX * gradX + gradY * gradY); - const erosionFactor = Math.min(1.0, magnitude * 2.0); - height *= 1.0 - erosionFactor * 0.1; - } - return height; - } -} +${buildNoiseGeneratorJS()} +${buildBiomeConstantsJS()} -const BIOME_IDS = { plains: 0, forest: 1, valley: 2, mountains: 3, tundra: 4, desert: 5, lakes: 6, swamp: 7 }; +var BIOME_IDS = {}; +BIOME_IDS[BT_TUNDRA] = 0; +BIOME_IDS[BT_FOREST] = 1; +BIOME_IDS[BT_DESERT] = 2; function generateQuadChunk(input) { const { centerX, centerZ, size, resolution, config, seed, biomeCenters, biomes } = input; @@ -228,108 +119,12 @@ function generateQuadChunk(input) { const halfSize = size * 0.5; const gridStep = size / (segments - 1); + var landscapeFeatures = config.landscapeFeatures || []; + ${buildComputeBiomeWeightsJS()} + ${buildApplyLandscapeFeaturesJS()} ${buildGetBaseHeightAtJS()} - - function getHeightAtWithoutShore(worldX, worldZ) { - var baseHeight = getBaseHeightAt(worldX, worldZ); - var height = baseHeight / MAX_HEIGHT; - var mountainBoost = 0; - for (var _i = 0; _i < biomeCenters.length; _i++) { - var center = biomeCenters[_i]; - if (center.type === 'mountains') { - var dx = worldX - center.x; - var dz = worldZ - center.z; - var distance = Math.sqrt(dx * dx + dz * dz); - var normalizedDist = distance / center.influence; - if (normalizedDist < ${MOUNTAIN_BOOST_MAX_NORM_DIST}) { - var boost = Math.exp(-normalizedDist * normalizedDist * ${MOUNTAIN_BOOST_GAUSSIAN_COEFF}); - mountainBoost = Math.max(mountainBoost, boost); - } - } - } - height = height * (1 + mountainBoost * MOUNTAIN_HEIGHT_BOOST); - height = Math.min(1, height); - return height * MAX_HEIGHT; - } - - function calculateBaseSlopeAt(worldX, worldZ, centerHeight) { - const d = SHORELINE_SLOPE_SAMPLE_DISTANCE; - const hN = getHeightAtWithoutShore(worldX, worldZ + d); - const hS = getHeightAtWithoutShore(worldX, worldZ - d); - const hE = getHeightAtWithoutShore(worldX + d, worldZ); - const hW = getHeightAtWithoutShore(worldX - d, worldZ); - return Math.max( - Math.abs(hN - centerHeight) / d, - Math.abs(hS - centerHeight) / d, - Math.abs(hE - centerHeight) / d, - Math.abs(hW - centerHeight) / d - ); - } - - function adjustHeightForShoreline(baseHeight, slope) { - if (baseHeight === WATER_THRESHOLD) return baseHeight; - const isLand = baseHeight > WATER_THRESHOLD; - const band = isLand ? SHORELINE_LAND_BAND : SHORELINE_UNDERWATER_BAND; - if (band <= 0) return baseHeight; - const delta = Math.abs(baseHeight - WATER_THRESHOLD); - if (delta >= band) return baseHeight; - if (SHORELINE_MIN_SLOPE <= 0) return baseHeight; - const maxMul = isLand ? SHORELINE_LAND_MAX_MULTIPLIER : UNDERWATER_DEPTH_MULTIPLIER; - if (maxMul <= 1) return baseHeight; - const slopeSafe = Math.max(0.0001, slope); - const targetMul = Math.min(maxMul, Math.max(1, SHORELINE_MIN_SLOPE / slopeSafe)); - const falloff = 1 - delta / band; - const mul = 1 + (targetMul - 1) * falloff; - const adjustedDelta = delta * mul; - return isLand ? WATER_THRESHOLD + adjustedDelta : WATER_THRESHOLD - adjustedDelta; - } - - function getHeightComputed(worldX, worldZ) { - const h = getHeightAtWithoutShore(worldX, worldZ); - if (h >= WATER_THRESHOLD + SHORELINE_LAND_BAND || h <= WATER_THRESHOLD - SHORELINE_UNDERWATER_BAND) { - return h; - } - const slope = calculateBaseSlopeAt(worldX, worldZ, h); - return adjustHeightForShoreline(h, slope); - } - - function getBiomeInfluences(worldX, worldZ, normalizedHeight) { - if (!biomeCenters || biomeCenters.length === 0) { - return [{ type: 'plains', weight: 1.0 }]; - } - const boundaryNoise = noise.simplex2D( - worldX * BIOME_BOUNDARY_NOISE_SCALE, - worldZ * BIOME_BOUNDARY_NOISE_SCALE - ); - const biomeWeightMap = {}; - for (const center of biomeCenters) { - const dx = worldX - center.x; - const dz = worldZ - center.z; - const distance = Math.sqrt(dx * dx + dz * dz); - const noisyDistance = distance * (1 + boundaryNoise * BIOME_BOUNDARY_NOISE_AMOUNT); - const normalizedDistance = noisyDistance / center.influence; - let weight = Math.exp(-normalizedDistance * normalizedDistance * BIOME_GAUSSIAN_COEFF); - if (center.type === 'mountains' && normalizedHeight > MOUNTAIN_HEIGHT_THRESHOLD) { - weight *= 1.0 + (normalizedHeight - MOUNTAIN_HEIGHT_THRESHOLD) * MOUNTAIN_WEIGHT_BOOST; - } - if ((center.type === 'valley' || center.type === 'plains') && normalizedHeight < VALLEY_HEIGHT_THRESHOLD) { - weight *= 1.0 + (VALLEY_HEIGHT_THRESHOLD - normalizedHeight) * VALLEY_WEIGHT_BOOST; - } - biomeWeightMap[center.type] = (biomeWeightMap[center.type] || 0) + weight; - } - const biomeInfluences = []; - for (const type in biomeWeightMap) { - biomeInfluences.push({ type, weight: biomeWeightMap[type] }); - } - const totalWeight = biomeInfluences.reduce((sum, b) => sum + b.weight, 0); - if (totalWeight > 0) { - for (const inf of biomeInfluences) { inf.weight /= totalWeight; } - } else { - biomeInfluences.push({ type: 'plains', weight: 1.0 }); - } - biomeInfluences.sort((a, b) => b.weight - a.weight); - return biomeInfluences.slice(0, 3); - } + ${buildHeightHelpersJS()} + ${buildBiomeInfluencesJS()} // Overflow grid for normals: (segments+2)^2 const gRes = segments + 2; @@ -381,6 +176,8 @@ function generateQuadChunk(input) { // Colors and biome IDs const colorData = new Float32Array(vertexCount * 3); const biomeData = new Uint8Array(vertexCount); + const biomeForestWeight = new Float32Array(vertexCount); + const biomeDesertWeight = new Float32Array(vertexCount); for (let iz = 0; iz < segments; iz++) { for (let ix = 0; ix < segments; ix++) { @@ -393,16 +190,27 @@ function generateQuadChunk(input) { const worldX = centerX + localX; const worldZ = centerZ + localZ; - const biomeInfluences = getBiomeInfluences(worldX, worldZ, normalizedHeight); - biomeData[idx] = BIOME_IDS[biomeInfluences[0].type] || 0; + var bw = computeBiomeWeightsByPosition(worldX, worldZ); + var forestW = bw[BT_FOREST] || 0; + var desertW = bw[BT_DESERT] || 0; + biomeForestWeight[idx] = forestW; + biomeDesertWeight[idx] = desertW; + + var dominantBiome = BT_DEFAULT; + var dominantWeight = -1; + for (var bk in bw) { + if (bw[bk] > dominantWeight) { dominantWeight = bw[bk]; dominantBiome = bk; } + } + biomeData[idx] = BIOME_IDS[dominantBiome] || 0; let colorR = 0, colorG = 0, colorB = 0; - for (const influence of biomeInfluences) { - const biomeConfig = biomes[influence.type] || { color: { r: 0.4, g: 0.6, b: 0.3 } }; - const color = biomeConfig.color || { r: 0.4, g: 0.6, b: 0.3 }; - colorR += color.r * influence.weight; - colorG += color.g * influence.weight; - colorB += color.b * influence.weight; + for (var bk2 in bw) { + var bwt = bw[bk2]; + var biomeConfig = biomes[bk2] || { color: { r: 0.4, g: 0.6, b: 0.3 } }; + var color = biomeConfig.color || { r: 0.4, g: 0.6, b: 0.3 }; + colorR += color.r * bwt; + colorG += color.g * bwt; + colorB += color.b * bwt; } if (normalizedHeight > WATER_LEVEL_NORMALIZED && normalizedHeight < SHORELINE_THRESHOLD) { @@ -428,7 +236,9 @@ function generateQuadChunk(input) { heightData, normalData, colorData, - biomeData + biomeData, + biomeForestWeight, + biomeDesertWeight }; } @@ -441,7 +251,9 @@ self.onmessage = function(e) { result.heightData.buffer, result.normalData.buffer, result.colorData.buffer, - result.biomeData.buffer + result.biomeData.buffer, + result.biomeForestWeight.buffer, + result.biomeDesertWeight.buffer ]); } catch (error) { self.postMessage({ error: error.message || 'Unknown error' }); diff --git a/packages/shared/src/utils/workers/TerrainWorker.ts b/packages/shared/src/utils/workers/TerrainWorker.ts index 129220bb6..efa990a3f 100644 --- a/packages/shared/src/utils/workers/TerrainWorker.ts +++ b/packages/shared/src/utils/workers/TerrainWorker.ts @@ -12,9 +12,15 @@ import { WorkerPool } from "./WorkerPool"; import { buildGetBaseHeightAtJS, - MOUNTAIN_BOOST_MAX_NORM_DIST, - MOUNTAIN_BOOST_GAUSSIAN_COEFF, + buildComputeBiomeWeightsJS, + buildApplyLandscapeFeaturesJS, } from "../../systems/shared/world/TerrainHeightParams"; +import { buildBiomeConstantsJS } from "../../systems/shared/world/TerrainBiomeTypes"; +import { + buildNoiseGeneratorJS, + buildHeightHelpersJS, + buildBiomeInfluencesJS, +} from "./TerrainWorkerShared"; // Types for terrain generation // MUST match TerrainSystem.CONFIG exactly for height and biome calculation @@ -44,6 +50,14 @@ export interface TerrainWorkerConfig { SHORELINE_LAND_MAX_MULTIPLIER: number; SHORELINE_UNDERWATER_BAND: number; UNDERWATER_DEPTH_MULTIPLIER: number; + landscapeFeatures?: Array<{ + type: string; + x: number; + z: number; + radius: number; + strength: number; + gaussianCoeff: number; + }>; } export interface TerrainWorkerInput { @@ -93,133 +107,13 @@ export interface TerrainWorkerOutput { * Synced with: packages/shared/src/systems/shared/world/TerrainSystem.ts */ const TERRAIN_WORKER_CODE = ` -// NoiseGenerator - exact copy from packages/shared/src/utils/NoiseGenerator.ts -class NoiseGenerator { - constructor(seed = 12345) { - this.permutation = []; - this.p = []; - this.initializePermutation(seed); - } - - initializePermutation(seed) { - const perm = Array.from({ length: 256 }, (_, i) => i); - let random = seed; - for (let i = perm.length - 1; i > 0; i--) { - random = (random * 1664525 + 1013904223) % 4294967296; - const j = Math.floor((random / 4294967296) * (i + 1)); - [perm[i], perm[j]] = [perm[j], perm[i]]; - } - this.permutation = perm; - this.p = [...perm, ...perm]; - } - - fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); } - lerp(t, a, b) { return a + t * (b - a); } - grad2D(hash, x, y) { - const h = hash & 3; - const u = h < 2 ? x : y; - const v = h < 2 ? y : x; - return (h & 1 ? -u : u) + (h & 2 ? -v : v); - } - - perlin2D(x, y) { - const X = Math.floor(x) & 255; - const Y = Math.floor(y) & 255; - x -= Math.floor(x); - y -= Math.floor(y); - const u = this.fade(x); - const v = this.fade(y); - const A = this.p[X] + Y; - const AA = this.p[A]; - const AB = this.p[A + 1]; - const B = this.p[X + 1] + Y; - const BA = this.p[B]; - const BB = this.p[B + 1]; - const result = this.lerp(v, - this.lerp(u, this.grad2D(this.p[AA], x, y), this.grad2D(this.p[BA], x - 1, y)), - this.lerp(u, this.grad2D(this.p[AB], x, y - 1), this.grad2D(this.p[BB], x - 1, y - 1)) - ); - return Math.max(-1, Math.min(1, result)); - } - - gradSimplex2D(hash, x, y) { - const grad3 = [[1,1,0],[-1,1,0],[1,-1,0],[-1,-1,0],[1,0,1],[-1,0,1],[1,0,-1],[-1,0,-1],[0,1,1],[0,-1,1],[0,1,-1],[0,-1,-1]]; - return grad3[hash % 12][0] * x + grad3[hash % 12][1] * y; - } - - simplex2D(x, y) { - const F2 = 0.5 * (Math.sqrt(3.0) - 1.0); - const G2 = (3.0 - Math.sqrt(3.0)) / 6.0; - const s = (x + y) * F2; - const i = Math.floor(x + s); - const j = Math.floor(y + s); - const t = (i + j) * G2; - const X0 = i - t; - const Y0 = j - t; - const x0 = x - X0; - const y0 = y - Y0; - let i1, j1; - if (x0 > y0) { i1 = 1; j1 = 0; } else { i1 = 0; j1 = 1; } - const x1 = x0 - i1 + G2; - const y1 = y0 - j1 + G2; - const x2 = x0 - 1.0 + 2.0 * G2; - const y2 = y0 - 1.0 + 2.0 * G2; - const ii = i & 255; - const jj = j & 255; - const gi0 = this.p[ii + this.p[jj]] % 12; - const gi1 = this.p[ii + i1 + this.p[jj + j1]] % 12; - const gi2 = this.p[ii + 1 + this.p[jj + 1]] % 12; - let n0, n1, n2; - let t0 = 0.5 - x0 * x0 - y0 * y0; - if (t0 < 0) n0 = 0.0; - else { t0 *= t0; n0 = t0 * t0 * this.gradSimplex2D(gi0, x0, y0); } - let t1 = 0.5 - x1 * x1 - y1 * y1; - if (t1 < 0) n1 = 0.0; - else { t1 *= t1; n1 = t1 * t1 * this.gradSimplex2D(gi1, x1, y1); } - let t2 = 0.5 - x2 * x2 - y2 * y2; - if (t2 < 0) n2 = 0.0; - else { t2 *= t2; n2 = t2 * t2 * this.gradSimplex2D(gi2, x2, y2); } - return 70.0 * (n0 + n1 + n2); - } - - ridgeNoise2D(x, y) { - const perlinValue = this.perlin2D(x, y); - return 1.0 - Math.abs(Math.max(-1, Math.min(1, perlinValue))); - } +${buildNoiseGeneratorJS()} +${buildBiomeConstantsJS()} - fractal2D(x, y, octaves = 4, persistence = 0.5, lacunarity = 2.0) { - let value = 0; - let amplitude = 1; - let frequency = 1; - let maxValue = 0; - for (let i = 0; i < octaves; i++) { - value += this.perlin2D(x * frequency, y * frequency) * amplitude; - maxValue += amplitude; - amplitude *= persistence; - frequency *= lacunarity; - } - return value / maxValue; - } - - erosionNoise2D(x, y, iterations = 3) { - let height = this.fractal2D(x, y, 6); - for (let i = 0; i < iterations; i++) { - const delta = 0.01; - const hC = this.perlin2D(x, y); - const hX = this.perlin2D(x + delta, y); - const hY = this.perlin2D(x, y + delta); - const gradX = (hX - hC) / delta; - const gradY = (hY - hC) / delta; - const magnitude = Math.sqrt(gradX * gradX + gradY * gradY); - const erosionFactor = Math.min(1.0, magnitude * 2.0); - height *= 1.0 - erosionFactor * 0.1; - } - return height; - } -} - -// Biome ID mapping -const BIOME_IDS = { plains: 0, forest: 1, valley: 2, mountains: 3, tundra: 4, desert: 5, lakes: 6, swamp: 7 }; +var BIOME_IDS = {}; +BIOME_IDS[BT_TUNDRA] = 0; +BIOME_IDS[BT_FOREST] = 1; +BIOME_IDS[BT_DESERT] = 2; function generateHeightmap(input) { const { tileX, tileZ, config, seed, biomeCenters, biomes } = input; @@ -255,112 +149,14 @@ function generateHeightmap(input) { // HEIGHT FUNCTIONS — generated from TerrainHeightParams.ts (single source of truth) // ============================================ - ${buildGetBaseHeightAtJS()} + var landscapeFeatures = (config.landscapeFeatures || []); - function getHeightAtWithoutShore(worldX, worldZ) { - var baseHeight = getBaseHeightAt(worldX, worldZ); - var height = baseHeight / MAX_HEIGHT; - var mountainBoost = 0; - for (var _i = 0; _i < biomeCenters.length; _i++) { - var center = biomeCenters[_i]; - if (center.type === 'mountains') { - var dx = worldX - center.x; - var dz = worldZ - center.z; - var distance = Math.sqrt(dx * dx + dz * dz); - var normalizedDist = distance / center.influence; - if (normalizedDist < ${MOUNTAIN_BOOST_MAX_NORM_DIST}) { - var boost = Math.exp(-normalizedDist * normalizedDist * ${MOUNTAIN_BOOST_GAUSSIAN_COEFF}); - mountainBoost = Math.max(mountainBoost, boost); - } - } - } - height = height * (1 + mountainBoost * MOUNTAIN_HEIGHT_BOOST); - height = Math.min(1, height); - return height * MAX_HEIGHT; - } - - function calculateBaseSlopeAt(worldX, worldZ, centerHeight) { - const d = SHORELINE_SLOPE_SAMPLE_DISTANCE; - const hN = getHeightAtWithoutShore(worldX, worldZ + d); - const hS = getHeightAtWithoutShore(worldX, worldZ - d); - const hE = getHeightAtWithoutShore(worldX + d, worldZ); - const hW = getHeightAtWithoutShore(worldX - d, worldZ); - return Math.max( - Math.abs(hN - centerHeight) / d, - Math.abs(hS - centerHeight) / d, - Math.abs(hE - centerHeight) / d, - Math.abs(hW - centerHeight) / d - ); - } - - function adjustHeightForShoreline(baseHeight, slope) { - if (baseHeight === WATER_THRESHOLD) return baseHeight; - const isLand = baseHeight > WATER_THRESHOLD; - const band = isLand ? SHORELINE_LAND_BAND : SHORELINE_UNDERWATER_BAND; - if (band <= 0) return baseHeight; - const delta = Math.abs(baseHeight - WATER_THRESHOLD); - if (delta >= band) return baseHeight; - if (SHORELINE_MIN_SLOPE <= 0) return baseHeight; - const maxMul = isLand ? SHORELINE_LAND_MAX_MULTIPLIER : UNDERWATER_DEPTH_MULTIPLIER; - if (maxMul <= 1) return baseHeight; - const slopeSafe = Math.max(0.0001, slope); - const targetMul = Math.min(maxMul, Math.max(1, SHORELINE_MIN_SLOPE / slopeSafe)); - const falloff = 1 - delta / band; - const mul = 1 + (targetMul - 1) * falloff; - const adjustedDelta = delta * mul; - return isLand ? WATER_THRESHOLD + adjustedDelta : WATER_THRESHOLD - adjustedDelta; - } - - function getHeightComputed(worldX, worldZ) { - const h = getHeightAtWithoutShore(worldX, worldZ); - if (h >= WATER_THRESHOLD + SHORELINE_LAND_BAND || h <= WATER_THRESHOLD - SHORELINE_UNDERWATER_BAND) { - return h; - } - const slope = calculateBaseSlopeAt(worldX, worldZ, h); - return adjustHeightForShoreline(h, slope); - } + ${buildComputeBiomeWeightsJS()} + ${buildApplyLandscapeFeaturesJS()} - // ============================================ - // BIOME INFLUENCES — synced with TerrainSystem.getBiomeInfluencesAtPosition() - // ============================================ - - function getBiomeInfluences(worldX, worldZ, normalizedHeight) { - if (!biomeCenters || biomeCenters.length === 0) { - return [{ type: 'plains', weight: 1.0 }]; - } - const boundaryNoise = noise.simplex2D( - worldX * BIOME_BOUNDARY_NOISE_SCALE, - worldZ * BIOME_BOUNDARY_NOISE_SCALE - ); - const biomeWeightMap = {}; - for (const center of biomeCenters) { - const dx = worldX - center.x; - const dz = worldZ - center.z; - const distance = Math.sqrt(dx * dx + dz * dz); - const noisyDistance = distance * (1 + boundaryNoise * BIOME_BOUNDARY_NOISE_AMOUNT); - const normalizedDistance = noisyDistance / center.influence; - let weight = Math.exp(-normalizedDistance * normalizedDistance * BIOME_GAUSSIAN_COEFF); - if (center.type === 'mountains' && normalizedHeight > MOUNTAIN_HEIGHT_THRESHOLD) { - weight *= 1.0 + (normalizedHeight - MOUNTAIN_HEIGHT_THRESHOLD) * MOUNTAIN_WEIGHT_BOOST; - } - if ((center.type === 'valley' || center.type === 'plains') && normalizedHeight < VALLEY_HEIGHT_THRESHOLD) { - weight *= 1.0 + (VALLEY_HEIGHT_THRESHOLD - normalizedHeight) * VALLEY_WEIGHT_BOOST; - } - biomeWeightMap[center.type] = (biomeWeightMap[center.type] || 0) + weight; - } - const biomeInfluences = []; - for (const type in biomeWeightMap) { - biomeInfluences.push({ type, weight: biomeWeightMap[type] }); - } - const totalWeight = biomeInfluences.reduce((sum, b) => sum + b.weight, 0); - if (totalWeight > 0) { - for (const inf of biomeInfluences) { inf.weight /= totalWeight; } - } else { - biomeInfluences.push({ type: 'plains', weight: 1.0 }); - } - biomeInfluences.sort((a, b) => b.weight - a.weight); - return biomeInfluences.slice(0, 3); - } + ${buildGetBaseHeightAtJS()} + ${buildHeightHelpersJS()} + ${buildBiomeInfluencesJS()} // ============================================ // OVERFLOW GRID — (resolution+2)^2 height grid for centered-difference normals diff --git a/packages/shared/src/utils/workers/TerrainWorkerShared.ts b/packages/shared/src/utils/workers/TerrainWorkerShared.ts new file mode 100644 index 000000000..d490ab0f8 --- /dev/null +++ b/packages/shared/src/utils/workers/TerrainWorkerShared.ts @@ -0,0 +1,250 @@ +/** + * TerrainWorkerShared — Single source of truth for inline worker JS code + * shared between QuadChunkWorker and TerrainWorker. + * + * Workers can't import TS modules, so we build JS strings that get injected + * into the inline worker code. Both workers call these builders to get + * identical copies of the shared logic. + * + * If you need to change noise generation, height helpers, biome influence + * calculation, or shoreline adjustment — change it HERE once. + */ + +/** + * NoiseGenerator class — worker-side noise implementation. + * Mirrors packages/shared/src/utils/NoiseGenerator.ts. + */ +export function buildNoiseGeneratorJS(): string { + return ` +class NoiseGenerator { + constructor(seed = 12345) { + this.permutation = []; + this.p = []; + this.initializePermutation(seed); + } + + initializePermutation(seed) { + const perm = Array.from({ length: 256 }, (_, i) => i); + let random = seed; + for (let i = perm.length - 1; i > 0; i--) { + random = (random * 1664525 + 1013904223) % 4294967296; + const j = Math.floor((random / 4294967296) * (i + 1)); + [perm[i], perm[j]] = [perm[j], perm[i]]; + } + this.permutation = perm; + this.p = [...perm, ...perm]; + } + + fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); } + lerp(t, a, b) { return a + t * (b - a); } + grad2D(hash, x, y) { + const h = hash & 3; + const u = h < 2 ? x : y; + const v = h < 2 ? y : x; + return (h & 1 ? -u : u) + (h & 2 ? -v : v); + } + + perlin2D(x, y) { + const X = Math.floor(x) & 255; + const Y = Math.floor(y) & 255; + x -= Math.floor(x); + y -= Math.floor(y); + const u = this.fade(x); + const v = this.fade(y); + const A = this.p[X] + Y; + const AA = this.p[A]; + const AB = this.p[A + 1]; + const B = this.p[X + 1] + Y; + const BA = this.p[B]; + const BB = this.p[B + 1]; + const result = this.lerp(v, + this.lerp(u, this.grad2D(this.p[AA], x, y), this.grad2D(this.p[BA], x - 1, y)), + this.lerp(u, this.grad2D(this.p[AB], x, y - 1), this.grad2D(this.p[BB], x - 1, y - 1)) + ); + return Math.max(-1, Math.min(1, result)); + } + + gradSimplex2D(hash, x, y) { + const grad3 = [[1,1,0],[-1,1,0],[1,-1,0],[-1,-1,0],[1,0,1],[-1,0,1],[1,0,-1],[-1,0,-1],[0,1,1],[0,-1,1],[0,1,-1],[0,-1,-1]]; + return grad3[hash % 12][0] * x + grad3[hash % 12][1] * y; + } + + simplex2D(x, y) { + const F2 = 0.5 * (Math.sqrt(3.0) - 1.0); + const G2 = (3.0 - Math.sqrt(3.0)) / 6.0; + const s = (x + y) * F2; + const i = Math.floor(x + s); + const j = Math.floor(y + s); + const t = (i + j) * G2; + const X0 = i - t; + const Y0 = j - t; + const x0 = x - X0; + const y0 = y - Y0; + let i1, j1; + if (x0 > y0) { i1 = 1; j1 = 0; } else { i1 = 0; j1 = 1; } + const x1 = x0 - i1 + G2; + const y1 = y0 - j1 + G2; + const x2 = x0 - 1.0 + 2.0 * G2; + const y2 = y0 - 1.0 + 2.0 * G2; + const ii = i & 255; + const jj = j & 255; + const gi0 = this.p[ii + this.p[jj]] % 12; + const gi1 = this.p[ii + i1 + this.p[jj + j1]] % 12; + const gi2 = this.p[ii + 1 + this.p[jj + 1]] % 12; + let n0, n1, n2; + let t0 = 0.5 - x0 * x0 - y0 * y0; + if (t0 < 0) n0 = 0.0; + else { t0 *= t0; n0 = t0 * t0 * this.gradSimplex2D(gi0, x0, y0); } + let t1 = 0.5 - x1 * x1 - y1 * y1; + if (t1 < 0) n1 = 0.0; + else { t1 *= t1; n1 = t1 * t1 * this.gradSimplex2D(gi1, x1, y1); } + let t2 = 0.5 - x2 * x2 - y2 * y2; + if (t2 < 0) n2 = 0.0; + else { t2 *= t2; n2 = t2 * t2 * this.gradSimplex2D(gi2, x2, y2); } + return 70.0 * (n0 + n1 + n2); + } + + ridgeNoise2D(x, y) { + const perlinValue = this.perlin2D(x, y); + return 1.0 - Math.abs(Math.max(-1, Math.min(1, perlinValue))); + } + + fractal2D(x, y, octaves = 4, persistence = 0.5, lacunarity = 2.0) { + let value = 0; + let amplitude = 1; + let frequency = 1; + let maxValue = 0; + for (let i = 0; i < octaves; i++) { + value += this.perlin2D(x * frequency, y * frequency) * amplitude; + maxValue += amplitude; + amplitude *= persistence; + frequency *= lacunarity; + } + return value / maxValue; + } + + erosionNoise2D(x, y, iterations = 3) { + let height = this.fractal2D(x, y, 6); + for (let i = 0; i < iterations; i++) { + const delta = 0.01; + const hC = this.perlin2D(x, y); + const hX = this.perlin2D(x + delta, y); + const hY = this.perlin2D(x, y + delta); + const gradX = (hX - hC) / delta; + const gradY = (hY - hC) / delta; + const magnitude = Math.sqrt(gradX * gradX + gradY * gradY); + const erosionFactor = Math.min(1.0, magnitude * 2.0); + height *= 1.0 - erosionFactor * 0.1; + } + return height; + } +}`; +} + +/** + * Height helper functions used by both workers. + * + * Expects these variables to already be in scope: + * - noise: NoiseGenerator + * - biomeCenters: array of biome center objects + * - MAX_HEIGHT: from config + * - WATER_THRESHOLD, SHORELINE_*: from config + * - getBaseHeightAt(): from buildGetBaseHeightAtJS() + * - computeBiomeWeightsByPosition(): from buildComputeBiomeWeightsJS() + */ +export function buildHeightHelpersJS(): string { + return ` + function getHeightAtWithoutShore(worldX, worldZ) { + var bw = computeBiomeWeightsByPosition(worldX, worldZ); + return getBaseHeightAt(worldX, worldZ, bw); + } + + function calculateBaseSlopeAt(worldX, worldZ, centerHeight) { + const d = SHORELINE_SLOPE_SAMPLE_DISTANCE; + const hN = getHeightAtWithoutShore(worldX, worldZ + d); + const hS = getHeightAtWithoutShore(worldX, worldZ - d); + const hE = getHeightAtWithoutShore(worldX + d, worldZ); + const hW = getHeightAtWithoutShore(worldX - d, worldZ); + return Math.max( + Math.abs(hN - centerHeight) / d, + Math.abs(hS - centerHeight) / d, + Math.abs(hE - centerHeight) / d, + Math.abs(hW - centerHeight) / d + ); + } + + function adjustHeightForShoreline(baseHeight, slope) { + if (baseHeight === WATER_THRESHOLD) return baseHeight; + const isLand = baseHeight > WATER_THRESHOLD; + const band = isLand ? SHORELINE_LAND_BAND : SHORELINE_UNDERWATER_BAND; + if (band <= 0) return baseHeight; + const delta = Math.abs(baseHeight - WATER_THRESHOLD); + if (delta >= band) return baseHeight; + if (SHORELINE_MIN_SLOPE <= 0) return baseHeight; + const maxMul = isLand ? SHORELINE_LAND_MAX_MULTIPLIER : UNDERWATER_DEPTH_MULTIPLIER; + if (maxMul <= 1) return baseHeight; + const slopeSafe = Math.max(0.0001, slope); + const targetMul = Math.min(maxMul, Math.max(1, SHORELINE_MIN_SLOPE / slopeSafe)); + const falloff = 1 - delta / band; + const mul = 1 + (targetMul - 1) * falloff; + const adjustedDelta = delta * mul; + return isLand ? WATER_THRESHOLD + adjustedDelta : WATER_THRESHOLD - adjustedDelta; + } + + function getHeightComputed(worldX, worldZ) { + const h = getHeightAtWithoutShore(worldX, worldZ); + if (h >= WATER_THRESHOLD + SHORELINE_LAND_BAND || h <= WATER_THRESHOLD - SHORELINE_UNDERWATER_BAND) { + return h; + } + const slope = calculateBaseSlopeAt(worldX, worldZ, h); + return adjustHeightForShoreline(h, slope); + }`; +} + +/** + * Biome influence function used by both workers. + * + * Expects these variables to already be in scope: + * - noise: NoiseGenerator + * - biomeCenters: array of biome center objects + * - BIOME_GAUSSIAN_COEFF, BIOME_BOUNDARY_NOISE_*: from config + * - VALLEY_HEIGHT_THRESHOLD, VALLEY_WEIGHT_BOOST: from config + * - BT_DEFAULT, BT_TUNDRA: from buildBiomeConstantsJS() + */ +export function buildBiomeInfluencesJS(): string { + return ` + function getBiomeInfluences(worldX, worldZ, normalizedHeight) { + if (!biomeCenters || biomeCenters.length === 0) { + return [{ type: BT_DEFAULT, weight: 1.0 }]; + } + const boundaryNoise = noise.simplex2D( + worldX * BIOME_BOUNDARY_NOISE_SCALE, + worldZ * BIOME_BOUNDARY_NOISE_SCALE + ); + const biomeWeightMap = {}; + for (const center of biomeCenters) { + const dx = worldX - center.x; + const dz = worldZ - center.z; + const distance = Math.sqrt(dx * dx + dz * dz); + const noisyDistance = distance * (1 + boundaryNoise * BIOME_BOUNDARY_NOISE_AMOUNT); + const normalizedDistance = noisyDistance / center.influence; + let weight = Math.exp(-normalizedDistance * normalizedDistance * BIOME_GAUSSIAN_COEFF); + if (center.type === BT_TUNDRA && normalizedHeight < VALLEY_HEIGHT_THRESHOLD) { + weight *= 1.0 + (VALLEY_HEIGHT_THRESHOLD - normalizedHeight) * VALLEY_WEIGHT_BOOST; + } + biomeWeightMap[center.type] = (biomeWeightMap[center.type] || 0) + weight; + } + const biomeInfluences = []; + for (const type in biomeWeightMap) { + biomeInfluences.push({ type, weight: biomeWeightMap[type] }); + } + const totalWeight = biomeInfluences.reduce((sum, b) => sum + b.weight, 0); + if (totalWeight > 0) { + for (const inf of biomeInfluences) { inf.weight /= totalWeight; } + } else { + biomeInfluences.push({ type: BT_DEFAULT, weight: 1.0 }); + } + biomeInfluences.sort((a, b) => b.weight - a.weight); + return biomeInfluences.slice(0, 3); + }`; +} From d404afdd7bed535ad80330b3940baa8b148d038b Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Fri, 6 Mar 2026 13:15:25 +0800 Subject: [PATCH 13/48] fix(terrain): road influence refresh for quadtree LOD chunks - Add quadtree chunk refresh pass to refreshRoadInfluence() so roads render correctly when USE_QUADTREE_LOD is enabled - Increase QUADTREE_RESOLUTION from 32 to 64 to match tile vertex density, preventing roads from appearing thinner in quadtree mode Made-with: Cursor --- .../src/systems/shared/world/TerrainSystem.ts | 69 ++++++++++++++++--- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts index ae815fc84..04f8effcb 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -1516,7 +1516,7 @@ export class TerrainSystem extends System { QUADTREE_SPLIT_RATIO: 1.5, QUADTREE_SKIRT_DROP: 15, QUADTREE_UNSPLIT_MULTIPLIER: 1.2, - QUADTREE_RESOLUTION: 32, + QUADTREE_RESOLUTION: 64, QUADTREE_MAX_SYNC_PER_FRAME: 4, QUADTREE_MAX_ASSEMBLIES_PER_FRAME: 6, @@ -3253,16 +3253,67 @@ export class TerrainSystem extends System { } console.log( - `[TerrainSystem] Road influence refresh complete: ${tilesUpdated}/${tiles.length} tiles updated, ${totalVerticesWithRoads} vertices with road influence`, + `[TerrainSystem] Road influence refresh (tiles): ${tilesUpdated}/${tiles.length} tiles updated, ${totalVerticesWithRoads} vertices with road influence`, ); - // Log which tiles were skipped (no road segments) - if (tilesUpdated === 0 && tiles.length > 0) { - console.warn( - `[TerrainSystem] WARNING: No tiles had road segments! Check if roads are in the visible area.`, + + // Also refresh quad-tree LOD chunks if enabled + if (this.quadTreeVisualManager) { + let quadChunksUpdated = 0; + let quadVerticesWithRoads = 0; + const chunks = this.quadTreeVisualManager.getChunks(); + const chunkEntries = Array.from(chunks.values()); + const CHUNKS_PER_BATCH = 4; + + for (let ci = 0; ci < chunkEntries.length; ci += CHUNKS_PER_BATCH) { + const batchEnd = Math.min(ci + CHUNKS_PER_BATCH, chunkEntries.length); + + for (let c = ci; c < batchEnd; c++) { + const chunk = chunkEntries[c]; + const geometry = chunk.mesh.geometry as THREE.BufferGeometry; + if (!geometry) continue; + + const positions = geometry.attributes.position; + const roadInfluenceAttr = geometry.getAttribute("roadInfluence"); + if ( + !positions || + !(roadInfluenceAttr instanceof THREE.BufferAttribute) + ) + continue; + + const posArray = positions.array as Float32Array; + const roadArray = roadInfluenceAttr.array as Float32Array; + const cx = chunk.mesh.position.x; + const cz = chunk.mesh.position.z; + let vertsWithRoad = 0; + + for (let i = 0; i < positions.count; i++) { + const worldX = posArray[i * 3] + cx; + const worldZ = posArray[i * 3 + 2] + cz; + const roadTileX = Math.floor(worldX / this.CONFIG.TILE_SIZE); + const roadTileZ = Math.floor(worldZ / this.CONFIG.TILE_SIZE); + const influence = this.calculateRoadInfluenceAtVertex( + worldX, + worldZ, + roadTileX, + roadTileZ, + ); + roadArray[i] = influence; + if (influence > 0) vertsWithRoad++; + } + + roadInfluenceAttr.needsUpdate = true; + quadChunksUpdated++; + quadVerticesWithRoads += vertsWithRoad; + } + + if (batchEnd < chunkEntries.length) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + console.log( + `[TerrainSystem] Road influence refresh (quadtree): ${quadChunksUpdated}/${chunkEntries.length} chunks updated, ${quadVerticesWithRoads} vertices with road influence`, ); - // Log tile keys for debugging - const tileKeys = Array.from(this.terrainTiles.keys()).join(", "); - console.log(`[TerrainSystem] Existing tile keys: ${tileKeys}`); } } From 2bb5c6b4bd01825a936b263e8cdf616ef66fbf5e Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Fri, 6 Mar 2026 15:28:23 +0800 Subject: [PATCH 14/48] feat(terrain): enlarge island 1.5x, per-biome terrace steps, biome-aware snow cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Increase ISLAND_RADIUS from 350 to 525 (1.5x), scale falloff and ocean buffer proportionally - Add terraceSteps to BiomeNoiseProfile for per-biome terrace density (tundra: 4, forest: 0, desert: 8) - Remove global TERRACE_STEPS constant — now blended per-vertex from biome weights - Snow overlay now only appears in tundra biome areas instead of all non-desert high elevations - Landscape features (mountains/ponds) sourced from LANDSCAPE_FEATURES constant array Made-with: Cursor --- .../shared/world/TerrainHeightParams.ts | 117 ++++++++++++------ .../src/systems/shared/world/TerrainShader.ts | 5 +- .../src/systems/shared/world/TerrainSystem.ts | 77 +++--------- 3 files changed, 98 insertions(+), 101 deletions(-) diff --git a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts index fcb0ec51c..1c520bf47 100644 --- a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts +++ b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts @@ -75,34 +75,37 @@ export interface BiomeNoiseProfile { detailWeight: number; powerCurve: number; terraceStrength: number; + terraceSteps: number; terraceCeiling: number; terraceFloor: number; } -// Tundra: gentle rolling plains, smooth and open with minimal detail +// Tundra: rugged snowy peaks with ridges and erosion export const TUNDRA_PROFILE: BiomeNoiseProfile = { - continentWeight: 0.4, - ridgeWeight: 0.05, - hillWeight: 0.12, - erosionWeight: 0.03, - detailWeight: 0.02, - powerCurve: 0.85, - terraceStrength: 0.0, - terraceCeiling: 0.8, - terraceFloor: -0.1, -}; - -// Forest: hilly terrain with moderate ridges and visible terracing -export const FOREST_PROFILE: BiomeNoiseProfile = { continentWeight: 0.28, ridgeWeight: 0.2, hillWeight: 0.35, erosionWeight: 0.12, detailWeight: 0.15, powerCurve: 1.15, - terraceStrength: 0.3, + terraceStrength: 0.1, + terraceSteps: 6, + terraceCeiling: 0.75, + terraceFloor: 0, +}; + +// Forest: gentle rolling hills, smooth and lush +export const FOREST_PROFILE: BiomeNoiseProfile = { + continentWeight: 0.4, + ridgeWeight: 0.05, + hillWeight: 0.12, + erosionWeight: 0.03, + detailWeight: 0.02, + powerCurve: 0.85, + terraceStrength: 0.1, + terraceSteps: 6, terraceCeiling: 0.75, - terraceFloor: 0.05, + terraceFloor: 0, }; // Desert: dramatic mesas and canyons with strong terracing and erosion @@ -114,7 +117,8 @@ export const DESERT_PROFILE: BiomeNoiseProfile = { detailWeight: 0.05, powerCurve: 1.5, terraceStrength: 0.55, - terraceCeiling: 0.85, + terraceSteps: 6, + terraceCeiling: 0.75, terraceFloor: 0.0, }; @@ -130,9 +134,9 @@ export const TERRACE_NOISE_SCALE = 0.005; // Island configuration // --------------------------------------------------------------------------- -export const ISLAND_RADIUS = 350; -export const ISLAND_FALLOFF = 200; -export const ISLAND_DEEP_OCEAN_BUFFER = 50; +export const ISLAND_RADIUS = 525; +export const ISLAND_FALLOFF = 300; +export const ISLAND_DEEP_OCEAN_BUFFER = 75; export const BASE_ELEVATION = 0.42; export const OCEAN_FLOOR_HEIGHT = 0.05; /** height = terrain * HEIGHT_TERRAIN_MIX + BASE_ELEVATION * islandMask */ @@ -143,8 +147,13 @@ export const BEACH_PROFILE_POWER = 3.0; // Landscape features — mountains & ponds, independent of biomes // --------------------------------------------------------------------------- +export enum LandscapeType { + Mountain = "mountain", + Pond = "pond", +} + export interface LandscapeFeatureDef { - type: "mountain" | "pond"; + type: LandscapeType; x: number; z: number; radius: number; @@ -168,6 +177,37 @@ export const POND_FEATURE_DEFAULTS = { gaussianCoeff: 0.5, }; +/** + * Predefined landscape features — add/remove entries here to control + * exactly where mountains and ponds appear on the island. + */ +export const LANDSCAPE_FEATURES: LandscapeFeatureDef[] = [ + { + type: LandscapeType.Mountain, + x: 28.5, + z: -237.5, + radius: 260, + strength: 2.5, + gaussianCoeff: 12.5, + }, + { + type: LandscapeType.Mountain, + x: 265.5, + z: 128.5, + radius: 180, + strength: 1.2, + gaussianCoeff: 5.5, + }, + { + type: LandscapeType.Pond, + x: -183.5, + z: 175.5, + radius: 90, + strength: 1.8, + gaussianCoeff: 3.0, + }, +]; + // --------------------------------------------------------------------------- // Coastline noise — varies the island radius for irregular shoreline // --------------------------------------------------------------------------- @@ -244,17 +284,15 @@ export function applyLandscapeFeaturesPure( if (dist > feat.radius * 3) continue; const nd = dist / feat.radius; const influence = Math.exp(-nd * nd * feat.gaussianCoeff); - if (feat.type === "mountain") { + if (feat.type === LandscapeType.Mountain) { height += influence * feat.strength; - } else if (feat.type === "pond") { + } else if (feat.type === LandscapeType.Pond) { height -= influence * feat.strength; } } return height; } -const TERRACE_STEPS = 6; - /** * THE height generation algorithm. Main thread calls this directly. * Workers use the JS-string mirror (buildGetBaseHeightAtJS) which @@ -308,6 +346,7 @@ export function computeBaseHeight( dW = 0, pC = 0, tS = 0, + tSt = 0, tC = 0, tF = 0; for (const key of Object.keys(biomeWeights)) { @@ -320,6 +359,7 @@ export function computeBaseHeight( dW += p.detailWeight * w; pC += p.powerCurve * w; tS += p.terraceStrength * w; + tSt += p.terraceSteps * w; tC += p.terraceCeiling * w; tF += p.terraceFloor * w; } @@ -330,8 +370,9 @@ export function computeBaseHeight( height = Math.max(0, Math.min(1, height)); height = Math.pow(height, pC); - // ── 4. Terracing (6-step quantization with noise-varied blend) ────── - if (tS > 0.001) { + // ── 4. Terracing (per-biome step count, noise-varied blend) ───────── + const roundedSteps = Math.round(tSt); + if (tS > 0.001 && roundedSteps >= 1) { const terraceNoise = noise.simplex2D( worldX * TERRACE_NOISE_SCALE, worldZ * TERRACE_NOISE_SCALE, @@ -339,7 +380,7 @@ export function computeBaseHeight( const range = Math.max(0.01, tC - tF); const normalized = Math.max(0, Math.min(1, (height - tF) / range)); const quantized = - (Math.round(normalized * TERRACE_STEPS) / TERRACE_STEPS) * range + tF; + (Math.round(normalized * roundedSteps) / roundedSteps) * range + tF; const terraceBlend = tS * (0.7 + 0.3 * (terraceNoise * 0.5 + 0.5)); height = height + (quantized - height) * terraceBlend; } @@ -501,9 +542,9 @@ export function buildApplyLandscapeFeaturesJS(): string { if (dist > feat.radius * 3) continue; var normDist = dist / feat.radius; var influence = Math.exp(-normDist * normDist * feat.gaussianCoeff); - if (feat.type === 'mountain') { + if (feat.type === '${LandscapeType.Mountain}') { height += influence * feat.strength; - } else if (feat.type === 'pond') { + } else if (feat.type === '${LandscapeType.Pond}') { height -= influence * feat.strength; } } @@ -514,11 +555,10 @@ 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}, tC: ${TUNDRA_PROFILE.terraceCeiling}, tF: ${TUNDRA_PROFILE.terraceFloor} }; - 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}, tC: ${FOREST_PROFILE.terraceCeiling}, tF: ${FOREST_PROFILE.terraceFloor} }; - BIOME_PROFILES[BT_DESERT] = { cW: ${DESERT_PROFILE.continentWeight}, rW: ${DESERT_PROFILE.ridgeWeight}, hW: ${DESERT_PROFILE.hillWeight}, eW: ${DESERT_PROFILE.erosionWeight}, dW: ${DESERT_PROFILE.detailWeight}, pC: ${DESERT_PROFILE.powerCurve}, tS: ${DESERT_PROFILE.terraceStrength}, tC: ${DESERT_PROFILE.terraceCeiling}, tF: ${DESERT_PROFILE.terraceFloor} }; + 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}, tSt: ${TUNDRA_PROFILE.terraceSteps}, tC: ${TUNDRA_PROFILE.terraceCeiling}, tF: ${TUNDRA_PROFILE.terraceFloor} }; + 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}, tSt: ${FOREST_PROFILE.terraceSteps}, tC: ${FOREST_PROFILE.terraceCeiling}, tF: ${FOREST_PROFILE.terraceFloor} }; + BIOME_PROFILES[BT_DESERT] = { cW: ${DESERT_PROFILE.continentWeight}, rW: ${DESERT_PROFILE.ridgeWeight}, hW: ${DESERT_PROFILE.hillWeight}, eW: ${DESERT_PROFILE.erosionWeight}, dW: ${DESERT_PROFILE.detailWeight}, pC: ${DESERT_PROFILE.powerCurve}, tS: ${DESERT_PROFILE.terraceStrength}, tSt: ${DESERT_PROFILE.terraceSteps}, tC: ${DESERT_PROFILE.terraceCeiling}, tF: ${DESERT_PROFILE.terraceFloor} }; var TERRACE_NOISE_SCALE = ${TERRACE_NOISE_SCALE}; - var TERRACE_STEPS = ${TERRACE_STEPS}; `; /** @@ -539,12 +579,12 @@ export function buildGetBaseHeightAtJS(): string { 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}); - var cW = 0, rW = 0, hW = 0, eW = 0, dW = 0, pC = 0, tS = 0, tC = 0, tF = 0; + var cW = 0, rW = 0, hW = 0, eW = 0, dW = 0, pC = 0, tS = 0, tSt = 0, tC = 0, tF = 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; tC += p.tC * w; tF += p.tF * w; + pC += p.pC * w; tS += p.tS * w; tSt += p.tSt * w; tC += p.tC * w; tF += p.tF * w; } var height = cN * cW + rN * rW + hN * hW + eN * eW + dN * dW; @@ -552,11 +592,12 @@ export function buildGetBaseHeightAtJS(): string { height = Math.max(0, Math.min(1, height)); height = Math.pow(height, pC); - if (tS > 0.001) { + var roundedSteps = Math.round(tSt); + if (tS > 0.001 && roundedSteps >= 1) { var terraceNoise = noise.simplex2D(worldX * TERRACE_NOISE_SCALE, worldZ * TERRACE_NOISE_SCALE); var range = Math.max(0.01, tC - tF); var normalized = Math.max(0, Math.min(1, (height - tF) / range)); - var quantized = Math.round(normalized * TERRACE_STEPS) / TERRACE_STEPS * range + tF; + var quantized = Math.round(normalized * roundedSteps) / roundedSteps * range + tF; var terraceBlend = tS * (0.7 + 0.3 * (terraceNoise * 0.5 + 0.5)); height = height + (quantized - height) * terraceBlend; } diff --git a/packages/shared/src/systems/shared/world/TerrainShader.ts b/packages/shared/src/systems/shared/world/TerrainShader.ts index 28aec6bbe..adb665ad4 100644 --- a/packages/shared/src/systems/shared/world/TerrainShader.ts +++ b/packages/shared/src/systems/shared/world/TerrainShader.ts @@ -152,8 +152,7 @@ export function computeTerrainBaseColor( const rockColor = mix(ROCK_GRAY, ROCK_DARK, rockVariation); c = mix(c, rockColor, smoothstep(float(0.45), float(0.75), slope)); - // Snow at high elevation (suppressed in desert) - const snowMask = sub(float(1.0), dW); + // Snow at high elevation — only in tundra biome (tW = tundra weight) c = mix( c, SNOW_WHITE, @@ -163,7 +162,7 @@ export function computeTerrainBaseColor( float(60.0), height, ), - snowMask, + tW, ), ); diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts index 04f8effcb..91771c33a 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -22,8 +22,7 @@ import { POND_DEPTH, POND_CENTER_X, POND_CENTER_Z, - MOUNTAIN_FEATURE_DEFAULTS, - POND_FEATURE_DEFAULTS, + LANDSCAPE_FEATURES, ISLAND_RADIUS, computeBaseHeight, adjustShorelineHeight, @@ -1248,64 +1247,22 @@ export class TerrainSystem extends System { } private initializeLandscapeFeatures(): void { - this.landscapeFeatures = []; - - const baseSeed = this.computeSeedFromWorldId() + 77777; - let state = baseSeed; - const rand = () => { - state = (state * 1664525 + 1013904223) >>> 0; - return state / 0xffffffff; - }; - - const worldSize = this.getActiveWorldSizeMeters(); - const halfWorld = worldSize / 2; - - const mountainCount = 4; - for (let i = 0; i < mountainCount; i++) { - const x = (rand() - 0.5) * worldSize * 0.7; - const z = (rand() - 0.5) * worldSize * 0.7; - const dist = Math.sqrt(x * x + z * z); - if (dist > halfWorld * 0.85) continue; - this.landscapeFeatures.push({ - type: "mountain", - x, - z, - radius: - MOUNTAIN_FEATURE_DEFAULTS.minRadius + - rand() * - (MOUNTAIN_FEATURE_DEFAULTS.maxRadius - - MOUNTAIN_FEATURE_DEFAULTS.minRadius), - strength: - MOUNTAIN_FEATURE_DEFAULTS.minStrength + - rand() * - (MOUNTAIN_FEATURE_DEFAULTS.maxStrength - - MOUNTAIN_FEATURE_DEFAULTS.minStrength), - gaussianCoeff: MOUNTAIN_FEATURE_DEFAULTS.gaussianCoeff, - }); - } - - const pondCount = 3; - for (let i = 0; i < pondCount; i++) { - const x = (rand() - 0.5) * worldSize * 0.6; - const z = (rand() - 0.5) * worldSize * 0.6; - const dist = Math.sqrt(x * x + z * z); - if (dist > halfWorld * 0.75) continue; - this.landscapeFeatures.push({ - type: "pond", - x, - z, - radius: - POND_FEATURE_DEFAULTS.minRadius + - rand() * - (POND_FEATURE_DEFAULTS.maxRadius - POND_FEATURE_DEFAULTS.minRadius), - strength: - POND_FEATURE_DEFAULTS.minStrength + - rand() * - (POND_FEATURE_DEFAULTS.maxStrength - - POND_FEATURE_DEFAULTS.minStrength), - gaussianCoeff: POND_FEATURE_DEFAULTS.gaussianCoeff, - }); - } + this.landscapeFeatures = [...LANDSCAPE_FEATURES]; + console.log( + "[TerrainSystem] Landscape features:", + this.landscapeFeatures.length, + JSON.stringify(this.landscapeFeatures), + ); + // Spot-check: test height at feature locations after terrain is ready + setTimeout(() => { + for (const f of this.landscapeFeatures) { + const h = this.getHeightAt(f.x, f.z); + const hNearby = this.getHeightAt(f.x + 200, f.z + 200); + console.log( + `[LandscapeDebug] ${f.type} at (${f.x}, ${f.z}): height=${h.toFixed(2)}, nearby height=${hNearby.toFixed(2)}, diff=${(h - hNearby).toFixed(2)}`, + ); + } + }, 5000); } /** From 35c05a6c97c6261d88d0223685491c0d6af35cb5 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Sat, 7 Mar 2026 01:50:25 +0800 Subject: [PATCH 15/48] feat(terrain): stepped power plateau system for mountains and ponds Replace Gaussian bell-curve landscape features with a configurable stepped plateau algorithm. Each feature now supports per-layer terracing with tunable shape, edge sharpness, slope, and noise wobble. - New LandscapeFeatureDef params: layers, shapePower, edgeSharpness, layerSlope, noiseScale, noiseAmount (replaces gaussianCoeff) - layerSlope controls incline within each terrace band (0=flat, 1=full slope) - Noise wobble on boundaries for organic non-circular edges - Island radius increased to 788 (1.5x from 525) - Updated worker mirror and serialization to match Made-with: Cursor --- .../shared/world/TerrainHeightParams.ts | 150 ++++++++++++------ .../src/systems/shared/world/TerrainSystem.ts | 7 +- .../shared/src/utils/workers/TerrainWorker.ts | 7 +- 3 files changed, 115 insertions(+), 49 deletions(-) diff --git a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts index 1c520bf47..9dbf83813 100644 --- a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts +++ b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts @@ -134,9 +134,9 @@ export const TERRACE_NOISE_SCALE = 0.005; // Island configuration // --------------------------------------------------------------------------- -export const ISLAND_RADIUS = 525; -export const ISLAND_FALLOFF = 300; -export const ISLAND_DEEP_OCEAN_BUFFER = 75; +export const ISLAND_RADIUS = 788; +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 */ @@ -158,53 +158,65 @@ export interface LandscapeFeatureDef { z: number; radius: number; strength: number; - gaussianCoeff: number; + layers: number; + shapePower: number; + edgeSharpness: number; + layerSlope: number; + noiseScale: number; + noiseAmount: number; } -export const MOUNTAIN_FEATURE_DEFAULTS = { - minRadius: 80, - maxRadius: 200, - minStrength: 0.3, - maxStrength: 0.6, - gaussianCoeff: 0.3, -}; - -export const POND_FEATURE_DEFAULTS = { - minRadius: 30, - maxRadius: 60, - minStrength: 0.4, - maxStrength: 0.6, - gaussianCoeff: 0.5, -}; - /** * Predefined landscape features — add/remove entries here to control - * exactly where mountains and ponds appear on the island. + * exactly where mountains, ponds, and plateaus appear on the island. + * + * Parameter guide: + * layers - number of terrace levels (1 = single plateau, 4+ = tiered mountain) + * shapePower - falloff curve (0.3 = dome, 1 = cone, 4+ = flat-topped mesa) + * edgeSharpness - transition between layers (0 = smooth blend, 1 = hard cliff) + * layerSlope - incline within each layer (0 = perfectly flat shelves, 1 = full natural slope) + * noiseScale - frequency of edge wobble (higher = more detailed wiggles) + * noiseAmount - amplitude of edge wobble (0 = perfect circles, 0.3 = organic edges) */ export const LANDSCAPE_FEATURES: LandscapeFeatureDef[] = [ { type: LandscapeType.Mountain, - x: 28.5, - z: -237.5, - radius: 260, - strength: 2.5, - gaussianCoeff: 12.5, + x: -168.5, + z: -352.5, + radius: 150, + strength: 4.0, + layers: 5, + shapePower: 1.8, + edgeSharpness: 0.7, + layerSlope: 0.5, + noiseScale: 0.015, + noiseAmount: 0.2, }, { type: LandscapeType.Mountain, x: 265.5, z: 128.5, - radius: 180, - strength: 1.2, - gaussianCoeff: 5.5, + radius: 150, + strength: 2.0, + layers: 3, + shapePower: 1.5, + edgeSharpness: 0.6, + layerSlope: 0.4, + noiseScale: 0.02, + noiseAmount: 0.15, }, { type: LandscapeType.Pond, - x: -183.5, - z: 175.5, + x: -134.5, + z: 127.5, radius: 90, - strength: 1.8, - gaussianCoeff: 3.0, + strength: 1.5, + layers: 1, + shapePower: 3.5, + edgeSharpness: 0.1, + layerSlope: 0.8, + noiseScale: 0.015, + noiseAmount: 0.06, }, ]; @@ -275,19 +287,43 @@ export function applyLandscapeFeaturesPure( worldX: number, worldZ: number, features: ReadonlyArray, + noise: TerrainNoiseAdapter, ): number { for (let i = 0; i < features.length; i++) { const feat = features[i]; const dx = worldX - feat.x; const dz = worldZ - feat.z; const dist = Math.sqrt(dx * dx + dz * dz); - if (dist > feat.radius * 3) continue; - const nd = dist / feat.radius; - const influence = Math.exp(-nd * nd * feat.gaussianCoeff); - if (feat.type === LandscapeType.Mountain) { - height += influence * feat.strength; - } else if (feat.type === LandscapeType.Pond) { + if (dist >= feat.radius) continue; + + const nv = + feat.noiseAmount > 0 + ? noise.simplex2D(worldX * feat.noiseScale, worldZ * feat.noiseScale) * + feat.noiseAmount + : 0; + + const t = Math.max(0, Math.min(1, 1 - dist / feat.radius + nv)); + const shaped = Math.pow(t, feat.shapePower); + + let influence: number; + if (feat.layers >= 1) { + const stepped = Math.floor(shaped * feat.layers) / feat.layers; + const nextStep = Math.min(1, stepped + 1 / feat.layers); + const frac = (shaped - 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 = shaped; + } + + if (feat.type === LandscapeType.Pond) { height -= influence * feat.strength; + } else { + height += influence * feat.strength; } } return height; @@ -427,7 +463,7 @@ export function computeBaseHeight( // ── 6. Island mask + landscape features ───────────────────────────── height = height * islandMask; - height = applyLandscapeFeaturesPure(height, worldX, worldZ, features); + height = applyLandscapeFeaturesPure(height, worldX, worldZ, features, noise); if (islandMask === 0) { height = OCEAN_FLOOR_HEIGHT; @@ -529,7 +565,7 @@ export function buildComputeBiomeWeightsJS(): string { /** * JS source — worker mirror of applyLandscapeFeaturesPure(). - * Depends on: landscapeFeatures array injected into worker scope. + * Depends on: landscapeFeatures array injected into worker scope, noise object. */ export function buildApplyLandscapeFeaturesJS(): string { return ` @@ -539,13 +575,33 @@ export function buildApplyLandscapeFeaturesJS(): string { var dx = worldX - feat.x; var dz = worldZ - feat.z; var dist = Math.sqrt(dx * dx + dz * dz); - if (dist > feat.radius * 3) continue; - var normDist = dist / feat.radius; - var influence = Math.exp(-normDist * normDist * feat.gaussianCoeff); - if (feat.type === '${LandscapeType.Mountain}') { - height += influence * feat.strength; - } else if (feat.type === '${LandscapeType.Pond}') { + if (dist >= feat.radius) continue; + + var nv = feat.noiseAmount > 0 + ? noise.simplex2D(worldX * feat.noiseScale, worldZ * feat.noiseScale) * feat.noiseAmount + : 0; + + var t = Math.max(0, Math.min(1, 1 - dist / feat.radius + nv)); + var shaped = Math.pow(t, feat.shapePower); + + var influence; + if (feat.layers >= 1) { + var stepped = Math.floor(shaped * feat.layers) / feat.layers; + var nextStep = Math.min(1, stepped + 1 / feat.layers); + var frac = (shaped - 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 = shaped; + } + + if (feat.type === '${LandscapeType.Pond}') { height -= influence * feat.strength; + } else { + height += influence * 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 91771c33a..de47f2194 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -786,7 +786,12 @@ export class TerrainSystem extends System { z: f.z, radius: f.radius, strength: f.strength, - gaussianCoeff: f.gaussianCoeff, + layers: f.layers, + shapePower: f.shapePower, + edgeSharpness: f.edgeSharpness, + layerSlope: f.layerSlope, + noiseScale: f.noiseScale, + noiseAmount: f.noiseAmount, })), }; diff --git a/packages/shared/src/utils/workers/TerrainWorker.ts b/packages/shared/src/utils/workers/TerrainWorker.ts index efa990a3f..64301a2d8 100644 --- a/packages/shared/src/utils/workers/TerrainWorker.ts +++ b/packages/shared/src/utils/workers/TerrainWorker.ts @@ -56,7 +56,12 @@ export interface TerrainWorkerConfig { z: number; radius: number; strength: number; - gaussianCoeff: number; + layers: number; + shapePower: number; + edgeSharpness: number; + layerSlope: number; + noiseScale: number; + noiseAmount: number; }>; } From e3d462eb9098a5ebdb5300bca977c8a16ac9e688 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Sat, 7 Mar 2026 05:16:50 +0800 Subject: [PATCH 16/48] feat(terrain): refactor terracing to floor-quantize with shelf/cliff control Replace round-based terracing (caused low-lying artifacts) with floor-quantize algorithm that creates natural downward-stepping contour bands. Add per-biome terraceSharpness param controlling flat shelf vs cliff transition ratio. Tune biome profiles: disable terracing for forest, strengthen for desert. Made-with: Cursor --- .../shared/world/TerrainHeightParams.ts | 123 ++++++++---------- 1 file changed, 55 insertions(+), 68 deletions(-) diff --git a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts index 9dbf83813..e8cb4ac48 100644 --- a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts +++ b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts @@ -74,52 +74,48 @@ export interface BiomeNoiseProfile { erosionWeight: number; detailWeight: number; powerCurve: number; - terraceStrength: number; + /** Number of terrace steps (0 = no terracing) */ terraceSteps: number; - terraceCeiling: number; - terraceFloor: number; + /** 0–1 blend from smooth to terraced (higher = more visible) */ + terraceStrength: number; + /** 0–1 flat zone per step (0.8 = 80% flat shelf, 20% cliff transition) */ + terraceSharpness: number; } -// Tundra: rugged snowy peaks with ridges and erosion export const TUNDRA_PROFILE: BiomeNoiseProfile = { - continentWeight: 0.28, - ridgeWeight: 0.2, - hillWeight: 0.35, - erosionWeight: 0.12, - detailWeight: 0.15, - powerCurve: 1.15, - terraceStrength: 0.1, - terraceSteps: 6, - terraceCeiling: 0.75, - terraceFloor: 0, + continentWeight: 0.32, + ridgeWeight: 0.15, + hillWeight: 0.28, + erosionWeight: 0.1, + detailWeight: 0.1, + powerCurve: 1.1, + terraceSteps: 10, + terraceStrength: 0.4, + terraceSharpness: 0.7, }; -// Forest: gentle rolling hills, smooth and lush export const FOREST_PROFILE: BiomeNoiseProfile = { - continentWeight: 0.4, - ridgeWeight: 0.05, - hillWeight: 0.12, - erosionWeight: 0.03, - detailWeight: 0.02, - powerCurve: 0.85, - terraceStrength: 0.1, - terraceSteps: 6, - terraceCeiling: 0.75, - terraceFloor: 0, + continentWeight: 0.15, + ridgeWeight: 0.08, + hillWeight: 0.1, + erosionWeight: 0.05, + detailWeight: 0.05, + powerCurve: 1, + terraceSteps: 10, + terraceStrength: 0, + terraceSharpness: 5.1, }; -// Desert: dramatic mesas and canyons with strong terracing and erosion export const DESERT_PROFILE: BiomeNoiseProfile = { continentWeight: 0.32, ridgeWeight: 0.25, hillWeight: 0.18, erosionWeight: 0.2, detailWeight: 0.05, - powerCurve: 1.5, - terraceStrength: 0.55, - terraceSteps: 6, - terraceCeiling: 0.75, - terraceFloor: 0.0, + powerCurve: 1.45, + terraceSteps: 10, + terraceStrength: 0.6, + terraceSharpness: 0.8, }; export const BIOME_PROFILES: Record = { @@ -128,8 +124,6 @@ export const BIOME_PROFILES: Record = { [BiomeType.Desert]: DESERT_PROFILE, }; -export const TERRACE_NOISE_SCALE = 0.005; - // --------------------------------------------------------------------------- // Island configuration // --------------------------------------------------------------------------- @@ -207,8 +201,8 @@ export const LANDSCAPE_FEATURES: LandscapeFeatureDef[] = [ }, { type: LandscapeType.Pond, - x: -134.5, - z: 127.5, + x: -238.5, + z: 325.5, radius: 90, strength: 1.5, layers: 1, @@ -381,10 +375,9 @@ export function computeBaseHeight( eW = 0, dW = 0, pC = 0, - tS = 0, tSt = 0, - tC = 0, - tF = 0; + tS = 0, + tSh = 0; for (const key of Object.keys(biomeWeights)) { const w = biomeWeights[key]; const p = BIOME_PROFILES[key] ?? BIOME_PROFILES[DEFAULT_BIOME]; @@ -394,10 +387,9 @@ export function computeBaseHeight( eW += p.erosionWeight * w; dW += p.detailWeight * w; pC += p.powerCurve * w; - tS += p.terraceStrength * w; tSt += p.terraceSteps * w; - tC += p.terraceCeiling * w; - tF += p.terraceFloor * w; + tS += p.terraceStrength * w; + tSh += p.terraceSharpness * w; } // ── 3. Combine, normalize, power curve ────────────────────────────── @@ -406,19 +398,15 @@ export function computeBaseHeight( height = Math.max(0, Math.min(1, height)); height = Math.pow(height, pC); - // ── 4. Terracing (per-biome step count, noise-varied blend) ───────── - const roundedSteps = Math.round(tSt); - if (tS > 0.001 && roundedSteps >= 1) { - const terraceNoise = noise.simplex2D( - worldX * TERRACE_NOISE_SCALE, - worldZ * TERRACE_NOISE_SCALE, - ); - const range = Math.max(0.01, tC - tF); - const normalized = Math.max(0, Math.min(1, (height - tF) / range)); - const quantized = - (Math.round(normalized * roundedSteps) / roundedSteps) * range + tF; - const terraceBlend = tS * (0.7 + 0.3 * (terraceNoise * 0.5 + 0.5)); - height = height + (quantized - height) * terraceBlend; + // ── 4. Terracing — floor-quantize into flat shelves with cliff edges ─ + const steps = Math.round(tSt); + 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 terraced = stepped + edgeBlend * (nextStep - stepped); + height = height + (terraced - height) * tS; } // ── 5. Coastline noise → island mask ──────────────────────────────── @@ -611,10 +599,9 @@ 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}, tSt: ${TUNDRA_PROFILE.terraceSteps}, tC: ${TUNDRA_PROFILE.terraceCeiling}, tF: ${TUNDRA_PROFILE.terraceFloor} }; - 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}, tSt: ${FOREST_PROFILE.terraceSteps}, tC: ${FOREST_PROFILE.terraceCeiling}, tF: ${FOREST_PROFILE.terraceFloor} }; - BIOME_PROFILES[BT_DESERT] = { cW: ${DESERT_PROFILE.continentWeight}, rW: ${DESERT_PROFILE.ridgeWeight}, hW: ${DESERT_PROFILE.hillWeight}, eW: ${DESERT_PROFILE.erosionWeight}, dW: ${DESERT_PROFILE.detailWeight}, pC: ${DESERT_PROFILE.powerCurve}, tS: ${DESERT_PROFILE.terraceStrength}, tSt: ${DESERT_PROFILE.terraceSteps}, tC: ${DESERT_PROFILE.terraceCeiling}, tF: ${DESERT_PROFILE.terraceFloor} }; - var TERRACE_NOISE_SCALE = ${TERRACE_NOISE_SCALE}; + 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}, tSt: ${TUNDRA_PROFILE.terraceSteps}, tS: ${TUNDRA_PROFILE.terraceStrength}, tSh: ${TUNDRA_PROFILE.terraceSharpness} }; + 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}, tSt: ${FOREST_PROFILE.terraceSteps}, tS: ${FOREST_PROFILE.terraceStrength}, tSh: ${FOREST_PROFILE.terraceSharpness} }; + BIOME_PROFILES[BT_DESERT] = { cW: ${DESERT_PROFILE.continentWeight}, rW: ${DESERT_PROFILE.ridgeWeight}, hW: ${DESERT_PROFILE.hillWeight}, eW: ${DESERT_PROFILE.erosionWeight}, dW: ${DESERT_PROFILE.detailWeight}, pC: ${DESERT_PROFILE.powerCurve}, tSt: ${DESERT_PROFILE.terraceSteps}, tS: ${DESERT_PROFILE.terraceStrength}, tSh: ${DESERT_PROFILE.terraceSharpness} }; `; /** @@ -635,12 +622,12 @@ export function buildGetBaseHeightAtJS(): string { 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}); - var cW = 0, rW = 0, hW = 0, eW = 0, dW = 0, pC = 0, tS = 0, tSt = 0, tC = 0, tF = 0; + var cW = 0, rW = 0, hW = 0, eW = 0, dW = 0, pC = 0, tSt = 0, tS = 0, tSh = 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; tSt += p.tSt * w; tC += p.tC * w; tF += p.tF * w; + pC += p.pC * w; tSt += p.tSt * w; tS += p.tS * w; tSh += p.tSh * w; } var height = cN * cW + rN * rW + hN * hW + eN * eW + dN * dW; @@ -648,14 +635,14 @@ export function buildGetBaseHeightAtJS(): string { height = Math.max(0, Math.min(1, height)); height = Math.pow(height, pC); - var roundedSteps = Math.round(tSt); - if (tS > 0.001 && roundedSteps >= 1) { - var terraceNoise = noise.simplex2D(worldX * TERRACE_NOISE_SCALE, worldZ * TERRACE_NOISE_SCALE); - var range = Math.max(0.01, tC - tF); - var normalized = Math.max(0, Math.min(1, (height - tF) / range)); - var quantized = Math.round(normalized * roundedSteps) / roundedSteps * range + tF; - var terraceBlend = tS * (0.7 + 0.3 * (terraceNoise * 0.5 + 0.5)); - height = height + (quantized - height) * terraceBlend; + var gSteps = Math.round(tSt); + 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 gTerraced = gStepped + gEdgeBlend * (gNextStep - gStepped); + height = height + (gTerraced - height) * tS; } var distFromCenter = Math.sqrt(worldX * worldX + worldZ * worldZ); From 841cd2cfbffa6b8c7eda9a0d75df3dd70d41bc0e Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Sat, 7 Mar 2026 06:02:14 +0800 Subject: [PATCH 17/48] feat(terrain): scale up terrain height and noise for larger features - Increase MAX_HEIGHT from 50 to 150 for taller terrain - Raise WATER_THRESHOLD from 9 to 50 to match new height scale - Halve all noise layer scales for spatially larger terrain features - Tweak forest hillWeight and landscape feature positions/strengths Made-with: Cursor --- .../shared/src/constants/GameConstants.ts | 2 +- .../shared/world/TerrainHeightParams.ts | 22 +++++++++---------- .../src/systems/shared/world/TerrainSystem.ts | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/shared/src/constants/GameConstants.ts b/packages/shared/src/constants/GameConstants.ts index b1fa4d98f..059dd5866 100644 --- a/packages/shared/src/constants/GameConstants.ts +++ b/packages/shared/src/constants/GameConstants.ts @@ -81,7 +81,7 @@ export const TERRAIN_CONSTANTS = { * Terrain below this height is underwater and impassable. * Used by: TerrainSystem, VegetationSystem, DissolveMaterial, RoadNetworkSystem, ResourceSystem */ - WATER_THRESHOLD: 9.0, + WATER_THRESHOLD: 50.0, /** * 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 e8cb4ac48..e7b2041e8 100644 --- a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts +++ b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts @@ -26,7 +26,7 @@ export interface NoiseLayerDef { } export const CONTINENT_LAYER: NoiseLayerDef = { - scale: 0.0008, + scale: 0.0004, octaves: 5, persistence: 0.7, lacunarity: 2.0, @@ -34,12 +34,12 @@ export const CONTINENT_LAYER: NoiseLayerDef = { }; export const RIDGE_LAYER: NoiseLayerDef = { - scale: 0.003, + scale: 0.0015, weight: 0.15, }; export const HILL_LAYER: NoiseLayerDef = { - scale: 0.02, + scale: 0.008, octaves: 4, persistence: 0.6, lacunarity: 2.2, @@ -47,13 +47,13 @@ export const HILL_LAYER: NoiseLayerDef = { }; export const EROSION_LAYER: NoiseLayerDef = { - scale: 0.005, + scale: 0.0025, iterations: 3, weight: 0.1, }; export const DETAIL_LAYER: NoiseLayerDef = { - scale: 0.04, + scale: 0.02, octaves: 2, persistence: 0.3, lacunarity: 2.5, @@ -97,7 +97,7 @@ export const TUNDRA_PROFILE: BiomeNoiseProfile = { export const FOREST_PROFILE: BiomeNoiseProfile = { continentWeight: 0.15, ridgeWeight: 0.08, - hillWeight: 0.1, + hillWeight: 0.01, erosionWeight: 0.05, detailWeight: 0.05, powerCurve: 1, @@ -178,7 +178,7 @@ export const LANDSCAPE_FEATURES: LandscapeFeatureDef[] = [ x: -168.5, z: -352.5, radius: 150, - strength: 4.0, + strength: 1.5, layers: 5, shapePower: 1.8, edgeSharpness: 0.7, @@ -189,9 +189,9 @@ export const LANDSCAPE_FEATURES: LandscapeFeatureDef[] = [ { type: LandscapeType.Mountain, x: 265.5, - z: 128.5, + z: 322.5, radius: 150, - strength: 2.0, + strength: 0.7, layers: 3, shapePower: 1.5, edgeSharpness: 0.6, @@ -201,8 +201,8 @@ export const LANDSCAPE_FEATURES: LandscapeFeatureDef[] = [ }, { type: LandscapeType.Pond, - x: -238.5, - z: 325.5, + x: -28.5, + z: 327.5, radius: 90, strength: 1.5, layers: 1, diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts index de47f2194..1ad1c20db 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -1386,7 +1386,7 @@ export class TerrainSystem extends System { TILE_SIZE: 100, // 100m x 100m tiles WORLD_SIZE: 100, // 100x100 grid = 10km x 10km world TILE_RESOLUTION: 64, // 64x64 vertices per tile for smooth terrain - MAX_HEIGHT: 50, // 50m max height variation (bumpy terrain to show flat zones) + MAX_HEIGHT: 150, // 50m max height variation (bumpy terrain to show flat zones) WATER_THRESHOLD: TERRAIN_CONSTANTS.WATER_THRESHOLD, // Water appears below this level // Performance: Reduced draw distance From a56c996284b41bebb7c85405f7326f0a12395721 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Sat, 7 Mar 2026 14:08:54 +0800 Subject: [PATCH 18/48] feat(terrain): add per-biome terraceHeightScale, terraceSlope and unify constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move MAX_HEIGHT, WATER_LEVEL_NORMALIZED, SHORELINE_CONFIG to TerrainHeightParams - Replace per-biome terraceSteps with global TERRACE_STEPS (10) to fix boundary artifacts - Add terraceHeightScale to BiomeNoiseProfile — stretches shelf separation for taller cliffs - Add terraceSlope to BiomeNoiseProfile — blends natural slope back onto shelves - Fix Forest terraceSharpness from 5.1 to 0, increase hillWeight for better terrain depth - Sync worker mirror (buildGetBaseHeightAtJS / PROFILES_JS) with all changes Made-with: Cursor --- .../shared/world/TerrainHeightParams.ts | 102 ++++++++++++++---- 1 file changed, 79 insertions(+), 23 deletions(-) diff --git a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts index e7b2041e8..4d83a5f53 100644 --- a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts +++ b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts @@ -10,6 +10,35 @@ */ import { BiomeType, DEFAULT_BIOME } from "./TerrainBiomeTypes"; +import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants"; + +// --------------------------------------------------------------------------- +// Core terrain generation constants +// --------------------------------------------------------------------------- + +export const MAX_HEIGHT = 50; + +/** + * Derived from WATER_THRESHOLD / MAX_HEIGHT so there's one source of truth. + * Terrain generation works in 0-1 normalized space; this is the cutoff. + */ +export const WATER_LEVEL_NORMALIZED = + TERRAIN_CONSTANTS.WATER_THRESHOLD / MAX_HEIGHT; + +// --------------------------------------------------------------------------- +// Shoreline constants — shape terrain near the water edge +// --------------------------------------------------------------------------- + +export const SHORELINE_CONFIG = { + THRESHOLD: 0.25, + STRENGTH: 0.6, + MIN_SLOPE: 0.06, + SLOPE_SAMPLE_DISTANCE: 1.0, + LAND_BAND: 3.0, + LAND_MAX_MULTIPLIER: 1.6, + UNDERWATER_BAND: 3.0, + UNDERWATER_DEPTH_MULTIPLIER: 1.8, +} as const; // --------------------------------------------------------------------------- // Noise layer definitions — drive getBaseHeightAt() @@ -67,6 +96,9 @@ export const HEIGHT_POWER_CURVE = 1.1; // Biome noise profiles — per-biome noise weight blending (Option A) // --------------------------------------------------------------------------- +/** Global terrace step count — shared across all biomes to prevent boundary artifacts. */ +export const TERRACE_STEPS = 10; + export interface BiomeNoiseProfile { continentWeight: number; ridgeWeight: number; @@ -74,12 +106,21 @@ export interface BiomeNoiseProfile { erosionWeight: number; detailWeight: number; powerCurve: number; - /** Number of terrace steps (0 = no terracing) */ - terraceSteps: number; - /** 0–1 blend from smooth to terraced (higher = more visible) */ + /** 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 = { @@ -89,21 +130,23 @@ export const TUNDRA_PROFILE: BiomeNoiseProfile = { erosionWeight: 0.1, detailWeight: 0.1, powerCurve: 1.1, - terraceSteps: 10, terraceStrength: 0.4, terraceSharpness: 0.7, + terraceHeightScale: 2.5, + terraceSlope: 0.25, }; export const FOREST_PROFILE: BiomeNoiseProfile = { continentWeight: 0.15, ridgeWeight: 0.08, - hillWeight: 0.01, + hillWeight: 0.1, erosionWeight: 0.05, detailWeight: 0.05, powerCurve: 1, - terraceSteps: 10, terraceStrength: 0, - terraceSharpness: 5.1, + terraceSharpness: 0, + terraceHeightScale: 1, + terraceSlope: 0, }; export const DESERT_PROFILE: BiomeNoiseProfile = { @@ -113,9 +156,10 @@ export const DESERT_PROFILE: BiomeNoiseProfile = { erosionWeight: 0.2, detailWeight: 0.05, powerCurve: 1.45, - terraceSteps: 10, terraceStrength: 0.6, terraceSharpness: 0.8, + terraceHeightScale: 7, + terraceSlope: 0.35, }; export const BIOME_PROFILES: Record = { @@ -178,7 +222,7 @@ export const LANDSCAPE_FEATURES: LandscapeFeatureDef[] = [ x: -168.5, z: -352.5, radius: 150, - strength: 1.5, + strength: 4.0, layers: 5, shapePower: 1.8, edgeSharpness: 0.7, @@ -375,9 +419,10 @@ export function computeBaseHeight( eW = 0, dW = 0, pC = 0, - tSt = 0, tS = 0, - tSh = 0; + tSh = 0, + tHS = 0, + tSl = 0; for (const key of Object.keys(biomeWeights)) { const w = biomeWeights[key]; const p = BIOME_PROFILES[key] ?? BIOME_PROFILES[DEFAULT_BIOME]; @@ -387,9 +432,10 @@ export function computeBaseHeight( eW += p.erosionWeight * w; dW += p.detailWeight * w; pC += p.powerCurve * w; - tSt += p.terraceSteps * w; tS += p.terraceStrength * w; tSh += p.terraceSharpness * w; + tHS += p.terraceHeightScale * w; + tSl += p.terraceSlope * w; } // ── 3. Combine, normalize, power curve ────────────────────────────── @@ -399,14 +445,20 @@ export function computeBaseHeight( height = Math.pow(height, pC); // ── 4. Terracing — floor-quantize into flat shelves with cliff edges ─ - const steps = Math.round(tSt); + // 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). + 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 terraced = stepped + edgeBlend * (nextStep - stepped); - height = height + (terraced - height) * tS; + const flatStep = stepped + edgeBlend * (nextStep - stepped); + const slopedStep = stepped + frac * (nextStep - stepped); + 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; } // ── 5. Coastline noise → island mask ──────────────────────────────── @@ -599,9 +651,9 @@ 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}, tSt: ${TUNDRA_PROFILE.terraceSteps}, tS: ${TUNDRA_PROFILE.terraceStrength}, tSh: ${TUNDRA_PROFILE.terraceSharpness} }; - 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}, tSt: ${FOREST_PROFILE.terraceSteps}, tS: ${FOREST_PROFILE.terraceStrength}, tSh: ${FOREST_PROFILE.terraceSharpness} }; - BIOME_PROFILES[BT_DESERT] = { cW: ${DESERT_PROFILE.continentWeight}, rW: ${DESERT_PROFILE.ridgeWeight}, hW: ${DESERT_PROFILE.hillWeight}, eW: ${DESERT_PROFILE.erosionWeight}, dW: ${DESERT_PROFILE.detailWeight}, pC: ${DESERT_PROFILE.powerCurve}, tSt: ${DESERT_PROFILE.terraceSteps}, tS: ${DESERT_PROFILE.terraceStrength}, tSh: ${DESERT_PROFILE.terraceSharpness} }; + 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_DESERT] = { cW: ${DESERT_PROFILE.continentWeight}, rW: ${DESERT_PROFILE.ridgeWeight}, hW: ${DESERT_PROFILE.hillWeight}, eW: ${DESERT_PROFILE.erosionWeight}, dW: ${DESERT_PROFILE.detailWeight}, pC: ${DESERT_PROFILE.powerCurve}, tS: ${DESERT_PROFILE.terraceStrength}, tSh: ${DESERT_PROFILE.terraceSharpness}, tHS: ${DESERT_PROFILE.terraceHeightScale}, tSl: ${DESERT_PROFILE.terraceSlope} }; `; /** @@ -622,12 +674,12 @@ export function buildGetBaseHeightAtJS(): string { 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}); - var cW = 0, rW = 0, hW = 0, eW = 0, dW = 0, pC = 0, tSt = 0, tS = 0, tSh = 0; + 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; tSt += p.tSt * w; tS += p.tS * w; tSh += p.tSh * w; + pC += p.pC * w; tS += p.tS * w; tSh += p.tSh * w; tHS += p.tHS * w; tSl += p.tSl * w; } var height = cN * cW + rN * rW + hN * hW + eN * eW + dN * dW; @@ -635,14 +687,18 @@ export function buildGetBaseHeightAtJS(): string { height = Math.max(0, Math.min(1, height)); height = Math.pow(height, pC); - var gSteps = Math.round(tSt); + 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 gTerraced = gStepped + gEdgeBlend * (gNextStep - gStepped); - height = height + (gTerraced - height) * tS; + var gFlatStep = gStepped + gEdgeBlend * (gNextStep - gStepped); + var gSlopedStep = gStepped + gFrac * (gNextStep - gStepped); + 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; } var distFromCenter = Math.sqrt(worldX * worldX + worldZ * worldZ); From 9986191cf1c3238624428b0ba3df8ba96cdc4110 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Sat, 7 Mar 2026 14:09:54 +0800 Subject: [PATCH 19/48] refactor(terrain): unify constants across TerrainSystem, TerrainShader, GameConstants - Rename TerrainShader TERRAIN_CONSTANTS to TERRAIN_SHADER_CONSTANTS to avoid naming conflict - TerrainSystem now imports MAX_HEIGHT, WATER_LEVEL_NORMALIZED, SHORELINE_CONFIG from TerrainHeightParams - Remove dead CONFIG fields (CAMERA_FAR, FOG_NEAR, FOG_FAR, shoreline duplicates) from TerrainSystem - TILE_SIZE now sourced from TERRAIN_CONSTANTS.TERRAIN_TILE_SIZE - Clean up stale WORLD_CONSTANTS (WORLD_SIZE, TERRAIN_HEIGHT_SCALE, SEA_LEVEL, etc.) - Fix WATER_THRESHOLD to 8.0 for correct water level Made-with: Cursor --- .../shared/src/constants/GameConstants.ts | 10 +- .../src/systems/shared/world/TerrainShader.ts | 20 +-- .../src/systems/shared/world/TerrainSystem.ts | 125 ++++++++---------- 3 files changed, 67 insertions(+), 88 deletions(-) diff --git a/packages/shared/src/constants/GameConstants.ts b/packages/shared/src/constants/GameConstants.ts index 059dd5866..c4a9b3a6b 100644 --- a/packages/shared/src/constants/GameConstants.ts +++ b/packages/shared/src/constants/GameConstants.ts @@ -56,14 +56,10 @@ export const XP_CONSTANTS = { }, } as const; -// === WORLD AND TERRAIN === +// === WORLD AND SPATIAL PARTITIONING === export const WORLD_CONSTANTS = { + /** Spatial partition chunk size for entity registry (meters). */ CHUNK_SIZE: 64, - WORLD_SIZE: 512, - TERRAIN_HEIGHT_SCALE: 20, - SEA_LEVEL: 0, - BIOME_TRANSITION_SMOOTHNESS: 0.1, - RESOURCE_SPAWN_DENSITY: 0.1, } as const; // === TERRAIN CONSTANTS === @@ -81,7 +77,7 @@ export const TERRAIN_CONSTANTS = { * Terrain below this height is underwater and impassable. * Used by: TerrainSystem, VegetationSystem, DissolveMaterial, RoadNetworkSystem, ResourceSystem */ - WATER_THRESHOLD: 50.0, + WATER_THRESHOLD: 8.0, /** * Buffer distance above water where vegetation shouldn't spawn. diff --git a/packages/shared/src/systems/shared/world/TerrainShader.ts b/packages/shared/src/systems/shared/world/TerrainShader.ts index adb665ad4..46413e0d9 100644 --- a/packages/shared/src/systems/shared/world/TerrainShader.ts +++ b/packages/shared/src/systems/shared/world/TerrainShader.ts @@ -45,7 +45,7 @@ import { getLamppostLightTextureState } from "./LamppostLightMask"; import { FOG_NEAR_SQ, FOG_FAR_SQ, fogRenderTarget } from "./FogConfig"; import { applyTerrainSunShade } from "./GPUMaterials"; -export const TERRAIN_CONSTANTS = { +export const TERRAIN_SHADER_CONSTANTS = { TRIPLANAR_SCALE: 0.5, SNOW_HEIGHT: 50.0, NOISE_SCALE: 0.0008, @@ -125,8 +125,8 @@ export function computeTerrainBaseColor( // Biome-blended dirt const dirtPatchFactor = smoothstep( - float(TERRAIN_CONSTANTS.DIRT_THRESHOLD - 0.05), - float(TERRAIN_CONSTANTS.DIRT_THRESHOLD + 0.15), + 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); @@ -158,7 +158,7 @@ export function computeTerrainBaseColor( SNOW_WHITE, mul( smoothstep( - float(TERRAIN_CONSTANTS.SNOW_HEIGHT - 5.0), + float(TERRAIN_SHADER_CONSTANTS.SNOW_HEIGHT - 5.0), float(60.0), height, ), @@ -405,8 +405,8 @@ export function sampleNoiseAtPosition( } // Calculate UV the same way the shader does - const u = worldX * TERRAIN_CONSTANTS.NOISE_SCALE; - const v = worldZ * TERRAIN_CONSTANTS.NOISE_SCALE; + const u = worldX * TERRAIN_SHADER_CONSTANTS.NOISE_SCALE; + const v = worldZ * TERRAIN_SHADER_CONSTANTS.NOISE_SCALE; // The texture tiles, so wrap to 0-1 const wrappedU = u - Math.floor(u); @@ -469,10 +469,10 @@ export function getGrassiness( // === SNOW AT HIGH ELEVATION === // Snow line at ~50m, full snow by 55m - if (height > TERRAIN_CONSTANTS.SNOW_HEIGHT - 5.0) { + if (height > TERRAIN_SHADER_CONSTANTS.SNOW_HEIGHT - 5.0) { const snowFactor = smoothstepCPU( - TERRAIN_CONSTANTS.SNOW_HEIGHT - 5.0, - TERRAIN_CONSTANTS.SNOW_HEIGHT + 5.0, + TERRAIN_SHADER_CONSTANTS.SNOW_HEIGHT - 5.0, + TERRAIN_SHADER_CONSTANTS.SNOW_HEIGHT + 5.0, height, ); grassiness -= snowFactor; @@ -600,7 +600,7 @@ export function createTerrainMaterial(): THREE.Material & { const sunDirectionUniform = uniform(vec3(0.5, 0.8, 0.3)); const shadeColorUniform = uniform(vec3(0.7, 1.08, 1.22)); const timeUniform = uniform(float(0)); - const noiseScale = uniform(float(TERRAIN_CONSTANTS.NOISE_SCALE)); + const noiseScale = uniform(float(TERRAIN_SHADER_CONSTANTS.NOISE_SCALE)); // Sky-color fog: uses the shared render target texture (updated in-place by SkySystem) const fogTexNode = texture(fogRenderTarget.texture, screenUV); diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts index 1ad1c20db..94ead83fb 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -28,6 +28,9 @@ import { adjustShorelineHeight, buildComputeBiomeWeightsJS, buildApplyLandscapeFeaturesJS, + MAX_HEIGHT, + WATER_LEVEL_NORMALIZED, + SHORELINE_CONFIG, } from "./TerrainHeightParams"; import type { LandscapeFeatureDef, @@ -755,7 +758,7 @@ export class TerrainSystem extends System { const workerConfig: TerrainWorkerConfig = { TILE_SIZE: this.CONFIG.TILE_SIZE, TILE_RESOLUTION: this.CONFIG.TILE_RESOLUTION, - MAX_HEIGHT: this.CONFIG.MAX_HEIGHT, + MAX_HEIGHT: MAX_HEIGHT, // Biome calculation - MUST match getBiomeInfluencesAtPosition() BIOME_GAUSSIAN_COEFF: this.CONFIG.BIOME_GAUSSIAN_COEFF, BIOME_BOUNDARY_NOISE_SCALE: this.CONFIG.BIOME_BOUNDARY_NOISE_SCALE, @@ -766,20 +769,17 @@ export class TerrainSystem extends System { VALLEY_WEIGHT_BOOST: this.CONFIG.VALLEY_WEIGHT_BOOST, // Mountain height boost - MUST match getHeightAtWithoutShore() MOUNTAIN_HEIGHT_BOOST: this.CONFIG.MOUNTAIN_HEIGHT_BOOST, - // Shoreline config - MUST match getHeightAt() and createTileGeometry() WATER_THRESHOLD: this.CONFIG.WATER_THRESHOLD, - WATER_LEVEL_NORMALIZED: this.CONFIG.WATER_LEVEL_NORMALIZED, - SHORELINE_THRESHOLD: this.CONFIG.SHORELINE_THRESHOLD, - SHORELINE_STRENGTH: this.CONFIG.SHORELINE_STRENGTH, - // Shoreline slope adjustment - MUST match adjustHeightForShoreline() - SHORELINE_MIN_SLOPE: this.CONFIG.SHORELINE_MIN_SLOPE, - SHORELINE_SLOPE_SAMPLE_DISTANCE: - this.CONFIG.SHORELINE_SLOPE_SAMPLE_DISTANCE, - SHORELINE_LAND_BAND: this.CONFIG.SHORELINE_LAND_BAND, - SHORELINE_LAND_MAX_MULTIPLIER: - this.CONFIG.SHORELINE_LAND_MAX_MULTIPLIER, - SHORELINE_UNDERWATER_BAND: this.CONFIG.SHORELINE_UNDERWATER_BAND, - UNDERWATER_DEPTH_MULTIPLIER: this.CONFIG.UNDERWATER_DEPTH_MULTIPLIER, + 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, landscapeFeatures: this.landscapeFeatures.map((f) => ({ type: f.type, x: f.x, @@ -1299,7 +1299,7 @@ export class TerrainSystem extends System { tileSize: this.CONFIG.TILE_SIZE, worldSize: this.CONFIG.WORLD_SIZE, tileResolution: this.CONFIG.TILE_RESOLUTION, - maxHeight: this.CONFIG.MAX_HEIGHT, + maxHeight: MAX_HEIGHT, waterThreshold: this.CONFIG.WATER_THRESHOLD, noise: { continent: { @@ -1348,15 +1348,15 @@ export class TerrainSystem extends System { edgeNoiseStrength: this.CONFIG.ISLAND_EDGE_NOISE_STRENGTH, }, shoreline: { - waterLevelNormalized: this.CONFIG.WATER_LEVEL_NORMALIZED, - threshold: this.CONFIG.SHORELINE_THRESHOLD, - colorStrength: this.CONFIG.SHORELINE_STRENGTH, - minSlope: this.CONFIG.SHORELINE_MIN_SLOPE, - slopeSampleDistance: this.CONFIG.SHORELINE_SLOPE_SAMPLE_DISTANCE, - landBand: this.CONFIG.SHORELINE_LAND_BAND, - landMaxMultiplier: this.CONFIG.SHORELINE_LAND_MAX_MULTIPLIER, - underwaterBand: this.CONFIG.SHORELINE_UNDERWATER_BAND, - underwaterDepthMultiplier: this.CONFIG.UNDERWATER_DEPTH_MULTIPLIER, + waterLevelNormalized: WATER_LEVEL_NORMALIZED, + threshold: SHORELINE_CONFIG.THRESHOLD, + colorStrength: SHORELINE_CONFIG.STRENGTH, + minSlope: SHORELINE_CONFIG.MIN_SLOPE, + slopeSampleDistance: SHORELINE_CONFIG.SLOPE_SAMPLE_DISTANCE, + landBand: SHORELINE_CONFIG.LAND_BAND, + landMaxMultiplier: SHORELINE_CONFIG.LAND_MAX_MULTIPLIER, + underwaterBand: SHORELINE_CONFIG.UNDERWATER_BAND, + underwaterDepthMultiplier: SHORELINE_CONFIG.UNDERWATER_DEPTH_MULTIPLIER, }, }; @@ -1383,16 +1383,10 @@ export class TerrainSystem extends System { // OSRS-STYLE: Rolling terrain with visible hills private readonly CONFIG = { // Core World Specs - TILE_SIZE: 100, // 100m x 100m tiles + TILE_SIZE: TERRAIN_CONSTANTS.TERRAIN_TILE_SIZE, WORLD_SIZE: 100, // 100x100 grid = 10km x 10km world TILE_RESOLUTION: 64, // 64x64 vertices per tile for smooth terrain - MAX_HEIGHT: 150, // 50m max height variation (bumpy terrain to show flat zones) - WATER_THRESHOLD: TERRAIN_CONSTANTS.WATER_THRESHOLD, // Water appears below this level - - // Performance: Reduced draw distance - CAMERA_FAR: 400, // Match fog far + buffer - FOG_NEAR: 150, - FOG_FAR: 350, + WATER_THRESHOLD: TERRAIN_CONSTANTS.WATER_THRESHOLD, // LOD (Level of Detail) - Resolution tiers based on distance LOD_DISTANCES: [100, 200, 350], // Distance thresholds @@ -1445,16 +1439,7 @@ export class TerrainSystem extends System { VALLEY_HEIGHT_THRESHOLD: 0.4, // Height below which valleys/plains get weight boost VALLEY_WEIGHT_BOOST: 1.5, // Max weight multiplier for valleys at low elevation - // Shoreline - WATER_LEVEL_NORMALIZED: 0.15, // Normalized height where water starts - SHORELINE_THRESHOLD: 0.25, // Normalized height where shoreline effect ends - SHORELINE_STRENGTH: 0.6, // How strong the brown tint is (0-1) - SHORELINE_MIN_SLOPE: 0.06, // Minimum slope to enforce near shorelines - SHORELINE_SLOPE_SAMPLE_DISTANCE: 1.0, // Sample distance for shoreline slope checks - SHORELINE_LAND_BAND: 3.0, // Meters above water to shape shoreline - SHORELINE_LAND_MAX_MULTIPLIER: 1.6, // Max land steepening multiplier - SHORELINE_UNDERWATER_BAND: 3.0, // Meters below water to deepen shoreline - UNDERWATER_DEPTH_MULTIPLIER: 1.8, // Max depth multiplier near shoreline + // Shoreline — delegated to TerrainHeightParams.SHORELINE_CONFIG (single source of truth) // Island Mask (default for main world) ISLAND_MASK_ENABLED: true, @@ -1838,10 +1823,10 @@ export class TerrainSystem extends System { getFlatZoneHeight: (worldX: number, worldZ: number) => this.getFlatZoneHeight(worldX, worldZ), - WATER_LEVEL_NORMALIZED: this.CONFIG.WATER_LEVEL_NORMALIZED, - SHORELINE_THRESHOLD: this.CONFIG.SHORELINE_THRESHOLD, - SHORELINE_STRENGTH: this.CONFIG.SHORELINE_STRENGTH, - MAX_HEIGHT: this.CONFIG.MAX_HEIGHT, + WATER_LEVEL_NORMALIZED: WATER_LEVEL_NORMALIZED, + SHORELINE_THRESHOLD: SHORELINE_CONFIG.THRESHOLD, + SHORELINE_STRENGTH: SHORELINE_CONFIG.STRENGTH, + MAX_HEIGHT: MAX_HEIGHT, TILE_SIZE: this.CONFIG.TILE_SIZE, }; } @@ -1872,7 +1857,7 @@ export class TerrainSystem extends System { const provider = this.buildChunkTerrainProvider(); const workerConfig: QuadChunkWorkerConfig = { - MAX_HEIGHT: this.CONFIG.MAX_HEIGHT, + MAX_HEIGHT: MAX_HEIGHT, BIOME_GAUSSIAN_COEFF: this.CONFIG.BIOME_GAUSSIAN_COEFF, BIOME_BOUNDARY_NOISE_SCALE: this.CONFIG.BIOME_BOUNDARY_NOISE_SCALE, BIOME_BOUNDARY_NOISE_AMOUNT: this.CONFIG.BIOME_BOUNDARY_NOISE_AMOUNT, @@ -1882,16 +1867,15 @@ export class TerrainSystem extends System { VALLEY_WEIGHT_BOOST: this.CONFIG.VALLEY_WEIGHT_BOOST, MOUNTAIN_HEIGHT_BOOST: this.CONFIG.MOUNTAIN_HEIGHT_BOOST, WATER_THRESHOLD: this.CONFIG.WATER_THRESHOLD, - WATER_LEVEL_NORMALIZED: this.CONFIG.WATER_LEVEL_NORMALIZED, - SHORELINE_THRESHOLD: this.CONFIG.SHORELINE_THRESHOLD, - SHORELINE_STRENGTH: this.CONFIG.SHORELINE_STRENGTH, - SHORELINE_MIN_SLOPE: this.CONFIG.SHORELINE_MIN_SLOPE, - SHORELINE_SLOPE_SAMPLE_DISTANCE: - this.CONFIG.SHORELINE_SLOPE_SAMPLE_DISTANCE, - SHORELINE_LAND_BAND: this.CONFIG.SHORELINE_LAND_BAND, - SHORELINE_LAND_MAX_MULTIPLIER: this.CONFIG.SHORELINE_LAND_MAX_MULTIPLIER, - SHORELINE_UNDERWATER_BAND: this.CONFIG.SHORELINE_UNDERWATER_BAND, - UNDERWATER_DEPTH_MULTIPLIER: this.CONFIG.UNDERWATER_DEPTH_MULTIPLIER, + 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, landscapeFeatures: this.landscapeFeatures, }; @@ -2640,7 +2624,7 @@ export class TerrainSystem extends System { // Get biome influences for smooth color blending const { biomeWeightMap, totalWeight } = this.computeBiomeWeightsAtPosition(x, z); - const normalizedHeight = height / this.CONFIG.MAX_HEIGHT; + const normalizedHeight = height / MAX_HEIGHT; // Store dominant biome ID for shader let dominantBiome = DEFAULT_BIOME as string; @@ -2694,18 +2678,17 @@ export class TerrainSystem extends System { } // Apply brownish shoreline tint near water level - const waterLevel = this.CONFIG.WATER_LEVEL_NORMALIZED; - const shorelineThreshold = this.CONFIG.SHORELINE_THRESHOLD; + const waterLevel = WATER_LEVEL_NORMALIZED; + const shorelineThreshold = SHORELINE_CONFIG.THRESHOLD; if ( normalizedHeight > waterLevel && normalizedHeight < shorelineThreshold ) { - // Sandy brown (0x8b7355) pre-computed: r=0.545, g=0.451, b=0.333 const shoreFactor = (1.0 - (normalizedHeight - waterLevel) / (shorelineThreshold - waterLevel)) * - this.CONFIG.SHORELINE_STRENGTH; + SHORELINE_CONFIG.STRENGTH; colorR = colorR + (0.545 - colorR) * shoreFactor; colorG = colorG + (0.451 - colorG) * shoreFactor; colorB = colorB + (0.333 - colorB) * shoreFactor; @@ -3348,7 +3331,7 @@ export class TerrainSystem extends System { this.noise, weights, this.landscapeFeatures, - this.CONFIG.MAX_HEIGHT, + MAX_HEIGHT, ); } @@ -3383,7 +3366,7 @@ export class TerrainSystem extends System { worldZ: number, centerHeight: number, ): number { - const checkDistance = this.CONFIG.SHORELINE_SLOPE_SAMPLE_DISTANCE; + const checkDistance = SHORELINE_CONFIG.SLOPE_SAMPLE_DISTANCE; const northHeight = this.getHeightAtWithoutShore( worldX, worldZ + checkDistance, @@ -3417,11 +3400,11 @@ export class TerrainSystem extends System { if (!this._shorelineConfig) { this._shorelineConfig = { waterThreshold: this.CONFIG.WATER_THRESHOLD, - shorelineLandBand: this.CONFIG.SHORELINE_LAND_BAND, - shorelineUnderwaterBand: this.CONFIG.SHORELINE_UNDERWATER_BAND, - shorelineMinSlope: this.CONFIG.SHORELINE_MIN_SLOPE, - shorelineLandMaxMultiplier: this.CONFIG.SHORELINE_LAND_MAX_MULTIPLIER, - underwaterDepthMultiplier: this.CONFIG.UNDERWATER_DEPTH_MULTIPLIER, + shorelineLandBand: SHORELINE_CONFIG.LAND_BAND, + shorelineUnderwaterBand: SHORELINE_CONFIG.UNDERWATER_BAND, + shorelineMinSlope: SHORELINE_CONFIG.MIN_SLOPE, + shorelineLandMaxMultiplier: SHORELINE_CONFIG.LAND_MAX_MULTIPLIER, + underwaterDepthMultiplier: SHORELINE_CONFIG.UNDERWATER_DEPTH_MULTIPLIER, }; } return this._shorelineConfig; @@ -3539,8 +3522,8 @@ export class TerrainSystem extends System { ): number { const baseHeight = this.getHeightAtWithoutShore(worldX, worldZ); const waterThreshold = this.CONFIG.WATER_THRESHOLD; - const landBand = this.CONFIG.SHORELINE_LAND_BAND; - const underwaterBand = this.CONFIG.SHORELINE_UNDERWATER_BAND; + const landBand = SHORELINE_CONFIG.LAND_BAND; + const underwaterBand = SHORELINE_CONFIG.UNDERWATER_BAND; if ( baseHeight >= waterThreshold + landBand || From a4431a60ebf6f38c7dc363c3f12c52a73d2d67bc Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Sat, 7 Mar 2026 14:23:03 +0800 Subject: [PATCH 20/48] tweak(terrain): reduce second mountain radius from 150 to 100 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 4d83a5f53..db9170f32 100644 --- a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts +++ b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts @@ -234,7 +234,7 @@ export const LANDSCAPE_FEATURES: LandscapeFeatureDef[] = [ type: LandscapeType.Mountain, x: 265.5, z: 322.5, - radius: 150, + radius: 100, strength: 0.7, layers: 3, shapePower: 1.5, From 6599be68212016604b21a8c9fa3d9ff99fbd527d Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Sat, 7 Mar 2026 14:55:05 +0800 Subject: [PATCH 21/48] refactor(biome): rename Desert to Canyon and consolidate to 3-biome system - Rename BiomeType.Desert to BiomeType.Canyon across all code, types, workers, shaders, and tests - Remove stale biome types (plains, valley, mountains, swamp, lakes, jungle) from all configs - Update biomes.json manifest to only contain tundra, forest, canyon - Align TownSystem, RoadNetworkSystem, POISystem, BiomeResourceGenerator, ProcgenRockCache, ProcgenPlantCache suitability/cost/preset maps to 3-biome system - Update all test files to reference only tundra/forest/canyon - Narrow type unions in terrain.ts and world-types.ts Made-with: Cursor --- .../shared/src/constants/GameConstants.ts | 9 +- .../shared/world/BiomeResourceGenerator.ts | 88 +++---------------- .../shared/world/GrassExclusionGrid.ts | 4 +- .../src/systems/shared/world/POISystem.ts | 24 ++--- .../systems/shared/world/ProcgenPlantCache.ts | 12 +-- .../systems/shared/world/ProcgenRockCache.ts | 11 +-- .../systems/shared/world/RoadNetworkSystem.ts | 9 +- .../systems/shared/world/TerrainBiomeTypes.ts | 6 +- .../shared/world/TerrainHeightParams.ts | 6 +- .../shared/world/TerrainQuadChunkGenerator.ts | 20 ++--- .../src/systems/shared/world/TerrainShader.ts | 30 +++---- .../src/systems/shared/world/TerrainSystem.ts | 55 +++++------- .../src/systems/shared/world/TownSystem.ts | 13 +-- .../__tests__/BiomeConfigLoading.test.ts | 14 +-- .../__tests__/ProcgenRocksPlants.test.ts | 72 ++++++--------- .../world/__tests__/RoadNetworkSystem.test.ts | 27 +++--- .../RoadNetworkSystemConfigLoading.test.ts | 23 ++--- .../__tests__/TownRoadIntegration.test.ts | 7 +- .../shared/world/__tests__/TownSystem.test.ts | 38 +++----- .../__tests__/TownSystemConfigLoading.test.ts | 21 ++--- packages/shared/src/types/world/terrain.ts | 18 +--- .../shared/src/types/world/world-types.ts | 12 +-- .../utils/compute/shaders/heightmap.wgsl.ts | 2 +- .../src/utils/workers/QuadChunkWorker.ts | 14 +-- .../shared/src/utils/workers/TerrainWorker.ts | 2 +- 25 files changed, 176 insertions(+), 361 deletions(-) diff --git a/packages/shared/src/constants/GameConstants.ts b/packages/shared/src/constants/GameConstants.ts index c4a9b3a6b..938989373 100644 --- a/packages/shared/src/constants/GameConstants.ts +++ b/packages/shared/src/constants/GameConstants.ts @@ -382,14 +382,9 @@ export const MOB_TYPES = {} as const; // === BIOME TYPES === export const BIOME_TYPES = { - PLAINS: "plains", - FOREST: "forest", - VALLEY: "valley", - MOUNTAINS: "mountains", TUNDRA: "tundra", - DESERT: "desert", - LAKES: "lakes", - SWAMP: "swamp", + FOREST: "forest", + CANYON: "canyon", } as const; // === SKILL NAMES === diff --git a/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts b/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts index b23079f1a..bc600c584 100644 --- a/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts +++ b/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts @@ -469,46 +469,18 @@ export const ROCK_BIOME_DEFAULTS: Record< string, { presets: string[]; distribution: Record } > = { + tundra: { + presets: ["granite", "basalt", "boulder"], + distribution: { granite: 2, basalt: 2, boulder: 2 }, + }, forest: { presets: ["boulder", "granite", "limestone"], distribution: { boulder: 3, granite: 2, limestone: 1 }, }, - plains: { - presets: ["boulder", "pebble", "sandstone"], - distribution: { boulder: 2, pebble: 3, sandstone: 1 }, - }, - desert: { + canyon: { presets: ["sandstone", "limestone", "pebble"], distribution: { sandstone: 4, limestone: 2, pebble: 1 }, }, - mountains: { - presets: ["granite", "basalt", "cliff"], - distribution: { granite: 3, basalt: 2, cliff: 2 }, - }, - mountain: { - presets: ["granite", "basalt", "cliff"], - distribution: { granite: 3, basalt: 2, cliff: 2 }, - }, - swamp: { - presets: ["limestone", "slate", "pebble"], - distribution: { limestone: 2, slate: 2, pebble: 3 }, - }, - frozen: { - presets: ["granite", "basalt", "boulder"], - distribution: { granite: 2, basalt: 2, boulder: 2 }, - }, - wastes: { - presets: ["basalt", "slate", "asteroid"], - distribution: { basalt: 3, slate: 2, asteroid: 1 }, - }, - corrupted: { - presets: ["obsidian", "basalt", "crystal"], - distribution: { obsidian: 3, basalt: 2, crystal: 2 }, - }, - lake: { - presets: ["pebble", "limestone", "boulder"], - distribution: { pebble: 4, limestone: 2, boulder: 1 }, - }, }; /** @@ -702,6 +674,10 @@ export const PLANT_BIOME_DEFAULTS: Record< string, { presets: string[]; distribution: Record } > = { + tundra: { + presets: ["bergenia", "pulmonaria"], + distribution: { bergenia: 2, pulmonaria: 2 }, + }, forest: { presets: ["monstera", "philodendron", "calathea", "ficus", "hosta"], distribution: { @@ -712,54 +688,10 @@ export const PLANT_BIOME_DEFAULTS: Record< hosta: 3, }, }, - plains: { - presets: ["hosta", "heuchera", "bergenia", "maranta"], - distribution: { hosta: 3, heuchera: 2, bergenia: 2, maranta: 1 }, - }, - desert: { + canyon: { presets: ["zamioculcas", "aglaonema", "syngonium"], distribution: { zamioculcas: 3, aglaonema: 2, syngonium: 1 }, }, - mountains: { - presets: ["bergenia", "pulmonaria", "heuchera"], - distribution: { bergenia: 2, pulmonaria: 2, heuchera: 2 }, - }, - mountain: { - presets: ["bergenia", "pulmonaria", "heuchera"], - distribution: { bergenia: 2, pulmonaria: 2, heuchera: 2 }, - }, - swamp: { - presets: [ - "monstera", - "colocasia", - "xanthosoma", - "alocasia", - "spathiphyllum", - ], - distribution: { - monstera: 2, - colocasia: 3, - xanthosoma: 2, - alocasia: 2, - spathiphyllum: 2, - }, - }, - frozen: { - presets: ["bergenia", "pulmonaria"], - distribution: { bergenia: 2, pulmonaria: 2 }, - }, - wastes: { - presets: ["zamioculcas", "aglaonema"], - distribution: { zamioculcas: 2, aglaonema: 2 }, - }, - corrupted: { - presets: ["alocasia", "caladium", "anthurium"], - distribution: { alocasia: 2, caladium: 2, anthurium: 2 }, - }, - lake: { - presets: ["colocasia", "calla", "arum", "spathiphyllum"], - distribution: { colocasia: 2, calla: 2, arum: 2, spathiphyllum: 2 }, - }, }; /** diff --git a/packages/shared/src/systems/shared/world/GrassExclusionGrid.ts b/packages/shared/src/systems/shared/world/GrassExclusionGrid.ts index 12bad901c..3505bb6eb 100644 --- a/packages/shared/src/systems/shared/world/GrassExclusionGrid.ts +++ b/packages/shared/src/systems/shared/world/GrassExclusionGrid.ts @@ -46,7 +46,7 @@ const GRID_CONFIG = { RECENTER_THRESHOLD: 64, // Re-center when player moves 64m from texture center /** Biomes where grass never grows - matched from biomes.json terrain types */ NON_GRASSY_TERRAINS: new Set([ - BiomeType.Desert, + BiomeType.Canyon, BiomeType.Tundra, "mountains", "lake", @@ -64,7 +64,7 @@ const GRID_CONFIG = { */ type ExclusionReason = | "collision" // CollisionMatrix blocked (rock, tree, building, etc.) - | "biome" // Non-grassy biome (desert, tundra, etc.) + | "biome" // Non-grassy biome (canyon, tundra, etc.) | "water" // Below water level | "slope"; // Too steep diff --git a/packages/shared/src/systems/shared/world/POISystem.ts b/packages/shared/src/systems/shared/world/POISystem.ts index a0d898ddc..62d85638c 100644 --- a/packages/shared/src/systems/shared/world/POISystem.ts +++ b/packages/shared/src/systems/shared/world/POISystem.ts @@ -50,47 +50,47 @@ const CATEGORY_PROPERTIES: Record< dungeon: { radius: 30, baseImportance: 0.9, - preferredBiomes: ["mountains", "forest", "swamp"], + preferredBiomes: ["canyon", "tundra"], }, shrine: { radius: 10, baseImportance: 0.6, - preferredBiomes: ["forest", "plains", "valley"], + preferredBiomes: ["forest", "tundra"], }, landmark: { radius: 20, baseImportance: 0.5, - preferredBiomes: ["mountains", "plains", "desert"], + preferredBiomes: ["canyon", "tundra"], }, resource_area: { radius: 25, baseImportance: 0.7, - preferredBiomes: ["forest", "mountains", "plains"], + preferredBiomes: ["forest", "canyon"], }, ruin: { radius: 35, baseImportance: 0.8, - preferredBiomes: ["desert", "forest", "swamp"], + preferredBiomes: ["canyon", "tundra"], }, camp: { radius: 20, baseImportance: 0.4, - preferredBiomes: ["forest", "plains", "mountains"], + preferredBiomes: ["forest", "tundra"], }, crossing: { radius: 15, baseImportance: 0.85, - preferredBiomes: ["mountains", "swamp", "valley"], + preferredBiomes: ["canyon", "tundra"], }, waystation: { radius: 12, baseImportance: 0.3, - preferredBiomes: ["plains", "valley", "forest"], + preferredBiomes: ["forest", "tundra"], }, fishing_spot: { radius: 15, - baseImportance: 0.75, // High importance to ensure road connections - preferredBiomes: ["plains", "forest", "valley"], // Near lakes in temperate areas + baseImportance: 0.75, + preferredBiomes: ["forest", "tundra"], }, }; @@ -310,7 +310,7 @@ export class POISystem extends System { // Get terrain info const y = this.terrainSystem!.getHeightAt(x, z); const biome = - this.terrainSystem?.getBiomeAtWorldPosition?.(x, z) ?? "plains"; + this.terrainSystem?.getBiomeAtWorldPosition?.(x, z) ?? "forest"; // Skip underwater const waterThreshold = 5.4; @@ -438,7 +438,7 @@ export class POISystem extends System { // Get terrain info at the water edge (on land side) const y = this.terrainSystem!.getHeightAt(x, z); const biome = - this.terrainSystem?.getBiomeAtWorldPosition?.(x, z) ?? "plains"; + this.terrainSystem?.getBiomeAtWorldPosition?.(x, z) ?? "forest"; // Calculate importance - fishing spots are important destinations let importance = properties.baseImportance; diff --git a/packages/shared/src/systems/shared/world/ProcgenPlantCache.ts b/packages/shared/src/systems/shared/world/ProcgenPlantCache.ts index 21a2c4c91..05a62cf39 100644 --- a/packages/shared/src/systems/shared/world/ProcgenPlantCache.ts +++ b/packages/shared/src/systems/shared/world/ProcgenPlantCache.ts @@ -510,17 +510,9 @@ export function clearPlantCache(): void { * Default plant presets per biome type. */ export const BIOME_PLANT_PRESETS: Record = { + tundra: ["bergenia", "pulmonaria"], forest: ["monstera", "philodendron", "calathea", "ficus", "hosta"], - plains: ["hosta", "heuchera", "bergenia", "maranta"], - desert: ["zamioculcas", "aglaonema", "syngonium"], - mountains: ["bergenia", "pulmonaria", "heuchera"], - mountain: ["bergenia", "pulmonaria", "heuchera"], - swamp: ["monstera", "colocasia", "xanthosoma", "alocasia", "spathiphyllum"], - frozen: ["bergenia", "pulmonaria"], - wastes: ["zamioculcas", "aglaonema"], - corrupted: ["alocasia", "caladium", "anthurium"], - lake: ["colocasia", "calla", "arum", "spathiphyllum"], - tropical: ["monstera", "philodendron", "alocasia", "colocasia", "calathea"], + canyon: ["zamioculcas", "aglaonema", "syngonium"], }; /** diff --git a/packages/shared/src/systems/shared/world/ProcgenRockCache.ts b/packages/shared/src/systems/shared/world/ProcgenRockCache.ts index 2f863c848..77e9955bb 100644 --- a/packages/shared/src/systems/shared/world/ProcgenRockCache.ts +++ b/packages/shared/src/systems/shared/world/ProcgenRockCache.ts @@ -727,16 +727,9 @@ export async function clearRockCacheAll(): Promise { * Used when no specific biome rock config is provided. */ export const BIOME_ROCK_PRESETS: Record = { + tundra: ["granite", "basalt", "boulder"], forest: ["boulder", "granite", "limestone"], - plains: ["boulder", "pebble", "sandstone"], - desert: ["sandstone", "limestone", "pebble"], - mountains: ["granite", "basalt", "cliff"], - mountain: ["granite", "basalt", "cliff"], - swamp: ["limestone", "slate", "pebble"], - frozen: ["granite", "basalt", "boulder"], - wastes: ["basalt", "slate", "asteroid"], - corrupted: ["obsidian", "basalt", "crystal"], - lake: ["pebble", "limestone", "boulder"], + canyon: ["sandstone", "limestone", "pebble"], }; /** diff --git a/packages/shared/src/systems/shared/world/RoadNetworkSystem.ts b/packages/shared/src/systems/shared/world/RoadNetworkSystem.ts index 98b7cc04b..efcdb1c8d 100644 --- a/packages/shared/src/systems/shared/world/RoadNetworkSystem.ts +++ b/packages/shared/src/systems/shared/world/RoadNetworkSystem.ts @@ -63,14 +63,9 @@ const DEFAULTS = { } as const; const DEFAULT_BIOME_COSTS: Record = { - plains: 1.0, - valley: 1.0, - forest: 1.3, + forest: 1.0, tundra: 1.5, - desert: 2.0, - swamp: 2.5, - mountains: 3.0, - lakes: 100, + canyon: 2.0, }; // Road mask generation settings (shared across terrain/grass/flowers) diff --git a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts index dabdaf0ac..51a29ec74 100644 --- a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts +++ b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts @@ -9,7 +9,7 @@ export enum BiomeType { Tundra = "tundra", Forest = "forest", - Desert = "desert", + Canyon = "canyon", } export const DEFAULT_BIOME = BiomeType.Forest; @@ -18,13 +18,13 @@ export const BIOME_LIST: BiomeType[] = Object.values(BiomeType); /** * Worker-injectable JS that defines BiomeType constants. * Injected once at the top of inline worker code so the worker - * can reference BT_TUNDRA, BT_FOREST, BT_DESERT without magic strings. + * can reference BT_TUNDRA, BT_FOREST, BT_CANYON without magic strings. */ export function buildBiomeConstantsJS(): string { return ` var BT_TUNDRA = "${BiomeType.Tundra}"; var BT_FOREST = "${BiomeType.Forest}"; - var BT_DESERT = "${BiomeType.Desert}"; + var BT_CANYON = "${BiomeType.Canyon}"; var BT_DEFAULT = BT_FOREST; `; } diff --git a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts index db9170f32..8ed69c2c2 100644 --- a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts +++ b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts @@ -149,7 +149,7 @@ export const FOREST_PROFILE: BiomeNoiseProfile = { terraceSlope: 0, }; -export const DESERT_PROFILE: BiomeNoiseProfile = { +export const CANYON_PROFILE: BiomeNoiseProfile = { continentWeight: 0.32, ridgeWeight: 0.25, hillWeight: 0.18, @@ -165,7 +165,7 @@ export const DESERT_PROFILE: BiomeNoiseProfile = { export const BIOME_PROFILES: Record = { [BiomeType.Tundra]: TUNDRA_PROFILE, [BiomeType.Forest]: FOREST_PROFILE, - [BiomeType.Desert]: DESERT_PROFILE, + [BiomeType.Canyon]: CANYON_PROFILE, }; // --------------------------------------------------------------------------- @@ -653,7 +653,7 @@ 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_DESERT] = { cW: ${DESERT_PROFILE.continentWeight}, rW: ${DESERT_PROFILE.ridgeWeight}, hW: ${DESERT_PROFILE.hillWeight}, eW: ${DESERT_PROFILE.erosionWeight}, dW: ${DESERT_PROFILE.detailWeight}, pC: ${DESERT_PROFILE.powerCurve}, tS: ${DESERT_PROFILE.terraceStrength}, tSh: ${DESERT_PROFILE.terraceSharpness}, tHS: ${DESERT_PROFILE.terraceHeightScale}, tSl: ${DESERT_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} }; `; /** diff --git a/packages/shared/src/systems/shared/world/TerrainQuadChunkGenerator.ts b/packages/shared/src/systems/shared/world/TerrainQuadChunkGenerator.ts index 8bab0fb55..3942a7978 100644 --- a/packages/shared/src/systems/shared/world/TerrainQuadChunkGenerator.ts +++ b/packages/shared/src/systems/shared/world/TerrainQuadChunkGenerator.ts @@ -90,7 +90,7 @@ export function assembleQuadChunkGeometry( colorData, biomeData, biomeForestWeight, - biomeDesertWeight, + biomeCanyonWeight, } = workerData; const segments = resolution; const halfSize = size * 0.5; @@ -103,7 +103,7 @@ export function assembleQuadChunkGeometry( const colors = new Float32Array(totalVertices * 3); const biomeIds = new Float32Array(totalVertices); const forestWeights = new Float32Array(totalVertices); - const desertWeights = new Float32Array(totalVertices); + const canyonWeights = new Float32Array(totalVertices); const roadInfluences = new Float32Array(totalVertices); let flatZoneModified = false; @@ -140,7 +140,7 @@ export function assembleQuadChunkGeometry( biomeIds[idx] = biomeData[idx]; forestWeights[idx] = biomeForestWeight[idx]; - desertWeights[idx] = biomeDesertWeight[idx]; + canyonWeights[idx] = biomeCanyonWeight[idx]; const roadTileX = Math.floor(worldX / provider.TILE_SIZE); const roadTileZ = Math.floor(worldZ / provider.TILE_SIZE); @@ -176,7 +176,7 @@ export function assembleQuadChunkGeometry( colors[si3 + 2] = colors[mi3 + 2]; biomeIds[skirtIdx] = biomeIds[mainIdx]; forestWeights[skirtIdx] = forestWeights[mainIdx]; - desertWeights[skirtIdx] = desertWeights[mainIdx]; + canyonWeights[skirtIdx] = canyonWeights[mainIdx]; roadInfluences[skirtIdx] = roadInfluences[mainIdx]; skirtIdx++; }; @@ -284,8 +284,8 @@ export function assembleQuadChunkGeometry( new THREE.BufferAttribute(forestWeights, 1), ); geometry.setAttribute( - "biomeDesertWeight", - new THREE.BufferAttribute(desertWeights, 1), + "biomeCanyonWeight", + new THREE.BufferAttribute(canyonWeights, 1), ); geometry.setAttribute( "roadInfluence", @@ -410,7 +410,7 @@ export function generateQuadChunkDataSync( const colorData = new Float32Array(vertexCount * 3); const biomeData = new Uint8Array(vertexCount); const biomeForestWeight = new Float32Array(vertexCount); - const biomeDesertWeight = new Float32Array(vertexCount); + const biomeCanyonWeight = new Float32Array(vertexCount); for (let iz = 0; iz < segments; iz++) { for (let ix = 0; ix < segments; ix++) { @@ -460,10 +460,10 @@ export function generateQuadChunkDataSync( : 0; const dwNorm = totalWeight > 0 - ? (biomeWeightMap.get(BiomeType.Desert) || 0) / totalWeight + ? (biomeWeightMap.get(BiomeType.Canyon) || 0) / totalWeight : 0; biomeForestWeight[idx] = fwNorm; - biomeDesertWeight[idx] = dwNorm; + biomeCanyonWeight[idx] = dwNorm; const waterLevel = provider.WATER_LEVEL_NORMALIZED; const shoreThreshold = provider.SHORELINE_THRESHOLD; @@ -494,6 +494,6 @@ export function generateQuadChunkDataSync( colorData, biomeData, biomeForestWeight, - biomeDesertWeight, + biomeCanyonWeight, }; } diff --git a/packages/shared/src/systems/shared/world/TerrainShader.ts b/packages/shared/src/systems/shared/world/TerrainShader.ts index 46413e0d9..5edcbe352 100644 --- a/packages/shared/src/systems/shared/world/TerrainShader.ts +++ b/packages/shared/src/systems/shared/world/TerrainShader.ts @@ -71,11 +71,11 @@ const FOREST_GRASS_DARK = vec3(0.18, 0.42, 0.08); const FOREST_DIRT = vec3(0.35, 0.24, 0.12); const FOREST_DIRT_DARK = vec3(0.22, 0.15, 0.08); -// --- Desert palette: red-orange sand with deep crimson rock --- -const DESERT_SAND = vec3(0.82, 0.52, 0.28); -const DESERT_SAND_DARK = vec3(0.72, 0.42, 0.2); -const DESERT_ROCK = vec3(0.62, 0.28, 0.15); -const DESERT_ROCK_DARK = vec3(0.48, 0.2, 0.1); +// --- 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_ROCK = vec3(0.62, 0.28, 0.15); +const CANYON_ROCK_DARK = vec3(0.48, 0.2, 0.1); // Legacy aliases used by road overlay and other shader sections (default = forest) const GRASS_GREEN = FOREST_GRASS; @@ -99,7 +99,7 @@ const WATER_EDGE = vec3(0.08, 0.06, 0.04); * @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 desertWeight - biome weight for desert [0..1] + * @param canyonWeight - biome weight for canyon [0..1] */ export function computeTerrainBaseColor( height: any, @@ -107,20 +107,20 @@ export function computeTerrainBaseColor( noiseVal: any, noiseVal2: any, forestWeight?: any, - desertWeight?: any, + canyonWeight?: any, ) { const fW = forestWeight ?? float(0.0); - const dW = desertWeight ?? float(0.0); + const dW = canyonWeight ?? float(0.0); const tW = sub(float(1.0), add(fW, dW)); // Biome-blended grass const grassVariation = smoothstep(float(0.4), float(0.6), noiseVal2); const tundraGrass = mix(TUNDRA_GRASS, TUNDRA_GRASS_DARK, grassVariation); const forestGrass = mix(FOREST_GRASS, FOREST_GRASS_DARK, grassVariation); - const desertGrass = mix(DESERT_SAND, DESERT_SAND_DARK, grassVariation); + const canyonGrass = mix(CANYON_SAND, CANYON_SAND_DARK, grassVariation); let c: any = add( add(mul(tundraGrass, tW), mul(forestGrass, fW)), - mul(desertGrass, dW), + mul(canyonGrass, dW), ); // Biome-blended dirt @@ -133,10 +133,10 @@ export function computeTerrainBaseColor( 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); - const desertDirt = mix(DESERT_ROCK, DESERT_ROCK_DARK, dirtVariation); + const canyonDirt = mix(CANYON_ROCK, CANYON_ROCK_DARK, dirtVariation); const dirtColor = add( add(mul(tundraDirt, tW), mul(forestDirt, fW)), - mul(desertDirt, dW), + mul(canyonDirt, dW), ); c = mix(c, dirtColor, mul(dirtPatchFactor, flatnessFactor)); @@ -166,7 +166,7 @@ export function computeTerrainBaseColor( ), ); - // Sand near water (flat areas, stronger in desert) + // Sand near water (flat areas, stronger in canyon) const sandBlend = mul( smoothstep(float(10.0), float(6.0), height), smoothstep(float(0.25), float(0.0), slope), @@ -652,7 +652,7 @@ export function createTerrainMaterial(): THREE.Material & { // Biome weight attributes (computed per-vertex by QuadChunkWorker) const biomeForestW = attribute("biomeForestWeight", "float"); - const biomeDesertW = attribute("biomeDesertWeight", "float"); + const biomeCanyonW = attribute("biomeCanyonWeight", "float"); // Base color from shared procedural palette const baseColor = computeTerrainBaseColor( @@ -661,7 +661,7 @@ export function createTerrainMaterial(): THREE.Material & { noiseValue, noiseValue2, biomeForestW, - biomeDesertW, + biomeCanyonW, ); // Anti-dithering noise variation (±4% brightness, ±2% color shift) diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts index 94ead83fb..d50c6fa9e 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -865,7 +865,7 @@ export class TerrainSystem extends System { const positions = geometry.attributes.position; const roadInfluences = new Float32Array(positions.count); const forestWeights = new Float32Array(positions.count); - const desertWeights = new Float32Array(positions.count); + const canyonWeights = new Float32Array(positions.count); const tileKey = `${tileX}_${tileZ}`; const hasFlatZones = (this.flatZonesByTile.get(tileKey)?.length ?? 0) > 0; @@ -913,7 +913,7 @@ export class TerrainSystem extends System { if (totalWeight > 0) { const invW = 1 / totalWeight; forestWeights[i] = (biomeWeightMap.get(BiomeType.Forest) || 0) * invW; - desertWeights[i] = (biomeWeightMap.get(BiomeType.Desert) || 0) * invW; + canyonWeights[i] = (biomeWeightMap.get(BiomeType.Canyon) || 0) * invW; } } @@ -932,8 +932,8 @@ export class TerrainSystem extends System { new THREE.BufferAttribute(forestWeights, 1), ); geometry.setAttribute( - "biomeDesertWeight", - new THREE.BufferAttribute(desertWeights, 1), + "biomeCanyonWeight", + new THREE.BufferAttribute(canyonWeights, 1), ); // DEBUG: log biome weights for first 5 tiles @@ -945,14 +945,14 @@ export class TerrainSystem extends System { sumD = 0; for (let _d = 0; _d < forestWeights.length; _d++) { sumF += forestWeights[_d]; - sumD += desertWeights[_d]; + sumD += canyonWeights[_d]; maxF = Math.max(maxF, forestWeights[_d]); - maxD = Math.max(maxD, desertWeights[_d]); + maxD = Math.max(maxD, canyonWeights[_d]); } const n = forestWeights.length; const avgP = (1 - sumF / n - sumD / n).toFixed(3); console.log( - `[BiomeDebug/WorkerTile] Tile(${tileX},${tileZ}) plains=${avgP} forest=${(sumF / n).toFixed(3)} desert=${(sumD / n).toFixed(3)}`, + `[BiomeDebug/WorkerTile] Tile(${tileX},${tileZ}) plains=${avgP} forest=${(sumF / n).toFixed(3)} canyon=${(sumD / n).toFixed(3)}`, ); } @@ -2585,7 +2585,7 @@ export class TerrainSystem extends System { const biomeIds = new Float32Array(positions.count); const roadInfluences = new Float32Array(positions.count); const forestWeights = new Float32Array(positions.count); - const desertWeights = new Float32Array(positions.count); + const canyonWeights = new Float32Array(positions.count); // Verify biome data is loaded - error if not if (Object.keys(BIOMES).length === 0) { @@ -2593,9 +2593,11 @@ export class TerrainSystem extends System { "[TerrainSystem] BIOMES data not loaded! DataManager must initialize before terrain generation.", ); } - const defaultBiomeData = BIOMES[DEFAULT_BIOME] || BIOMES["plains"]; + const defaultBiomeData = BIOMES[DEFAULT_BIOME]; if (!defaultBiomeData) { - throw new Error("[TerrainSystem] Plains biome not found in BIOMES data!"); + throw new Error( + "[TerrainSystem] Default biome not found in BIOMES data!", + ); } // Generate heightmap and vertex colors @@ -2658,7 +2660,7 @@ export class TerrainSystem extends System { colorB += this._tempColor.b * weight; } } else { - const biomeData = BIOMES[DEFAULT_BIOME] || BIOMES["plains"]; + const biomeData = BIOMES[DEFAULT_BIOME]; if (!biomeData) { throw new Error( `[TerrainSystem] Default biome not found in BIOMES data!`, @@ -2674,7 +2676,7 @@ export class TerrainSystem extends System { if (totalWeight > 0) { const invW = 1 / totalWeight; forestWeights[i] = (biomeWeightMap.get(BiomeType.Forest) || 0) * invW; - desertWeights[i] = (biomeWeightMap.get(BiomeType.Desert) || 0) * invW; + canyonWeights[i] = (biomeWeightMap.get(BiomeType.Canyon) || 0) * invW; } // Apply brownish shoreline tint near water level @@ -2720,8 +2722,8 @@ export class TerrainSystem extends System { new THREE.BufferAttribute(forestWeights, 1), ); geometry.setAttribute( - "biomeDesertWeight", - new THREE.BufferAttribute(desertWeights, 1), + "biomeCanyonWeight", + new THREE.BufferAttribute(canyonWeights, 1), ); // DEBUG: log biome weights for first 5 sync tiles @@ -2731,12 +2733,12 @@ export class TerrainSystem extends System { sumD = 0; for (let _d = 0; _d < forestWeights.length; _d++) { sumF += forestWeights[_d]; - sumD += desertWeights[_d]; + sumD += canyonWeights[_d]; } const n = forestWeights.length; const avgP = (1 - sumF / n - sumD / n).toFixed(3); console.log( - `[BiomeDebug/SyncTile] Tile(${tileX},${tileZ}) plains=${avgP} forest=${(sumF / n).toFixed(3)} desert=${(sumD / n).toFixed(3)}`, + `[BiomeDebug/SyncTile] Tile(${tileX},${tileZ}) plains=${avgP} forest=${(sumF / n).toFixed(3)} canyon=${(sumD / n).toFixed(3)}`, ); } @@ -3267,9 +3269,9 @@ export class TerrainSystem extends System { */ private getBiomeId(biomeName: string): number { const biomeIds: Record = { - plains: 0, + tundra: 0, forest: 1, - desert: 2, + canyon: 2, }; return biomeIds[biomeName] ?? 0; } @@ -4657,23 +4659,12 @@ export class TerrainSystem extends System { ); } - // Map internal biome keys to generic TerrainTileData biome set. - // Only tundra/forest/desert biome centers exist (see initializeBiomeCenters). - private mapBiomeToGeneric( - internal: string, - ): - | "forest" - | "plains" - | "desert" - | "mountains" - | "swamp" - | "tundra" - | "jungle" { + private mapBiomeToGeneric(internal: string): "tundra" | "forest" | "canyon" { switch (internal) { case BiomeType.Forest: return "forest"; - case BiomeType.Desert: - return "desert"; + case BiomeType.Canyon: + return "canyon"; case BiomeType.Tundra: default: return "tundra"; diff --git a/packages/shared/src/systems/shared/world/TownSystem.ts b/packages/shared/src/systems/shared/world/TownSystem.ts index 1e5938103..e2846b869 100644 --- a/packages/shared/src/systems/shared/world/TownSystem.ts +++ b/packages/shared/src/systems/shared/world/TownSystem.ts @@ -95,14 +95,9 @@ const DEFAULT_TOWN_SIZES: Record = { }; const DEFAULT_BIOME_SUITABILITY: Record = { - plains: 1.0, - valley: 0.95, - forest: 0.7, + forest: 0.8, tundra: 0.4, - desert: 0.3, - swamp: 0.2, - mountains: 0.15, - lakes: 0.0, + canyon: 0.3, }; function isTruthyEnv(rawValue: string | undefined): boolean { @@ -320,7 +315,7 @@ export class TownSystem extends System { return this.terrainSystem?.getHeightAt(x, z) ?? 10; }, getBiomeAt: (x: number, z: number): string => { - return this.terrainSystem?.getBiomeAtWorldPosition?.(x, z) ?? "plains"; + return this.terrainSystem?.getBiomeAtWorldPosition?.(x, z) ?? "forest"; }, getWaterThreshold: (): number => { return this.config.waterThreshold; @@ -494,7 +489,7 @@ export class TownSystem extends System { this.terrainSystem?.getBiomeAtWorldPosition?.( manifest.position.x, manifest.position.z, - ) ?? "plains"; + ) ?? "forest"; // Convert buildings - positions are relative in manifest, convert to world coords // Also calculate entrance positions for each building diff --git a/packages/shared/src/systems/shared/world/__tests__/BiomeConfigLoading.test.ts b/packages/shared/src/systems/shared/world/__tests__/BiomeConfigLoading.test.ts index e9108e39e..87c97b278 100644 --- a/packages/shared/src/systems/shared/world/__tests__/BiomeConfigLoading.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/BiomeConfigLoading.test.ts @@ -43,10 +43,10 @@ describe("Biome Configuration Loading", () => { ); const mockBiomes: Array = [ { - id: "plains", - name: "Plains", + id: "tundra", + name: "Tundra", difficultyLevel: 0, - terrain: { type: "plains" } as any, + terrain: "tundra" as any, vegetation: { enabled: true, layers: [ @@ -61,7 +61,7 @@ describe("Biome Configuration Loading", () => { id: "forest", name: "Forest", difficultyLevel: 1, - terrain: { type: "forest" } as any, + terrain: "forest" as any, vegetation: { enabled: true, layers: [ @@ -73,10 +73,10 @@ describe("Biome Configuration Loading", () => { colorScheme: {} as any, }, { - id: "mountains", - name: "Mountains", + id: "canyon", + name: "Canyon", difficultyLevel: 2, - terrain: { type: "mountains" } as any, + terrain: "canyon" as any, colorScheme: {} as any, }, ]; diff --git a/packages/shared/src/systems/shared/world/__tests__/ProcgenRocksPlants.test.ts b/packages/shared/src/systems/shared/world/__tests__/ProcgenRocksPlants.test.ts index 84c7ec8b0..6d4a0bd1b 100644 --- a/packages/shared/src/systems/shared/world/__tests__/ProcgenRocksPlants.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/ProcgenRocksPlants.test.ts @@ -69,17 +69,7 @@ function createTestContext( describe("Rock Generation Algorithms", () => { describe("ROCK_BIOME_DEFAULTS", () => { it("has presets defined for all major biome types", () => { - const expectedBiomes = [ - "forest", - "plains", - "desert", - "mountains", - "swamp", - "frozen", - "wastes", - "corrupted", - "lake", - ]; + const expectedBiomes = ["tundra", "forest", "canyon"]; for (const biome of expectedBiomes) { expect(ROCK_BIOME_DEFAULTS[biome]).toBeDefined(); @@ -96,15 +86,15 @@ describe("Rock Generation Algorithms", () => { expect(forestRocks.presets).toContain("granite"); }); - it("desert biome has appropriate rock types", () => { - const desertRocks = ROCK_BIOME_DEFAULTS.desert; - expect(desertRocks.presets).toContain("sandstone"); + it("canyon biome has appropriate rock types", () => { + const canyonRocks = ROCK_BIOME_DEFAULTS.canyon; + expect(canyonRocks.presets).toContain("sandstone"); }); - it("corrupted biome has unique rock types", () => { - const corruptedRocks = ROCK_BIOME_DEFAULTS.corrupted; - expect(corruptedRocks.presets).toContain("obsidian"); - expect(corruptedRocks.presets).toContain("crystal"); + it("tundra biome has appropriate rock types", () => { + const tundraRocks = ROCK_BIOME_DEFAULTS.tundra; + expect(tundraRocks.presets).toContain("granite"); + expect(tundraRocks.presets).toContain("basalt"); }); }); @@ -113,8 +103,8 @@ describe("Rock Generation Algorithms", () => { const forestPresets = getRockPresetsForBiome("forest"); expect(forestPresets.presets).toContain("boulder"); - const desertPresets = getRockPresetsForBiome("desert"); - expect(desertPresets.presets).toContain("sandstone"); + const canyonPresets = getRockPresetsForBiome("canyon"); + expect(canyonPresets.presets).toContain("sandstone"); }); it("returns default presets for unknown biome", () => { @@ -285,15 +275,7 @@ describe("Rock Generation Algorithms", () => { describe("Plant Generation Algorithms", () => { describe("PLANT_BIOME_DEFAULTS", () => { it("has presets defined for all major biome types", () => { - const expectedBiomes = [ - "forest", - "plains", - "desert", - "mountains", - "swamp", - "frozen", - "lake", - ]; + const expectedBiomes = ["tundra", "forest", "canyon"]; for (const biome of expectedBiomes) { expect(PLANT_BIOME_DEFAULTS[biome]).toBeDefined(); @@ -307,10 +289,10 @@ describe("Plant Generation Algorithms", () => { expect(forestPlants.presets).toContain("philodendron"); }); - it("swamp biome has water-loving plants", () => { - const swampPlants = PLANT_BIOME_DEFAULTS.swamp; - expect(swampPlants.presets).toContain("colocasia"); - expect(swampPlants.presets).toContain("spathiphyllum"); + it("tundra biome has hardy plants", () => { + const tundraPlants = PLANT_BIOME_DEFAULTS.tundra; + expect(tundraPlants.presets).toContain("bergenia"); + expect(tundraPlants.presets).toContain("pulmonaria"); }); }); @@ -319,8 +301,8 @@ describe("Plant Generation Algorithms", () => { const forestPresets = getPlantPresetsForBiome("forest"); expect(forestPresets.presets).toContain("monstera"); - const swampPresets = getPlantPresetsForBiome("swamp"); - expect(swampPresets.presets).toContain("colocasia"); + const canyonPresets = getPlantPresetsForBiome("canyon"); + expect(canyonPresets.presets).toContain("zamioculcas"); }); it("returns default presets for unknown biome", () => { @@ -472,7 +454,7 @@ describe("Biome Integration", () => { it("different biomes produce different vegetation", () => { const forestCtx = createTestContext(0, 0); - const desertCtx = createTestContext(10, 10); + const canyonCtx = createTestContext(10, 10); const rockConfig: BiomeRockConfig = { enabled: true, @@ -484,14 +466,14 @@ describe("Biome Integration", () => { }; const forestRocks = generateRocks(forestCtx, rockConfig, "forest"); - const desertRocks = generateRocks(desertCtx, rockConfig, "desert"); + const canyonRocks = generateRocks(canyonCtx, rockConfig, "canyon"); // Should use different rock types const forestTypes = new Set(forestRocks.map((r) => r.assetId)); - const desertTypes = new Set(desertRocks.map((r) => r.assetId)); + const canyonTypes = new Set(canyonRocks.map((r) => r.assetId)); - // Desert should have sandstone, forest should not - expect(desertTypes.has("sandstone")).toBe(true); + // Canyon should have sandstone, forest should not + expect(canyonTypes.has("sandstone")).toBe(true); expect(forestTypes.has("sandstone")).toBe(false); }); }); @@ -1328,10 +1310,10 @@ describe("Biome Defaults Completeness", () => { } }); - it("mountain and mountains aliases have same presets", () => { - const mountainPresets = getRockPresetsForBiome("mountain"); - const mountainsPresets = getRockPresetsForBiome("mountains"); - expect(mountainPresets.presets).toEqual(mountainsPresets.presets); + it("unknown biomes fall back to forest presets", () => { + const unknownPresets = getRockPresetsForBiome("nonexistent"); + const forestPresets = getRockPresetsForBiome("forest"); + expect(unknownPresets.presets).toEqual(forestPresets.presets); }); }); @@ -1501,7 +1483,7 @@ describe("ProcgenPlantCache Exports", () => { describe("getCachePlantPresets", () => { it("returns array for known biomes", () => { - const presets = getCachePlantPresets("swamp"); + const presets = getCachePlantPresets("canyon"); expect(Array.isArray(presets)).toBe(true); expect(presets.length).toBeGreaterThan(0); }); diff --git a/packages/shared/src/systems/shared/world/__tests__/RoadNetworkSystem.test.ts b/packages/shared/src/systems/shared/world/__tests__/RoadNetworkSystem.test.ts index 4b96ba9fe..9353213f1 100644 --- a/packages/shared/src/systems/shared/world/__tests__/RoadNetworkSystem.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/RoadNetworkSystem.test.ts @@ -27,14 +27,9 @@ const COST_BASE = 1.0; const COST_SLOPE_MULTIPLIER = 5.0; const COST_WATER_PENALTY = 1000; const COST_BIOME_MULTIPLIER: Record = { - plains: 1.0, - valley: 1.0, - forest: 1.3, + forest: 1.0, tundra: 1.5, - desert: 2.0, - swamp: 2.5, - mountains: 3.0, - lakes: 100, + canyon: 2.0, }; const SMOOTHING_ITERATIONS = 2; @@ -474,33 +469,33 @@ describe("RoadNetworkSystem Algorithms", () => { it("biome affects cost", () => { const flatHeight = () => 10; - const plainsCost = calculateMovementCost( + const forestCost = calculateMovementCost( 0, 0, 10, 0, flatHeight, - () => "plains", + () => "forest", ); - const swampCost = calculateMovementCost( + const tundraCost = calculateMovementCost( 0, 0, 10, 0, flatHeight, - () => "swamp", + () => "tundra", ); - const mountainsCost = calculateMovementCost( + const canyonCost = calculateMovementCost( 0, 0, 10, 0, flatHeight, - () => "mountains", + () => "canyon", ); - expect(swampCost).toBeGreaterThan(plainsCost); - expect(mountainsCost).toBeGreaterThan(swampCost); + expect(tundraCost).toBeGreaterThan(forestCost); + expect(canyonCost).toBeGreaterThan(tundraCost); }); it("diagonal movement costs more than cardinal", () => { @@ -1873,7 +1868,7 @@ describe("RoadNetworkSystem Algorithms", () => { const biome = (x: number, z: number) => { const tx = Math.floor(x / 50); const tz = Math.floor(z / 50); - const biomes = ["plains", "forest", "desert", "mountains"]; + const biomes = ["tundra", "forest", "canyon"]; return biomes[(tx + tz) % biomes.length]; }; diff --git a/packages/shared/src/systems/shared/world/__tests__/RoadNetworkSystemConfigLoading.test.ts b/packages/shared/src/systems/shared/world/__tests__/RoadNetworkSystemConfigLoading.test.ts index d0f45bd9b..e75810afa 100644 --- a/packages/shared/src/systems/shared/world/__tests__/RoadNetworkSystemConfigLoading.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/RoadNetworkSystemConfigLoading.test.ts @@ -22,14 +22,9 @@ const DEFAULTS = { } as const; const DEFAULT_BIOME_COSTS: Record = { - plains: 1.0, - valley: 1.0, - forest: 1.3, + forest: 1.0, tundra: 1.5, - desert: 2.0, - swamp: 2.5, - mountains: 3.0, - lakes: 100, + canyon: 2.0, }; function makeConfig( @@ -112,8 +107,8 @@ describe("RoadNetworkSystem Config Loading", () => { expect(config.roadWidth).toBe(DEFAULTS.roadWidth); expect(config.pathStepSize).toBe(DEFAULTS.pathStepSize); expect(config.costWaterPenalty).toBe(DEFAULTS.costWaterPenalty); - expect(config.biomeCosts.plains).toBe(DEFAULT_BIOME_COSTS.plains); - expect(config.biomeCosts.lakes).toBe(DEFAULT_BIOME_COSTS.lakes); + expect(config.biomeCosts.forest).toBe(DEFAULT_BIOME_COSTS.forest); + expect(config.biomeCosts.canyon).toBe(DEFAULT_BIOME_COSTS.canyon); }); }); @@ -128,7 +123,7 @@ describe("RoadNetworkSystem Config Loading", () => { extraConnectionsRatio: 0.35, costBase: 1.5, costWaterPenalty: 1500, - costBiomeMultipliers: { plains: 0.8, desert: 3.0, swamp: 5.0 }, + costBiomeMultipliers: { forest: 0.8, canyon: 3.0, tundra: 5.0 }, }, }), ); @@ -136,9 +131,9 @@ describe("RoadNetworkSystem Config Loading", () => { expect(config.roadWidth).toBe(6); expect(config.pathStepSize).toBe(25); - expect(config.biomeCosts.plains).toBe(0.8); - expect(config.biomeCosts.desert).toBe(3.0); - expect(config.biomeCosts.valley).toBe(DEFAULT_BIOME_COSTS.valley); + expect(config.biomeCosts.forest).toBe(0.8); + expect(config.biomeCosts.canyon).toBe(3.0); + expect(config.biomeCosts.tundra).toBe(5.0); }); }); @@ -160,7 +155,7 @@ describe("RoadNetworkSystem Config Loading", () => { expect(config.roadWidth).toBe(8); expect(config.pathStepSize).toBe(DEFAULTS.pathStepSize); - expect(config.biomeCosts.plains).toBe(DEFAULT_BIOME_COSTS.plains); + expect(config.biomeCosts.forest).toBe(DEFAULT_BIOME_COSTS.forest); }); }); diff --git a/packages/shared/src/systems/shared/world/__tests__/TownRoadIntegration.test.ts b/packages/shared/src/systems/shared/world/__tests__/TownRoadIntegration.test.ts index 2172a8d41..b59306c68 100644 --- a/packages/shared/src/systems/shared/world/__tests__/TownRoadIntegration.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/TownRoadIntegration.test.ts @@ -596,10 +596,9 @@ class MockTerrainSystem { getBiome(x: number, z: number): string { const noise = Math.sin(x * 0.005 + z * 0.003 + this.seed); - if (noise > 0.3) return "plains"; - if (noise > 0) return "forest"; - if (noise > -0.3) return "valley"; - return "mountains"; + if (noise > 0.3) return "forest"; + if (noise > 0) return "tundra"; + return "canyon"; } } diff --git a/packages/shared/src/systems/shared/world/__tests__/TownSystem.test.ts b/packages/shared/src/systems/shared/world/__tests__/TownSystem.test.ts index 64ab6c451..c3213e784 100644 --- a/packages/shared/src/systems/shared/world/__tests__/TownSystem.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/TownSystem.test.ts @@ -48,14 +48,9 @@ const BUILDING_CONFIG: Record< }; const BIOME_SUITABILITY: Record = { - plains: 1.0, - valley: 0.95, - forest: 0.7, + forest: 0.8, tundra: 0.4, - desert: 0.3, - swamp: 0.2, - mountains: 0.15, - lakes: 0.0, + canyon: 0.3, }; const NAME_PREFIXES = [ @@ -533,25 +528,12 @@ describe("TownSystem Algorithms", () => { }); describe("Biome Suitability", () => { - it("plains is most suitable", () => { - expect(BIOME_SUITABILITY.plains).toBe(1.0); - }); - - it("water is completely unsuitable", () => { - expect(BIOME_SUITABILITY.lakes).toBe(0.0); + it("forest is most suitable", () => { + expect(BIOME_SUITABILITY.forest).toBe(0.8); }); it("all biomes have defined suitability", () => { - const biomes = [ - "plains", - "valley", - "forest", - "tundra", - "desert", - "swamp", - "mountains", - "lakes", - ]; + const biomes = ["tundra", "forest", "canyon"]; for (const biome of biomes) { expect(BIOME_SUITABILITY[biome]).toBeDefined(); expect(BIOME_SUITABILITY[biome]).toBeGreaterThanOrEqual(0); @@ -560,12 +542,12 @@ describe("TownSystem Algorithms", () => { }); it("suitability reflects logical preferences", () => { - // Plains > forest > mountains makes sense for town building - expect(BIOME_SUITABILITY.plains).toBeGreaterThan( - BIOME_SUITABILITY.forest, - ); + // Forest > tundra > canyon makes sense for town building expect(BIOME_SUITABILITY.forest).toBeGreaterThan( - BIOME_SUITABILITY.mountains, + BIOME_SUITABILITY.tundra, + ); + expect(BIOME_SUITABILITY.tundra).toBeGreaterThan( + BIOME_SUITABILITY.canyon, ); expect(BIOME_SUITABILITY.valley).toBeGreaterThan(BIOME_SUITABILITY.swamp); }); diff --git a/packages/shared/src/systems/shared/world/__tests__/TownSystemConfigLoading.test.ts b/packages/shared/src/systems/shared/world/__tests__/TownSystemConfigLoading.test.ts index 1e5df4cbf..ab72fc16b 100644 --- a/packages/shared/src/systems/shared/world/__tests__/TownSystemConfigLoading.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/TownSystemConfigLoading.test.ts @@ -29,14 +29,9 @@ const DEFAULT_TOWN_SIZES = { }; const DEFAULT_BIOME_SUITABILITY: Record = { - plains: 1.0, - valley: 0.95, - forest: 0.7, + forest: 0.8, tundra: 0.4, - desert: 0.3, - swamp: 0.2, - mountains: 0.15, - lakes: 0.0, + canyon: 0.3, }; // Factory for creating test configs with minimal boilerplate @@ -125,8 +120,8 @@ describe("TownSystem Config Loading", () => { expect(config.townSizes.hamlet.buildingCount.min).toBe( DEFAULT_TOWN_SIZES.hamlet.buildingCount.min, ); - expect(config.biomeSuitability.plains).toBe( - DEFAULT_BIOME_SUITABILITY.plains, + expect(config.biomeSuitability.forest).toBe( + DEFAULT_BIOME_SUITABILITY.forest, ); expect(config.landmarks).toEqual(DEFAULT_LANDMARK_CONFIG); }); @@ -164,7 +159,7 @@ describe("TownSystem Config Loading", () => { safeZoneRadius: 90, }, }, - biomeSuitability: { plains: 0.9, desert: 0.5, swamp: 0.1 }, + biomeSuitability: { forest: 0.9, canyon: 0.5, tundra: 0.1 }, landmarks: { fencesEnabled: false, fenceDensity: 0.25, @@ -183,10 +178,8 @@ describe("TownSystem Config Loading", () => { expect(config.minTownSpacing).toBe(1000); expect(config.townSizes.hamlet.buildingCount.min).toBe(4); expect(config.townSizes.town.safeZoneRadius).toBe(90); - expect(config.biomeSuitability.plains).toBe(0.9); - expect(config.biomeSuitability.valley).toBe( - DEFAULT_BIOME_SUITABILITY.valley, - ); + expect(config.biomeSuitability.forest).toBe(0.9); + expect(config.biomeSuitability.tundra).toBe(0.1); expect(config.landmarks.fencesEnabled).toBe(false); expect(config.landmarks.lamppostSpacing).toBe(20); }); diff --git a/packages/shared/src/types/world/terrain.ts b/packages/shared/src/types/world/terrain.ts index 3bc4eb876..b45652c6d 100644 --- a/packages/shared/src/types/world/terrain.ts +++ b/packages/shared/src/types/world/terrain.ts @@ -50,14 +50,7 @@ export interface TerrainResourceSpawnPoint { export interface TerrainTileData { tileId: string; position: { x: number; z: number }; - biome: - | "forest" - | "plains" - | "desert" - | "mountains" - | "swamp" - | "tundra" - | "jungle"; + biome: "tundra" | "forest" | "canyon"; tileX: number; tileZ: number; resources: TerrainResource[]; @@ -76,14 +69,7 @@ export interface TerrainTile { z: number; mesh: THREE.Mesh; collision: PMeshHandle | null; - biome: - | "forest" - | "plains" - | "desert" - | "mountains" - | "swamp" - | "tundra" - | "jungle"; + biome: "tundra" | "forest" | "canyon"; resources: ResourceNode[]; roads: RoadSegment[]; waterMeshes: THREE.Mesh[]; diff --git a/packages/shared/src/types/world/world-types.ts b/packages/shared/src/types/world/world-types.ts index 2c0257095..f00ecd934 100644 --- a/packages/shared/src/types/world/world-types.ts +++ b/packages/shared/src/types/world/world-types.ts @@ -358,17 +358,7 @@ export interface BiomeData { name: string; description: string; difficultyLevel: 0 | 1 | 2 | 3; // 0 = safe zones, 1-3 = mob levels - terrain: - | "forest" - | "wastes" - | "plains" - | "frozen" - | "corrupted" - | "lake" - | "mountain" - | "mountains" - | "desert" - | "swamp"; + terrain: "tundra" | "forest" | "canyon"; resources: string[]; // Available resource types mobs: string[]; // Mob types that spawn here fogIntensity: number; // 0-1 for visual atmosphere diff --git a/packages/shared/src/utils/compute/shaders/heightmap.wgsl.ts b/packages/shared/src/utils/compute/shaders/heightmap.wgsl.ts index a2dfb765f..05d216c4a 100644 --- a/packages/shared/src/utils/compute/shaders/heightmap.wgsl.ts +++ b/packages/shared/src/utils/compute/shaders/heightmap.wgsl.ts @@ -52,7 +52,7 @@ struct BiomeColors { plains: vec4, forest: vec4, mountains: vec4, - desert: vec4, + canyon: vec4, swamp: vec4, shore: vec4, water: vec4, diff --git a/packages/shared/src/utils/workers/QuadChunkWorker.ts b/packages/shared/src/utils/workers/QuadChunkWorker.ts index ae8c074f2..45eaa1f9f 100644 --- a/packages/shared/src/utils/workers/QuadChunkWorker.ts +++ b/packages/shared/src/utils/workers/QuadChunkWorker.ts @@ -77,7 +77,7 @@ export interface QuadChunkWorkerOutput { colorData: Float32Array; biomeData: Uint8Array; biomeForestWeight: Float32Array; - biomeDesertWeight: Float32Array; + biomeCanyonWeight: Float32Array; } const QUAD_CHUNK_WORKER_CODE = ` @@ -87,7 +87,7 @@ ${buildBiomeConstantsJS()} var BIOME_IDS = {}; BIOME_IDS[BT_TUNDRA] = 0; BIOME_IDS[BT_FOREST] = 1; -BIOME_IDS[BT_DESERT] = 2; +BIOME_IDS[BT_CANYON] = 2; function generateQuadChunk(input) { const { centerX, centerZ, size, resolution, config, seed, biomeCenters, biomes } = input; @@ -177,7 +177,7 @@ function generateQuadChunk(input) { const colorData = new Float32Array(vertexCount * 3); const biomeData = new Uint8Array(vertexCount); const biomeForestWeight = new Float32Array(vertexCount); - const biomeDesertWeight = new Float32Array(vertexCount); + const biomeCanyonWeight = new Float32Array(vertexCount); for (let iz = 0; iz < segments; iz++) { for (let ix = 0; ix < segments; ix++) { @@ -192,9 +192,9 @@ function generateQuadChunk(input) { var bw = computeBiomeWeightsByPosition(worldX, worldZ); var forestW = bw[BT_FOREST] || 0; - var desertW = bw[BT_DESERT] || 0; + var canyonW = bw[BT_CANYON] || 0; biomeForestWeight[idx] = forestW; - biomeDesertWeight[idx] = desertW; + biomeCanyonWeight[idx] = canyonW; var dominantBiome = BT_DEFAULT; var dominantWeight = -1; @@ -238,7 +238,7 @@ function generateQuadChunk(input) { colorData, biomeData, biomeForestWeight, - biomeDesertWeight + biomeCanyonWeight }; } @@ -253,7 +253,7 @@ self.onmessage = function(e) { result.colorData.buffer, result.biomeData.buffer, result.biomeForestWeight.buffer, - result.biomeDesertWeight.buffer + result.biomeCanyonWeight.buffer ]); } catch (error) { self.postMessage({ error: error.message || 'Unknown error' }); diff --git a/packages/shared/src/utils/workers/TerrainWorker.ts b/packages/shared/src/utils/workers/TerrainWorker.ts index 64301a2d8..0416620ac 100644 --- a/packages/shared/src/utils/workers/TerrainWorker.ts +++ b/packages/shared/src/utils/workers/TerrainWorker.ts @@ -118,7 +118,7 @@ ${buildBiomeConstantsJS()} var BIOME_IDS = {}; BIOME_IDS[BT_TUNDRA] = 0; BIOME_IDS[BT_FOREST] = 1; -BIOME_IDS[BT_DESERT] = 2; +BIOME_IDS[BT_CANYON] = 2; function generateHeightmap(input) { const { tileX, tileZ, config, seed, biomeCenters, biomes } = input; From a9df3fe42258e102b71bf5d54f3b0304a2498b3a Mon Sep 17 00:00:00 2001 From: Shaw Date: Sun, 8 Mar 2026 16:35:08 -0700 Subject: [PATCH 22/48] fix: recover when cdp stream startup stalls --- packages/server/scripts/stream-to-rtmp.ts | 54 ++++++++++++++++++++--- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/packages/server/scripts/stream-to-rtmp.ts b/packages/server/scripts/stream-to-rtmp.ts index 5bc7eea72..fd57ccbbe 100644 --- a/packages/server/scripts/stream-to-rtmp.ts +++ b/packages/server/scripts/stream-to-rtmp.ts @@ -174,6 +174,13 @@ const CAPTURE_RECOVERY_MAX_FAILURES = Math.max( 10, ) || 2, ); +const CDP_STARTUP_TIMEOUT_MS = Math.max( + 5_000, + Number.parseInt( + process.env.STREAM_CAPTURE_START_TIMEOUT_MS || "15_000", + 10, + ) || 15_000, +); // ── CDP Frame Rate Tracking ──────────────────────────────────────────────── @@ -642,14 +649,12 @@ async function startLegacyCapture(bridge: ReturnType) { // Start WebSocket bridge for MediaRecorder chunks bridge.start(BRIDGE_PORT); - if ( - !REQUIRE_IN_PAGE_READY_PROBE && - selectedGameUrl?.includes("?page=stream") - ) { + const streamPageMayAlreadyCapture = + !REQUIRE_IN_PAGE_READY_PROBE && selectedGameUrl?.includes("?page=stream"); + if (streamPageMayAlreadyCapture) { console.log( - "[Main] Relying on built-in stream-page bridge capture; skipping Playwright MediaRecorder injection.", + "[Main] Stream page capture bridge may already be active; will inject MediaRecorder only if the in-page bridge is inactive.", ); - return null; } const captureScript = generateCaptureScript({ @@ -910,7 +915,42 @@ async function main() { if (CAPTURE_MODE === "cdp") { // ── CDP Mode: Direct screencast frame piping ── - await startCdpCapture(bridge); + try { + await withTimeout( + startCdpCapture(bridge), + CDP_STARTUP_TIMEOUT_MS, + "CDP screencast startup", + ); + } catch (err) { + console.warn( + `[Main] CDP startup failed; falling back to MediaRecorder injection: ${errMsg(err)}`, + ); + await withTimeout( + stopCdpCapture(), + 5_000, + "Stop failed CDP capture", + ).catch(() => undefined); + bridge.stop(); + bridge.startSpectatorServer(SPECTATOR_PORT); + captureWatchdog = (await startLegacyCapture(bridge)) ?? null; + activeCaptureMode = "mediarecorder"; + + const healthy = await waitForCaptureTraffic(bridge, 20_000); + if (!healthy) { + console.warn( + "[Main] MediaRecorder fallback produced no media within 20s; trying WebCodecs capture.", + ); + if (captureWatchdog) { + clearInterval(captureWatchdog); + captureWatchdog = null; + } + await stopInPageCaptureControl(); + bridge.stop(); + bridge.startSpectatorServer(SPECTATOR_PORT); + captureWatchdog = (await startWebCodecsCapture(bridge)) ?? null; + activeCaptureMode = "webcodecs"; + } + } } else if (CAPTURE_MODE === "webcodecs") { // ── WebCodecs Mode: Native VideoEncoder API to FFmpeg -c:v copy ── captureWatchdog = (await startWebCodecsCapture(bridge)) ?? null; From b61d5d9b583394fb78aa2be93783a6d82e3bea6e Mon Sep 17 00:00:00 2001 From: dreaminglucid Date: Mon, 9 Mar 2026 07:06:45 -0400 Subject: [PATCH 23/48] fix(camera): eliminate Y-axis jitter in cinematic duel camera MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: during duel combat, camera radius and phi (pitch) were both computed from live entity separation distance. As agents repositioned every ~1.2s via DuelCombatAI, separation oscillated causing ~2+ units of camera height swing per movement tick. Additionally, the LOS grid search (500ms refresh cycle) caused phi oscillation when it alternated between rating adjacent angles as "clear" vs "blocked", and 3 cascading smoothing layers with different linear rate caps created mechanical motion and inter-layer conflicts. Changes: - Lock entity Y positions during duel combat (COUNTDOWN/FIGHTING/RESOLUTION) to eliminate terrain sampling noise from competing systems - Lock separation distance at phase start so radius and phi stay stable - Skip LOS grid search during combat phases (arena has clear sightlines); use smooth exponential damping toward desired angles instead - Collapse 3-layer smoothing (cache→targetSpherical→spherical) into single-layer exponential damping for film-quality motion - Smooth phase bias transitions to prevent focus-point Y jumps - Remove separation-dependent phi variation that caused discontinuous pitch changes when agents moved - Remove all combat-reactive camera effects (punch-in, shake, dramatic low angle, FOV intensification) for clean cinematic experience - Add phase-aware camera parameters (radius, phi, FOV, orbit amplitude, focus bias) per duel phase for intentional cinematic language --- .../src/systems/client/ClientCameraSystem.ts | 768 ++++++++++++++---- 1 file changed, 608 insertions(+), 160 deletions(-) diff --git a/packages/shared/src/systems/client/ClientCameraSystem.ts b/packages/shared/src/systems/client/ClientCameraSystem.ts index 12c869449..0801307ef 100644 --- a/packages/shared/src/systems/client/ClientCameraSystem.ts +++ b/packages/shared/src/systems/client/ClientCameraSystem.ts @@ -25,8 +25,16 @@ interface StreamingCameraStateUpdate { cameraTarget?: string | null; cycle?: { phase?: "IDLE" | "ANNOUNCEMENT" | "COUNTDOWN" | "FIGHTING" | "RESOLUTION"; - agent1?: { id?: string | null } | null; - agent2?: { id?: string | null } | null; + agent1?: { + id?: string | null; + hp?: number | null; + maxHp?: number | null; + } | null; + agent2?: { + id?: string | null; + hp?: number | null; + maxHp?: number | null; + } | null; winnerId?: string | null; }; } @@ -48,6 +56,8 @@ const _cinematicTransitionDir = new THREE.Vector3(); const _cinematicOrientationMatrix = new THREE.Matrix4(); const _cinematicOrientationQuat = new THREE.Quaternion(); const _cinematicOrientationUp = new THREE.Vector3(0, 1, 0); +const _cinematicShakeOffset = new THREE.Vector3(); +const _cinematicLeadOffset = new THREE.Vector3(); // Pre-allocated arrays for getCameraInfo to avoid allocations const _cameraInfoOffset: number[] = [0, 0, 0]; const _cameraInfoPosition: number[] = [0, 0, 0]; @@ -120,14 +130,63 @@ export class ClientCameraSystem extends SystemBase { private cinematicLookSlerpReady = false; private cinematicLastActorSample = new THREE.Vector3(); private cinematicLastOpponentSample = new THREE.Vector3(); + // Combat-reactive camera state + private cinematicPunchIn = 0; + private cinematicDramaticLow = 0; + private cinematicLastActorHP: number | null = null; + private cinematicLastOpponentHP: number | null = null; + // Last known good target position (fallback when entity position unavailable) + private lastKnownTargetPosition = new THREE.Vector3(); + private hasLastKnownPosition = false; + // Cached terrain system reference + private terrainSystemRef: TerrainSystem | null | undefined = undefined; + // Phase-aware camera state + private cinematicPhase: + | "IDLE" + | "ANNOUNCEMENT" + | "COUNTDOWN" + | "FIGHTING" + | "RESOLUTION" = "IDLE"; + private cinematicPhaseChangedAt = 0; + // Smart camera cuts + private cinematicHardCutPending = false; + private cinematicFastSnapRemaining = 0; + // Camera shake + private cinematicShakeIntensity = 0; + private cinematicShakeTime = 0; + // Dynamic FOV + private cinematicTargetFov = 55; + // Movement lead (velocity tracking) + private cinematicPrevActorPos = new THREE.Vector3(); + private cinematicHasPrevActorPos = false; + private cinematicVelocity = new THREE.Vector3(); + // Smoothed entity Y to filter out frame-to-frame jitter from interpolation/terrain + private cinematicSmoothedActorY = 0; + private cinematicSmoothedOpponentY = 0; + private cinematicHasSmoothedY = false; + // Locked Y positions during duel combat — entities are in a flat arena so Y should be constant. + // Without this lock, TileInterpolator terrain sampling, InterpolationEngine snapshots, + // and ClientNetwork direct writes all compete for entity.position.y, causing frame-to-frame noise. + private cinematicLockedActorY: number | null = null; + private cinematicLockedOpponentY: number | null = null; + // Locked separation during combat — prevents radius and phi from oscillating + // as agents reposition. Without this, every 1.2s agent movement causes ~2+ units + // of camera height swing via separation-dependent radius and phi calculations. + private cinematicLockedSeparation: number | null = null; + // Smoothed phase bias to prevent instant Y jumps when duel phase changes + private cinematicSmoothedBias = 0.5; + private cinematicSmoothedBiasValid = false; + // Reverse angle cuts during FIGHTING + private cinematicLastReverseAt = 0; + private cinematicNextReverseCooldown = 14000; private readonly cinematicTuning = { - thetaRefreshRate: 1.35, - thetaIdleDriftRate: 0.6, + thetaRefreshRate: 0.8, + thetaIdleDriftRate: 0.25, thetaTargetRate: 0.85, thetaAppliedRate: 0.8, - phiTargetRate: 0.65, - phiAppliedRate: 0.6, - maxDriftStep: 0.028, + phiTargetRate: 0.35, + phiAppliedRate: 0.3, + maxDriftStep: 0.012, flipPenaltyStartRad: 1.45, focusBaseSpeed: 8.5, focusDistanceSpeedGain: 0.75, @@ -135,7 +194,7 @@ export class ClientCameraSystem extends SystemBase { lookBaseSpeed: 9, lookDistanceSpeedGain: 0.85, lookMaxSpeed: 220, - lookSlerpRate: 4.8, + lookSlerpRate: 2.5, } as const; // Mouse state @@ -241,7 +300,15 @@ export class ClientCameraSystem extends SystemBase { (state) => { this.latestStreamingState = state; this.lastStreamingStateAt = Date.now(); + // Detect phase changes for camera style transitions + const newPhase = state.cycle?.phase ?? "IDLE"; + if (newPhase !== this.cinematicPhase) { + this.onCinematicPhaseChange(this.cinematicPhase, newPhase); + this.cinematicPhase = newPhase; + this.cinematicPhaseChangedAt = Date.now(); + } this.tryRetargetFromStreamingState(); + this.onStreamingStateHP(state); }, ); @@ -876,6 +943,22 @@ export class ClientCameraSystem extends SystemBase { const previousTarget = this.target; this.target = event.target; this.resetCinematicSamplingState(); + + // Smart cut: measure distance to decide transition style + if ( + previousTarget && + this.isCinematicCameraActive() && + this.hasLastKnownPosition && + this.getTargetWorldPosition(_v3_1) + ) { + const dist = this.lastKnownTargetPosition.distanceTo(_v3_1); + if (dist > 20) { + this.cinematicHardCutPending = true; + } else if (dist > 8) { + this.cinematicFastSnapRemaining = 0.3; + } + } + if (this.getTargetWorldPosition(_v3_1)) { this.logger.info("Target set", { x: _v3_1.x, @@ -1011,7 +1094,7 @@ export class ClientCameraSystem extends SystemBase { } const hasFreshStreamingSignal = - Date.now() - this.lastStreamingStateAt <= 20_000; + Date.now() - this.lastStreamingStateAt <= 120_000; return hasFreshStreamingSignal; } @@ -1068,6 +1151,11 @@ export class ClientCameraSystem extends SystemBase { const entity = this.resolveEntityById(targetId); if (!entity) { + if (!this.target) { + console.warn( + `[ClientCameraSystem] Streaming target "${targetId}" not found in entity store`, + ); + } return false; } @@ -1411,6 +1499,121 @@ export class ClientCameraSystem extends SystemBase { this.cinematicLastHasOpponent = false; this.cinematicFacingTheta = this.spherical.theta; this.cinematicFacingThetaValid = false; + this.cinematicPunchIn = 0; + this.cinematicDramaticLow = 0; + this.cinematicLastActorHP = null; + this.cinematicLastOpponentHP = null; + this.cinematicShakeIntensity = 0; + this.cinematicHasPrevActorPos = false; + this.cinematicHasSmoothedY = false; + this.cinematicLockedActorY = null; + this.cinematicLockedOpponentY = null; + this.cinematicLockedSeparation = null; + this.cinematicSmoothedBiasValid = false; + this.cinematicVelocity.set(0, 0, 0); + } + + private onCinematicPhaseChange(_oldPhase: string, newPhase: string): void { + // Phase transitions trigger hard cuts for dramatic effect + if ( + newPhase === "ANNOUNCEMENT" || + newPhase === "FIGHTING" || + newPhase === "RESOLUTION" + ) { + this.cinematicHardCutPending = true; + } + this.cinematicLastReverseAt = Date.now(); + this.cinematicPunchIn = 0; + this.cinematicDramaticLow = 0; + this.cinematicShakeIntensity = 0; + this.cinematicFacingThetaValid = false; + // Reset Y and separation locks so they re-capture at the new phase's positions + this.cinematicLockedActorY = null; + this.cinematicLockedOpponentY = null; + this.cinematicLockedSeparation = null; + } + + private getCinematicPhaseParams(): { + radiusMin: number; + radiusMax: number; + basePhi: number; + driftSpeed: number; + targetFov: number; + orbitAmplitude: number; + focusBias: number; + } { + switch (this.cinematicPhase) { + case "ANNOUNCEMENT": + return { + radiusMin: 10, + radiusMax: 14, + basePhi: Math.PI * 0.26, + driftSpeed: 0.02, + targetFov: 48, + orbitAmplitude: 0.08, + focusBias: 0.35, + }; + case "COUNTDOWN": + return { + radiusMin: 4, + radiusMax: 6, + basePhi: Math.PI * 0.4, + driftSpeed: 0.0, + targetFov: 42, + orbitAmplitude: 0.02, + focusBias: 0.85, + }; + case "FIGHTING": + return { + radiusMin: 5, + radiusMax: 9, + basePhi: Math.PI * 0.31, + driftSpeed: 0.035, + targetFov: 55, + orbitAmplitude: 0.15, + focusBias: 0.58, + }; + case "RESOLUTION": + return { + radiusMin: 5.5, + radiusMax: 8, + basePhi: Math.PI * 0.44, + driftSpeed: 0.025, + targetFov: 45, + orbitAmplitude: 0.06, + focusBias: 0.5, + }; + default: + return { + radiusMin: 8, + radiusMax: 12, + basePhi: Math.PI * 0.28, + driftSpeed: 0.015, + targetFov: 52, + orbitAmplitude: 0.1, + focusBias: 0.5, + }; + } + } + + private computeCameraShake(dt: number): THREE.Vector3 { + if (this.cinematicShakeIntensity < 0.001) { + _cinematicShakeOffset.set(0, 0, 0); + return _cinematicShakeOffset; + } + this.cinematicShakeTime += dt; + this.cinematicShakeIntensity *= Math.exp(-6.0 * dt); + if (this.cinematicShakeIntensity < 0.001) { + this.cinematicShakeIntensity = 0; + } + const t = this.cinematicShakeTime * 60; + const i = this.cinematicShakeIntensity; + _cinematicShakeOffset.set( + Math.sin(t * 1.1) * Math.sin(t * 0.47) * i * 0.12, + Math.sin(t * 1.37) * Math.sin(t * 0.63) * i * 0.04, + Math.sin(t * 0.93) * Math.sin(t * 0.37) * i * 0.1, + ); + return _cinematicShakeOffset; } private moveAngleToward( @@ -1499,7 +1702,7 @@ export class ClientCameraSystem extends SystemBase { return true; } - if (now - this.cinematicLastLosRefreshAt >= 120) { + if (now - this.cinematicLastLosRefreshAt >= 500) { return true; } @@ -1516,15 +1719,14 @@ export class ClientCameraSystem extends SystemBase { return true; } - if (actorPosition.distanceToSquared(this.cinematicLastActorSample) > 0.18) { + if (actorPosition.distanceToSquared(this.cinematicLastActorSample) > 0.6) { return true; } if ( hasOpponent && opponentPosition && - opponentPosition.distanceToSquared(this.cinematicLastOpponentSample) > - 0.28 + opponentPosition.distanceToSquared(this.cinematicLastOpponentSample) > 0.8 ) { return true; } @@ -1542,6 +1744,40 @@ export class ClientCameraSystem extends SystemBase { actorPosition: THREE.Vector3, opponentPosition: THREE.Vector3 | null, ): { theta: number; phi: number } { + // During active duel combat (COUNTDOWN/FIGHTING/RESOLUTION), skip the + // periodic LOS grid search entirely. The duel arena is a controlled + // environment with clear sightlines. The grid search's 500ms refresh + // cycle causes oscillation when it alternates between rating adjacent + // angles as "clear" vs "blocked" — the root cause of vertical jitter. + // Instead, use smooth continuous exponential damping toward the desired + // angles, giving a heavy, deliberate, film-quality camera feel. + if ( + this.cinematicPhase === "COUNTDOWN" || + this.cinematicPhase === "FIGHTING" || + this.cinematicPhase === "RESOLUTION" + ) { + if (!this.cinematicThetaCacheValid || !this.cinematicPhiCacheValid) { + this.cinematicThetaCache = baseTheta; + this.cinematicPhiCache = phi; + this.cinematicThetaCacheValid = true; + this.cinematicPhiCacheValid = true; + } else { + // Exponential damping: fast when far from target, slow when close. + // This is the same approach used by AAA cinematic cameras — no linear + // rate caps that create mechanical start/stop movement. + const damp = 1 - Math.exp(-1.8 * deltaSeconds); + const thetaDelta = this.shortestAngleDelta( + this.cinematicThetaCache, + baseTheta, + ); + this.cinematicThetaCache += thetaDelta * damp; + this.cinematicPhiCache += (phi - this.cinematicPhiCache) * damp; + } + return { theta: this.cinematicThetaCache, phi: this.cinematicPhiCache }; + } + + // IDLE / ANNOUNCEMENT: full LOS grid search (agents roaming the world + // where buildings and trees can obstruct the view). const shouldRefresh = this.shouldRefreshCinematicView( now, baseTheta, @@ -1575,7 +1811,7 @@ export class ClientCameraSystem extends SystemBase { ? this.moveAngleToward( this.cinematicPhiCache, selectedView.phi, - this.cinematicTuning.phiTargetRate, + this.cinematicTuning.phiTargetRate * 0.6, refreshDeltaSeconds, ) : selectedView.phi; @@ -1595,7 +1831,8 @@ export class ClientCameraSystem extends SystemBase { }; } - // Keep subtle motion even between LOS refreshes. + // Between LOS refreshes (IDLE/ANNOUNCEMENT only): subtle theta drift, + // phi stays locked to prevent down-blocked-up oscillation. const drift = this.shortestAngleDelta(this.cinematicThetaCache, baseTheta); this.cinematicThetaCache = this.moveAngleToward( this.cinematicThetaCache, @@ -1608,12 +1845,6 @@ export class ClientCameraSystem extends SystemBase { -this.cinematicTuning.maxDriftStep, this.cinematicTuning.maxDriftStep, ); - this.cinematicPhiCache = this.moveAngleToward( - this.cinematicPhiCache, - phi, - this.cinematicTuning.phiTargetRate * 0.55, - deltaSeconds, - ); return { theta: this.cinematicThetaCache, phi: this.cinematicPhiCache, @@ -1754,6 +1985,17 @@ export class ClientCameraSystem extends SystemBase { } score -= Math.abs(candidatePhi - anchorPhi) * 1.05; + // Hysteresis: bias toward the current cached angle to prevent oscillation. + // The camera only switches when a new angle is substantially better. + if (this.cinematicThetaCacheValid) { + const distFromCurrent = + Math.abs(this.shortestAngleDelta(this.cinematicThetaCache, theta)) + + Math.abs(this.cinematicPhiCache - candidatePhi); + if (distFromCurrent < 0.15) { + score += 2.0; + } + } + const probeDirection = _cinematicProbeDir .copy(_cinematicProbePos) .sub(focus); @@ -1877,8 +2119,26 @@ export class ClientCameraSystem extends SystemBase { this.copyEntityPosition(actorEntity, _cinematicActorPos) || this.copyEntityPosition(this.target, _cinematicActorPos); if (!hasActorPos) { - return null; + if (this.hasLastKnownPosition) { + _cinematicActorPos.copy(this.lastKnownTargetPosition); + } else { + return null; + } + } else { + this.lastKnownTargetPosition.copy(_cinematicActorPos); + this.hasLastKnownPosition = true; + } + + // Track velocity for movement lead + if (this.cinematicHasPrevActorPos) { + this.cinematicVelocity.set( + _cinematicActorPos.x - this.cinematicPrevActorPos.x, + 0, + _cinematicActorPos.z - this.cinematicPrevActorPos.z, + ); } + this.cinematicPrevActorPos.copy(_cinematicActorPos); + this.cinematicHasPrevActorPos = true; const actorId = this.resolveEntityId(actorEntity); const opponentEntity = this.resolveOpponentEntity(actorEntity, actorId); @@ -1888,20 +2148,120 @@ export class ClientCameraSystem extends SystemBase { const now = Date.now(); const dt = Math.max(0.001, deltaTime || 0.016); - this.cinematicClock += Math.max(0.001, deltaTime || 0.016); + this.cinematicClock += dt; + + // Lock entity Y during duel combat phases. Entities fight in a flat arena + // so their Y should be constant. Without locking, TileInterpolator terrain + // sampling, InterpolationEngine snapshots, and ClientNetwork direct writes + // all compete for entity.position.y causing frame-to-frame noise that no + // amount of smoothing can fully eliminate. + const inDuelCombat = + this.cinematicPhase === "COUNTDOWN" || + this.cinematicPhase === "FIGHTING" || + this.cinematicPhase === "RESOLUTION"; + + if (inDuelCombat) { + // Lock Y on first frame of duel combat, or if not yet locked + if (this.cinematicLockedActorY === null) { + this.cinematicLockedActorY = _cinematicActorPos.y; + } + if (hasOpponent && this.cinematicLockedOpponentY === null) { + this.cinematicLockedOpponentY = _cinematicOpponentPos.y; + } + // Use locked Y — completely ignores noisy terrain/interpolation updates + _cinematicActorPos.y = this.cinematicLockedActorY; + if (hasOpponent) { + _cinematicOpponentPos.y = this.cinematicLockedOpponentY!; + } + } else { + // Outside duel combat, clear locks and use gentle smoothing for idle following + this.cinematicLockedActorY = null; + this.cinematicLockedOpponentY = null; + this.cinematicLockedSeparation = null; + + if (!this.cinematicHasSmoothedY) { + this.cinematicSmoothedActorY = _cinematicActorPos.y; + this.cinematicSmoothedOpponentY = hasOpponent + ? _cinematicOpponentPos.y + : _cinematicActorPos.y; + this.cinematicHasSmoothedY = true; + } else { + const ySmooth = 1 - Math.exp(-0.5 * dt); + this.cinematicSmoothedActorY += + (_cinematicActorPos.y - this.cinematicSmoothedActorY) * ySmooth; + if (hasOpponent) { + this.cinematicSmoothedOpponentY += + (_cinematicOpponentPos.y - this.cinematicSmoothedOpponentY) * + ySmooth; + } + } + _cinematicActorPos.y = this.cinematicSmoothedActorY; + if (hasOpponent) { + _cinematicOpponentPos.y = this.cinematicSmoothedOpponentY; + } + } + + // Phase-aware camera parameters + const pp = this.getCinematicPhaseParams(); + this.cinematicTargetFov = pp.targetFov; + + // Movement lead offset (camera anticipates movement direction) + const leadScale = this.cinematicPhase === "IDLE" ? 1.5 : 0.8; + const speed = Math.sqrt( + this.cinematicVelocity.x * this.cinematicVelocity.x + + this.cinematicVelocity.z * this.cinematicVelocity.z, + ); + _cinematicLeadOffset.set(0, 0, 0); + if (speed > 0.02) { + _cinematicLeadOffset.set( + this.cinematicVelocity.x * leadScale, + 0, + this.cinematicVelocity.z * leadScale, + ); + } if (hasOpponent) { - const separation = _cinematicActorPos.distanceTo(_cinematicOpponentPos); + const rawSeparation = _cinematicActorPos.distanceTo( + _cinematicOpponentPos, + ); + // Lock separation during duel combat so radius and phi don't oscillate + // when agents reposition. Without this, every 1.2s movement tick causes + // separation to fluctuate (e.g., 2→3 units), which swings both the radius + // (via separation*1.1) and phi (via closeCombatBlend), producing ~2+ units + // of camera height change — the root cause of persistent Y-axis jitter. + if (inDuelCombat) { + if (this.cinematicLockedSeparation === null) { + this.cinematicLockedSeparation = rawSeparation; + } + } else { + this.cinematicLockedSeparation = null; + } + const separation = this.cinematicLockedSeparation ?? rawSeparation; + + // Focus point: phase-aware bias between actor and opponent. + // Smooth the bias transition to prevent Y jumps when phase changes + // (e.g., ANNOUNCEMENT bias=0.35 → FIGHTING bias=0.58 would cause + // an instant focus-point jump if actor and opponent have different Y). + if (!this.cinematicSmoothedBiasValid) { + this.cinematicSmoothedBias = pp.focusBias; + this.cinematicSmoothedBiasValid = true; + } else { + const biasSmooth = 1 - Math.exp(-2.0 * dt); + this.cinematicSmoothedBias += + (pp.focusBias - this.cinematicSmoothedBias) * biasSmooth; + } + const bias = this.cinematicSmoothedBias; _cinematicFocusPos .copy(_cinematicActorPos) - .multiplyScalar(0.58) + .multiplyScalar(bias) .add( _cinematicProbeTarget .copy(_cinematicOpponentPos) - .multiplyScalar(0.42), + .multiplyScalar(1 - bias), ); _cinematicFocusPos.y += 1.05; + _cinematicFocusPos.add(_cinematicLeadOffset); _cinematicLookAtPos .copy(_cinematicActorPos) @@ -1909,6 +2269,7 @@ export class ClientCameraSystem extends SystemBase { .multiplyScalar(0.5); _cinematicLookAtPos.y += 1.12; + // Facing theta (smoothed toward opponent direction) let facingTheta = this.cinematicFacingTheta; if (separation > 0.25) { const rawFacingTheta = Math.atan2( @@ -1933,28 +2294,83 @@ export class ClientCameraSystem extends SystemBase { facingTheta = this.cinematicFacingTheta; } + const t = this.cinematicClock; + // Orbit drift with phase-controlled amplitude + const amp = pp.orbitAmplitude; const orbitDrift = - this.cinematicClock * 0.05 + - Math.sin(this.cinematicClock * 0.21) * 0.12 + - Math.sin(this.cinematicClock * 0.08 + 1.7) * 0.08; - const baseTheta = facingTheta + Math.PI * 0.56 + orbitDrift; + t * pp.driftSpeed + + Math.sin(t * 0.17) * amp + + Math.sin(t * 0.089 + 2.1) * amp * 0.73 + + Math.sin(t * 0.31 + 0.7) * amp * 0.4; + const bigSwing = Math.sin(t * 0.048) * Math.sin(t * 0.032) * amp * 2.3; + + // Reverse angle cuts during FIGHTING for visual variety + let reverseAngleBoost = 0; + if (this.cinematicPhase === "FIGHTING") { + const timeSinceReverse = now - this.cinematicLastReverseAt; + if (timeSinceReverse > this.cinematicNextReverseCooldown) { + reverseAngleBoost = Math.PI * 0.7; + this.cinematicLastReverseAt = now; + this.cinematicNextReverseCooldown = 12000 + Math.random() * 6000; + // Reset theta cache so LOS scorer accepts the new angle + this.cinematicThetaCacheValid = false; + this.cinematicLastLosRefreshAt = 0; + } + } + + const baseTheta = + facingTheta + + Math.PI * 0.56 + + orbitDrift + + bigSwing + + reverseAngleBoost; + + // Phase-aware radius — use smoothstep blend instead of hard threshold + // to prevent discrete jumps when agents hover near 3 units apart + const closeCombatBlend = clamp((3.5 - separation) / 1.5, 0, 1); + const combatTightening = closeCombatBlend * 1.2; const baseRadius = clamp( - 6.2 + separation * 1.45, - this.settings.minDistance + 2, - this.settings.maxDistance, + pp.radiusMin + separation * 1.1 - combatTightening, + pp.radiusMin, + pp.radiusMax, ); const radius = clamp( - baseRadius + Math.sin(this.cinematicClock * 0.32) * 0.2, - this.settings.minDistance + 2, - this.settings.maxDistance, - ); - const phi = clamp( - Math.PI * 0.285 + - Math.sin(this.cinematicClock * 0.18 + separation * 0.2) * 0.035, - this.settings.minPolarAngle + 0.03, - this.settings.maxPolarAngle - 0.03, + baseRadius + Math.sin(t * 0.32) * 0.2, + pp.radiusMin, + pp.radiusMax, ); + // Phase-aware phi (pitch angle) — smooth blend for close combat + let phi: number; + if (this.cinematicPhase === "RESOLUTION") { + // Low heroic angle for winner + phi = clamp( + pp.basePhi + Math.sin(t * 0.09) * 0.03, + this.settings.minPolarAngle + 0.03, + this.settings.maxPolarAngle - 0.01, + ); + } else if (this.cinematicPhase === "COUNTDOWN") { + // Tight hero shot, slight variation + phi = clamp( + pp.basePhi + Math.sin(t * 0.11) * 0.02, + this.settings.minPolarAngle + 0.03, + this.settings.maxPolarAngle - 0.03, + ); + } else { + // FIGHTING / ANNOUNCEMENT / IDLE — blend between base and close-combat phi. + // Use only time-based variation (no separation dependency) to prevent + // discontinuous phi changes when agents move during combat. + const closeCombatPhi = + pp.basePhi + closeCombatBlend * (Math.PI * 0.38 - pp.basePhi); + const phiVariation = + Math.sin(t * 0.13) * 0.015 + Math.sin(t * 0.07) * 0.01; + phi = clamp( + closeCombatPhi + phiVariation, + this.settings.minPolarAngle + 0.03, + this.settings.maxPolarAngle - 0.03, + ); + } + const cinematicView = this.resolveCinematicView( now, dt, @@ -1975,22 +2391,32 @@ export class ClientCameraSystem extends SystemBase { }; } + // Solo (no opponent) — phase-aware this.cinematicFacingThetaValid = false; _cinematicFocusPos.copy(_cinematicActorPos); _cinematicFocusPos.y += 1; + _cinematicFocusPos.add(_cinematicLeadOffset); _cinematicLookAtPos.copy(_cinematicActorPos); _cinematicLookAtPos.y += 1.12; + const tSolo = this.cinematicClock; + const soloAmp = pp.orbitAmplitude; const thetaDrift = - Math.sin(this.cinematicClock * 0.15) * 0.03 + this.cinematicClock * 0.012; + Math.sin(tSolo * 0.15) * soloAmp * 0.5 + + Math.sin(tSolo * 0.067 + 1.3) * soloAmp * 0.4 + + tSolo * pp.driftSpeed; const baseTheta = this.spherical.theta + thetaDrift; const radius = clamp( - this.targetSpherical.radius + Math.sin(this.cinematicClock * 0.34) * 0.12, - this.settings.minDistance + 1.5, - this.settings.maxDistance - 0.5, + (pp.radiusMin + pp.radiusMax) * 0.5 + + Math.sin(tSolo * 0.34) * 0.3 + + Math.sin(tSolo * 0.12 + 0.8) * 0.2, + pp.radiusMin, + pp.radiusMax, ); const phi = clamp( - Math.PI * 0.3 + Math.sin(this.cinematicClock * 0.17 + 0.6) * 0.028, + pp.basePhi + + Math.sin(tSolo * 0.13 + 0.6) * 0.05 + + Math.sin(tSolo * 0.07) * 0.03, this.settings.minPolarAngle + 0.02, this.settings.maxPolarAngle - 0.02, ); @@ -2046,39 +2472,60 @@ export class ClientCameraSystem extends SystemBase { const cinematicFrame = this.buildCinematicFrame(deltaTime); if (cinematicFrame) { this.targetPosition.copy(cinematicFrame.focus); - this.moveVectorToward( - this.smoothedTarget, - this.targetPosition, - frameDt, - this.cinematicTuning.focusBaseSpeed, - this.cinematicTuning.focusDistanceSpeedGain, - this.cinematicTuning.focusMaxSpeed, - ); - this.targetSpherical.radius += clamp( - cinematicFrame.radius - this.targetSpherical.radius, - -2.2 * frameDt, - 2.2 * frameDt, - ); - this.targetSpherical.phi = this.moveAngleToward( - this.targetSpherical.phi, - cinematicFrame.phi, - this.cinematicTuning.phiTargetRate, - frameDt, - ); - this.targetSpherical.theta = this.moveAngleToward( - this.targetSpherical.theta, - cinematicFrame.theta, - this.cinematicTuning.thetaTargetRate, - frameDt, - ); - this.moveVectorToward( - this.lookAtTarget, - cinematicFrame.lookAt, - frameDt, - this.cinematicTuning.lookBaseSpeed, - this.cinematicTuning.lookDistanceSpeedGain, - this.cinematicTuning.lookMaxSpeed, - ); + + // Handle hard cuts and fast snaps + if (this.cinematicHardCutPending) { + // Hard cut: snap everything instantly + this.smoothedTarget.copy(this.targetPosition); + this.targetSpherical.radius = cinematicFrame.radius; + this.targetSpherical.phi = cinematicFrame.phi; + this.targetSpherical.theta = cinematicFrame.theta; + this.spherical.radius = cinematicFrame.radius; + this.spherical.phi = cinematicFrame.phi; + this.spherical.theta = cinematicFrame.theta; + this.effectiveRadius = cinematicFrame.radius; + this.lookAtTarget.copy(cinematicFrame.lookAt); + this.cinematicLookSlerpReady = false; + this.cinematicHardCutPending = false; + this.cinematicFastSnapRemaining = 0; + } else { + // Single-layer exponential damping for all cinematic smoothing. + // Exponential decay (fast when far, slow when close) produces the + // heavy, deliberate camera motion of AAA cinematic cameras like RDR2. + // This replaces the previous 3-layer pipeline (cinematicCache → + // targetSpherical → spherical) which had conflicting linear rate + // caps that created mechanical start/stop motion and oscillation. + // + // Rate 3.5 → half-life ~0.2s, reaches 95% in ~0.6s. + // Rate 5.0 for position → half-life ~0.14s, reaches 95% in ~0.4s. + const snapM = this.cinematicFastSnapRemaining > 0 ? 3.0 : 1.0; + if (this.cinematicFastSnapRemaining > 0) { + this.cinematicFastSnapRemaining = Math.max( + 0, + this.cinematicFastSnapRemaining - frameDt, + ); + } + + // Position: exponential damping (unified X/Y/Z — no separate Y rate limit). + // Y is locked during duel combat so there's no terrain noise to filter. + const posDamp = 1 - Math.exp(-5.0 * snapM * frameDt); + this.smoothedTarget.lerp(this.targetPosition, posDamp); + this.lookAtTarget.lerp(cinematicFrame.lookAt, posDamp); + + // Angles: single-layer exponential damping directly to cinematicFrame + // values. No intermediate targetSpherical — that extra layer added + // latency and created phase conflicts between smoothers. + const angleDamp = 1 - Math.exp(-3.5 * snapM * frameDt); + const thetaDelta = this.shortestAngleDelta( + this.targetSpherical.theta, + cinematicFrame.theta, + ); + this.targetSpherical.theta += thetaDelta * angleDamp; + this.targetSpherical.phi += + (cinematicFrame.phi - this.targetSpherical.phi) * angleDamp; + this.targetSpherical.radius += + (cinematicFrame.radius - this.targetSpherical.radius) * angleDamp; + } } else { let hasTargetPosition = this.getTargetWorldPosition(_v3_1); if (!hasTargetPosition) { @@ -2089,10 +2536,18 @@ export class ClientCameraSystem extends SystemBase { hasTargetPosition = this.getTargetWorldPosition(_v3_1); } if (!hasTargetPosition) { - return; + if (this.hasLastKnownPosition) { + _v3_1.copy(this.lastKnownTargetPosition); + } else { + return; + } } } + // Save last known good position + this.lastKnownTargetPosition.copy(_v3_1); + this.hasLastKnownPosition = true; + // For server-authoritative movement, follow target directly without smoothing. this.targetPosition.copy(_v3_1); this.targetPosition.add(this.cameraOffset); @@ -2110,18 +2565,12 @@ export class ClientCameraSystem extends SystemBase { const shouldSmoothSpherical = isOrbiting || Boolean(cinematicFrame); if (shouldSmoothSpherical) { if (cinematicFrame) { - this.spherical.phi = this.moveAngleToward( - this.spherical.phi, - this.targetSpherical.phi, - this.cinematicTuning.phiAppliedRate, - frameDt, - ); - this.spherical.theta = this.moveAngleToward( - this.spherical.theta, - this.targetSpherical.theta, - this.cinematicTuning.thetaAppliedRate, - frameDt, - ); + // In cinematic mode, targetSpherical already contains the smoothed + // values (single-layer exponential damping applied above). Copy + // directly — adding a second smoothing layer creates sluggishness + // and can cause oscillation when the two layers have different rates. + this.spherical.phi = this.targetSpherical.phi; + this.spherical.theta = this.targetSpherical.theta; } else { const phiDelta = this.targetSpherical.phi - this.spherical.phi; const thetaDelta = this.shortestAngleDelta( @@ -2144,6 +2593,13 @@ export class ClientCameraSystem extends SystemBase { this.spherical.theta = this.targetSpherical.theta; } + // In cinematic mode, propagate targetSpherical.radius → spherical.radius + // (the cinematic frame sets targetSpherical.radius but the smoothing block + // above only handles phi/theta) + if (cinematicFrame) { + this.spherical.radius = this.targetSpherical.radius; + } + // Hard clamp after smoothing to enforce strict RS3-like limits this.spherical.radius = clamp( this.spherical.radius, @@ -2151,18 +2607,24 @@ export class ClientCameraSystem extends SystemBase { this.settings.maxDistance, ); - // Collision-aware effective radius with smoothing to avoid snap on MMB press - const desiredDistance = this.spherical.radius; - const collidedDistance = - this.computeCollisionAdjustedDistance(desiredDistance); - const targetEffective = Math.min(desiredDistance, collidedDistance); - if (this.zoomDirty || this.orbitingActive) { - // When zoom just changed, honor immediate response - this.effectiveRadius = targetEffective; + // Collision-aware effective radius — skip in cinematic mode because the + // LOS scorer already avoids obstructed camera angles. Running the collision + // raycast here causes jitter as the ray alternates between hitting and + // missing terrain at different orbit angles each frame. + if (cinematicFrame) { + this.effectiveRadius = this.spherical.radius; } else { - const radiusDamping = this.settings.radiusDampingFactor ?? 0.18; - this.effectiveRadius += - (targetEffective - this.effectiveRadius) * radiusDamping; + const desiredDistance = this.spherical.radius; + const collidedDistance = + this.computeCollisionAdjustedDistance(desiredDistance); + const targetEffective = Math.min(desiredDistance, collidedDistance); + if (this.zoomDirty || this.orbitingActive) { + this.effectiveRadius = targetEffective; + } else { + const radiusDamping = this.settings.radiusDampingFactor ?? 0.18; + this.effectiveRadius += + (targetEffective - this.effectiveRadius) * radiusDamping; + } } // Calculate camera position from spherical coordinates using effective radius @@ -2174,6 +2636,13 @@ export class ClientCameraSystem extends SystemBase { this.cameraPosition.setFromSpherical(tempSpherical); this.cameraPosition.add(this.smoothedTarget); + // Prevent camera from going underground — skip in cinematic mode where + // the LOS scorer handles obstruction avoidance. The hard Y snap fights + // with the smooth spherical orbit and causes vertical jitter. + if (!cinematicFrame) { + this.clampAboveTerrain(this.cameraPosition, 1.5); + } + if (!cinematicFrame) { // Calculate look-at target - look at player's chest/torso height this.lookAtTarget.copy(this.smoothedTarget); @@ -2216,10 +2685,17 @@ export class ClientCameraSystem extends SystemBase { this.cinematicLookSlerpReady = false; } + // Dynamic FOV for cinematic mode + if (cinematicFrame && this.camera) { + const fovDelta = this.cinematicTargetFov - this.camera.fov; + if (Math.abs(fovDelta) > 0.1) { + this.camera.fov += clamp(fovDelta, -15 * frameDt, 15 * frameDt); + this.camera.updateProjectionMatrix(); + } + } + // Update camera matrices since it has no parent transform to inherit from this.camera.updateMatrixWorld(true); - - // Do not clamp camera height to terrain; effective radius collision handles occlusion } private computeCollisionAdjustedDistance(desiredDistance: number): number { @@ -2257,65 +2733,35 @@ export class ClientCameraSystem extends SystemBase { return desiredDistance; } - private shortestAngleDelta(a: number, b: number): number { - let delta = (b - a) % (Math.PI * 2); - if (delta > Math.PI) delta -= Math.PI * 2; - if (delta < -Math.PI) delta += Math.PI * 2; - return delta; - } - - private validatePlayerOnTerrain( - playerPos: THREE.Vector3 | { x: number; y: number; z: number }, - ): void { - // Get terrain system - const terrainSystem = this.world.getSystem("terrain"); - if (!terrainSystem) { - // No terrain system available - return; - } - - // Get player coordinates - const px = "x" in playerPos ? playerPos.x : (playerPos as THREE.Vector3).x; - const py = "y" in playerPos ? playerPos.y : (playerPos as THREE.Vector3).y; - const pz = "z" in playerPos ? playerPos.z : (playerPos as THREE.Vector3).z; - - // Get terrain height at player position - const terrainHeight = terrainSystem.getHeightAt(px, pz); - - // Check if terrain height is valid - if (!isFinite(terrainHeight) || isNaN(terrainHeight)) { - const errorMsg = `[CRITICAL] Invalid terrain height at player position: x=${px}, z=${pz}, terrainHeight=${terrainHeight}`; - console.error(errorMsg); - throw new Error(errorMsg); - } - - // Check if player is properly positioned on terrain - // Allow some tolerance for player height above terrain (0.0 to 5.0 units) - const heightDifference = py - terrainHeight; - - if (heightDifference < -0.5) { - const errorMsg = `[CRITICAL] Player is BELOW terrain! Player Y: ${py}, Terrain Height: ${terrainHeight}, Difference: ${heightDifference}`; - console.error(errorMsg); - throw new Error(errorMsg); + private getTerrainSystem(): TerrainSystem | null { + if (this.terrainSystemRef === undefined) { + this.terrainSystemRef = + (this.world.getSystem("terrain") as TerrainSystem | null) ?? null; } + return this.terrainSystemRef; + } - if (heightDifference > 10.0) { - const errorMsg = `[CRITICAL] Player is FLOATING above terrain! Player Y: ${py}, Terrain Height: ${terrainHeight}, Difference: ${heightDifference}`; - console.error(errorMsg); - throw new Error(errorMsg); + private clampAboveTerrain(pos: THREE.Vector3, minClearance: number): void { + const terrain = this.getTerrainSystem(); + if (!terrain) return; + const height = terrain.getHeightAt(pos.x, pos.z); + if (Number.isFinite(height) && pos.y < height + minClearance) { + pos.y = height + minClearance; } + } - // Additional check: if player Y is exactly 0 or very close to 0, might indicate spawn issue - if (Math.abs(py) < 0.01 && Math.abs(terrainHeight) > 1.0) { - const errorMsg = `[CRITICAL] Player Y position is near zero (${py}) but terrain height is ${terrainHeight} - likely spawn failure!`; - console.error(errorMsg); - throw new Error(errorMsg); - } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private onStreamingStateHP(_state: StreamingCameraStateUpdate): void { + // Intentionally empty — all combat-reactive camera effects (punch-in, + // shake, dramatic low angle) have been removed for a smooth cinematic + // experience. HP changes no longer affect the camera. + } - // Log successful validation periodically (every 60 frames) - if (Math.random() < 0.0167) { - // ~1/60 chance - } + private shortestAngleDelta(a: number, b: number): number { + let delta = (b - a) % (Math.PI * 2); + if (delta > Math.PI) delta -= Math.PI * 2; + if (delta < -Math.PI) delta += Math.PI * 2; + return delta; } // Public API methods for testing and external access @@ -2437,6 +2883,8 @@ export class ClientCameraSystem extends SystemBase { this.cinematicLosMask = null; this.cinematicCollisionMask = null; this.cinematicLookSlerpReady = false; + this.terrainSystemRef = undefined; + this.hasLastKnownPosition = false; this.resetCinematicSamplingState(); } From 9e46f42890f35ef156a50a47f05e70e82e0d5f66 Mon Sep 17 00:00:00 2001 From: dreaminglucid Date: Mon, 9 Mar 2026 07:07:57 -0400 Subject: [PATCH 24/48] fix(streaming): correct announcement timer and tune camera director timing - Use MIN_ANNOUNCEMENT_DURATION for getPhaseEndTime during ANNOUNCEMENT phase since the phase early-exits as soon as both agents are alive - Tune FIGHTING camera hold timing: reduce min/max hold and idle threshold for more dynamic camera switching during combat --- packages/server/src/systems/StreamingDuelScheduler/index.ts | 6 +++++- .../StreamingDuelScheduler/managers/CameraDirector.ts | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/server/src/systems/StreamingDuelScheduler/index.ts b/packages/server/src/systems/StreamingDuelScheduler/index.ts index 2b3b7101a..3ad7c8e58 100644 --- a/packages/server/src/systems/StreamingDuelScheduler/index.ts +++ b/packages/server/src/systems/StreamingDuelScheduler/index.ts @@ -2049,7 +2049,11 @@ export class StreamingDuelScheduler { switch (phase) { case "ANNOUNCEMENT": - return phaseStartTime + STREAMING_TIMING.ANNOUNCEMENT_DURATION; + // Use MIN_ANNOUNCEMENT_DURATION for the timer since the phase + // early-exits as soon as both agents are alive (which is almost + // always immediate). ANNOUNCEMENT_DURATION is only a maximum + // fallback and would make the timer misleadingly long. + return phaseStartTime + STREAMING_TIMING.MIN_ANNOUNCEMENT_DURATION; case "COUNTDOWN": return ( this.currentCycle.fightStartTime ?? diff --git a/packages/server/src/systems/StreamingDuelScheduler/managers/CameraDirector.ts b/packages/server/src/systems/StreamingDuelScheduler/managers/CameraDirector.ts index 1c9dd45fd..dc1f4552a 100644 --- a/packages/server/src/systems/StreamingDuelScheduler/managers/CameraDirector.ts +++ b/packages/server/src/systems/StreamingDuelScheduler/managers/CameraDirector.ts @@ -83,9 +83,9 @@ const CAMERA_DIRECTOR = { }, COUNTDOWN: { minHoldMs: 6_000, maxHoldMs: 24_000, idleThresholdMs: 6_000 }, FIGHTING: { - minHoldMs: 28_000, - maxHoldMs: 130_000, - idleThresholdMs: 18_000, + minHoldMs: 12_000, + maxHoldMs: 45_000, + idleThresholdMs: 12_000, }, RESOLUTION: { minHoldMs: 8_000, maxHoldMs: 45_000, idleThresholdMs: 8_000 }, } as Record, From acf2ef06814b1e01b4d5d184a6dec9e36e973c54 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Mon, 9 Mar 2026 22:22:38 +0800 Subject: [PATCH 25/48] feat(terrain): noise-driven landscape algorithm and per-biome cliff colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace stepped-power-plateau with envelope × domain-warped ridge noise so terrace contours follow organic ridges instead of concentric circles. Add per-biome cliff colors (tundra/forest/canyon) and remove snow line. Made-with: Cursor --- .../shared/world/TerrainHeightParams.ts | 105 +++++++++++------- .../src/systems/shared/world/TerrainShader.ts | 55 ++++----- 2 files changed, 87 insertions(+), 73 deletions(-) diff --git a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts index 8ed69c2c2..7fafda1cc 100644 --- a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts +++ b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts @@ -208,40 +208,44 @@ 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, 4+ = tiered mountain) - * shapePower - falloff curve (0.3 = dome, 1 = cone, 4+ = flat-topped mesa) - * edgeSharpness - transition between layers (0 = smooth blend, 1 = hard cliff) - * layerSlope - incline within each layer (0 = perfectly flat shelves, 1 = full natural slope) - * noiseScale - frequency of edge wobble (higher = more detailed wiggles) - * noiseAmount - amplitude of edge wobble (0 = perfect circles, 0.3 = organic edges) + * 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) */ export const LANDSCAPE_FEATURES: LandscapeFeatureDef[] = [ { type: LandscapeType.Mountain, x: -168.5, z: -352.5, - radius: 150, - strength: 4.0, + radius: 250, + strength: 5.5, layers: 5, - shapePower: 1.8, - edgeSharpness: 0.7, - layerSlope: 0.5, - noiseScale: 0.015, - noiseAmount: 0.2, + shapePower: 2.0, + edgeSharpness: 0.2, + layerSlope: 0.9, + noiseScale: 0.025, + noiseAmount: 0.6, }, { type: LandscapeType.Mountain, x: 265.5, z: 322.5, - radius: 100, - strength: 0.7, - layers: 3, - shapePower: 1.5, - edgeSharpness: 0.6, - layerSlope: 0.4, - noiseScale: 0.02, - noiseAmount: 0.15, + 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, @@ -334,20 +338,32 @@ export function applyLandscapeFeaturesPure( const dist = Math.sqrt(dx * dx + dz * dz); if (dist >= feat.radius) continue; - const nv = - feat.noiseAmount > 0 - ? noise.simplex2D(worldX * feat.noiseScale, worldZ * feat.noiseScale) * - feat.noiseAmount - : 0; + 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 t = Math.max(0, Math.min(1, 1 - dist / feat.radius + nv)); - const shaped = Math.pow(t, feat.shapePower); + 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(shaped * feat.layers) / feat.layers; + const stepped = Math.floor(rawH * feat.layers) / feat.layers; const nextStep = Math.min(1, stepped + 1 / feat.layers); - const frac = (shaped - stepped) * feat.layers; + const frac = (rawH - stepped) * feat.layers; const blendStart = 1 - feat.edgeSharpness; const edgeBlend = frac <= blendStart ? 0 : (frac - blendStart) / (1 - blendStart); @@ -355,7 +371,7 @@ export function applyLandscapeFeaturesPure( const slopedStep = stepped + frac * (nextStep - stepped); influence = flatStep + feat.layerSlope * (slopedStep - flatStep); } else { - influence = shaped; + influence = rawH; } if (feat.type === LandscapeType.Pond) { @@ -617,25 +633,36 @@ export function buildApplyLandscapeFeaturesJS(): string { var dist = Math.sqrt(dx * dx + dz * dz); if (dist >= feat.radius) continue; - var nv = feat.noiseAmount > 0 - ? noise.simplex2D(worldX * feat.noiseScale, worldZ * feat.noiseScale) * feat.noiseAmount - : 0; + 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 t = Math.max(0, Math.min(1, 1 - dist / feat.radius + nv)); - var shaped = Math.pow(t, feat.shapePower); + 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(shaped * feat.layers) / feat.layers; + var stepped = Math.floor(rawH * feat.layers) / feat.layers; var nextStep = Math.min(1, stepped + 1 / feat.layers); - var frac = (shaped - stepped) * 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 = shaped; + influence = rawH; } if (feat.type === '${LandscapeType.Pond}') { diff --git a/packages/shared/src/systems/shared/world/TerrainShader.ts b/packages/shared/src/systems/shared/world/TerrainShader.ts index 5edcbe352..a4618e43e 100644 --- a/packages/shared/src/systems/shared/world/TerrainShader.ts +++ b/packages/shared/src/systems/shared/world/TerrainShader.ts @@ -64,18 +64,24 @@ const TUNDRA_GRASS = vec3(0.78, 0.82, 0.85); const TUNDRA_GRASS_DARK = vec3(0.65, 0.7, 0.75); 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); +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_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); +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_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); // Legacy aliases used by road overlay and other shader sections (default = forest) const GRASS_GREEN = FOREST_GRASS; @@ -140,31 +146,23 @@ export function computeTerrainBaseColor( ); c = mix(c, dirtColor, mul(dirtPatchFactor, flatnessFactor)); - // Slope-based dirt - c = mix( - c, - dirtColor, - mul(smoothstep(float(0.15), float(0.5), slope), float(0.6)), + // 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), ); - - // Rock on steep slopes - const rockVariation = smoothstep(float(0.3), float(0.7), noiseVal); - const rockColor = mix(ROCK_GRAY, ROCK_DARK, rockVariation); - c = mix(c, rockColor, smoothstep(float(0.45), float(0.75), slope)); - - // Snow at high elevation — only in tundra biome (tW = tundra weight) - c = mix( - c, - SNOW_WHITE, - mul( - smoothstep( - float(TERRAIN_SHADER_CONSTANTS.SNOW_HEIGHT - 5.0), - float(60.0), - height, - ), - tW, - ), + c = mix(c, dirtColor, mul(dirtSlopeFactor, float(0.6))); + + // Per-biome cliff color on steep slopes (terrace sides, rock faces) + 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( + add(mul(tundraCliff, tW), mul(forestCliff, fW)), + mul(canyonCliff, dW), ); + c = mix(c, cliffColor, smoothstep(float(0.3), float(0.55), slope)); // Sand near water (flat areas, stronger in canyon) const sandBlend = mul( @@ -467,17 +465,6 @@ export function getGrassiness( grassiness -= rockFactor; } - // === SNOW AT HIGH ELEVATION === - // Snow line at ~50m, full snow by 55m - if (height > TERRAIN_SHADER_CONSTANTS.SNOW_HEIGHT - 5.0) { - const snowFactor = smoothstepCPU( - TERRAIN_SHADER_CONSTANTS.SNOW_HEIGHT - 5.0, - TERRAIN_SHADER_CONSTANTS.SNOW_HEIGHT + 5.0, - height, - ); - grassiness -= snowFactor; - } - // Clamp to 0-1 return Math.max(0, Math.min(1, grassiness)); } From adcc0fb9119132c38484ec6b120f2ffe8178ad27 Mon Sep 17 00:00:00 2001 From: dreaminglucid Date: Mon, 9 Mar 2026 13:09:26 -0400 Subject: [PATCH 26/48] fix(betting): add STREAMING_STATE_UPDATE type to streaming API and detect external agents in monitor - Add `type: "STREAMING_STATE_UPDATE"` to streaming state API response so the keeper bot can process duel lifecycle events (was silently discarding) - Add external agent detection in admin monitor endpoint by scanning world entities with isAgent flag or agent- ID prefix - Update local anvil world address --- packages/contracts/deploys/31337/latest.json | 4 +- packages/contracts/worlds.json | 2 +- packages/server/src/routes/streaming.ts | 6 ++- .../server/src/startup/routes/admin-routes.ts | 49 +++++++++++++++++++ 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/packages/contracts/deploys/31337/latest.json b/packages/contracts/deploys/31337/latest.json index 86271a8ac..500e6fe5d 100644 --- a/packages/contracts/deploys/31337/latest.json +++ b/packages/contracts/deploys/31337/latest.json @@ -1,4 +1,4 @@ { - "worldAddress": "0x6c14442F32ba360bbB175739E18900a5b1751fa0", - "blockNumber": 177 + "worldAddress": "0x02f3d513283A0C9C0eF049269A40afbE3298DcEC", + "blockNumber": 47930 } diff --git a/packages/contracts/worlds.json b/packages/contracts/worlds.json index 111128eb9..3937f7978 100644 --- a/packages/contracts/worlds.json +++ b/packages/contracts/worlds.json @@ -4,6 +4,6 @@ "blockNumber": 7 }, "31337": { - "address": "0x6c14442F32ba360bbB175739E18900a5b1751fa0" + "address": "0x02f3d513283A0C9C0eF049269A40afbE3298DcEC" } } diff --git a/packages/server/src/routes/streaming.ts b/packages/server/src/routes/streaming.ts index d4efd9894..62d689201 100644 --- a/packages/server/src/routes/streaming.ts +++ b/packages/server/src/routes/streaming.ts @@ -413,7 +413,10 @@ export function registerStreamingRoutes( >["cameraTarget"]; } | null => { if (STREAMING_PUBLIC_DELAY_MS <= 0) { - return scheduler.getStreamingState(); + return { + type: "STREAMING_STATE_UPDATE" as const, + ...scheduler.getStreamingState(), + }; } // Keep delayed replay frames fresh for REST polling consumers @@ -430,6 +433,7 @@ export function registerStreamingRoutes( if (!delayed) return null; return { + type: "STREAMING_STATE_UPDATE" as const, cycle: delayed.cycle as ReturnType< typeof scheduler.getStreamingState >["cycle"], diff --git a/packages/server/src/startup/routes/admin-routes.ts b/packages/server/src/startup/routes/admin-routes.ts index 89afb0416..9e71d1529 100644 --- a/packages/server/src/startup/routes/admin-routes.ts +++ b/packages/server/src/startup/routes/admin-routes.ts @@ -1508,6 +1508,55 @@ export function registerAdminRoutes( } } + // 3. External agents (e.g. duel bots) — connected via WebSocket but + // not tracked by AgentManager or ModelAgentSpawner. Detect them + // from world entities with the isAgent flag or agent- ID prefix. + const detectExternalAgent = ( + entityId: string, + entity: unknown, + requirePlayerType: boolean, + ) => { + if (agentPromises.has(entityId)) return; + const typed = entity as { + type?: string; + name?: string; + data?: { isAgent?: boolean | number; name?: string }; + }; + if ( + requirePlayerType && + typed.type !== "player" && + typed.type !== "Player" + ) + return; + const isAgentEntity = + entityId.startsWith("agent-") || + typed.data?.isAgent === true || + typed.data?.isAgent === 1; + if (!isAgentEntity) return; + agentPromises.set( + entityId, + buildAgentData( + entityId, + typed.name || typed.data?.name || entityId, + "running", + Date.now(), + Date.now(), + ), + ); + }; + // Check players map first (primary) + if (world.entities?.players) { + for (const [entityId, entity] of world.entities.players) { + detectExternalAgent(entityId, entity, false); + } + } + // Fallback: check items map for player-type entities + if (world.entities?.items) { + for (const [entityId, entity] of world.entities.items) { + detectExternalAgent(entityId, entity, true); + } + } + const agents = await Promise.all(agentPromises.values()); return reply.send({ From fcf7416a02bec1725ba596f70ab57190a232179a Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 10 Mar 2026 02:42:28 +0800 Subject: [PATCH 27/48] feat(terrain): replace flat colors with biome texture sampling Swap procedural flat-color terrain shading for CDN-loaded biome textures with triplanar mapping on cliffs and top-down projection on flat surfaces. Textures load async with palette-colored placeholders for instant display. Made-with: Cursor --- .../src/systems/shared/world/TerrainShader.ts | 256 +++++++++++++++++- 1 file changed, 245 insertions(+), 11 deletions(-) diff --git a/packages/shared/src/systems/shared/world/TerrainShader.ts b/packages/shared/src/systems/shared/world/TerrainShader.ts index a4618e43e..14175c1ee 100644 --- a/packages/shared/src/systems/shared/world/TerrainShader.ts +++ b/packages/shared/src/systems/shared/world/TerrainShader.ts @@ -1,6 +1,6 @@ /** - * TerrainShader - TSL Node Material for OSRS-style vertex color terrain - * Flat shaded, no textures - pure vertex colors based on height/slope/noise + * TerrainShader - TSL Node Material for stylized terrain + * Biome textures via triplanar/top-down mapping, blended by height/slope/noise * * **SHARED CODE:** * The core terrain material is also available in @hyperscape/procgen TerrainGen module. @@ -29,6 +29,7 @@ import { add, sub, mul, + div, dot, mix, smoothstep, @@ -55,6 +56,109 @@ export const TERRAIN_SHADER_CONSTANTS = { WATER_LEVEL: 5.0, }; +const TERRAIN_TEX_TILE = 0.08; +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], + }, + dirt: { + file: "dirt.png", + fallback: [0.35, 0.24, 0.12] as [number, number, number], + }, + cliff: { + file: "cliff.png", + fallback: [0.4, 0.38, 0.32] as [number, number, number], + }, + desertGrass: { + file: "desertGrass.png", + fallback: [0.82, 0.52, 0.28] as [number, number, number], + }, + desertDirt: { + file: "desertDirt.png", + fallback: [0.62, 0.28, 0.15] as [number, number, number], + }, + desertCliff: { + file: "desertCliff.png", + fallback: [0.72, 0.38, 0.18] as [number, number, number], + }, + snowGrass: { + file: "snowgrass.png", + fallback: [0.78, 0.82, 0.85] as [number, number, number], + }, + snowDirt: { + file: "snowdirt.png", + fallback: [0.55, 0.55, 0.58] as [number, number, number], + }, + snowCliff: { + file: "snowdirt.png", + fallback: [0.5, 0.52, 0.56] as [number, number, number], + }, +}; + +function getCdnUrl(): string { + if (typeof window !== "undefined") { + const w = window as Window & { __CDN_URL?: string }; + if (w.__CDN_URL) return w.__CDN_URL; + if ( + typeof import.meta !== "undefined" && + (import.meta as any).env?.PUBLIC_CDN_URL + ) + return (import.meta as any).env.PUBLIC_CDN_URL; + } + return "http://localhost:5555/game-assets"; +} + +function createTerrainBiomeTex( + url: string, + pr: number, + pg: number, + pb: number, +): THREE.Texture { + let tex: THREE.Texture; + if (typeof document !== "undefined") { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 2; + const ctx = canvas.getContext("2d")!; + ctx.fillStyle = `rgb(${Math.round(pr * 255)},${Math.round(pg * 255)},${Math.round(pb * 255)})`; + ctx.fillRect(0, 0, 2, 2); + tex = new THREE.Texture(canvas); + } else { + const data = new Uint8Array([ + Math.round(pr * 255), + Math.round(pg * 255), + Math.round(pb * 255), + 255, + ]); + tex = new THREE.DataTexture(data, 1, 1, THREE.RGBAFormat); + } + tex.wrapS = tex.wrapT = THREE.RepeatWrapping; + tex.magFilter = THREE.LinearFilter; + tex.minFilter = THREE.LinearMipmapLinearFilter; + tex.colorSpace = THREE.SRGBColorSpace; + tex.generateMipmaps = true; + tex.needsUpdate = true; + if (typeof window !== "undefined") { + new THREE.TextureLoader().load( + url, + (loaded) => { + tex.image = loaded.image; + tex.colorSpace = THREE.SRGBColorSpace; + tex.needsUpdate = true; + }, + undefined, + (err) => + console.warn( + `[TerrainShader] Failed to load ${url.split("/").pop()}:`, + err, + ), + ); + } + return tex; +} + // ============================================================================ // SHARED TERRAIN BASE COLOR (used by terrain shader AND tree ground-blend) // ============================================================================ @@ -574,8 +678,8 @@ export function updateTerrainVertexLights( } /** - * OSRS-style vertex color terrain material - * No textures - pure flat shaded colors based on height, slope, and noise + * Stylized terrain material with biome texture sampling + * Grass/dirt textures use top-down projection; cliff textures use triplanar mapping */ export function createTerrainMaterial(): THREE.Material & { terrainUniforms: TerrainUniforms; @@ -640,15 +744,145 @@ export function createTerrainMaterial(): THREE.Material & { // Biome weight attributes (computed per-vertex by QuadChunkWorker) const biomeForestW = attribute("biomeForestWeight", "float"); const biomeCanyonW = attribute("biomeCanyonWeight", "float"); + const fW = biomeForestW; + const dW = biomeCanyonW; + const tW = sub(float(1.0), add(fW, dW)); + + // --- TERRAIN BIOME TEXTURES --- + const texBase = `${getCdnUrl()}/${TERRAIN_TEX_DIR}`; + const loadBiomeTex = (key: keyof typeof TERRAIN_BIOME_TEXTURES) => { + const cfg = TERRAIN_BIOME_TEXTURES[key]; + return createTerrainBiomeTex(`${texBase}/${cfg.file}`, ...cfg.fallback); + }; + + const tGrass = loadBiomeTex("grass"); + const tDirt = loadBiomeTex("dirt"); + const tCliff = loadBiomeTex("cliff"); + const tDesertGrass = loadBiomeTex("desertGrass"); + const tDesertDirt = loadBiomeTex("desertDirt"); + const tDesertCliff = loadBiomeTex("desertCliff"); + const tSnowGrass = loadBiomeTex("snowGrass"); + const tSnowDirt = loadBiomeTex("snowDirt"); + const tSnowCliff = loadBiomeTex("snowCliff"); + + // UV projections (top-down for grass/dirt, triplanar for cliffs) + const tileScale = float(TERRAIN_TEX_TILE); + 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); + + // Triplanar blend weights for cliff textures (^4 sharpening) + const tnx = abs(worldNormal.x); + const tny = abs(worldNormal.y); + const tnz = abs(worldNormal.z); + const tw4x = mul(mul(tnx, tnx), mul(tnx, tnx)); + const tw4y = mul(mul(tny, tny), mul(tny, tny)); + const tw4z = mul(mul(tnz, tnz), mul(tnz, tnz)); + const twSum = add(add(tw4x, tw4y), tw4z); + const twX = div(tw4x, twSum); + 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( + add(mul(texture(t, uvFlat).rgb, twY), mul(texture(t, uvSide).rgb, twX)), + mul(texture(t, uvFront).rgb, twZ), + ); + const sCliff = triCliff(tCliff); + const sDesertCliff = triCliff(tDesertCliff); + const sSnowCliff = triCliff(tSnowCliff); + + const TEX_DARKEN = float(0.65); + + // Biome-blended grass (textured) + const grassVar = smoothstep(float(0.4), float(0.6), noiseValue2); + const tundraGrassC = mix(sSnowGrass, mul(sSnowGrass, TEX_DARKEN), grassVar); + const forestGrassC = mix(sGrass, mul(sGrass, TEX_DARKEN), grassVar); + const canyonGrassC = mix( + sDesertGrass, + mul(sDesertGrass, TEX_DARKEN), + grassVar, + ); + let baseColor: any = add( + add(mul(tundraGrassC, tW), mul(forestGrassC, fW)), + mul(canyonGrassC, dW), + ); - // Base color from shared procedural palette - const baseColor = computeTerrainBaseColor( - height, - slope, + // Biome-blended dirt patches + const dirtPatchFactor = smoothstep( + float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD - 0.05), + float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD + 0.15), noiseValue, - noiseValue2, - biomeForestW, - biomeCanyonW, + ); + const flatnessFactor = smoothstep(float(0.3), float(0.05), slope); + 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); + const canyonDirtC = mix(sDesertDirt, mul(sDesertDirt, TEX_DARKEN), dirtVar); + const dirtColor = add( + 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) + 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); + const canyonCliffC = mix( + sDesertCliff, + mul(sDesertCliff, TEX_DARKEN), + cliffVar, + ); + const cliffColor = add( + add(mul(tundraCliffC, tW), mul(forestCliffC, fW)), + mul(canyonCliffC, dW), + ); + baseColor = mix( + baseColor, + cliffColor, + smoothstep(float(0.3), float(0.55), slope), + ); + + // Sand near water (keep flat color - no sand texture) + const sandBlend = mul( + smoothstep(float(10.0), float(6.0), height), + 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) + baseColor = mix( + baseColor, + DIRT_DARK, + mul(smoothstep(float(14.0), float(8.0), height), float(0.4)), + ); + baseColor = mix( + baseColor, + MUD_BROWN, + mul(smoothstep(float(9.0), float(6.0), height), float(0.7)), + ); + baseColor = mix( + baseColor, + WATER_EDGE, + mul(smoothstep(float(6.5), float(5.0), height), float(0.9)), ); // Anti-dithering noise variation (±4% brightness, ±2% color shift) From 25b32a6e98806580e59f2f1fe25d737db7e58875 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 10 Mar 2026 03:09:29 +0800 Subject: [PATCH 28/48] refactor(trees): soft clamped lighting with day/night and wind support - Replace PBR toon banding with portfolio-style Lambert lighting - Bypass PBR output, sample albedo directly for custom soft lighting - smoothstep + clamp compresses light range; clamp floor scales with dayFactor so trees properly darken at night - Add Fresnel edge brightening on leaves (morpho effect) - Apply terrain sun shade for consistent shadow-side tinting - Wire wind uniforms and isLeafMaterial detection in both instancers - Add knotwood tree type with spawn weight Made-with: Cursor --- packages/shared/src/constants/TreeTypes.ts | 57 +++++- .../shared/world/GLBTreeBatchedInstancer.ts | 17 +- .../systems/shared/world/GLBTreeInstancer.ts | 20 ++- .../src/systems/shared/world/GPUMaterials.ts | 168 +++++++++++++----- 4 files changed, 217 insertions(+), 45 deletions(-) diff --git a/packages/shared/src/constants/TreeTypes.ts b/packages/shared/src/constants/TreeTypes.ts index 7d64d01cd..f67f4bd22 100644 --- a/packages/shared/src/constants/TreeTypes.ts +++ b/packages/shared/src/constants/TreeTypes.ts @@ -32,7 +32,62 @@ export const TREE_TYPES = { fir: { name: "Fir Tree", levelRequired: 1, - spawnWeight: 100, + spawnWeight: 10, + }, + pine: { + name: "Pine Tree", + levelRequired: 1, + spawnWeight: 10, + }, + oak: { + name: "Oak Tree", + levelRequired: 15, + spawnWeight: 10, + }, + birch: { + name: "Birch Tree", + levelRequired: 1, + spawnWeight: 10, + }, + bamboo: { + name: "Bamboo Tree", + levelRequired: 1, + spawnWeight: 10, + }, + chinaPine: { + name: "China Pine", + levelRequired: 1, + spawnWeight: 10, + }, + maple: { + name: "Maple Tree", + levelRequired: 45, + spawnWeight: 10, + }, + coconut: { + name: "Coconut Palm", + levelRequired: 1, + spawnWeight: 10, + }, + palm: { + name: "Desert Palm", + levelRequired: 1, + spawnWeight: 10, + }, + dead: { + name: "Dead Tree", + levelRequired: 1, + spawnWeight: 10, + }, + cactus: { + name: "Cactus", + levelRequired: 1, + spawnWeight: 10, + }, + knotwood: { + name: "Knotwood Tree", + levelRequired: 1, + spawnWeight: 80, }, } as const satisfies Record; diff --git a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts index 78a407c0a..31ea7f192 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts @@ -22,7 +22,9 @@ import { GPU_VEG_CONFIG, type DissolveMaterial, type TreeDissolveMaterial, + type TreeMaterialOptions, } from "./GPUMaterials"; +import type { Wind } from "./Wind"; import { getLODDistances } from "./LODConfig"; const MAX_INSTANCES = 512; @@ -337,10 +339,14 @@ async function ensureTreeTypePool( }; function buildMaterialForPart(p: MeshPart): DissolveMaterial { + const isLeaf = + !!(p.material as any).transparent || + (p.material as any).side === THREE.DoubleSide; const dm = createTreeDissolveMaterial(p.material, { ...dissolveOpts, batched: true, - }); + isLeafMaterial: isLeaf, + } as TreeMaterialOptions); dm.side = THREE.DoubleSide; enableTextureRepeat(dm); world!.setupMaterial(dm); @@ -878,6 +884,8 @@ export function updateGLBTreeBatchedInstancer(): void { hemisphereLight?: { color: THREE.Color }; } | null; + const wind = world.getSystem("wind") as Wind | null; + for (const pool of pools.values()) { for (const lodPool of [pool.lod0, pool.lod1, pool.lod2, pool.depleted]) { if (!lodPool) continue; @@ -914,6 +922,13 @@ export function updateGLBTreeBatchedInstancer(): void { ); } } + if (wind) { + treeMat.treeUniforms.windTime.value = wind.uniforms.time.value; + treeMat.treeUniforms.windStrength.value = + wind.uniforms.windStrength.value; + const wd = wind.uniforms.windDirection.value; + treeMat.treeUniforms.windDirection.value.set(wd.x, wd.z); + } } } } diff --git a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts index 58b879dc6..30f68f057 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts @@ -25,7 +25,9 @@ import { GPU_VEG_CONFIG, type DissolveMaterial, type TreeDissolveMaterial, + type TreeMaterialOptions, } from "./GPUMaterials"; +import type { Wind } from "./Wind"; import { getLODDistances } from "./LODConfig"; const MAX_INSTANCES = 512; @@ -253,7 +255,13 @@ async function ensureModelPool( parts: MeshPart[], ): { geometry: THREE.BufferGeometry; material: DissolveMaterial }[] { return parts.map((p) => { - const dm = createTreeDissolveMaterial(p.material, dissolveOpts); + const isLeaf = + !!(p.material as any).transparent || + (p.material as any).side === THREE.DoubleSide; + const dm = createTreeDissolveMaterial(p.material, { + ...dissolveOpts, + isLeafMaterial: isLeaf, + } as TreeMaterialOptions); dm.side = THREE.DoubleSide; enableTextureRepeat(dm); world!.setupMaterial(dm); @@ -733,6 +741,8 @@ export function updateGLBTreeInstancer(): void { hemisphereLight?: { color: THREE.Color }; } | null; + const wind = world.getSystem("wind") as Wind | null; + for (const pool of pools.values()) { for (const lodPool of [pool.lod0, pool.lod1, pool.lod2, pool.depleted]) { if (!lodPool) continue; @@ -752,7 +762,6 @@ export function updateGLBTreeInstancer(): void { playerPos.z, ); - // Sync tree-specific uniforms (sun direction, intensity, shade color) const treeMat = mat as TreeDissolveMaterial; if (treeMat.treeUniforms) { if (env?.lightDirection) { @@ -777,6 +786,13 @@ export function updateGLBTreeInstancer(): void { ); } } + if (wind) { + treeMat.treeUniforms.windTime.value = wind.uniforms.time.value; + treeMat.treeUniforms.windStrength.value = + wind.uniforms.windStrength.value; + const wd = wind.uniforms.windDirection.value; + treeMat.treeUniforms.windDirection.value.set(wd.x, wd.z); + } } } } diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index 42575f248..b98ad8190 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -29,9 +29,11 @@ import { MeshStandardNodeMaterial, float, dot, + vec2, vec3, vec4, smoothstep, + positionLocal, positionWorld, screenUV, cameraPosition, @@ -46,6 +48,7 @@ import { mod, floor, abs, + sin, viewportCoordinate, normalView, normalWorld, @@ -943,11 +946,21 @@ export function applyRimHighlight( // ============================================================================ /** - * Tree-specific dissolve material with stylized foliage shading. + * Options for creating tree dissolve materials. + */ +export type TreeMaterialOptions = DissolveMaterialOptions & { + /** Whether this material covers leaf geometry (enables wind + SSS) */ + isLeafMaterial?: boolean; +}; + +/** + * Tree-specific dissolve material with soft stylized 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) + * - Back-SSS translucency for leaves (warm glow when backlit by sun) + * - Wind vertex animation for leaves * - Vertex-color AO (G channel darkens crevices) - * - Back-SSS translucency (warm glow when backlit by sun) - * - Subtle saturation boost for rich foliage colors * - Per-instance rim highlight */ export type TreeDissolveMaterial = DissolveMaterial & { @@ -955,28 +968,29 @@ export type TreeDissolveMaterial = DissolveMaterial & { sunDirection: { value: THREE.Vector3 }; sunIntensity: { value: number }; shadeColor: { value: THREE.Color }; + windTime: { value: number }; + windStrength: { value: number }; + windDirection: { value: THREE.Vector2 }; }; }; /** - * Creates a tree dissolve material from an existing source material. - * Builds on the standard dissolve material (dithered fade, water culling, etc.) - * and layers Fortnite-inspired foliage shading on top: + * Creates a tree dissolve material with soft clamped lighting, SSS, and wind. * - * 1. **AO** — Reads the vertex color G channel as ambient occlusion, - * darkening crevices and inner branches for depth. - * 2. **Sun shade** — Shadow-side surfaces get tinted by the sky's ambient - * color (from the hemisphere light), shifting cool during day and - * dark-blue at night. - * 3. **Saturation** — Subtle boost keeps colors rich without being cartoonish. - * 4. **Rim highlight** — Per-instance Fresnel glow for hover feedback. + * 1. **Soft lighting** — Lambert + smoothstep clamped to [0.7, 1.0] (no harsh shadows). + * 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). + * 6. **Saturation** — Subtle boost keeps colors rich. + * 7. **Rim highlight** — Per-instance Fresnel glow for hover feedback. * * @param source - Source material to clone PBR properties from - * @param options - Dissolve configuration (fade distances, etc.) + * @param options - Dissolve + tree configuration (fade distances, isLeafMaterial, etc.) */ export function createTreeDissolveMaterial( source: THREE.MeshStandardMaterial | THREE.Material, - options: DissolveMaterialOptions = {}, + options: TreeMaterialOptions = {}, ): TreeDissolveMaterial { const baseDm = createDissolveMaterial(source, { ...options, @@ -984,57 +998,126 @@ export function createTreeDissolveMaterial( }); const material = baseDm as unknown as THREE.MeshStandardNodeMaterial; + const isLeaf = options.isLeafMaterial ?? false; - // Check if source has vertex colors before disabling them. - // We read vertex color manually in the shader for AO only (G channel). const hasVertexColors = !!(source as any).vertexColors; material.vertexColors = false; - // Boost normal map effect when present for more realistic surface detail const srcStd = source as THREE.MeshStandardMaterial; if (srcStd.normalMap) { material.normalScale = new THREE.Vector2(2, 2); } - // --- Tree-specific uniforms --- + // --- Uniforms --- const uSunDir = uniform(new THREE.Vector3(0.5, 0.8, 0.3)); const uSunIntensity = uniform(1.0); const uShadeColor = uniform(new THREE.Color(0.7, 1.08, 1.22)); const uHighlightColor = uniform(new THREE.Color(0x00ffff)); + const uWindTime = uniform(0.0); + const uWindStrength = uniform(0.3); + const uWindDir = uniform(new THREE.Vector2(1, 0)); - // --- Tuning constants --- + // --- Tuning --- const AO_POWER = 1.8; const AO_DARK = 0.35; - const SAT_BOOST = 1.1; + const SAT_BOOST = 1.15; 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; + + // --- Wind vertex displacement (leaf materials only) --- + // Displacement is proportional to local Y so it auto-scales to any model + // coordinate system (bamboo Y~15 at scale 0.8 vs fir Y~1900 at scale 0.008). + if (isLeaf) { + material.positionNode = Fn(() => { + const pos = positionLocal; + const phase = add(mul(pos.x, float(0.013)), mul(pos.z, float(0.017))); + const wave1 = sin(add(mul(uWindTime, float(1.8)), phase)); + const wave2 = sin( + add(mul(uWindTime, float(3.2)), mul(phase, float(0.6))), + ); + const combined = add(mul(wave1, float(0.65)), mul(wave2, float(0.35))); + const amplitude = mul(abs(pos.y), float(0.006)); + const disp = mul(combined, mul(uWindStrength, amplitude)); + return vec3( + add(pos.x, mul(disp, uWindDir.x)), + pos.y, + add(pos.z, mul(disp, uWindDir.y)), + ); + })(); + } + + // --- Output: soft clamped lighting (bypass PBR, compute Lambert from scratch) --- + const albedoMap = material.map; + const matColor = vec3(material.color.r, material.color.g, material.color.b); material.outputNode = Fn(() => { - const litColor = output; + const pbrOut = output; + + // ---- Albedo (sample texture directly, bypass PBR lighting) ---- + const texCoord = attribute("uv", "vec2"); + const albedoSample = albedoMap + ? texture(albedoMap, texCoord) + : vec4(1, 1, 1, 1); + let baseAlbedo: any = mul(albedoSample.rgb, matColor); + + // ---- Vertex-color AO ---- + if (hasVertexColors) { + const aoRaw = attribute("color", "vec3").y; + const aoFactor = pow(aoRaw, float(AO_POWER)); + const aoMul = mix(float(AO_DARK), float(1.0), aoFactor); + baseAlbedo = mul(baseAlbedo, aoMul); + } - // ---- Vertex-color AO (skip if model has no vertex colors) ---- - const aoResult = hasVertexColors - ? (() => { - const aoRaw = attribute("color", "vec3").y; - const aoFactor = pow(aoRaw, float(AO_POWER)); - const aoMul = mix(float(AO_DARK), float(1.0), aoFactor); - return mul(litColor.rgb, aoMul); - })() - : litColor.rgb; - - // ---- Sun shade ---- - const shadeResult = applyTerrainSunShade( - aoResult, - normalWorld, - vec3(uSunDir), - vec3(uShadeColor), + // ---- Custom Lambert lighting (soft clamped, scaled by sun intensity) ---- + const N = normalize(normalWorld); + 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 = mul(float(LIGHT_AMBIENT), dayFactor); + const totalLight = add(ambient, diffuse); + const clampLo = max(float(0.08), mul(float(LIGHT_CLAMP_LO), dayFactor)); + const softLight = clamp( + smoothstep(float(0.9), float(1.1), totalLight), + clampLo, + float(LIGHT_CLAMP_HI), + ); + const ambientBoost = mul(float(LIGHT_AMBIENT_BOOST), dayFactor); + let result: any = mul(baseAlbedo, add(softLight, ambientBoost)); + + // ---- Sun shade (shadow-side sky tint, matches terrain) ---- + result = applyTerrainSunShade(result, N, L, vec3(uShadeColor)); + + // ---- SSS + Fresnel edge brightening (leaf only) ---- + if (isLeaf) { + const V = normalize(sub(cameraPosition, positionWorld)); + + // Back-scatter SSS + const backL = normalize(sub(vec3(0, 0, 0), L)); + const backSSS = clamp(dot(V, backL), float(0), float(1)); + const sssFactor = mul(pow(backSSS, float(3.0)), float(0.12)); + result = add(result, mul(vec3(0.95, 1.0, 0.7), sssFactor)); + + // Edge brightening (morpho effect) + const EDotN = clamp(dot(V, N), float(0.0), float(1.0)); + result = mix(mul(result, float(EDGE_BRIGHT)), result, EDotN); + } // ---- Saturation boost ---- - const luma = dot(shadeResult, vec3(0.299, 0.587, 0.114)); + const luma = dot(result, vec3(0.299, 0.587, 0.114)); const boosted = add( - mul(sub(shadeResult, vec3(luma, luma, luma)), float(SAT_BOOST)), + mul(sub(result, vec3(luma, luma, luma)), float(SAT_BOOST)), vec3(luma, luma, luma), ); @@ -1061,7 +1144,7 @@ export function createTreeDissolveMaterial( const highlighted = add(brightened, rimGlow); const finalRgb = mix(boosted, highlighted, hlIntensity); - return vec4(finalRgb, litColor.a); + return vec4(finalRgb, pbrOut.a); })(); material.needsUpdate = true; @@ -1072,6 +1155,9 @@ export function createTreeDissolveMaterial( sunDirection: uSunDir as unknown as { value: THREE.Vector3 }, sunIntensity: uSunIntensity 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 }, + windDirection: uWindDir as unknown as { value: THREE.Vector2 }, }; return treeMat; From f1a31de096964f6d9184ae62525c909aaef72eda Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 10 Mar 2026 03:55:35 +0800 Subject: [PATCH 29/48] feat(shadows): replace CSM with single shadow map, enable terrain shadows CSM is too heavy and exceeds WebGPU's 16-texture binding limit when combined with the terrain's 13 biome/utility textures. Default to a single 2048 shadow map (ENABLE_CSM=false) which adds only 2 shadow textures (total 15, within limit). Terrain now receives shadows with an auto-disable guard when CSM is on. Made-with: Cursor --- .../src/systems/shared/world/Environment.ts | 166 +++++++++++------- .../src/systems/shared/world/TerrainSystem.ts | 12 +- 2 files changed, 109 insertions(+), 69 deletions(-) diff --git a/packages/shared/src/systems/shared/world/Environment.ts b/packages/shared/src/systems/shared/world/Environment.ts index e08333633..173116cb6 100644 --- a/packages/shared/src/systems/shared/world/Environment.ts +++ b/packages/shared/src/systems/shared/world/Environment.ts @@ -36,6 +36,26 @@ const _sunDirection = new THREE.Vector3(0, -1, 0); // - More cascades = better near/far resolution but more draw calls // - CSMShadowNode handles texel snapping internally - don't add extra snapping // +// Set ENABLE_CSM=true to use cascaded shadow maps (heavy GPU cost). +// Default: false — uses a single 2048 shadow map centered on the player. +export function isCsmEnabled(): boolean { + try { + if ( + typeof import.meta !== "undefined" && + (import.meta as any).env?.ENABLE_CSM + ) + return (import.meta as any).env.ENABLE_CSM === "true"; + if (typeof process !== "undefined" && process.env?.ENABLE_CSM) + return process.env.ENABLE_CSM === "true"; + } catch { + /* ignore */ + } + return false; +} + +const SINGLE_SHADOW_MAP_SIZE = 2048; +const SINGLE_SHADOW_FRUSTUM = 80; + // IMPORTANT: Vegetation fade distances should be <= maxFar so trees don't appear unshadowed export const csmLevels = { none: { @@ -929,10 +949,9 @@ export class Environment extends System { } /** - * Build directional light (sun/moon) with CSMShadowNode for cascaded shadows - * CSMShadowNode handles cascade splitting internally - we just configure it - * - * Note: WebGPU is required. CSM shadows only work with WebGPU's TSL pipeline. + * Build directional light (sun/moon) with optional CSMShadowNode. + * When ENABLE_CSM=true: uses cascaded shadow maps (multiple passes, heavy). + * When ENABLE_CSM=false (default): uses a single shadow map centered on the player. */ buildSunLight(): void { if (!this.isClientWithGraphics) return; @@ -941,6 +960,7 @@ export class Environment extends System { const shadowsLevel = this.world.prefs?.shadows || "med"; const csmConfig = csmLevels[shadowsLevel as keyof typeof csmLevels] || csmLevels.med; + const useCSM = isCsmEnabled() && csmConfig.enabled; if (!this.world.stage?.scene) { console.warn( @@ -972,74 +992,90 @@ export class Environment extends System { return; } - // Create directional light for CSM + // Create directional light this.sunLight = new THREE.DirectionalLight(0xffffff, 1.8); - this.sunLight.name = useWebGPU ? "SunLight_CSM" : "SunLight_WebGL"; this.sunLight.castShadow = true; - // Shadow map settings (CSMShadowNode will use this as base resolution per cascade) - this.sunLight.shadow.mapSize.width = csmConfig.shadowMapSize; - this.sunLight.shadow.mapSize.height = csmConfig.shadowMapSize; - this.sunLight.shadow.bias = csmConfig.shadowBias; - this.sunLight.shadow.normalBias = csmConfig.shadowNormalBias; - - // Shadow camera settings - CSMShadowNode overrides these per-cascade - const shadowCam = this.sunLight.shadow.camera; - shadowCam.near = 0.5; - shadowCam.far = this.LIGHT_DISTANCE + 200; // Light distance + scene depth - // Base frustum - CSMShadowNode will manage actual cascade frustums - const baseFrustumSize = 100; - shadowCam.left = -baseFrustumSize; - shadowCam.right = baseFrustumSize; - shadowCam.top = baseFrustumSize; - shadowCam.bottom = -baseFrustumSize; - shadowCam.updateProjectionMatrix(); - - // Initial position - this.sunLight.position.set(100, 200, 100); - this.sunLight.target.position.set(0, 0, 0); - - // Create CSMShadowNode for cascaded shadows - // Light direction is derived from sunLight.position and sunLight.target.position - // - // Custom split callback biases toward logarithmic for sharper near shadows: - // - Higher lambda (0.7-0.9) = more logarithmic = smaller near cascade = sharper near shadows - // - Lower lambda (0.3-0.5) = more uniform = larger near cascade = blurrier near shadows - const customSplitCallback = ( - cascades: number, - near: number, - far: number, - breaks: number[], - ) => { - const lambda = 0.8; // Bias toward logarithmic for better near-cascade resolution - for (let i = 1; i < cascades; i++) { - const log = (near * Math.pow(far / near, i / cascades)) / far; - const uniform = (near + ((far - near) * i) / cascades) / far; - breaks.push(lambda * log + (1 - lambda) * uniform); - } - breaks.push(1); - }; + if (useCSM) { + // ---- CSM PATH ---- + this.sunLight.name = useWebGPU ? "SunLight_CSM" : "SunLight_WebGL"; + this.sunLight.shadow.mapSize.width = csmConfig.shadowMapSize; + this.sunLight.shadow.mapSize.height = csmConfig.shadowMapSize; + this.sunLight.shadow.bias = csmConfig.shadowBias; + this.sunLight.shadow.normalBias = csmConfig.shadowNormalBias; + + const shadowCam = this.sunLight.shadow.camera; + shadowCam.near = 0.5; + shadowCam.far = this.LIGHT_DISTANCE + 200; + const baseFrustumSize = 100; + shadowCam.left = -baseFrustumSize; + shadowCam.right = baseFrustumSize; + shadowCam.top = baseFrustumSize; + shadowCam.bottom = -baseFrustumSize; + shadowCam.updateProjectionMatrix(); + + this.sunLight.position.set(100, 200, 100); + this.sunLight.target.position.set(0, 0, 0); + + const customSplitCallback = ( + cascades: number, + near: number, + far: number, + breaks: number[], + ) => { + const lambda = 0.8; + for (let i = 1; i < cascades; i++) { + const log = (near * Math.pow(far / near, i / cascades)) / far; + const uniform = (near + ((far - near) * i) / cascades) / far; + breaks.push(lambda * log + (1 - lambda) * uniform); + } + breaks.push(1); + }; - this.csmShadowNode = new CSMShadowNode(this.sunLight, { - cascades: csmConfig.cascades, - maxFar: csmConfig.maxFar, - mode: "custom", - customSplitsCallback: customSplitCallback, - lightMargin: csmConfig.lightMargin, // Prevents shadow "swimming" artifacts - }); - // Avoid pre-assigning camera so CSMShadowNode can initialize internally. + this.csmShadowNode = new CSMShadowNode(this.sunLight, { + cascades: csmConfig.cascades, + maxFar: csmConfig.maxFar, + mode: "custom", + customSplitsCallback: customSplitCallback, + lightMargin: csmConfig.lightMargin, + }); + this.csmShadowNode.fade = true; + + const shadow = this.sunLight.shadow as THREE.DirectionalLightShadow & { + shadowNode?: InstanceType; + }; + shadow.shadowNode = this.csmShadowNode; + this.needsFrustumUpdate = true; - // Enable smooth cascade transitions (prevents hard seams between cascades) - this.csmShadowNode.fade = true; + console.log( + `[Environment] CSM shadows enabled (${csmConfig.cascades} cascades, ${csmConfig.shadowMapSize}px)`, + ); + } else { + // ---- SINGLE SHADOW MAP PATH (default) ---- + this.sunLight.name = "SunLight_Single"; + this.sunLight.shadow.mapSize.width = SINGLE_SHADOW_MAP_SIZE; + this.sunLight.shadow.mapSize.height = SINGLE_SHADOW_MAP_SIZE; + this.sunLight.shadow.bias = 0.0002; + this.sunLight.shadow.normalBias = 0.01; + + const shadowCam = this.sunLight.shadow.camera; + shadowCam.near = 0.5; + shadowCam.far = this.LIGHT_DISTANCE + 200; + shadowCam.left = -SINGLE_SHADOW_FRUSTUM; + shadowCam.right = SINGLE_SHADOW_FRUSTUM; + shadowCam.top = SINGLE_SHADOW_FRUSTUM; + shadowCam.bottom = -SINGLE_SHADOW_FRUSTUM; + shadowCam.updateProjectionMatrix(); + + this.sunLight.position.set(100, 200, 100); + this.sunLight.target.position.set(0, 0, 0); + this.csmShadowNode = null; - const shadow = this.sunLight.shadow as THREE.DirectionalLightShadow & { - shadowNode?: InstanceType; - }; - shadow.shadowNode = this.csmShadowNode; + console.log( + `[Environment] Single shadow map (${SINGLE_SHADOW_MAP_SIZE}px, ${SINGLE_SHADOW_FRUSTUM}m frustum)`, + ); + } - // Defer frustum initialization until after the first render when the camera is ready. - this.needsFrustumUpdate = true; - // Add light to scene scene.add(this.sunLight); scene.add(this.sunLight.target); } diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts index d50c6fa9e..7b2688a7e 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -107,6 +107,7 @@ import { TerrainUniforms, } from "./TerrainShader"; import { isLamppostLightTextureReady } from "./LamppostLightMask"; +import { isCsmEnabled } from "./Environment"; import type { RoadNetworkSystem } from "./RoadNetworkSystem"; import type { TownSystem } from "./TownSystem"; import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants"; @@ -1013,7 +1014,7 @@ export class TerrainSystem extends System { tileZ * this.CONFIG.TILE_SIZE, ); mesh.name = `Terrain_${key}`; - mesh.receiveShadow = this.CONFIG.TERRAIN_RECEIVE_SHADOW; + mesh.receiveShadow = this.CONFIG.TERRAIN_RECEIVE_SHADOW && !isCsmEnabled(); mesh.castShadow = this.CONFIG.TERRAIN_CAST_SHADOW; mesh.frustumCulled = true; mesh.userData = { @@ -1468,7 +1469,10 @@ export class TerrainSystem extends System { QUADTREE_MAX_ASSEMBLIES_PER_FRAME: 6, // Shadow settings for terrain meshes (applies to both flat-grid and quad-tree) - TERRAIN_RECEIVE_SHADOW: false, + // NOTE: receiveShadow only works with single shadow map (ENABLE_CSM=false). + // CSM adds too many shadow textures per cascade, exceeding WebGPU's 16-texture + // limit (terrain already uses 13 textures for biomes + noise + fog + masks). + TERRAIN_RECEIVE_SHADOW: true, TERRAIN_CAST_SHADOW: false, }; @@ -1938,7 +1942,7 @@ export class TerrainSystem extends System { biomeCenters, workerBiomes, this.CONFIG.QUADTREE_DEBUG_WIREFRAME, - this.CONFIG.TERRAIN_RECEIVE_SHADOW, + this.CONFIG.TERRAIN_RECEIVE_SHADOW && !isCsmEnabled(), this.CONFIG.TERRAIN_CAST_SHADOW, this.CONFIG.QUADTREE_MAX_SYNC_PER_FRAME, this.CONFIG.QUADTREE_MAX_ASSEMBLIES_PER_FRAME, @@ -2293,7 +2297,7 @@ export class TerrainSystem extends System { ); mesh.name = `Terrain_${key}`; - mesh.receiveShadow = this.CONFIG.TERRAIN_RECEIVE_SHADOW; + mesh.receiveShadow = this.CONFIG.TERRAIN_RECEIVE_SHADOW && !isCsmEnabled(); mesh.castShadow = this.CONFIG.TERRAIN_CAST_SHADOW; // Enable frustum culling (Three.js built-in) From bfbd9eeb4f2a179357f87a2e06f30fb1ac2950fc Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 10 Mar 2026 04:05:11 +0800 Subject: [PATCH 30/48] fix(trees): add sky-color fog to tree shader The tree material's outputNode was overriding the base dissolve material's fog without re-adding it, so trees never faded into the sky fog at distance. Made-with: Cursor --- .../src/systems/shared/world/GPUMaterials.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts index b98ad8190..2e8a4974d 100644 --- a/packages/shared/src/systems/shared/world/GPUMaterials.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -1053,6 +1053,17 @@ export function createTreeDissolveMaterial( })(); } + // --- Sky-color fog (same as terrain/vegetation) --- + const treeFogTex = texture(fogRenderTarget.texture, screenUV); + const treeToCam = sub(cameraPosition, positionWorld); + const treeFogDistSq = dot(treeToCam, treeToCam); + const treeFogFactor = smoothstep( + float(FOG_NEAR_SQ), + float(FOG_FAR_SQ), + treeFogDistSq, + ); + material.fog = false; + // --- Output: soft clamped lighting (bypass PBR, compute Lambert from scratch) --- const albedoMap = material.map; const matColor = vec3(material.color.r, material.color.g, material.color.b); @@ -1144,7 +1155,10 @@ export function createTreeDissolveMaterial( const highlighted = add(brightened, rimGlow); const finalRgb = mix(boosted, highlighted, hlIntensity); - return vec4(finalRgb, pbrOut.a); + // ---- Sky-color fog ---- + const fogged = mix(finalRgb, treeFogTex.rgb, treeFogFactor); + + return vec4(fogged, pbrOut.a); })(); material.needsUpdate = true; From 54a054644774db1a54f28423dd60e2aa83c6eea5 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 10 Mar 2026 14:22:53 +0800 Subject: [PATCH 31/48] fix(terrain): relax slope walkability to match terrain generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SLOPE_CHECK_DISTANCE 1.0 → 4.0 and MAX_WALKABLE_SLOPE 0.7 → 1.5 (~56°) so terraced cliffs and landscape features no longer block all movement. Made-with: Cursor --- packages/shared/src/constants/GameConstants.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/constants/GameConstants.ts b/packages/shared/src/constants/GameConstants.ts index 938989373..bd67fe533 100644 --- a/packages/shared/src/constants/GameConstants.ts +++ b/packages/shared/src/constants/GameConstants.ts @@ -88,14 +88,16 @@ export const TERRAIN_CONSTANTS = { /** * Maximum slope for walkable terrain (tan of angle). * Slopes steeper than this block movement. - * 0.7 ≈ 35 degree angle + * 1.5 ≈ 56 degree angle. */ - MAX_WALKABLE_SLOPE: 0.7, + MAX_WALKABLE_SLOPE: 1.5, /** * Distance to sample for slope calculation (in meters). + * Larger values average slope over a wider area, preventing + * terraced cliffs and landscape features from blocking movement. */ - SLOPE_CHECK_DISTANCE: 1.0, + SLOPE_CHECK_DISTANCE: 4.0, /** * Tile size in meters (1 tile = 1 meter for movement grid). From fe02ab35fa84e0e8f7b1848ff8b31f0df911d72a Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 10 Mar 2026 15:19:17 +0800 Subject: [PATCH 32/48] refactor(biomes): use BiomeType enum as single source of truth - Replace all hardcoded "forest"/"canyon"/"tundra" string literals with BiomeType enum from TerrainBiomeTypes.ts - Replace ?? "forest" fallbacks with ?? DEFAULT_BIOME - Replace "tundra" | "forest" | "canyon" type unions with BiomeType - Re-export BiomeType/DEFAULT_BIOME/BIOME_LIST from GameConstants - Revert 11 prettier-only formatting changes from merge commit Files updated: GameConstants, TownSystem, POISystem, TerrainSystem, world-types.ts, terrain.ts, and 5 test files. Made-with: Cursor --- .../shared/src/constants/GameConstants.ts | 15 +- .../src/systems/shared/world/POISystem.ts | 23 +-- .../src/systems/shared/world/TerrainSystem.ts | 55 +++--- .../src/systems/shared/world/TownSystem.ts | 7 +- .../__tests__/BiomeConfigLoading.test.ts | 7 +- .../__tests__/ProcgenRocksPlants.test.ts | 157 +++++++++--------- .../world/__tests__/RoadNetworkSystem.test.ts | 9 +- .../__tests__/TownRoadIntegration.test.ts | 7 +- .../shared/world/__tests__/TownSystem.test.ts | 3 +- packages/shared/src/types/world/terrain.ts | 5 +- .../shared/src/types/world/world-types.ts | 3 +- 11 files changed, 150 insertions(+), 141 deletions(-) diff --git a/packages/shared/src/constants/GameConstants.ts b/packages/shared/src/constants/GameConstants.ts index bd67fe533..96d7e87b0 100644 --- a/packages/shared/src/constants/GameConstants.ts +++ b/packages/shared/src/constants/GameConstants.ts @@ -7,6 +7,12 @@ // Import COMBAT_CONSTANTS from dedicated file import { COMBAT_CONSTANTS } from "./CombatConstants"; +// Re-export biome enum as the single source of truth +export { + BiomeType, + DEFAULT_BIOME, + BIOME_LIST, +} from "../systems/shared/world/TerrainBiomeTypes"; // Import banking constants - single source of truth for MAX_BANK_SLOTS import { BANKING_CONSTANTS } from "./BankingConstants"; @@ -383,10 +389,13 @@ export const ITEM_ID_TO_KEY: Record = { export const MOB_TYPES = {} as const; // === BIOME TYPES === +// Deprecated: use BiomeType enum (re-exported above) instead. +// Kept for backward compat; maps to the same string values. +import { BiomeType as _BT } from "../systems/shared/world/TerrainBiomeTypes"; export const BIOME_TYPES = { - TUNDRA: "tundra", - FOREST: "forest", - CANYON: "canyon", + TUNDRA: _BT.Tundra, + FOREST: _BT.Forest, + CANYON: _BT.Canyon, } as const; // === SKILL NAMES === diff --git a/packages/shared/src/systems/shared/world/POISystem.ts b/packages/shared/src/systems/shared/world/POISystem.ts index 62d85638c..1144c1326 100644 --- a/packages/shared/src/systems/shared/world/POISystem.ts +++ b/packages/shared/src/systems/shared/world/POISystem.ts @@ -18,6 +18,7 @@ import type { } from "../../../types/world/world-types"; import { NoiseGenerator } from "../../../utils/NoiseGenerator"; import { Logger } from "../../../utils/Logger"; +import { BiomeType, DEFAULT_BIOME } from "./TerrainBiomeTypes"; import { DataManager } from "../../../data/DataManager"; import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants"; import { dist2D } from "../../../utils/MathUtils"; @@ -50,47 +51,47 @@ const CATEGORY_PROPERTIES: Record< dungeon: { radius: 30, baseImportance: 0.9, - preferredBiomes: ["canyon", "tundra"], + preferredBiomes: [BiomeType.Canyon, BiomeType.Tundra], }, shrine: { radius: 10, baseImportance: 0.6, - preferredBiomes: ["forest", "tundra"], + preferredBiomes: [BiomeType.Forest, BiomeType.Tundra], }, landmark: { radius: 20, baseImportance: 0.5, - preferredBiomes: ["canyon", "tundra"], + preferredBiomes: [BiomeType.Canyon, BiomeType.Tundra], }, resource_area: { radius: 25, baseImportance: 0.7, - preferredBiomes: ["forest", "canyon"], + preferredBiomes: [BiomeType.Forest, BiomeType.Canyon], }, ruin: { radius: 35, baseImportance: 0.8, - preferredBiomes: ["canyon", "tundra"], + preferredBiomes: [BiomeType.Canyon, BiomeType.Tundra], }, camp: { radius: 20, baseImportance: 0.4, - preferredBiomes: ["forest", "tundra"], + preferredBiomes: [BiomeType.Forest, BiomeType.Tundra], }, crossing: { radius: 15, baseImportance: 0.85, - preferredBiomes: ["canyon", "tundra"], + preferredBiomes: [BiomeType.Canyon, BiomeType.Tundra], }, waystation: { radius: 12, baseImportance: 0.3, - preferredBiomes: ["forest", "tundra"], + preferredBiomes: [BiomeType.Forest, BiomeType.Tundra], }, fishing_spot: { radius: 15, baseImportance: 0.75, - preferredBiomes: ["forest", "tundra"], + preferredBiomes: [BiomeType.Forest, BiomeType.Tundra], }, }; @@ -310,7 +311,7 @@ export class POISystem extends System { // Get terrain info const y = this.terrainSystem!.getHeightAt(x, z); const biome = - this.terrainSystem?.getBiomeAtWorldPosition?.(x, z) ?? "forest"; + this.terrainSystem?.getBiomeAtWorldPosition?.(x, z) ?? DEFAULT_BIOME; // Skip underwater const waterThreshold = 5.4; @@ -438,7 +439,7 @@ export class POISystem extends System { // Get terrain info at the water edge (on land side) const y = this.terrainSystem!.getHeightAt(x, z); const biome = - this.terrainSystem?.getBiomeAtWorldPosition?.(x, z) ?? "forest"; + this.terrainSystem?.getBiomeAtWorldPosition?.(x, z) ?? DEFAULT_BIOME; // Calculate importance - fishing spots are important destinations let importance = properties.baseImportance; diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts index f911ba7ca..ff1fce3af 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -68,7 +68,6 @@ import type { RoadTileSegment } from "../../../types/world/world-types"; import { PhysicsHandle } from "../../../types/systems/physics"; import { getPhysX } from "../../../physics/PhysXManager"; import { Layers } from "../../../physics/Layers"; -import { isStreamingLikeViewport } from "../../../runtime/clientViewportMode"; import { BIOMES } from "../../../data/world-structure"; import { ALL_WORLD_AREAS } from "../../../data/world-areas"; import { getDuelArenaConfig } from "../../../data/duel-manifest"; @@ -2183,20 +2182,10 @@ export class TerrainSystem extends System { const players = this.world.getPlayers() || []; const centers: Array<{ id: string; position: THREE.Vector3 }> = []; for (const player of players) { - // Support both node.position (client) and direct position property (server) - const pos = player?.node?.position ?? player?.position; - if (!pos) { - // MEMORY DEBUG: Log players without position - if (Math.random() < 0.01) { - console.warn( - `[TerrainSystem] Player ${player?.id} missing position (node: ${!!player?.node}, position: ${!!player?.position})`, - ); - } - continue; - } + if (!player?.node?.position) continue; centers.push({ id: player.id || "player", - position: pos, + position: player.node.position, }); } @@ -4674,16 +4663,11 @@ export class TerrainSystem extends System { ); } - private mapBiomeToGeneric(internal: string): "tundra" | "forest" | "canyon" { - switch (internal) { - case BiomeType.Forest: - return "forest"; - case BiomeType.Canyon: - return "canyon"; - case BiomeType.Tundra: - default: - return "tundra"; + private mapBiomeToGeneric(internal: string): BiomeType { + if (Object.values(BiomeType).includes(internal as BiomeType)) { + return internal as BiomeType; } + return DEFAULT_BIOME; } private generateTileResources(tile: TerrainTile): void { @@ -6583,13 +6567,23 @@ export class TerrainSystem extends System { * Initialize chunk loading system with 9 core + ring strategy */ private initializeChunkLoadingSystem(): void { - const isStreamingViewport = isStreamingLikeViewport(); + const isEmbeddedSpectator = (() => { + if (typeof window === "undefined") return false; + const win = window as Window & { + __HYPERSCAPE_EMBEDDED__?: boolean; + __HYPERSCAPE_CONFIG__?: { mode?: string }; + }; + return ( + win.__HYPERSCAPE_EMBEDDED__ === true && + win.__HYPERSCAPE_CONFIG__?.mode === "spectator" + ); + })(); // world.isServer can be false during early bootstrap (before network mode // is finalized). Resolve runtime role explicitly so server startup always // uses tight headless chunk ranges. const { isServer: isServerRuntime } = this.resolveRuntimeRole(); - // Stream and spectator viewers prioritize first-frame time over long-range preload. + // 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. @@ -6598,10 +6592,10 @@ export class TerrainSystem extends System { this.terrainOnlyChunkRange = 0; // Never load render-only distant tiles this.maxTilesPerFrame = 2; this.generationBudgetMsPerFrame = 4; - } else if (isStreamingViewport) { + } else if (isEmbeddedSpectator) { this.coreChunkRange = 1; // 3x3 core grid this.ringChunkRange = 2; // Preload ring up to 5x5 - this.terrainOnlyChunkRange = 2; // Avoid far-horizon churn in stream/spectator view + this.terrainOnlyChunkRange = 2; // Avoid far-horizon churn in embedded stream view this.maxTilesPerFrame = 4; // Catch up faster after camera retargets this.generationBudgetMsPerFrame = 10; } else { @@ -6784,7 +6778,6 @@ export class TerrainSystem extends System { // Remove tiles that are no longer needed, with hysteresis padding // Approximate each player's center from their core chunk set - let unloadedCount = 0; for (const [tileKey, tile] of this.terrainTiles) { if (!neededTiles.has(tileKey)) { let minChebyshev = Infinity; @@ -6794,21 +6787,17 @@ export class TerrainSystem extends System { } if (minChebyshev > this.terrainOnlyChunkRange + this.unloadPadding) { this.unloadTile(tile); - unloadedCount++; } } } - // Log simulation status every 10 updates (~10 seconds) + // Log simulation status every 10 updates if (Math.random() < 0.1) { const _totalPlayers = centers.length; const _simulatedChunkCount = this.simulatedChunks.size; const _loadedChunkCount = this.terrainTiles.size; - // MEMORY DEBUG: Log terrain tile stats - console.log( - `[TerrainSystem] Tiles: ${_loadedChunkCount} loaded, ${unloadedCount} unloaded this cycle | Players: ${_totalPlayers} | PlayerCenters: ${playerCenters.length} | Needed: ${neededTiles.size}`, - ); + // Simulation status tracked for debugging // Log shared world status const sharedChunks = Array.from(this.chunkPlayerCounts.entries()) diff --git a/packages/shared/src/systems/shared/world/TownSystem.ts b/packages/shared/src/systems/shared/world/TownSystem.ts index e2846b869..e68452906 100644 --- a/packages/shared/src/systems/shared/world/TownSystem.ts +++ b/packages/shared/src/systems/shared/world/TownSystem.ts @@ -69,6 +69,7 @@ import { } from "../../../types/world/building-collision-types"; import { BFSPathfinder } from "../movement/BFSPathfinder"; import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants"; +import { DEFAULT_BIOME } from "./TerrainBiomeTypes"; // Default configuration values // IMPORTANT: waterThreshold must match TERRAIN_CONSTANTS.WATER_THRESHOLD (9.0) @@ -315,7 +316,9 @@ export class TownSystem extends System { return this.terrainSystem?.getHeightAt(x, z) ?? 10; }, getBiomeAt: (x: number, z: number): string => { - return this.terrainSystem?.getBiomeAtWorldPosition?.(x, z) ?? "forest"; + return ( + this.terrainSystem?.getBiomeAtWorldPosition?.(x, z) ?? DEFAULT_BIOME + ); }, getWaterThreshold: (): number => { return this.config.waterThreshold; @@ -489,7 +492,7 @@ export class TownSystem extends System { this.terrainSystem?.getBiomeAtWorldPosition?.( manifest.position.x, manifest.position.z, - ) ?? "forest"; + ) ?? DEFAULT_BIOME; // Convert buildings - positions are relative in manifest, convert to world coords // Also calculate entrance positions for each building diff --git a/packages/shared/src/systems/shared/world/__tests__/BiomeConfigLoading.test.ts b/packages/shared/src/systems/shared/world/__tests__/BiomeConfigLoading.test.ts index 87c97b278..3c98d2a09 100644 --- a/packages/shared/src/systems/shared/world/__tests__/BiomeConfigLoading.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/BiomeConfigLoading.test.ts @@ -12,6 +12,7 @@ import fs from "fs"; import path from "path"; import { BIOMES } from "../../../../data/world-structure"; import type { BiomeData } from "../../../../types/core/core"; +import { BiomeType } from "../TerrainBiomeTypes"; /** * Get path to local biomes manifest for tests @@ -46,7 +47,7 @@ describe("Biome Configuration Loading", () => { id: "tundra", name: "Tundra", difficultyLevel: 0, - terrain: "tundra" as any, + terrain: BiomeType.Tundra as any, vegetation: { enabled: true, layers: [ @@ -61,7 +62,7 @@ describe("Biome Configuration Loading", () => { id: "forest", name: "Forest", difficultyLevel: 1, - terrain: "forest" as any, + terrain: BiomeType.Forest as any, vegetation: { enabled: true, layers: [ @@ -76,7 +77,7 @@ describe("Biome Configuration Loading", () => { id: "canyon", name: "Canyon", difficultyLevel: 2, - terrain: "canyon" as any, + terrain: BiomeType.Canyon as any, colorScheme: {} as any, }, ]; diff --git a/packages/shared/src/systems/shared/world/__tests__/ProcgenRocksPlants.test.ts b/packages/shared/src/systems/shared/world/__tests__/ProcgenRocksPlants.test.ts index 6d4a0bd1b..06107e512 100644 --- a/packages/shared/src/systems/shared/world/__tests__/ProcgenRocksPlants.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/ProcgenRocksPlants.test.ts @@ -20,6 +20,7 @@ import type { BiomeRockConfig, BiomePlantConfig, } from "../../../../types/world/world-types"; +import { BiomeType, BIOME_LIST } from "../TerrainBiomeTypes"; /** * Deterministic PRNG - creates seeded random for reproducible tests @@ -69,7 +70,7 @@ function createTestContext( describe("Rock Generation Algorithms", () => { describe("ROCK_BIOME_DEFAULTS", () => { it("has presets defined for all major biome types", () => { - const expectedBiomes = ["tundra", "forest", "canyon"]; + const expectedBiomes = BIOME_LIST; for (const biome of expectedBiomes) { expect(ROCK_BIOME_DEFAULTS[biome]).toBeDefined(); @@ -100,10 +101,10 @@ describe("Rock Generation Algorithms", () => { describe("getRockPresetsForBiome", () => { it("returns correct presets for known biomes", () => { - const forestPresets = getRockPresetsForBiome("forest"); + const forestPresets = getRockPresetsForBiome(BiomeType.Forest); expect(forestPresets.presets).toContain("boulder"); - const canyonPresets = getRockPresetsForBiome("canyon"); + const canyonPresets = getRockPresetsForBiome(BiomeType.Canyon); expect(canyonPresets.presets).toContain("sandstone"); }); @@ -113,7 +114,7 @@ describe("Rock Generation Algorithms", () => { }); it("is case-insensitive", () => { - const lower = getRockPresetsForBiome("forest"); + const lower = getRockPresetsForBiome(BiomeType.Forest); const upper = getRockPresetsForBiome("FOREST"); expect(lower.presets).toEqual(upper.presets); }); @@ -136,29 +137,29 @@ describe("Rock Generation Algorithms", () => { }); it("generates rocks when enabled", () => { - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); }); it("returns empty array when disabled", () => { rockConfig.enabled = false; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBe(0); }); it("respects density setting", () => { rockConfig.density = 5; - const rocksLow = generateRocks(ctx, rockConfig, "forest"); + const rocksLow = generateRocks(ctx, rockConfig, BiomeType.Forest); rockConfig.density = 20; - const rocksHigh = generateRocks(ctx, rockConfig, "forest"); + const rocksHigh = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocksHigh.length).toBeGreaterThan(rocksLow.length); }); it("generates deterministic results for same tile", () => { - const rocks1 = generateRocks(ctx, rockConfig, "forest"); - const rocks2 = generateRocks(ctx, rockConfig, "forest"); + const rocks1 = generateRocks(ctx, rockConfig, BiomeType.Forest); + const rocks2 = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks1.length).toBe(rocks2.length); @@ -172,8 +173,8 @@ describe("Rock Generation Algorithms", () => { it("generates different results for different tiles", () => { const ctx2 = createTestContext(1, 0); - const rocks1 = generateRocks(ctx, rockConfig, "forest"); - const rocks2 = generateRocks(ctx2, rockConfig, "forest"); + const rocks1 = generateRocks(ctx, rockConfig, BiomeType.Forest); + const rocks2 = generateRocks(ctx2, rockConfig, BiomeType.Forest); // Positions should differ if (rocks1.length > 0 && rocks2.length > 0) { @@ -183,7 +184,7 @@ describe("Rock Generation Algorithms", () => { it("uses presets from config", () => { rockConfig.presets = ["granite"]; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); for (const rock of rocks) { expect(rock.assetId).toBe("granite"); @@ -192,7 +193,7 @@ describe("Rock Generation Algorithms", () => { it("falls back to biome presets when config presets is empty", () => { rockConfig.presets = []; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); // Should use forest biome presets for (const rock of rocks) { @@ -202,7 +203,7 @@ describe("Rock Generation Algorithms", () => { it("applies scale within range", () => { rockConfig.scaleRange = [0.2, 0.8]; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); for (const rock of rocks) { expect(rock.scale).toBeGreaterThanOrEqual(0.2); @@ -211,7 +212,7 @@ describe("Rock Generation Algorithms", () => { }); it("sets category to rock", () => { - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); for (const rock of rocks) { expect(rock.category).toBe("rock"); @@ -219,7 +220,7 @@ describe("Rock Generation Algorithms", () => { }); it("sets tileKey correctly", () => { - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); for (const rock of rocks) { expect(rock.tileKey).toBe("0_0"); @@ -232,7 +233,7 @@ describe("Rock Generation Algorithms", () => { getHeightAt: (x, _z) => (x < 50 ? 5 : 15), // Left half underwater }); - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); // All rocks should be on the right side (above water) for (const rock of rocks) { @@ -245,7 +246,7 @@ describe("Rock Generation Algorithms", () => { isOnRoad: (x, _z) => x > 40 && x < 60, // Road in middle }); - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); // No rocks should be on the road for (const rock of rocks) { @@ -257,7 +258,7 @@ describe("Rock Generation Algorithms", () => { it("respects minimum spacing", () => { rockConfig.minSpacing = 10; rockConfig.density = 50; // High density to test spacing - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); // Check spacing between all pairs for (let i = 0; i < rocks.length; i++) { @@ -275,7 +276,7 @@ describe("Rock Generation Algorithms", () => { describe("Plant Generation Algorithms", () => { describe("PLANT_BIOME_DEFAULTS", () => { it("has presets defined for all major biome types", () => { - const expectedBiomes = ["tundra", "forest", "canyon"]; + const expectedBiomes = BIOME_LIST; for (const biome of expectedBiomes) { expect(PLANT_BIOME_DEFAULTS[biome]).toBeDefined(); @@ -298,10 +299,10 @@ describe("Plant Generation Algorithms", () => { describe("getPlantPresetsForBiome", () => { it("returns correct presets for known biomes", () => { - const forestPresets = getPlantPresetsForBiome("forest"); + const forestPresets = getPlantPresetsForBiome(BiomeType.Forest); expect(forestPresets.presets).toContain("monstera"); - const canyonPresets = getPlantPresetsForBiome("canyon"); + const canyonPresets = getPlantPresetsForBiome(BiomeType.Canyon); expect(canyonPresets.presets).toContain("zamioculcas"); }); @@ -329,29 +330,29 @@ describe("Plant Generation Algorithms", () => { }); it("generates plants when enabled", () => { - const plants = generatePlants(ctx, plantConfig, "forest"); + const plants = generatePlants(ctx, plantConfig, BiomeType.Forest); expect(plants.length).toBeGreaterThan(0); }); it("returns empty array when disabled", () => { plantConfig.enabled = false; - const plants = generatePlants(ctx, plantConfig, "forest"); + const plants = generatePlants(ctx, plantConfig, BiomeType.Forest); expect(plants.length).toBe(0); }); it("respects density setting", () => { plantConfig.density = 5; - const plantsLow = generatePlants(ctx, plantConfig, "forest"); + const plantsLow = generatePlants(ctx, plantConfig, BiomeType.Forest); plantConfig.density = 30; - const plantsHigh = generatePlants(ctx, plantConfig, "forest"); + const plantsHigh = generatePlants(ctx, plantConfig, BiomeType.Forest); expect(plantsHigh.length).toBeGreaterThan(plantsLow.length); }); it("generates deterministic results for same tile", () => { - const plants1 = generatePlants(ctx, plantConfig, "forest"); - const plants2 = generatePlants(ctx, plantConfig, "forest"); + const plants1 = generatePlants(ctx, plantConfig, BiomeType.Forest); + const plants2 = generatePlants(ctx, plantConfig, BiomeType.Forest); expect(plants1.length).toBe(plants2.length); @@ -363,7 +364,7 @@ describe("Plant Generation Algorithms", () => { it("uses presets from config", () => { plantConfig.presets = ["calathea"]; - const plants = generatePlants(ctx, plantConfig, "forest"); + const plants = generatePlants(ctx, plantConfig, BiomeType.Forest); for (const plant of plants) { expect(plant.assetId).toBe("calathea"); @@ -372,7 +373,7 @@ describe("Plant Generation Algorithms", () => { it("applies scale within range", () => { plantConfig.scaleRange = [0.3, 0.6]; - const plants = generatePlants(ctx, plantConfig, "forest"); + const plants = generatePlants(ctx, plantConfig, BiomeType.Forest); for (const plant of plants) { expect(plant.scale).toBeGreaterThanOrEqual(0.3); @@ -381,7 +382,7 @@ describe("Plant Generation Algorithms", () => { }); it("sets category to plant", () => { - const plants = generatePlants(ctx, plantConfig, "forest"); + const plants = generatePlants(ctx, plantConfig, BiomeType.Forest); for (const plant of plants) { expect(plant.category).toBe("plant"); @@ -394,7 +395,7 @@ describe("Plant Generation Algorithms", () => { getHeightAt: (x, _z) => (x < 50 ? 5 : 15), }); - const plants = generatePlants(ctx, plantConfig, "forest"); + const plants = generatePlants(ctx, plantConfig, BiomeType.Forest); for (const plant of plants) { expect(plant.position.x).toBeGreaterThanOrEqual(50); @@ -405,7 +406,7 @@ describe("Plant Generation Algorithms", () => { plantConfig.minSpacing = 5; plantConfig.density = 50; plantConfig.clustering = false; - const plants = generatePlants(ctx, plantConfig, "forest"); + const plants = generatePlants(ctx, plantConfig, BiomeType.Forest); for (let i = 0; i < plants.length; i++) { for (let j = i + 1; j < plants.length; j++) { @@ -440,8 +441,8 @@ describe("Biome Integration", () => { minSpacing: 1.5, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); - const plants = generatePlants(ctx, plantConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); + const plants = generatePlants(ctx, plantConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); expect(plants.length).toBeGreaterThan(0); @@ -465,8 +466,8 @@ describe("Biome Integration", () => { minSpacing: 2, }; - const forestRocks = generateRocks(forestCtx, rockConfig, "forest"); - const canyonRocks = generateRocks(canyonCtx, rockConfig, "canyon"); + const forestRocks = generateRocks(forestCtx, rockConfig, BiomeType.Forest); + const canyonRocks = generateRocks(canyonCtx, rockConfig, BiomeType.Canyon); // Should use different rock types const forestTypes = new Set(forestRocks.map((r) => r.assetId)); @@ -491,7 +492,7 @@ describe("ID Generation", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); const ids = rocks.map((r) => r.id); const uniqueIds = new Set(ids); @@ -510,7 +511,7 @@ describe("ID Generation", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); for (const rock of rocks) { expect(rock.id).toContain("3_7"); @@ -535,7 +536,7 @@ describe("Boundary Conditions - Rocks", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBe(0); }); @@ -550,7 +551,7 @@ describe("Boundary Conditions - Rocks", () => { minSpacing: 10, // Large spacing will limit actual count }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); // Spacing should limit the count significantly // 50x50 tile with 10m spacing can fit ~25 rocks maximum expect(rocks.length).toBeLessThan(30); @@ -568,7 +569,7 @@ describe("Boundary Conditions - Rocks", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBe(0); }); }); @@ -585,7 +586,7 @@ describe("Boundary Conditions - Rocks", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); // Position should be in negative world space @@ -607,7 +608,7 @@ describe("Boundary Conditions - Rocks", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); // Positions should be in far positive world space @@ -630,7 +631,7 @@ describe("Boundary Conditions - Rocks", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); for (const rock of rocks) { @@ -649,7 +650,7 @@ describe("Boundary Conditions - Rocks", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); for (const rock of rocks) { @@ -669,7 +670,7 @@ describe("Boundary Conditions - Rocks", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); for (const rock of rocks) { @@ -691,7 +692,7 @@ describe("Boundary Conditions - Rocks", () => { minSpacing: 0, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); // With zero spacing, rocks can be placed anywhere }); @@ -707,7 +708,7 @@ describe("Boundary Conditions - Rocks", () => { minSpacing: 100, // Larger than tile }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); // Should only be able to place 1 rock max expect(rocks.length).toBeLessThanOrEqual(1); }); @@ -725,7 +726,7 @@ describe("Boundary Conditions - Rocks", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); // Rocks should be more evenly distributed }); @@ -742,7 +743,7 @@ describe("Boundary Conditions - Rocks", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); }); }); @@ -760,7 +761,7 @@ describe("Boundary Conditions - Plants", () => { minSpacing: 1.5, }; - const plants = generatePlants(ctx, plantConfig, "forest"); + const plants = generatePlants(ctx, plantConfig, BiomeType.Forest); expect(plants.length).toBe(0); }); }); @@ -777,7 +778,7 @@ describe("Boundary Conditions - Plants", () => { clustering: false, }; - const plants = generatePlants(ctx, plantConfig, "forest"); + const plants = generatePlants(ctx, plantConfig, BiomeType.Forest); expect(plants.length).toBeGreaterThan(0); }); @@ -793,7 +794,7 @@ describe("Boundary Conditions - Plants", () => { clusterSize: [5, 10], }; - const plants = generatePlants(ctx, plantConfig, "forest"); + const plants = generatePlants(ctx, plantConfig, BiomeType.Forest); expect(plants.length).toBeGreaterThan(0); }); }); @@ -838,7 +839,7 @@ describe("Error Handling - Invalid Inputs", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); for (const rock of rocks) { @@ -860,7 +861,7 @@ describe("Error Handling - Invalid Inputs", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); // All presets should appear (granite/limestone get weight 1) @@ -890,7 +891,7 @@ describe("Error Handling - Invalid Inputs", () => { minSpacing: 1, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(10); // Count each type @@ -925,7 +926,7 @@ describe("Data Verification", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); for (const rock of rocks) { @@ -947,7 +948,7 @@ describe("Data Verification", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); const minX = 5 * tileSize; const maxX = 6 * tileSize; @@ -975,7 +976,7 @@ describe("Data Verification", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); for (const rock of rocks) { @@ -997,7 +998,7 @@ describe("Data Verification", () => { minSpacing: 1.5, }; - const plants = generatePlants(ctx, plantConfig, "forest"); + const plants = generatePlants(ctx, plantConfig, BiomeType.Forest); expect(plants.length).toBeGreaterThan(0); for (const plant of plants) { @@ -1022,7 +1023,7 @@ describe("Data Verification", () => { minSpacing: 1, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(20); const boulderCount = rocks.filter((r) => r.assetId === "boulder").length; @@ -1057,7 +1058,7 @@ describe("Terrain Constraints", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBe(0); }); }); @@ -1077,7 +1078,7 @@ describe("Terrain Constraints", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBe(0); }); }); @@ -1110,7 +1111,7 @@ describe("Terrain Constraints", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); for (const rock of rocks) { const localX = rock.position.x % 100; @@ -1139,7 +1140,7 @@ describe("Terrain Constraints", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); }); }); @@ -1164,9 +1165,9 @@ describe("Concurrent Generation", () => { minSpacing: 2, }; - const rocks1 = generateRocks(ctx1, rockConfig, "forest"); - const rocks2 = generateRocks(ctx2, rockConfig, "forest"); - const rocks3 = generateRocks(ctx3, rockConfig, "forest"); + const rocks1 = generateRocks(ctx1, rockConfig, BiomeType.Forest); + const rocks2 = generateRocks(ctx2, rockConfig, BiomeType.Forest); + const rocks3 = generateRocks(ctx3, rockConfig, BiomeType.Forest); expect(rocks1.length).toBe(rocks2.length); expect(rocks2.length).toBe(rocks3.length); @@ -1198,7 +1199,7 @@ describe("Concurrent Generation", () => { }; const results = tiles.map((ctx) => - generateRocks(ctx, rockConfig, "forest"), + generateRocks(ctx, rockConfig, BiomeType.Forest), ); // Each tile should have different positions @@ -1227,7 +1228,7 @@ describe("VegetationInstance Structure", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); for (const rock of rocks) { @@ -1267,7 +1268,7 @@ describe("VegetationInstance Structure", () => { minSpacing: 1.5, }; - const plants = generatePlants(ctx, plantConfig, "forest"); + const plants = generatePlants(ctx, plantConfig, BiomeType.Forest); expect(plants.length).toBeGreaterThan(0); for (const plant of plants) { @@ -1312,7 +1313,7 @@ describe("Biome Defaults Completeness", () => { it("unknown biomes fall back to forest presets", () => { const unknownPresets = getRockPresetsForBiome("nonexistent"); - const forestPresets = getRockPresetsForBiome("forest"); + const forestPresets = getRockPresetsForBiome(BiomeType.Forest); expect(unknownPresets.presets).toEqual(forestPresets.presets); }); }); @@ -1410,7 +1411,7 @@ describe("ProcgenRockCache Exports", () => { describe("getCacheRockPresets", () => { it("returns array for known biomes", () => { - const presets = getCacheRockPresets("forest"); + const presets = getCacheRockPresets(BiomeType.Forest); expect(Array.isArray(presets)).toBe(true); expect(presets.length).toBeGreaterThan(0); }); @@ -1483,7 +1484,7 @@ describe("ProcgenPlantCache Exports", () => { describe("getCachePlantPresets", () => { it("returns array for known biomes", () => { - const presets = getCachePlantPresets("canyon"); + const presets = getCachePlantPresets(BiomeType.Canyon); expect(Array.isArray(presets)).toBe(true); expect(presets.length).toBeGreaterThan(0); }); @@ -1513,7 +1514,7 @@ describe("Performance Characteristics", () => { }; const start = performance.now(); - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); const elapsed = performance.now() - start; expect(rocks.length).toBeGreaterThan(0); @@ -1534,7 +1535,7 @@ describe("Performance Characteristics", () => { const start = performance.now(); for (let i = 0; i < 10; i++) { const ctx = createTestContext(i, i); - generateRocks(ctx, rockConfig, "forest"); + generateRocks(ctx, rockConfig, BiomeType.Forest); } const elapsed = performance.now() - start; diff --git a/packages/shared/src/systems/shared/world/__tests__/RoadNetworkSystem.test.ts b/packages/shared/src/systems/shared/world/__tests__/RoadNetworkSystem.test.ts index 9353213f1..49d2beb96 100644 --- a/packages/shared/src/systems/shared/world/__tests__/RoadNetworkSystem.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/RoadNetworkSystem.test.ts @@ -4,6 +4,7 @@ */ import { describe, it, expect } from "vitest"; +import { BiomeType, BIOME_LIST } from "../TerrainBiomeTypes"; // ============== Types for testing ============== type TileEdge = "north" | "south" | "east" | "west"; @@ -475,7 +476,7 @@ describe("RoadNetworkSystem Algorithms", () => { 10, 0, flatHeight, - () => "forest", + () => BiomeType.Forest, ); const tundraCost = calculateMovementCost( 0, @@ -483,7 +484,7 @@ describe("RoadNetworkSystem Algorithms", () => { 10, 0, flatHeight, - () => "tundra", + () => BiomeType.Tundra, ); const canyonCost = calculateMovementCost( 0, @@ -491,7 +492,7 @@ describe("RoadNetworkSystem Algorithms", () => { 10, 0, flatHeight, - () => "canyon", + () => BiomeType.Canyon, ); expect(tundraCost).toBeGreaterThan(forestCost); @@ -1868,7 +1869,7 @@ describe("RoadNetworkSystem Algorithms", () => { const biome = (x: number, z: number) => { const tx = Math.floor(x / 50); const tz = Math.floor(z / 50); - const biomes = ["tundra", "forest", "canyon"]; + const biomes = BIOME_LIST; return biomes[(tx + tz) % biomes.length]; }; diff --git a/packages/shared/src/systems/shared/world/__tests__/TownRoadIntegration.test.ts b/packages/shared/src/systems/shared/world/__tests__/TownRoadIntegration.test.ts index b59306c68..5f43a4d64 100644 --- a/packages/shared/src/systems/shared/world/__tests__/TownRoadIntegration.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/TownRoadIntegration.test.ts @@ -11,6 +11,7 @@ */ import { describe, it, expect, beforeEach } from "vitest"; +import { BiomeType } from "../TerrainBiomeTypes"; // ============== Constants ============== const TOWN_COUNT = 25; @@ -596,9 +597,9 @@ class MockTerrainSystem { getBiome(x: number, z: number): string { const noise = Math.sin(x * 0.005 + z * 0.003 + this.seed); - if (noise > 0.3) return "forest"; - if (noise > 0) return "tundra"; - return "canyon"; + if (noise > 0.3) return BiomeType.Forest; + if (noise > 0) return BiomeType.Tundra; + return BiomeType.Canyon; } } diff --git a/packages/shared/src/systems/shared/world/__tests__/TownSystem.test.ts b/packages/shared/src/systems/shared/world/__tests__/TownSystem.test.ts index c3213e784..104766603 100644 --- a/packages/shared/src/systems/shared/world/__tests__/TownSystem.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/TownSystem.test.ts @@ -4,6 +4,7 @@ */ import { describe, it, expect } from "vitest"; +import { BiomeType, BIOME_LIST } from "../TerrainBiomeTypes"; // ============== Constants (must match TownSystem.ts) ============== const TOWN_COUNT = 25; @@ -533,7 +534,7 @@ describe("TownSystem Algorithms", () => { }); it("all biomes have defined suitability", () => { - const biomes = ["tundra", "forest", "canyon"]; + const biomes = BIOME_LIST; for (const biome of biomes) { expect(BIOME_SUITABILITY[biome]).toBeDefined(); expect(BIOME_SUITABILITY[biome]).toBeGreaterThanOrEqual(0); diff --git a/packages/shared/src/types/world/terrain.ts b/packages/shared/src/types/world/terrain.ts index b45652c6d..7e2d525da 100644 --- a/packages/shared/src/types/world/terrain.ts +++ b/packages/shared/src/types/world/terrain.ts @@ -8,6 +8,7 @@ import * as THREE from "../../extras/three/three"; import type { Position3D } from "../core/core"; import type { PMeshHandle } from "../../extras/three/geometryToPxMesh"; +import type { BiomeType } from "../../systems/shared/world/TerrainBiomeTypes"; import type { ActorHandle } from "../systems/physics"; import type { TreeSubType as _TreeSubType } from "../../constants/TreeTypes"; @@ -50,7 +51,7 @@ export interface TerrainResourceSpawnPoint { export interface TerrainTileData { tileId: string; position: { x: number; z: number }; - biome: "tundra" | "forest" | "canyon"; + biome: BiomeType; tileX: number; tileZ: number; resources: TerrainResource[]; @@ -69,7 +70,7 @@ export interface TerrainTile { z: number; mesh: THREE.Mesh; collision: PMeshHandle | null; - biome: "tundra" | "forest" | "canyon"; + biome: BiomeType; resources: ResourceNode[]; roads: RoadSegment[]; waterMeshes: THREE.Mesh[]; diff --git a/packages/shared/src/types/world/world-types.ts b/packages/shared/src/types/world/world-types.ts index f00ecd934..9954263e9 100644 --- a/packages/shared/src/types/world/world-types.ts +++ b/packages/shared/src/types/world/world-types.ts @@ -5,6 +5,7 @@ import * as THREE from "../../extras/three/three"; import type { Position3D } from "../core/base-types"; +import type { BiomeType } from "../../systems/shared/world/TerrainBiomeTypes"; // Temporary imports from core.ts - will be updated when those modules are created import type { MobData } from "../core/core"; @@ -358,7 +359,7 @@ export interface BiomeData { name: string; description: string; difficultyLevel: 0 | 1 | 2 | 3; // 0 = safe zones, 1-3 = mob levels - terrain: "tundra" | "forest" | "canyon"; + terrain: BiomeType; resources: string[]; // Available resource types mobs: string[]; // Mob types that spawn here fogIntensity: number; // 0-1 for visual atmosphere From b4b149817f94444e343e1a32dbb5b89ecc3d1173 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 10 Mar 2026 15:19:47 +0800 Subject: [PATCH 33/48] chore: revert prettier-only formatting changes These 11 files had whitespace-only changes from the pre-commit prettier hook during the merge commit. Reverting to main's version to keep the PR diff clean. Made-with: Cursor --- packages/client/playwright.config.ts | 12 ++-- packages/client/src/screens/StreamingMode.tsx | 12 ++-- packages/client/tests/e2e/utils/testWorld.ts | 22 +++--- packages/server/src/eliza/AgentManager.ts | 36 +++++----- packages/server/src/eliza/agentHelpers.ts | 6 +- .../src/eliza/managers/AgentBehaviorTicker.ts | 8 +-- .../ServerNetwork/connection-handler.ts | 70 +++++++++---------- .../server/src/systems/ServerNetwork/index.ts | 66 ++++++++--------- packages/shared/src/core/World.ts | 12 ++-- packages/shared/src/entities/npc/MobEntity.ts | 40 +++++------ .../src/systems/client/ClientNetwork.ts | 22 +++--- 11 files changed, 153 insertions(+), 153 deletions(-) diff --git a/packages/client/playwright.config.ts b/packages/client/playwright.config.ts index a2bffc734..24ed3a99c 100644 --- a/packages/client/playwright.config.ts +++ b/packages/client/playwright.config.ts @@ -49,13 +49,13 @@ export default defineConfig({ forbidOnly: !!process.env.CI, reporter: process.env.CI ? [ - ["html", { open: "never", outputFolder: "playwright-report" }], - ["github"], - ] + ["html", { open: "never", outputFolder: "playwright-report" }], + ["github"], + ] : [ - ["list"], - ["html", { open: "never", outputFolder: "playwright-report" }], - ], + ["list"], + ["html", { open: "never", outputFolder: "playwright-report" }], + ], use: { // WebGPU is required; run headed browser sessions for all E2E tests. headless: false, diff --git a/packages/client/src/screens/StreamingMode.tsx b/packages/client/src/screens/StreamingMode.tsx index c0bf978a0..8646b805d 100644 --- a/packages/client/src/screens/StreamingMode.tsx +++ b/packages/client/src/screens/StreamingMode.tsx @@ -238,7 +238,7 @@ export function StreamingMode() { c.agent1?.damageDealtThisFight === p.agent1?.damageDealtThisFight && c.agent2?.damageDealtThisFight === p.agent2?.damageDealtThisFight && Math.floor(c.timeRemaining / 1000) === - Math.floor(p.timeRemaining / 1000) && + Math.floor(p.timeRemaining / 1000) && state.leaderboard.length === prev.leaderboard.length ) { return prev; // Same reference = no re-render @@ -634,7 +634,7 @@ export function StreamingMode() { if (!recorder || recorder.state !== "recording") return; try { recorder.requestData(); - } catch {} + } catch { } }, 250); healthTimer = setInterval(() => { if (!recorder || recorder.state !== "recording") return; @@ -646,7 +646,7 @@ export function StreamingMode() { ); try { recorder.requestData(); - } catch {} + } catch { } } }, 2000); const videoTrack = stream?.getVideoTracks?.()[0] as // eslint-disable-next-line no-undef @@ -657,7 +657,7 @@ export function StreamingMode() { forceFrameTimer = setInterval(() => { try { videoTrack.requestFrame?.(); - } catch {} + } catch { } }, frameIntervalMs); } if (captureDebug) { @@ -676,7 +676,7 @@ export function StreamingMode() { if (recorder && recorder.state !== "inactive") { try { recorder.stop(); - } catch {} + } catch { } } if (recorder) { recorder.ondataavailable = null; @@ -690,7 +690,7 @@ export function StreamingMode() { if (oscillator) { try { oscillator.stop(); - } catch {} + } catch { } oscillator.disconnect(); oscillator = null; } diff --git a/packages/client/tests/e2e/utils/testWorld.ts b/packages/client/tests/e2e/utils/testWorld.ts index 0ff613132..6c193d424 100644 --- a/packages/client/tests/e2e/utils/testWorld.ts +++ b/packages/client/tests/e2e/utils/testWorld.ts @@ -36,7 +36,7 @@ export async function waitForGameLoad( .catch(() => null); if (loadedHandle) { - await loadedHandle.dispose().catch(() => {}); + await loadedHandle.dispose().catch(() => { }); return; } @@ -96,7 +96,7 @@ export async function waitForLoadingScreenHidden( .catch(() => null); if (hiddenHandle) { - await hiddenHandle.dispose().catch(() => {}); + await hiddenHandle.dispose().catch(() => { }); return; } @@ -201,7 +201,7 @@ export async function waitForPlayerSpawn( .catch(() => null); if (spawnedHandle) { - await spawnedHandle.dispose().catch(() => {}); + await spawnedHandle.dispose().catch(() => { }); return; } @@ -340,7 +340,7 @@ export async function takeGameScreenshot( const maxRetries = options.maxRetries ?? 5; const retryDelay = options.retryDelay ?? 500; - await mkdir("screenshots", { recursive: true }).catch(() => {}); + await mkdir("screenshots", { recursive: true }).catch(() => { }); for (let attempt = 0; attempt < maxRetries; attempt++) { // Wait for canvas to be ready @@ -356,7 +356,7 @@ export async function takeGameScreenshot( if (!navigator.gpu) return false; // Try to get a basic 2D context to verify canvas has dimensions and is ready - // (Note: WebGPU contexts can't readPixels directly without a staging buffer, + // (Note: WebGPU contexts can't readPixels directly without a staging buffer, // so we just rely on dimensions and Context availability here) return canvas.width > 0 && canvas.height > 0; }) @@ -395,7 +395,7 @@ export async function takeGameScreenshot( // Check if screenshot is large enough (actual rendered content) if (pngBuffer.length >= minSize) { - await writeFile(screenshotPath, pngBuffer).catch(() => {}); + await writeFile(screenshotPath, pngBuffer).catch(() => { }); console.log( `[Screenshot] Captured "${name}": ${pngBuffer.length} bytes (attempt ${attempt + 1})`, ); @@ -412,7 +412,7 @@ export async function takeGameScreenshot( } // Last attempt, accept whatever we got - await writeFile(screenshotPath, pngBuffer).catch(() => {}); + await writeFile(screenshotPath, pngBuffer).catch(() => { }); console.log( `[Screenshot] Captured "${name}": ${pngBuffer.length} bytes (fallback)`, ); @@ -513,16 +513,16 @@ export function assertScreenshotsDifferent( if (comparison.identical) { throw new Error( `Screenshots "${name1}" and "${name2}" are pixel-identical! ` + - `This indicates the game may not be rendering correctly or the scene isn't changing.`, + `This indicates the game may not be rendering correctly or the scene isn't changing.`, ); } if (comparison.diffPercentage < minDiffPercentage) { throw new Error( `Screenshots "${name1}" and "${name2}" are nearly identical ` + - `(only ${comparison.diffPercentage.toFixed(4)}% different, ${comparison.diffPixels} bytes). ` + - `Expected at least ${minDiffPercentage}% difference. ` + - `This may indicate the game isn't responding to input.`, + `(only ${comparison.diffPercentage.toFixed(4)}% different, ${comparison.diffPixels} bytes). ` + + `Expected at least ${minDiffPercentage}% difference. ` + + `This may indicate the game isn't responding to input.`, ); } diff --git a/packages/server/src/eliza/AgentManager.ts b/packages/server/src/eliza/AgentManager.ts index 5ed44e912..80e6a19e0 100644 --- a/packages/server/src/eliza/AgentManager.ts +++ b/packages/server/src/eliza/AgentManager.ts @@ -185,11 +185,11 @@ export interface HyperscapeService { getNearbyEntities(): Array<{ id: string; harvestSkill?: - | "woodcutting" - | "fishing" - | "mining" - | "firemaking" - | "cooking"; + | "woodcutting" + | "fishing" + | "mining" + | "firemaking" + | "cooking"; resourceType?: string; }>; @@ -586,21 +586,21 @@ export class AgentManager { async loadAgentsFromDatabase(): Promise { const databaseSystem = this.world.getSystem("database") as | { - db: { - select: () => { - from: (table: unknown) => { - where: (condition: unknown) => Promise< - Array<{ - id: string; - accountId: string; - name: string; - isAgent: boolean; - }> - >; - }; + db: { + select: () => { + from: (table: unknown) => { + where: (condition: unknown) => Promise< + Array<{ + id: string; + accountId: string; + name: string; + isAgent: boolean; + }> + >; }; }; - } + }; + } | undefined; if (!databaseSystem?.db) { diff --git a/packages/server/src/eliza/agentHelpers.ts b/packages/server/src/eliza/agentHelpers.ts index 454468db4..40654f43e 100644 --- a/packages/server/src/eliza/agentHelpers.ts +++ b/packages/server/src/eliza/agentHelpers.ts @@ -174,9 +174,9 @@ export function buildModelSecrets( // Provider-specific overrides ...(keys ? { - [keys.small]: small, - [keys.large]: config.model, - } + [keys.small]: small, + [keys.large]: config.model, + } : {}), }; } diff --git a/packages/server/src/eliza/managers/AgentBehaviorTicker.ts b/packages/server/src/eliza/managers/AgentBehaviorTicker.ts index 67d5cbcc5..13f8f2e30 100644 --- a/packages/server/src/eliza/managers/AgentBehaviorTicker.ts +++ b/packages/server/src/eliza/managers/AgentBehaviorTicker.ts @@ -137,7 +137,7 @@ export class AgentBehaviorTicker { characterId: string, ) => AgentInstance | undefined, private readonly getAllAgentIds: () => string[], - ) {} + ) { } // ─── BEHAVIOR LOOP ─────────────────────────────────────────────────── @@ -207,7 +207,7 @@ export class AgentBehaviorTicker { } // Best-effort stop so paused/stopped agents don't keep pathing or attacking. - void instance.service.executeStop().catch(() => {}); + void instance.service.executeStop().catch(() => { }); } /** @@ -598,8 +598,8 @@ export class AgentBehaviorTicker { const itemData = getItem(slot.itemId); const healAmount = itemData ? ((itemData as unknown as Record).healAmount as - | number - | undefined) + | number + | undefined) : undefined; const isFood = healAmount && healAmount > 0; const isWeapon = diff --git a/packages/server/src/systems/ServerNetwork/connection-handler.ts b/packages/server/src/systems/ServerNetwork/connection-handler.ts index 4040f35c3..e0556f298 100644 --- a/packages/server/src/systems/ServerNetwork/connection-handler.ts +++ b/packages/server/src/systems/ServerNetwork/connection-handler.ts @@ -124,7 +124,7 @@ export class ConnectionHandler { private sockets: Map, private broadcast: BroadcastManager, private db: SystemDatabase, - ) {} + ) { } private isLoopbackWs(ws: NodeWebSocket): boolean { const rawAddress = @@ -713,15 +713,15 @@ export class ConnectionHandler { // Get towns from TownSystem const townSystem = this.world.getSystem("towns") as | { - getTowns?: () => Array<{ - id: string; - name: string; - position: { x: number; y: number; z: number }; - size: string; - biome: string; - buildings: Array<{ type: string }>; - }>; - } + getTowns?: () => Array<{ + id: string; + name: string; + position: { x: number; y: number; z: number }; + size: string; + biome: string; + buildings: Array<{ type: string }>; + }>; + } | undefined; if (townSystem?.getTowns) { @@ -739,14 +739,14 @@ export class ConnectionHandler { // Get POIs from POISystem const poiSystem = this.world.getSystem("pois") as | { - getPOIs?: () => Array<{ - id: string; - name: string; - category: string; - position: { x: number; y: number; z: number }; - biome: string; - }>; - } + getPOIs?: () => Array<{ + id: string; + name: string; + category: string; + position: { x: number; y: number; z: number }; + biome: string; + }>; + } | undefined; if (poiSystem?.getPOIs) { @@ -978,19 +978,19 @@ export class ConnectionHandler { entity as { data?: { position?: - | [number, number, number] - | { x?: number; y?: number; z?: number }; - }; - position?: | [number, number, number] | { x?: number; y?: number; z?: number }; + }; + position?: + | [number, number, number] + | { x?: number; y?: number; z?: number }; } ).data?.position ?? ( entity as { position?: - | [number, number, number] - | { x?: number; y?: number; z?: number }; + | [number, number, number] + | { x?: number; y?: number; z?: number }; } ).position; @@ -1135,10 +1135,10 @@ export class ConnectionHandler { const state = scheduler?.getStreamingState(); const cycle = state?.cycle as | { - phase?: string; - agent1?: { id?: string } | null; - agent2?: { id?: string } | null; - } + phase?: string; + agent1?: { id?: string } | null; + agent2?: { id?: string } | null; + } | undefined; const contestants = [ @@ -1458,13 +1458,13 @@ export class ConnectionHandler { try { const invSystem = this.world.getSystem?.("inventory") as | { - getInventoryData?: (id: string) => { - items: unknown[]; - coins: number; - maxSlots: number; - }; - isInventoryReady?: (id: string) => boolean; - } + getInventoryData?: (id: string) => { + items: unknown[]; + coins: number; + maxSlots: number; + }; + isInventoryReady?: (id: string) => boolean; + } | undefined; // Wait a bit for inventory to be ready if loading diff --git a/packages/server/src/systems/ServerNetwork/index.ts b/packages/server/src/systems/ServerNetwork/index.ts index d13e6af71..1e60a8959 100644 --- a/packages/server/src/systems/ServerNetwork/index.ts +++ b/packages/server/src/systems/ServerNetwork/index.ts @@ -413,11 +413,11 @@ export class ServerNetwork extends System implements NetworkWithSocket { urgency: "critical" | "warning" | "safe"; }; decisionPath?: - | "short-circuit" - | "llm" - | "scripted" - | "planner" - | "curiosity"; + | "short-circuit" + | "llm" + | "scripted" + | "planner" + | "curiosity"; providers?: string[]; }> > = new Map(); @@ -801,7 +801,7 @@ export class ServerNetwork extends System implements NetworkWithSocket { const spawnTerrainHeight = terrain.getHeightAt(spawnX, spawnZ); const safeY = typeof spawnTerrainHeight === "number" && - Number.isFinite(spawnTerrainHeight) + Number.isFinite(spawnTerrainHeight) ? spawnTerrainHeight + 0.1 : baseY; @@ -2805,11 +2805,11 @@ export class ServerNetwork extends System implements NetworkWithSocket { const relevantEntities = entity && "position" in entity ? collectInitialSyncEntities( - this.world, - entity.position.x, - entity.position.z, - reconnectedPlayerId, - ) + this.world, + entity.position.x, + entity.position.z, + reconnectedPlayerId, + ) : []; const relevantEntityIds = new Set( relevantEntities.map((entry) => entry.id), @@ -2835,10 +2835,10 @@ export class ServerNetwork extends System implements NetworkWithSocket { // (initial join flow sends this but packets may be lost during socket reconnect) const equipSys = this.world.getSystem?.("equipment") as | { - getPlayerEquipment?: ( - id: string, - ) => Record | undefined; - } + getPlayerEquipment?: ( + id: string, + ) => Record | undefined; + } | undefined; if (equipSys?.getPlayerEquipment && this.world.entities?.items) { for (const [entityId, ent] of this.world.entities.items.entries()) { @@ -3369,16 +3369,16 @@ export class ServerNetwork extends System implements NetworkWithSocket { const equipmentSystem = this.world.getSystem("equipment") as | { - getPlayerEquipment?: (id: string) => { - weapon?: { - item?: { - attackRange?: number; - attackType?: string; - id?: string; - }; + getPlayerEquipment?: (id: string) => { + weapon?: { + item?: { + attackRange?: number; + attackType?: string; + id?: string; }; - } | null; - } + }; + } | null; + } | undefined; if (equipmentSystem?.getPlayerEquipment) { @@ -3394,7 +3394,7 @@ export class ServerNetwork extends System implements NetworkWithSocket { String(weaponItem.attackType || "").toLowerCase() === "magic" || (weaponItem.id && String(getItem(weaponItem.id)?.attackType || "").toLowerCase() === - "magic"); + "magic"); if (!isMagicWeapon) { // Non-magic weapons use their attackRange (e.g., bows) @@ -3437,15 +3437,15 @@ export class ServerNetwork extends System implements NetworkWithSocket { const equipmentSystem = this.world.getSystem("equipment") as | { - getPlayerEquipment?: (id: string) => { - weapon?: { - item?: { - attackType?: AttackType; - weaponType?: WeaponType; - }; + getPlayerEquipment?: (id: string) => { + weapon?: { + item?: { + attackType?: AttackType; + weaponType?: WeaponType; }; - } | null; - } + }; + } | null; + } | undefined; if (equipmentSystem?.getPlayerEquipment) { diff --git a/packages/shared/src/core/World.ts b/packages/shared/src/core/World.ts index 8a8ab7fb3..26b885025 100644 --- a/packages/shared/src/core/World.ts +++ b/packages/shared/src/core/World.ts @@ -622,10 +622,10 @@ export class World extends EventEmitter { /** Action registry for context-based player actions */ actionRegistry?: { - getAll(): Array<{ name: string; [key: string]: unknown }>; + getAll(): Array<{ name: string;[key: string]: unknown }>; getAvailable( context: Record, - ): Array<{ name: string; [key: string]: unknown }>; + ): Array<{ name: string;[key: string]: unknown }>; execute( name: string, context: Record, @@ -634,7 +634,7 @@ export class World extends EventEmitter { }; /** Reference to all registered RPG systems (PlayerSystem, CombatSystem, etc.) */ - rpgSystems?: Record; + rpgSystems?: Record; /** All available RPG actions that can be executed */ rpgActions?: Record< @@ -656,7 +656,7 @@ export class World extends EventEmitter { /** Get RPG player data by ID */ getRPGPlayer?( playerId: string, - ): { id: string; [key: string]: unknown } | undefined; + ): { id: string;[key: string]: unknown } | undefined; /** Save player data to database */ savePlayer?(playerId: string, data: Record): unknown; @@ -721,12 +721,12 @@ export class World extends EventEmitter { /** Get player's inventory contents */ getInventory?( playerId: string, - ): Array<{ itemId: string; quantity: number; [key: string]: unknown }>; + ): Array<{ itemId: string; quantity: number;[key: string]: unknown }>; /** Get player's equipped items */ getEquipment?( playerId: string, - ): Record; + ): Record; /** Check if player has item in inventory */ hasItem?( diff --git a/packages/shared/src/entities/npc/MobEntity.ts b/packages/shared/src/entities/npc/MobEntity.ts index d8dab1efe..b38edaadb 100644 --- a/packages/shared/src/entities/npc/MobEntity.ts +++ b/packages/shared/src/entities/npc/MobEntity.ts @@ -926,7 +926,7 @@ export class MobEntity extends CombatantEntity { // PERFORMANCE: Disable raycasting on VRM meshes - use _raycastProxy instead // SkinnedMesh raycast is extremely slow (~700-1800ms) because THREE.js // must transform every vertex by bone weights. The capsule proxy is instant. - child.raycast = () => {}; + child.raycast = () => { }; }); // Apply manifest scale on top of VRM's height normalization @@ -1053,8 +1053,8 @@ export class MobEntity extends CombatantEntity { if (!initialClip) { throw new Error( `[MobEntity] NO CLIPS: ${this.config.mobType}\n` + - ` Dir: ${modelDir}/animations/\n` + - ` Result: idle=${!!animationClips.idle}, walk=${!!animationClips.walk}, run=${!!animationClips.run}`, + ` Dir: ${modelDir}/animations/\n` + + ` Result: idle=${!!animationClips.idle}, walk=${!!animationClips.walk}, run=${!!animationClips.run}`, ); } @@ -1203,7 +1203,7 @@ export class MobEntity extends CombatantEntity { // PERFORMANCE: Set all children to layer 1 (minimap only sees layer 0) child.layers.set(1); // PERFORMANCE: Disable raycasting on GLB meshes - use _raycastProxy instead - child.raycast = () => {}; + child.raycast = () => { }; if (child instanceof THREE.SkinnedMesh && child.skeleton) { // Ensure mesh matrix is updated @@ -1970,18 +1970,18 @@ export class MobEntity extends CombatantEntity { const cameraPos = getCameraPosition(this.world); const animLODResult = cameraPos ? this._animationLOD.updateFromPosition( - this.node.position.x, - this.node.position.z, - cameraPos.x, - cameraPos.z, - deltaTime, - ) + this.node.position.x, + this.node.position.z, + cameraPos.x, + cameraPos.z, + deltaTime, + ) : { - shouldUpdate: true, - effectiveDelta: deltaTime, - lodLevel: 0, - distanceSq: 0, - }; + shouldUpdate: true, + effectiveDelta: deltaTime, + lodLevel: 0, + distanceSq: 0, + }; const isAnimatedImpostor = this.animatedHLODState?.isImpostor === true; // Update health bar position (HealthBars system uses atlas + instanced mesh) @@ -2306,11 +2306,11 @@ export class MobEntity extends CombatantEntity { const mixer = (this as { mixer?: THREE.AnimationMixer }).mixer; throw new Error( `[MobEntity] BONES NOT MOVING: ${this.config.mobType}\n` + - ` Start: [${this.initialBonePosition.toArray().map((v) => v.toFixed(4))}]\n` + - ` Now: [${hipsBone.position.toArray().map((v) => v.toFixed(4))}]\n` + - ` Distance: ${distance.toFixed(6)} (need > 0.001)\n` + - ` Mixer time: ${mixer?.time.toFixed(2) ?? "N/A"}s\n` + - ` Animation runs but doesn't affect bones!`, + ` Start: [${this.initialBonePosition.toArray().map((v) => v.toFixed(4))}]\n` + + ` Now: [${hipsBone.position.toArray().map((v) => v.toFixed(4))}]\n` + + ` Distance: ${distance.toFixed(6)} (need > 0.001)\n` + + ` Mixer time: ${mixer?.time.toFixed(2) ?? "N/A"}s\n` + + ` Animation runs but doesn't affect bones!`, ); } } diff --git a/packages/shared/src/systems/client/ClientNetwork.ts b/packages/shared/src/systems/client/ClientNetwork.ts index 1a7539287..6dac4ee38 100644 --- a/packages/shared/src/systems/client/ClientNetwork.ts +++ b/packages/shared/src/systems/client/ClientNetwork.ts @@ -1675,10 +1675,10 @@ export class ClientNetwork extends SystemBase { pArr && pArr.length === 3 ? { x: pArr[0], y: pArr[1], z: pArr[2] } : { - x: entity.position.x, - y: entity.position.y, - z: entity.position.z, - }; + x: entity.position.x, + y: entity.position.y, + z: entity.position.z, + }; this.tileInterpolator.setCombatRotation( id, changesObj.q as number[], @@ -3428,11 +3428,11 @@ export class ClientNetwork extends SystemBase { */ onPrivateMessageFailed = (data: { reason: - | "offline" - | "ignored" - | "not_friends" - | "player_not_found" - | "rate_limited"; + | "offline" + | "ignored" + | "not_friends" + | "player_not_found" + | "rate_limited"; targetName: string; }) => { const reasonMessages: Record = { @@ -3714,7 +3714,7 @@ export class ClientNetwork extends SystemBase { // Uses bounding box which includes door approach areas collisionService ? (x: number, z: number) => - collisionService.isNearBuildingForElevation(x, z) + collisionService.isNearBuildingForElevation(x, z) : undefined, // Pass step height function for smooth entrance stair walking collisionService @@ -3722,7 +3722,7 @@ export class ClientNetwork extends SystemBase { : undefined, this.world.frameBudget ? (minimumMs: number = 1) => - this.world.frameBudget!.hasTimeRemaining(minimumMs) + this.world.frameBudget!.hasTimeRemaining(minimumMs) : undefined, ); } From 897b9321cc247af65face44a550816ae3a86c4ae Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 10 Mar 2026 15:20:25 +0800 Subject: [PATCH 34/48] Revert "chore: revert prettier-only formatting changes" This reverts commit b4b149817f94444e343e1a32dbb5b89ecc3d1173. --- packages/client/playwright.config.ts | 12 ++-- packages/client/src/screens/StreamingMode.tsx | 12 ++-- packages/client/tests/e2e/utils/testWorld.ts | 22 +++--- packages/server/src/eliza/AgentManager.ts | 36 +++++----- packages/server/src/eliza/agentHelpers.ts | 6 +- .../src/eliza/managers/AgentBehaviorTicker.ts | 8 +-- .../ServerNetwork/connection-handler.ts | 70 +++++++++---------- .../server/src/systems/ServerNetwork/index.ts | 66 ++++++++--------- packages/shared/src/core/World.ts | 12 ++-- packages/shared/src/entities/npc/MobEntity.ts | 40 +++++------ .../src/systems/client/ClientNetwork.ts | 22 +++--- 11 files changed, 153 insertions(+), 153 deletions(-) diff --git a/packages/client/playwright.config.ts b/packages/client/playwright.config.ts index 24ed3a99c..a2bffc734 100644 --- a/packages/client/playwright.config.ts +++ b/packages/client/playwright.config.ts @@ -49,13 +49,13 @@ export default defineConfig({ forbidOnly: !!process.env.CI, reporter: process.env.CI ? [ - ["html", { open: "never", outputFolder: "playwright-report" }], - ["github"], - ] + ["html", { open: "never", outputFolder: "playwright-report" }], + ["github"], + ] : [ - ["list"], - ["html", { open: "never", outputFolder: "playwright-report" }], - ], + ["list"], + ["html", { open: "never", outputFolder: "playwright-report" }], + ], use: { // WebGPU is required; run headed browser sessions for all E2E tests. headless: false, diff --git a/packages/client/src/screens/StreamingMode.tsx b/packages/client/src/screens/StreamingMode.tsx index 8646b805d..c0bf978a0 100644 --- a/packages/client/src/screens/StreamingMode.tsx +++ b/packages/client/src/screens/StreamingMode.tsx @@ -238,7 +238,7 @@ export function StreamingMode() { c.agent1?.damageDealtThisFight === p.agent1?.damageDealtThisFight && c.agent2?.damageDealtThisFight === p.agent2?.damageDealtThisFight && Math.floor(c.timeRemaining / 1000) === - Math.floor(p.timeRemaining / 1000) && + Math.floor(p.timeRemaining / 1000) && state.leaderboard.length === prev.leaderboard.length ) { return prev; // Same reference = no re-render @@ -634,7 +634,7 @@ export function StreamingMode() { if (!recorder || recorder.state !== "recording") return; try { recorder.requestData(); - } catch { } + } catch {} }, 250); healthTimer = setInterval(() => { if (!recorder || recorder.state !== "recording") return; @@ -646,7 +646,7 @@ export function StreamingMode() { ); try { recorder.requestData(); - } catch { } + } catch {} } }, 2000); const videoTrack = stream?.getVideoTracks?.()[0] as // eslint-disable-next-line no-undef @@ -657,7 +657,7 @@ export function StreamingMode() { forceFrameTimer = setInterval(() => { try { videoTrack.requestFrame?.(); - } catch { } + } catch {} }, frameIntervalMs); } if (captureDebug) { @@ -676,7 +676,7 @@ export function StreamingMode() { if (recorder && recorder.state !== "inactive") { try { recorder.stop(); - } catch { } + } catch {} } if (recorder) { recorder.ondataavailable = null; @@ -690,7 +690,7 @@ export function StreamingMode() { if (oscillator) { try { oscillator.stop(); - } catch { } + } catch {} oscillator.disconnect(); oscillator = null; } diff --git a/packages/client/tests/e2e/utils/testWorld.ts b/packages/client/tests/e2e/utils/testWorld.ts index 6c193d424..0ff613132 100644 --- a/packages/client/tests/e2e/utils/testWorld.ts +++ b/packages/client/tests/e2e/utils/testWorld.ts @@ -36,7 +36,7 @@ export async function waitForGameLoad( .catch(() => null); if (loadedHandle) { - await loadedHandle.dispose().catch(() => { }); + await loadedHandle.dispose().catch(() => {}); return; } @@ -96,7 +96,7 @@ export async function waitForLoadingScreenHidden( .catch(() => null); if (hiddenHandle) { - await hiddenHandle.dispose().catch(() => { }); + await hiddenHandle.dispose().catch(() => {}); return; } @@ -201,7 +201,7 @@ export async function waitForPlayerSpawn( .catch(() => null); if (spawnedHandle) { - await spawnedHandle.dispose().catch(() => { }); + await spawnedHandle.dispose().catch(() => {}); return; } @@ -340,7 +340,7 @@ export async function takeGameScreenshot( const maxRetries = options.maxRetries ?? 5; const retryDelay = options.retryDelay ?? 500; - await mkdir("screenshots", { recursive: true }).catch(() => { }); + await mkdir("screenshots", { recursive: true }).catch(() => {}); for (let attempt = 0; attempt < maxRetries; attempt++) { // Wait for canvas to be ready @@ -356,7 +356,7 @@ export async function takeGameScreenshot( if (!navigator.gpu) return false; // Try to get a basic 2D context to verify canvas has dimensions and is ready - // (Note: WebGPU contexts can't readPixels directly without a staging buffer, + // (Note: WebGPU contexts can't readPixels directly without a staging buffer, // so we just rely on dimensions and Context availability here) return canvas.width > 0 && canvas.height > 0; }) @@ -395,7 +395,7 @@ export async function takeGameScreenshot( // Check if screenshot is large enough (actual rendered content) if (pngBuffer.length >= minSize) { - await writeFile(screenshotPath, pngBuffer).catch(() => { }); + await writeFile(screenshotPath, pngBuffer).catch(() => {}); console.log( `[Screenshot] Captured "${name}": ${pngBuffer.length} bytes (attempt ${attempt + 1})`, ); @@ -412,7 +412,7 @@ export async function takeGameScreenshot( } // Last attempt, accept whatever we got - await writeFile(screenshotPath, pngBuffer).catch(() => { }); + await writeFile(screenshotPath, pngBuffer).catch(() => {}); console.log( `[Screenshot] Captured "${name}": ${pngBuffer.length} bytes (fallback)`, ); @@ -513,16 +513,16 @@ export function assertScreenshotsDifferent( if (comparison.identical) { throw new Error( `Screenshots "${name1}" and "${name2}" are pixel-identical! ` + - `This indicates the game may not be rendering correctly or the scene isn't changing.`, + `This indicates the game may not be rendering correctly or the scene isn't changing.`, ); } if (comparison.diffPercentage < minDiffPercentage) { throw new Error( `Screenshots "${name1}" and "${name2}" are nearly identical ` + - `(only ${comparison.diffPercentage.toFixed(4)}% different, ${comparison.diffPixels} bytes). ` + - `Expected at least ${minDiffPercentage}% difference. ` + - `This may indicate the game isn't responding to input.`, + `(only ${comparison.diffPercentage.toFixed(4)}% different, ${comparison.diffPixels} bytes). ` + + `Expected at least ${minDiffPercentage}% difference. ` + + `This may indicate the game isn't responding to input.`, ); } diff --git a/packages/server/src/eliza/AgentManager.ts b/packages/server/src/eliza/AgentManager.ts index 80e6a19e0..5ed44e912 100644 --- a/packages/server/src/eliza/AgentManager.ts +++ b/packages/server/src/eliza/AgentManager.ts @@ -185,11 +185,11 @@ export interface HyperscapeService { getNearbyEntities(): Array<{ id: string; harvestSkill?: - | "woodcutting" - | "fishing" - | "mining" - | "firemaking" - | "cooking"; + | "woodcutting" + | "fishing" + | "mining" + | "firemaking" + | "cooking"; resourceType?: string; }>; @@ -586,21 +586,21 @@ export class AgentManager { async loadAgentsFromDatabase(): Promise { const databaseSystem = this.world.getSystem("database") as | { - db: { - select: () => { - from: (table: unknown) => { - where: (condition: unknown) => Promise< - Array<{ - id: string; - accountId: string; - name: string; - isAgent: boolean; - }> - >; + db: { + select: () => { + from: (table: unknown) => { + where: (condition: unknown) => Promise< + Array<{ + id: string; + accountId: string; + name: string; + isAgent: boolean; + }> + >; + }; }; }; - }; - } + } | undefined; if (!databaseSystem?.db) { diff --git a/packages/server/src/eliza/agentHelpers.ts b/packages/server/src/eliza/agentHelpers.ts index 40654f43e..454468db4 100644 --- a/packages/server/src/eliza/agentHelpers.ts +++ b/packages/server/src/eliza/agentHelpers.ts @@ -174,9 +174,9 @@ export function buildModelSecrets( // Provider-specific overrides ...(keys ? { - [keys.small]: small, - [keys.large]: config.model, - } + [keys.small]: small, + [keys.large]: config.model, + } : {}), }; } diff --git a/packages/server/src/eliza/managers/AgentBehaviorTicker.ts b/packages/server/src/eliza/managers/AgentBehaviorTicker.ts index 13f8f2e30..67d5cbcc5 100644 --- a/packages/server/src/eliza/managers/AgentBehaviorTicker.ts +++ b/packages/server/src/eliza/managers/AgentBehaviorTicker.ts @@ -137,7 +137,7 @@ export class AgentBehaviorTicker { characterId: string, ) => AgentInstance | undefined, private readonly getAllAgentIds: () => string[], - ) { } + ) {} // ─── BEHAVIOR LOOP ─────────────────────────────────────────────────── @@ -207,7 +207,7 @@ export class AgentBehaviorTicker { } // Best-effort stop so paused/stopped agents don't keep pathing or attacking. - void instance.service.executeStop().catch(() => { }); + void instance.service.executeStop().catch(() => {}); } /** @@ -598,8 +598,8 @@ export class AgentBehaviorTicker { const itemData = getItem(slot.itemId); const healAmount = itemData ? ((itemData as unknown as Record).healAmount as - | number - | undefined) + | number + | undefined) : undefined; const isFood = healAmount && healAmount > 0; const isWeapon = diff --git a/packages/server/src/systems/ServerNetwork/connection-handler.ts b/packages/server/src/systems/ServerNetwork/connection-handler.ts index e0556f298..4040f35c3 100644 --- a/packages/server/src/systems/ServerNetwork/connection-handler.ts +++ b/packages/server/src/systems/ServerNetwork/connection-handler.ts @@ -124,7 +124,7 @@ export class ConnectionHandler { private sockets: Map, private broadcast: BroadcastManager, private db: SystemDatabase, - ) { } + ) {} private isLoopbackWs(ws: NodeWebSocket): boolean { const rawAddress = @@ -713,15 +713,15 @@ export class ConnectionHandler { // Get towns from TownSystem const townSystem = this.world.getSystem("towns") as | { - getTowns?: () => Array<{ - id: string; - name: string; - position: { x: number; y: number; z: number }; - size: string; - biome: string; - buildings: Array<{ type: string }>; - }>; - } + getTowns?: () => Array<{ + id: string; + name: string; + position: { x: number; y: number; z: number }; + size: string; + biome: string; + buildings: Array<{ type: string }>; + }>; + } | undefined; if (townSystem?.getTowns) { @@ -739,14 +739,14 @@ export class ConnectionHandler { // Get POIs from POISystem const poiSystem = this.world.getSystem("pois") as | { - getPOIs?: () => Array<{ - id: string; - name: string; - category: string; - position: { x: number; y: number; z: number }; - biome: string; - }>; - } + getPOIs?: () => Array<{ + id: string; + name: string; + category: string; + position: { x: number; y: number; z: number }; + biome: string; + }>; + } | undefined; if (poiSystem?.getPOIs) { @@ -978,19 +978,19 @@ export class ConnectionHandler { entity as { data?: { position?: - | [number, number, number] - | { x?: number; y?: number; z?: number }; + | [number, number, number] + | { x?: number; y?: number; z?: number }; }; position?: - | [number, number, number] - | { x?: number; y?: number; z?: number }; + | [number, number, number] + | { x?: number; y?: number; z?: number }; } ).data?.position ?? ( entity as { position?: - | [number, number, number] - | { x?: number; y?: number; z?: number }; + | [number, number, number] + | { x?: number; y?: number; z?: number }; } ).position; @@ -1135,10 +1135,10 @@ export class ConnectionHandler { const state = scheduler?.getStreamingState(); const cycle = state?.cycle as | { - phase?: string; - agent1?: { id?: string } | null; - agent2?: { id?: string } | null; - } + phase?: string; + agent1?: { id?: string } | null; + agent2?: { id?: string } | null; + } | undefined; const contestants = [ @@ -1458,13 +1458,13 @@ export class ConnectionHandler { try { const invSystem = this.world.getSystem?.("inventory") as | { - getInventoryData?: (id: string) => { - items: unknown[]; - coins: number; - maxSlots: number; - }; - isInventoryReady?: (id: string) => boolean; - } + getInventoryData?: (id: string) => { + items: unknown[]; + coins: number; + maxSlots: number; + }; + isInventoryReady?: (id: string) => boolean; + } | undefined; // Wait a bit for inventory to be ready if loading diff --git a/packages/server/src/systems/ServerNetwork/index.ts b/packages/server/src/systems/ServerNetwork/index.ts index 1e60a8959..d13e6af71 100644 --- a/packages/server/src/systems/ServerNetwork/index.ts +++ b/packages/server/src/systems/ServerNetwork/index.ts @@ -413,11 +413,11 @@ export class ServerNetwork extends System implements NetworkWithSocket { urgency: "critical" | "warning" | "safe"; }; decisionPath?: - | "short-circuit" - | "llm" - | "scripted" - | "planner" - | "curiosity"; + | "short-circuit" + | "llm" + | "scripted" + | "planner" + | "curiosity"; providers?: string[]; }> > = new Map(); @@ -801,7 +801,7 @@ export class ServerNetwork extends System implements NetworkWithSocket { const spawnTerrainHeight = terrain.getHeightAt(spawnX, spawnZ); const safeY = typeof spawnTerrainHeight === "number" && - Number.isFinite(spawnTerrainHeight) + Number.isFinite(spawnTerrainHeight) ? spawnTerrainHeight + 0.1 : baseY; @@ -2805,11 +2805,11 @@ export class ServerNetwork extends System implements NetworkWithSocket { const relevantEntities = entity && "position" in entity ? collectInitialSyncEntities( - this.world, - entity.position.x, - entity.position.z, - reconnectedPlayerId, - ) + this.world, + entity.position.x, + entity.position.z, + reconnectedPlayerId, + ) : []; const relevantEntityIds = new Set( relevantEntities.map((entry) => entry.id), @@ -2835,10 +2835,10 @@ export class ServerNetwork extends System implements NetworkWithSocket { // (initial join flow sends this but packets may be lost during socket reconnect) const equipSys = this.world.getSystem?.("equipment") as | { - getPlayerEquipment?: ( - id: string, - ) => Record | undefined; - } + getPlayerEquipment?: ( + id: string, + ) => Record | undefined; + } | undefined; if (equipSys?.getPlayerEquipment && this.world.entities?.items) { for (const [entityId, ent] of this.world.entities.items.entries()) { @@ -3369,16 +3369,16 @@ export class ServerNetwork extends System implements NetworkWithSocket { const equipmentSystem = this.world.getSystem("equipment") as | { - getPlayerEquipment?: (id: string) => { - weapon?: { - item?: { - attackRange?: number; - attackType?: string; - id?: string; + getPlayerEquipment?: (id: string) => { + weapon?: { + item?: { + attackRange?: number; + attackType?: string; + id?: string; + }; }; - }; - } | null; - } + } | null; + } | undefined; if (equipmentSystem?.getPlayerEquipment) { @@ -3394,7 +3394,7 @@ export class ServerNetwork extends System implements NetworkWithSocket { String(weaponItem.attackType || "").toLowerCase() === "magic" || (weaponItem.id && String(getItem(weaponItem.id)?.attackType || "").toLowerCase() === - "magic"); + "magic"); if (!isMagicWeapon) { // Non-magic weapons use their attackRange (e.g., bows) @@ -3437,15 +3437,15 @@ export class ServerNetwork extends System implements NetworkWithSocket { const equipmentSystem = this.world.getSystem("equipment") as | { - getPlayerEquipment?: (id: string) => { - weapon?: { - item?: { - attackType?: AttackType; - weaponType?: WeaponType; + getPlayerEquipment?: (id: string) => { + weapon?: { + item?: { + attackType?: AttackType; + weaponType?: WeaponType; + }; }; - }; - } | null; - } + } | null; + } | undefined; if (equipmentSystem?.getPlayerEquipment) { diff --git a/packages/shared/src/core/World.ts b/packages/shared/src/core/World.ts index 26b885025..8a8ab7fb3 100644 --- a/packages/shared/src/core/World.ts +++ b/packages/shared/src/core/World.ts @@ -622,10 +622,10 @@ export class World extends EventEmitter { /** Action registry for context-based player actions */ actionRegistry?: { - getAll(): Array<{ name: string;[key: string]: unknown }>; + getAll(): Array<{ name: string; [key: string]: unknown }>; getAvailable( context: Record, - ): Array<{ name: string;[key: string]: unknown }>; + ): Array<{ name: string; [key: string]: unknown }>; execute( name: string, context: Record, @@ -634,7 +634,7 @@ export class World extends EventEmitter { }; /** Reference to all registered RPG systems (PlayerSystem, CombatSystem, etc.) */ - rpgSystems?: Record; + rpgSystems?: Record; /** All available RPG actions that can be executed */ rpgActions?: Record< @@ -656,7 +656,7 @@ export class World extends EventEmitter { /** Get RPG player data by ID */ getRPGPlayer?( playerId: string, - ): { id: string;[key: string]: unknown } | undefined; + ): { id: string; [key: string]: unknown } | undefined; /** Save player data to database */ savePlayer?(playerId: string, data: Record): unknown; @@ -721,12 +721,12 @@ export class World extends EventEmitter { /** Get player's inventory contents */ getInventory?( playerId: string, - ): Array<{ itemId: string; quantity: number;[key: string]: unknown }>; + ): Array<{ itemId: string; quantity: number; [key: string]: unknown }>; /** Get player's equipped items */ getEquipment?( playerId: string, - ): Record; + ): Record; /** Check if player has item in inventory */ hasItem?( diff --git a/packages/shared/src/entities/npc/MobEntity.ts b/packages/shared/src/entities/npc/MobEntity.ts index b38edaadb..d8dab1efe 100644 --- a/packages/shared/src/entities/npc/MobEntity.ts +++ b/packages/shared/src/entities/npc/MobEntity.ts @@ -926,7 +926,7 @@ export class MobEntity extends CombatantEntity { // PERFORMANCE: Disable raycasting on VRM meshes - use _raycastProxy instead // SkinnedMesh raycast is extremely slow (~700-1800ms) because THREE.js // must transform every vertex by bone weights. The capsule proxy is instant. - child.raycast = () => { }; + child.raycast = () => {}; }); // Apply manifest scale on top of VRM's height normalization @@ -1053,8 +1053,8 @@ export class MobEntity extends CombatantEntity { if (!initialClip) { throw new Error( `[MobEntity] NO CLIPS: ${this.config.mobType}\n` + - ` Dir: ${modelDir}/animations/\n` + - ` Result: idle=${!!animationClips.idle}, walk=${!!animationClips.walk}, run=${!!animationClips.run}`, + ` Dir: ${modelDir}/animations/\n` + + ` Result: idle=${!!animationClips.idle}, walk=${!!animationClips.walk}, run=${!!animationClips.run}`, ); } @@ -1203,7 +1203,7 @@ export class MobEntity extends CombatantEntity { // PERFORMANCE: Set all children to layer 1 (minimap only sees layer 0) child.layers.set(1); // PERFORMANCE: Disable raycasting on GLB meshes - use _raycastProxy instead - child.raycast = () => { }; + child.raycast = () => {}; if (child instanceof THREE.SkinnedMesh && child.skeleton) { // Ensure mesh matrix is updated @@ -1970,18 +1970,18 @@ export class MobEntity extends CombatantEntity { const cameraPos = getCameraPosition(this.world); const animLODResult = cameraPos ? this._animationLOD.updateFromPosition( - this.node.position.x, - this.node.position.z, - cameraPos.x, - cameraPos.z, - deltaTime, - ) + this.node.position.x, + this.node.position.z, + cameraPos.x, + cameraPos.z, + deltaTime, + ) : { - shouldUpdate: true, - effectiveDelta: deltaTime, - lodLevel: 0, - distanceSq: 0, - }; + shouldUpdate: true, + effectiveDelta: deltaTime, + lodLevel: 0, + distanceSq: 0, + }; const isAnimatedImpostor = this.animatedHLODState?.isImpostor === true; // Update health bar position (HealthBars system uses atlas + instanced mesh) @@ -2306,11 +2306,11 @@ export class MobEntity extends CombatantEntity { const mixer = (this as { mixer?: THREE.AnimationMixer }).mixer; throw new Error( `[MobEntity] BONES NOT MOVING: ${this.config.mobType}\n` + - ` Start: [${this.initialBonePosition.toArray().map((v) => v.toFixed(4))}]\n` + - ` Now: [${hipsBone.position.toArray().map((v) => v.toFixed(4))}]\n` + - ` Distance: ${distance.toFixed(6)} (need > 0.001)\n` + - ` Mixer time: ${mixer?.time.toFixed(2) ?? "N/A"}s\n` + - ` Animation runs but doesn't affect bones!`, + ` Start: [${this.initialBonePosition.toArray().map((v) => v.toFixed(4))}]\n` + + ` Now: [${hipsBone.position.toArray().map((v) => v.toFixed(4))}]\n` + + ` Distance: ${distance.toFixed(6)} (need > 0.001)\n` + + ` Mixer time: ${mixer?.time.toFixed(2) ?? "N/A"}s\n` + + ` Animation runs but doesn't affect bones!`, ); } } diff --git a/packages/shared/src/systems/client/ClientNetwork.ts b/packages/shared/src/systems/client/ClientNetwork.ts index 6dac4ee38..1a7539287 100644 --- a/packages/shared/src/systems/client/ClientNetwork.ts +++ b/packages/shared/src/systems/client/ClientNetwork.ts @@ -1675,10 +1675,10 @@ export class ClientNetwork extends SystemBase { pArr && pArr.length === 3 ? { x: pArr[0], y: pArr[1], z: pArr[2] } : { - x: entity.position.x, - y: entity.position.y, - z: entity.position.z, - }; + x: entity.position.x, + y: entity.position.y, + z: entity.position.z, + }; this.tileInterpolator.setCombatRotation( id, changesObj.q as number[], @@ -3428,11 +3428,11 @@ export class ClientNetwork extends SystemBase { */ onPrivateMessageFailed = (data: { reason: - | "offline" - | "ignored" - | "not_friends" - | "player_not_found" - | "rate_limited"; + | "offline" + | "ignored" + | "not_friends" + | "player_not_found" + | "rate_limited"; targetName: string; }) => { const reasonMessages: Record = { @@ -3714,7 +3714,7 @@ export class ClientNetwork extends SystemBase { // Uses bounding box which includes door approach areas collisionService ? (x: number, z: number) => - collisionService.isNearBuildingForElevation(x, z) + collisionService.isNearBuildingForElevation(x, z) : undefined, // Pass step height function for smooth entrance stair walking collisionService @@ -3722,7 +3722,7 @@ export class ClientNetwork extends SystemBase { : undefined, this.world.frameBudget ? (minimumMs: number = 1) => - this.world.frameBudget!.hasTimeRemaining(minimumMs) + this.world.frameBudget!.hasTimeRemaining(minimumMs) : undefined, ); } From d1590d38331073177b4b6ad2476a77f6dbc6c954 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 10 Mar 2026 15:55:34 +0800 Subject: [PATCH 35/48] refactor(biome): unify on procgen BiomeSystem as single source of truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate all biome queries from TerrainSystem's local implementation to TerrainGenerator.BiomeSystem, eliminating the duplicate biome system. procgen/BiomeSystem: - Add computePolygonCenters() for N-biome regular polygon placement - Add explicitCenters support to skip grid-jitter when provided - Remove height-biome coupling (mountain/valley weight boosts) - Make biome IDs dynamic instead of hardcoded procgen/TerrainGenerator: - Remove getHeightWithBiomeBoost() — mountains use LandscapeFeatureDef - Simplify height pipeline to getBaseHeightAt() + shoreline shared/TerrainSystem: - Delegate all biome queries to terrainGenerator.getBiomeSystem() - Remove initializeBiomeCenters, biomeCenters property, guard clauses - Remove 15 dead CONFIG fields (BIOME_*, MOUNTAIN_*, VALLEY_*) - Remove dead methods (getBiomeNoise, mapBiomeToGeneric) - Add BIOME_CONFIG to TerrainHeightParams as single config source Workers: - Remove 5 dead params from TerrainWorkerConfig interfaces - Fix worker/main-thread mismatch: remove Tundra valley boost from buildBiomeInfluencesJS (main thread never had it) Net: -223 lines, zero visual change. Made-with: Cursor --- packages/procgen/src/terrain/BiomeSystem.ts | 112 ++++----- .../procgen/src/terrain/TerrainGenerator.ts | 34 +-- packages/procgen/src/terrain/presets.ts | 20 -- packages/procgen/src/terrain/types.ts | 20 +- .../shared/world/TerrainHeightParams.ts | 12 + .../src/systems/shared/world/TerrainSystem.ts | 233 ++++-------------- .../src/utils/workers/QuadChunkWorker.ts | 10 - .../shared/src/utils/workers/TerrainWorker.ts | 11 - .../src/utils/workers/TerrainWorkerShared.ts | 8 +- 9 files changed, 119 insertions(+), 341 deletions(-) diff --git a/packages/procgen/src/terrain/BiomeSystem.ts b/packages/procgen/src/terrain/BiomeSystem.ts index 3fcb97121..20ba060c8 100644 --- a/packages/procgen/src/terrain/BiomeSystem.ts +++ b/packages/procgen/src/terrain/BiomeSystem.ts @@ -114,11 +114,6 @@ export const DEFAULT_BIOME_CONFIG: BiomeConfig = { gaussianCoeff: 0.15, boundaryNoiseScale: 0.003, boundaryNoiseAmount: 0.15, - mountainHeightThreshold: 0.4, - mountainWeightBoost: 2.0, - valleyHeightThreshold: 0.4, - valleyWeightBoost: 1.5, - mountainHeightBoost: 0.5, }; /** @@ -161,12 +156,44 @@ export class BiomeSystem { this.worldSize = worldSizeMeters; this.initializeBiomeCenters(seed); + + for (const id of Object.keys(this.biomeDefinitions)) { + this.biomeIds[id] = this.nextBiomeId++; + } + } + + /** + * Compute biome centers arranged in a regular polygon. + * For 3 types = equilateral triangle, 4 = square, etc. + * Generalizes the island-style biome placement for any N. + */ + static computePolygonCenters( + biomeTypes: string[], + radius: number, + influence: number, + ): BiomeCenter[] { + const centers: BiomeCenter[] = []; + for (let i = 0; i < biomeTypes.length; i++) { + const angle = (i / biomeTypes.length) * Math.PI * 2 - Math.PI / 2; + centers.push({ + x: Math.cos(angle) * radius, + z: Math.sin(angle) * radius, + type: biomeTypes[i], + influence, + }); + } + return centers; } /** * Initialize biome centers using deterministic grid-jitter placement */ private initializeBiomeCenters(seed: number): void { + if (this.config.explicitCenters) { + this.biomeCenters = [...this.config.explicitCenters]; + return; + } + const { gridSize, jitter, minInfluence, maxInfluence } = this.config; const cellSize = this.worldSize / gridSize; @@ -231,22 +258,15 @@ export class BiomeSystem { * * @param worldX - World X coordinate * @param worldZ - World Z coordinate - * @param baseHeight - Normalized base height (0-1) for height-biome coupling + * @param _baseHeight - Reserved for future height-biome coupling (currently unused) */ getBiomeInfluencesAtPosition( worldX: number, worldZ: number, - baseHeight: number, + _baseHeight: number, ): BiomeInfluence[] { - const { - gaussianCoeff, - boundaryNoiseScale, - boundaryNoiseAmount, - mountainHeightThreshold, - mountainWeightBoost, - valleyHeightThreshold, - valleyWeightBoost, - } = this.config; + const { gaussianCoeff, boundaryNoiseScale, boundaryNoiseAmount } = + this.config; // Add boundary noise for organic edges const boundaryNoise = this.noise.simplex2D( @@ -270,24 +290,10 @@ export class BiomeSystem { // Pure Gaussian falloff - NO hard distance cutoff // The gaussian naturally approaches 0 at large distances const normalizedDistance = noisyDistance / center.influence; - let weight = Math.exp( + const weight = Math.exp( -normalizedDistance * normalizedDistance * gaussianCoeff, ); - // Height-based weight adjustments - if (center.type === "mountains" && baseHeight > mountainHeightThreshold) { - const heightFactor = baseHeight - mountainHeightThreshold; - weight *= 1.0 + heightFactor * mountainWeightBoost; - } - - if ( - (center.type === "valley" || center.type === "plains") && - baseHeight < valleyHeightThreshold - ) { - const heightFactor = valleyHeightThreshold - baseHeight; - weight *= 1.0 + heightFactor * valleyWeightBoost; - } - // Merge same-type biomes const existing = biomeWeightMap.get(center.type) ?? 0; biomeWeightMap.set(center.type, existing + weight); @@ -340,50 +346,18 @@ export class BiomeSystem { return this.getDominantBiome(worldX, worldZ, 0.5); } - /** - * Apply mountain height boost based on biome influence - * Call this after getting base height to add mountain elevation - */ - applyMountainHeightBoost( - worldX: number, - worldZ: number, - baseHeightNormalized: number, - ): number { - const { mountainHeightBoost } = this.config; - - let maxBoost = 0; - - for (const center of this.biomeCenters) { - if (center.type === "mountains") { - const dx = worldX - center.x; - const dz = worldZ - center.z; - const distance = Math.sqrt(dx * dx + dz * dz); - const normalizedDist = distance / center.influence; - - if (normalizedDist < 2.5) { - // Smooth boost that peaks at center and fades out - const boost = Math.exp(-normalizedDist * normalizedDist * 0.3); - maxBoost = Math.max(maxBoost, boost); - } - } - } - - // Apply mountain height boost - const boostedHeight = - baseHeightNormalized * (1 + maxBoost * mountainHeightBoost); - return Math.min(1, boostedHeight); - } - /** @deprecated Use exported BIOME_IDS constant instead */ static readonly BIOME_IDS = BIOME_IDS; + private biomeIds: Record = {}; + private nextBiomeId = 0; + /** Get numeric biome ID for shader use */ getBiomeId(biomeName: string): number { - const id = BIOME_IDS[biomeName]; + const id = this.biomeIds[biomeName]; if (id === undefined) { - throw new Error( - `[BiomeSystem] Unknown biome name: ${biomeName}. Add to BIOME_IDS.`, - ); + this.biomeIds[biomeName] = this.nextBiomeId++; + return this.biomeIds[biomeName]; } return id; } diff --git a/packages/procgen/src/terrain/TerrainGenerator.ts b/packages/procgen/src/terrain/TerrainGenerator.ts index a10456997..51676b51a 100644 --- a/packages/procgen/src/terrain/TerrainGenerator.ts +++ b/packages/procgen/src/terrain/TerrainGenerator.ts @@ -88,11 +88,6 @@ export const DEFAULT_TERRAIN_CONFIG: TerrainConfig = { gaussianCoeff: 0.15, boundaryNoiseScale: 0.003, boundaryNoiseAmount: 0.15, - mountainHeightThreshold: 0.4, - mountainWeightBoost: 2.0, - valleyHeightThreshold: 0.4, - valleyWeightBoost: 1.5, - mountainHeightBoost: 0.5, }, island: DEFAULT_ISLAND_CONFIG, shoreline: DEFAULT_SHORELINE_CONFIG, @@ -252,25 +247,6 @@ export class TerrainGenerator { return height * maxHeight; } - /** - * Get height with mountain biome boost applied - * @returns Height in meters - */ - getHeightWithBiomeBoost(worldX: number, worldZ: number): number { - const { maxHeight } = this.config; - const baseHeight = this.getBaseHeightAt(worldX, worldZ); - const normalizedBase = baseHeight / maxHeight; - - // Apply mountain biome height boost - const boostedNormalized = this.biomeSystem.applyMountainHeightBoost( - worldX, - worldZ, - normalizedBase, - ); - - return boostedNormalized * maxHeight; - } - /** * Calculate terrain slope at a position */ @@ -281,19 +257,19 @@ export class TerrainGenerator { ): number { const { slopeSampleDistance } = this.config.shoreline; - const northHeight = this.getHeightWithBiomeBoost( + const northHeight = this.getBaseHeightAt( worldX, worldZ + slopeSampleDistance, ); - const southHeight = this.getHeightWithBiomeBoost( + const southHeight = this.getBaseHeightAt( worldX, worldZ - slopeSampleDistance, ); - const eastHeight = this.getHeightWithBiomeBoost( + const eastHeight = this.getBaseHeightAt( worldX + slopeSampleDistance, worldZ, ); - const westHeight = this.getHeightWithBiomeBoost( + const westHeight = this.getBaseHeightAt( worldX - slopeSampleDistance, worldZ, ); @@ -359,7 +335,7 @@ export class TerrainGenerator { const { waterThreshold } = this.config; const { landBand, underwaterBand } = this.config.shoreline; - const baseHeight = this.getHeightWithBiomeBoost(worldX, worldZ); + const baseHeight = this.getBaseHeightAt(worldX, worldZ); // Skip shoreline adjustment if far from water if ( diff --git a/packages/procgen/src/terrain/presets.ts b/packages/procgen/src/terrain/presets.ts index d794cce2b..b8ffa0899 100644 --- a/packages/procgen/src/terrain/presets.ts +++ b/packages/procgen/src/terrain/presets.ts @@ -37,11 +37,6 @@ export const SMALL_ISLAND_PRESET: TerrainPreset = { gaussianCoeff: 0.2, boundaryNoiseScale: 0.005, boundaryNoiseAmount: 0.1, - mountainHeightThreshold: 0.5, - mountainWeightBoost: 1.5, - valleyHeightThreshold: 0.3, - valleyWeightBoost: 1.2, - mountainHeightBoost: 0.3, }, }, }; @@ -145,11 +140,6 @@ export const CONTINENT_PRESET: TerrainPreset = { gaussianCoeff: 0.12, boundaryNoiseScale: 0.002, boundaryNoiseAmount: 0.2, - mountainHeightThreshold: 0.35, - mountainWeightBoost: 2.5, - valleyHeightThreshold: 0.4, - valleyWeightBoost: 1.8, - mountainHeightBoost: 0.6, }, noise: { continent: { @@ -207,11 +197,6 @@ export const MOUNTAIN_RANGE_PRESET: TerrainPreset = { gaussianCoeff: 0.1, boundaryNoiseScale: 0.003, boundaryNoiseAmount: 0.15, - mountainHeightThreshold: 0.3, - mountainWeightBoost: 3.0, // Strong mountain boost - valleyHeightThreshold: 0.5, - valleyWeightBoost: 2.0, - mountainHeightBoost: 0.8, // Very high mountain boost }, noise: { continent: { @@ -269,11 +254,6 @@ export const FLAT_PLAINS_PRESET: TerrainPreset = { gaussianCoeff: 0.18, boundaryNoiseScale: 0.002, boundaryNoiseAmount: 0.1, - mountainHeightThreshold: 0.7, // Mountains rare - mountainWeightBoost: 0.5, - valleyHeightThreshold: 0.5, - valleyWeightBoost: 2.0, // Plains dominant - mountainHeightBoost: 0.2, }, noise: { continent: { diff --git a/packages/procgen/src/terrain/types.ts b/packages/procgen/src/terrain/types.ts index 3491877ab..c146b5865 100644 --- a/packages/procgen/src/terrain/types.ts +++ b/packages/procgen/src/terrain/types.ts @@ -100,13 +100,13 @@ export interface BiomeInfluence { * Configuration for biome generation */ export interface BiomeConfig { - /** Grid size for biome center placement (e.g., 3 = 3x3 grid) */ + /** Grid size for biome center placement (e.g., 3 = 3x3 grid). Ignored when explicitCenters is set. */ gridSize: number; - /** Jitter amount for randomizing positions within grid cells (0-0.5) */ + /** Jitter amount for randomizing positions within grid cells (0-0.5). Ignored when explicitCenters is set. */ jitter: number; - /** Minimum influence radius in meters */ + /** Minimum influence radius in meters. Ignored when explicitCenters is set. */ minInfluence: number; - /** Maximum influence radius in meters */ + /** Maximum influence radius in meters. Ignored when explicitCenters is set. */ maxInfluence: number; /** Gaussian falloff coefficient for influence calculation */ gaussianCoeff: number; @@ -114,16 +114,8 @@ export interface BiomeConfig { boundaryNoiseScale: number; /** Noise amount for boundary variation */ boundaryNoiseAmount: number; - /** Height threshold above which mountains get boosted weight */ - mountainHeightThreshold: number; - /** Weight multiplier for mountains at high elevation */ - mountainWeightBoost: number; - /** Height threshold below which valleys/plains get boosted weight */ - valleyHeightThreshold: number; - /** Weight multiplier for valleys at low elevation */ - valleyWeightBoost: number; - /** Height boost factor for mountain biomes */ - mountainHeightBoost: number; + /** Pre-computed biome centers. When provided, grid-jitter placement is skipped. */ + explicitCenters?: BiomeCenter[]; } // ============== ISLAND CONFIGURATION ============== diff --git a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts index 7fafda1cc..965d8998f 100644 --- a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts +++ b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts @@ -181,6 +181,18 @@ export const OCEAN_FLOOR_HEIGHT = 0.05; export const HEIGHT_TERRAIN_MIX = 0.2; export const BEACH_PROFILE_POWER = 3.0; +// --------------------------------------------------------------------------- +// Biome configuration — single source of truth for biome placement & blending +// --------------------------------------------------------------------------- + +export const BIOME_CONFIG = { + gaussianCoeff: 12.0, + boundaryNoiseScale: 0.003, + boundaryNoiseAmount: 0.15, + placementRadius: ISLAND_RADIUS * 0.45, + influenceRadius: ISLAND_RADIUS * 0.6, +} as const; + // --------------------------------------------------------------------------- // Landscape features — mountains & ponds, independent of biomes // --------------------------------------------------------------------------- diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts index ff1fce3af..e8d43fd04 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -31,6 +31,7 @@ import { MAX_HEIGHT, WATER_LEVEL_NORMALIZED, SHORELINE_CONFIG, + BIOME_CONFIG, } from "./TerrainHeightParams"; import type { LandscapeFeatureDef, @@ -41,6 +42,7 @@ import { BiomeType, DEFAULT_BIOME, BIOME_LIST } from "./TerrainBiomeTypes"; // Import terrain generator from procgen package import { TerrainGenerator, + BiomeSystem, type TerrainConfig, type BiomeDefinition, } from "@hyperscape/procgen/terrain"; @@ -184,10 +186,7 @@ export class TerrainSystem extends System { private updateTimer = 0; private terrainTime = 0; // For animated caustics private noise!: NoiseGenerator; - private biomeCenters: BiomeCenter[] = []; private landscapeFeatures: LandscapeFeatureDef[] = []; - private biomeWeightPositionalScratch = new Map(); - private biomeWeightScratch = new Map(); private _loggedWorkerTileBiome = 0; private _loggedSyncTileBiome = 0; private databaseSystem!: { @@ -761,15 +760,9 @@ export class TerrainSystem extends System { TILE_RESOLUTION: this.CONFIG.TILE_RESOLUTION, MAX_HEIGHT: MAX_HEIGHT, // Biome calculation - MUST match getBiomeInfluencesAtPosition() - BIOME_GAUSSIAN_COEFF: this.CONFIG.BIOME_GAUSSIAN_COEFF, - BIOME_BOUNDARY_NOISE_SCALE: this.CONFIG.BIOME_BOUNDARY_NOISE_SCALE, - BIOME_BOUNDARY_NOISE_AMOUNT: this.CONFIG.BIOME_BOUNDARY_NOISE_AMOUNT, - MOUNTAIN_HEIGHT_THRESHOLD: this.CONFIG.MOUNTAIN_HEIGHT_THRESHOLD, - MOUNTAIN_WEIGHT_BOOST: this.CONFIG.MOUNTAIN_WEIGHT_BOOST, - VALLEY_HEIGHT_THRESHOLD: this.CONFIG.VALLEY_HEIGHT_THRESHOLD, - VALLEY_WEIGHT_BOOST: this.CONFIG.VALLEY_WEIGHT_BOOST, - // Mountain height boost - MUST match getHeightAtWithoutShore() - MOUNTAIN_HEIGHT_BOOST: this.CONFIG.MOUNTAIN_HEIGHT_BOOST, + 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, @@ -815,7 +808,9 @@ export class TerrainSystem extends System { tilesToProcess, workerConfig, this.computeSeedFromWorldId(), - this.biomeCenters, + this.terrainGenerator + .getBiomeSystem() + .getBiomeCenters() as BiomeCenter[], biomeData, ); @@ -1164,7 +1159,7 @@ export class TerrainSystem extends System { z: originZ + r.position.z, }, })); - const genericBiome = this.mapBiomeToGeneric(tile.biome as string); + const genericBiome = (tile.biome as BiomeType) || DEFAULT_BIOME; this.world.emit(EventType.TERRAIN_TILE_GENERATED, { tileId: `${tileX},${tileZ}`, position: { x: originX, z: originZ }, @@ -1225,33 +1220,6 @@ export class TerrainSystem extends System { return tile; } - private initializeBiomeCenters(): void { - // Exactly one center per biome type, placed in a triangle within the island - const placementRadius = ISLAND_RADIUS * 0.45; - - this.biomeCenters = []; - for (let i = 0; i < BIOME_LIST.length; i++) { - const angle = (i / BIOME_LIST.length) * Math.PI * 2 - Math.PI / 2; - this.biomeCenters.push({ - x: Math.cos(angle) * placementRadius, - z: Math.sin(angle) * placementRadius, - type: BIOME_LIST[i], - influence: ISLAND_RADIUS * 0.6, - }); - } - - const typeCounts: Record = {}; - for (const c of this.biomeCenters) { - typeCounts[c.type] = (typeCounts[c.type] || 0) + 1; - } - console.log( - "[TerrainSystem] Biome centers:", - this.biomeCenters.length, - "types:", - JSON.stringify(typeCounts), - ); - } - private initializeLandscapeFeatures(): void { this.landscapeFeatures = [...LANDSCAPE_FEATURES]; console.log( @@ -1328,18 +1296,18 @@ export class TerrainSystem extends System { }, }, biomes: { - gridSize: this.CONFIG.BIOME_GRID_SIZE, - jitter: this.CONFIG.BIOME_JITTER, - minInfluence: this.CONFIG.BIOME_MIN_INFLUENCE, - maxInfluence: this.CONFIG.BIOME_MAX_INFLUENCE, - gaussianCoeff: this.CONFIG.BIOME_GAUSSIAN_COEFF, - boundaryNoiseScale: this.CONFIG.BIOME_BOUNDARY_NOISE_SCALE, - boundaryNoiseAmount: this.CONFIG.BIOME_BOUNDARY_NOISE_AMOUNT, - mountainHeightThreshold: this.CONFIG.MOUNTAIN_HEIGHT_THRESHOLD, - mountainWeightBoost: this.CONFIG.MOUNTAIN_WEIGHT_BOOST, - valleyHeightThreshold: this.CONFIG.VALLEY_HEIGHT_THRESHOLD, - valleyWeightBoost: this.CONFIG.VALLEY_WEIGHT_BOOST, - mountainHeightBoost: this.CONFIG.MOUNTAIN_HEIGHT_BOOST, + gridSize: 3, + jitter: 0, + minInfluence: BIOME_CONFIG.influenceRadius, + maxInfluence: BIOME_CONFIG.influenceRadius, + gaussianCoeff: BIOME_CONFIG.gaussianCoeff, + boundaryNoiseScale: BIOME_CONFIG.boundaryNoiseScale, + boundaryNoiseAmount: BIOME_CONFIG.boundaryNoiseAmount, + explicitCenters: BiomeSystem.computePolygonCenters( + BIOME_LIST as string[], + BIOME_CONFIG.placementRadius, + BIOME_CONFIG.influenceRadius, + ), }, island: { enabled: this.CONFIG.ISLAND_MASK_ENABLED, @@ -1423,23 +1391,6 @@ export class TerrainSystem extends System { BOSS_HOTSPOT_RADIUS: 120, BOSS_MIN_LEVEL: 800, - // Biome Generation - BIOME_GRID_SIZE: 5, // 5x5 grid = 25 biomes, visible within camera range - BIOME_JITTER: 0.35, // How much to randomize position within grid cell (0-0.5) - BIOME_MIN_INFLUENCE: 120, // Biome influence radius — roughly 1 grid cell - BIOME_MAX_INFLUENCE: 200, // Biome influence radius — ~1.5 grid cells - BIOME_GAUSSIAN_COEFF: 12.0, // Gaussian falloff — higher = sharper biome boundaries - BIOME_RANGE_MULT: 10, // Not used - no hard cutoff - BIOME_BOUNDARY_NOISE_SCALE: 0.003, // Larger scale noise for organic boundaries - BIOME_BOUNDARY_NOISE_AMOUNT: 0.15, // Subtle noise - - // Height-Biome Coupling - MOUNTAIN_HEIGHT_BOOST: 0.5, // How much mountains raise terrain (0.5 = 50%) - MOUNTAIN_HEIGHT_THRESHOLD: 0.4, // Height above which mountains get weight boost - MOUNTAIN_WEIGHT_BOOST: 2.0, // Max weight multiplier for mountains at high elevation - VALLEY_HEIGHT_THRESHOLD: 0.4, // Height below which valleys/plains get weight boost - VALLEY_WEIGHT_BOOST: 1.5, // Max weight multiplier for valleys at low elevation - // Shoreline — delegated to TerrainHeightParams.SHORELINE_CONFIG (single source of truth) // Island Mask (default for main world) @@ -1498,8 +1449,6 @@ export class TerrainSystem extends System { // Initialize deterministic noise from world id this.noise = new NoiseGenerator(this.computeSeedFromWorldId()); - // Initialize biome centers using deterministic random placement - this.initializeBiomeCenters(); this.initializeLandscapeFeatures(); // Initialize the unified terrain generator from @hyperscape/procgen @@ -1610,7 +1559,6 @@ export class TerrainSystem extends System { // Initialize noise generator if not already initialized (failsafe) if (!this.noise) { this.noise = new NoiseGenerator(this.computeSeedFromWorldId()); - this.initializeBiomeCenters(); this.initializeLandscapeFeatures(); } @@ -1862,14 +1810,9 @@ export class TerrainSystem extends System { const workerConfig: QuadChunkWorkerConfig = { MAX_HEIGHT: MAX_HEIGHT, - BIOME_GAUSSIAN_COEFF: this.CONFIG.BIOME_GAUSSIAN_COEFF, - BIOME_BOUNDARY_NOISE_SCALE: this.CONFIG.BIOME_BOUNDARY_NOISE_SCALE, - BIOME_BOUNDARY_NOISE_AMOUNT: this.CONFIG.BIOME_BOUNDARY_NOISE_AMOUNT, - MOUNTAIN_HEIGHT_THRESHOLD: this.CONFIG.MOUNTAIN_HEIGHT_THRESHOLD, - MOUNTAIN_WEIGHT_BOOST: this.CONFIG.MOUNTAIN_WEIGHT_BOOST, - VALLEY_HEIGHT_THRESHOLD: this.CONFIG.VALLEY_HEIGHT_THRESHOLD, - VALLEY_WEIGHT_BOOST: this.CONFIG.VALLEY_WEIGHT_BOOST, - MOUNTAIN_HEIGHT_BOOST: this.CONFIG.MOUNTAIN_HEIGHT_BOOST, + 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, @@ -1883,7 +1826,9 @@ export class TerrainSystem extends System { landscapeFeatures: this.landscapeFeatures, }; - const biomeCenters = this.biomeCenters.map((c) => ({ + const biomeCenters = [ + ...this.terrainGenerator.getBiomeSystem().getBiomeCenters(), + ].map((c) => ({ x: c.x, z: c.z, type: c.type, @@ -2486,7 +2431,7 @@ export class TerrainSystem extends System { }; return { id: r.id, type: r.type, position: pos }; }); - const genericBiome = this.mapBiomeToGeneric(tile.biome as string); + const genericBiome = (tile.biome as BiomeType) || DEFAULT_BIOME; this.world.emit(EventType.TERRAIN_TILE_GENERATED, { tileId: `${tileX},${tileZ}`, position: { x: originX, z: originZ }, @@ -3323,10 +3268,6 @@ export class TerrainSystem extends System { ): number { if (!this.noise) { this.noise = new NoiseGenerator(this.computeSeedFromWorldId()); - if (!this.biomeCenters || this.biomeCenters.length === 0) { - this.initializeBiomeCenters(); - this.initializeLandscapeFeatures(); - } } const weights = @@ -3342,12 +3283,8 @@ export class TerrainSystem extends System { } private getHeightAtWithoutShore(worldX: number, worldZ: number): number { - if (!this.biomeCenters || this.biomeCenters.length === 0) { - if (!this.noise) { - this.noise = new NoiseGenerator(this.computeSeedFromWorldId()); - } - this.initializeBiomeCenters(); - this.initializeLandscapeFeatures(); + if (!this.noise) { + this.noise = new NoiseGenerator(this.computeSeedFromWorldId()); } const flatHeight = this.getFlatZoneHeight(worldX, worldZ); @@ -4567,65 +4504,24 @@ export class TerrainSystem extends System { worldX: number, worldZ: number, ): Array<{ type: string; weight: number }> { - const { biomeWeightMap, totalWeight } = this.computeBiomeWeightsAtPosition( - worldX, - worldZ, - ); - - const biomeInfluences: Array<{ type: string; weight: number }> = []; - if (totalWeight > 0) { - const invTotal = 1 / totalWeight; - for (const [type, weight] of biomeWeightMap) { - biomeInfluences.push({ type, weight: weight * invTotal }); - } - } else { - // Fallback to plains if no biome centers are nearby - biomeInfluences.push({ type: DEFAULT_BIOME, weight: 1.0 }); - } - - return biomeInfluences; + return this.terrainGenerator + .getBiomeSystem() + .getBiomeInfluencesAtPosition(worldX, worldZ, 0); } private computeBiomeWeightsAtPosition( worldX: number, worldZ: number, ): { biomeWeightMap: Map; totalWeight: number } { - // Biome weights are purely positional (gaussian distance from centres + - // boundary noise). No height lookup here — getBaseHeightAt depends on - // biome weights, so calling it would create infinite recursion. - const boundaryNoise = this.noise.simplex2D( - worldX * this.CONFIG.BIOME_BOUNDARY_NOISE_SCALE, - worldZ * this.CONFIG.BIOME_BOUNDARY_NOISE_SCALE, - ); - - const biomeWeightMap = this.biomeWeightScratch; - biomeWeightMap.clear(); - - for (const center of this.biomeCenters) { - const dx = worldX - center.x; - const dz = worldZ - center.z; - const distance = Math.sqrt(dx * dx + dz * dz); - - const noisyDistance = - distance * - (1 + boundaryNoise * this.CONFIG.BIOME_BOUNDARY_NOISE_AMOUNT); - - const normalizedDistance = noisyDistance / center.influence; - const weight = Math.exp( - -normalizedDistance * - normalizedDistance * - this.CONFIG.BIOME_GAUSSIAN_COEFF, - ); - - const existing = biomeWeightMap.get(center.type) || 0; - biomeWeightMap.set(center.type, existing + weight); - } - + const influences = this.terrainGenerator + .getBiomeSystem() + .getBiomeInfluencesAtPosition(worldX, worldZ, 0); + const biomeWeightMap = new Map(); let totalWeight = 0; - for (const [type, weight] of biomeWeightMap) { - totalWeight += weight; + for (const inf of influences) { + biomeWeightMap.set(inf.type, inf.weight); + totalWeight += inf.weight; } - return { biomeWeightMap, totalWeight }; } @@ -4633,41 +4529,23 @@ export class TerrainSystem extends System { worldX: number, worldZ: number, ): Record { - const { biomeWeightMap, totalWeight } = this.computeBiomeWeightsAtPosition( - worldX, - worldZ, - ); + const influences = this.terrainGenerator + .getBiomeSystem() + .getBiomeInfluencesAtPosition(worldX, worldZ, 0); const result: Record = {}; - if (totalWeight > 0) { - const inv = 1 / totalWeight; - for (const [type, weight] of biomeWeightMap) { - result[type] = weight * inv; - } - } else { + for (const inf of influences) { + result[inf.type] = inf.weight; + } + if (influences.length === 0) { result[DEFAULT_BIOME] = 1.0; } return result; } private getBiomeAtWorldPosition(worldX: number, worldZ: number): string { - const influences = this.getBiomeInfluencesAtPosition(worldX, worldZ); - return influences.length > 0 ? influences[0].type : DEFAULT_BIOME; - } - - private getBiomeNoise(x: number, z: number): number { - // Simple noise function for biome determination - return ( - Math.sin(x * 2.1 + z * 1.7) * Math.cos(x * 1.3 - z * 2.4) * 0.5 + - Math.sin(x * 4.2 + z * 3.8) * Math.cos(x * 2.7 - z * 4.1) * 0.3 + - Math.sin(x * 8.1 - z * 6.2) * Math.cos(x * 5.9 + z * 7.3) * 0.2 - ); - } - - private mapBiomeToGeneric(internal: string): BiomeType { - if (Object.values(BiomeType).includes(internal as BiomeType)) { - return internal as BiomeType; - } - return DEFAULT_BIOME; + return this.terrainGenerator + .getBiomeSystem() + .getDominantBiome(worldX, worldZ, 0); } private generateTileResources(tile: TerrainTile): void { @@ -6029,13 +5907,8 @@ export class TerrainSystem extends System { worldZ: number, overrideDifficultyLevel?: number, ): DifficultySample { - // Ensure noise and biome centers are initialized if (!this.noise) { this.noise = new NoiseGenerator(this.computeSeedFromWorldId()); - if (this.biomeCenters.length === 0) { - this.initializeBiomeCenters(); - this.initializeLandscapeFeatures(); - } } const biome = this.getBiomeAtWorldPosition(worldX, worldZ); @@ -6200,10 +6073,6 @@ export class TerrainSystem extends System { if (!this.noise) { this.noise = new NoiseGenerator(this.computeSeedFromWorldId()); - if (this.biomeCenters.length === 0) { - this.initializeBiomeCenters(); - this.initializeLandscapeFeatures(); - } } const worldSizeMeters = this.getActiveWorldSizeMeters(); @@ -6315,7 +6184,7 @@ export class TerrainSystem extends System { tilesData.push({ tileX: tile.x, tileZ: tile.z, - biome: this.mapBiomeToGeneric(tile.biome), + biome: (tile.biome as BiomeType) || DEFAULT_BIOME, }); } diff --git a/packages/shared/src/utils/workers/QuadChunkWorker.ts b/packages/shared/src/utils/workers/QuadChunkWorker.ts index 45eaa1f9f..e41c9d970 100644 --- a/packages/shared/src/utils/workers/QuadChunkWorker.ts +++ b/packages/shared/src/utils/workers/QuadChunkWorker.ts @@ -31,11 +31,6 @@ export interface QuadChunkWorkerConfig { BIOME_GAUSSIAN_COEFF: number; BIOME_BOUNDARY_NOISE_SCALE: number; BIOME_BOUNDARY_NOISE_AMOUNT: number; - MOUNTAIN_HEIGHT_THRESHOLD: number; - MOUNTAIN_WEIGHT_BOOST: number; - VALLEY_HEIGHT_THRESHOLD: number; - VALLEY_WEIGHT_BOOST: number; - MOUNTAIN_HEIGHT_BOOST: number; WATER_THRESHOLD: number; WATER_LEVEL_NORMALIZED: number; SHORELINE_THRESHOLD: number; @@ -96,11 +91,6 @@ function generateQuadChunk(input) { BIOME_GAUSSIAN_COEFF, BIOME_BOUNDARY_NOISE_SCALE, BIOME_BOUNDARY_NOISE_AMOUNT, - MOUNTAIN_HEIGHT_THRESHOLD, - MOUNTAIN_WEIGHT_BOOST, - VALLEY_HEIGHT_THRESHOLD, - VALLEY_WEIGHT_BOOST, - MOUNTAIN_HEIGHT_BOOST, WATER_THRESHOLD, WATER_LEVEL_NORMALIZED, SHORELINE_THRESHOLD, diff --git a/packages/shared/src/utils/workers/TerrainWorker.ts b/packages/shared/src/utils/workers/TerrainWorker.ts index 0416620ac..0dc59aba0 100644 --- a/packages/shared/src/utils/workers/TerrainWorker.ts +++ b/packages/shared/src/utils/workers/TerrainWorker.ts @@ -32,12 +32,6 @@ export interface TerrainWorkerConfig { BIOME_GAUSSIAN_COEFF: number; BIOME_BOUNDARY_NOISE_SCALE: number; BIOME_BOUNDARY_NOISE_AMOUNT: number; - MOUNTAIN_HEIGHT_THRESHOLD: number; - MOUNTAIN_WEIGHT_BOOST: number; - VALLEY_HEIGHT_THRESHOLD: number; - VALLEY_WEIGHT_BOOST: number; - // Mountain height boost - MUST match TerrainSystem.getHeightAtWithoutShore() - MOUNTAIN_HEIGHT_BOOST: number; // Shoreline config - MUST match TerrainSystem.getHeightAt() and createTileGeometry() WATER_THRESHOLD: number; WATER_LEVEL_NORMALIZED: number; @@ -129,11 +123,6 @@ function generateHeightmap(input) { BIOME_GAUSSIAN_COEFF, BIOME_BOUNDARY_NOISE_SCALE, BIOME_BOUNDARY_NOISE_AMOUNT, - MOUNTAIN_HEIGHT_THRESHOLD, - MOUNTAIN_WEIGHT_BOOST, - VALLEY_HEIGHT_THRESHOLD, - VALLEY_WEIGHT_BOOST, - MOUNTAIN_HEIGHT_BOOST, WATER_THRESHOLD, WATER_LEVEL_NORMALIZED, SHORELINE_THRESHOLD, diff --git a/packages/shared/src/utils/workers/TerrainWorkerShared.ts b/packages/shared/src/utils/workers/TerrainWorkerShared.ts index d490ab0f8..40c984c81 100644 --- a/packages/shared/src/utils/workers/TerrainWorkerShared.ts +++ b/packages/shared/src/utils/workers/TerrainWorkerShared.ts @@ -208,8 +208,7 @@ export function buildHeightHelpersJS(): string { * - noise: NoiseGenerator * - biomeCenters: array of biome center objects * - BIOME_GAUSSIAN_COEFF, BIOME_BOUNDARY_NOISE_*: from config - * - VALLEY_HEIGHT_THRESHOLD, VALLEY_WEIGHT_BOOST: from config - * - BT_DEFAULT, BT_TUNDRA: from buildBiomeConstantsJS() + * - BT_DEFAULT: from buildBiomeConstantsJS() */ export function buildBiomeInfluencesJS(): string { return ` @@ -228,10 +227,7 @@ export function buildBiomeInfluencesJS(): string { const distance = Math.sqrt(dx * dx + dz * dz); const noisyDistance = distance * (1 + boundaryNoise * BIOME_BOUNDARY_NOISE_AMOUNT); const normalizedDistance = noisyDistance / center.influence; - let weight = Math.exp(-normalizedDistance * normalizedDistance * BIOME_GAUSSIAN_COEFF); - if (center.type === BT_TUNDRA && normalizedHeight < VALLEY_HEIGHT_THRESHOLD) { - weight *= 1.0 + (VALLEY_HEIGHT_THRESHOLD - normalizedHeight) * VALLEY_WEIGHT_BOOST; - } + const weight = Math.exp(-normalizedDistance * normalizedDistance * BIOME_GAUSSIAN_COEFF); biomeWeightMap[center.type] = (biomeWeightMap[center.type] || 0) + weight; } const biomeInfluences = []; From e403f6edca557ed6535cbb79177f10695417bee7 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Tue, 10 Mar 2026 16:52:42 +0800 Subject: [PATCH 36/48] refactor(biome): remove dead code and fix biome lookup consistency - Remove unused DEFAULT_BIOMES, BIOME_IDS, BIOME_TYPE_WEIGHTS from BiomeSystem and their exports from procgen/terrain/index - Remove hardcoded town override from TerrainSystem.getBiomeAt() that forced forest biome near legacy starter town coordinates - Fix BiomeSystem coordinate math: use tile center (tileX * tileSize) instead of offset (tileX * tileSize + tileSize/2) - Delegate TerrainSystem.getBiomeAt() directly to BiomeSystem.getBiomeForTile() - Use dynamic biome keys for fallbacks instead of hardcoded "plains" - Update TerrainGenerator.test.ts to use local TEST_BIOMES fixture - Remove temporary biome debug logging Made-with: Cursor --- packages/procgen/src/terrain/BiomeSystem.ts | 163 ++++-------------- .../src/terrain/TerrainGenerator.test.ts | 43 ++++- .../procgen/src/terrain/TerrainGenerator.ts | 6 +- packages/procgen/src/terrain/index.ts | 7 +- .../src/systems/shared/world/TerrainSystem.ts | 22 +-- 5 files changed, 76 insertions(+), 165 deletions(-) diff --git a/packages/procgen/src/terrain/BiomeSystem.ts b/packages/procgen/src/terrain/BiomeSystem.ts index 20ba060c8..179498c48 100644 --- a/packages/procgen/src/terrain/BiomeSystem.ts +++ b/packages/procgen/src/terrain/BiomeSystem.ts @@ -14,95 +14,6 @@ import type { BiomeDefinition, } from "./types"; -/** Numeric biome IDs for shader use */ -export const BIOME_IDS: Record = { - plains: 0, - forest: 1, - valley: 2, - mountains: 3, - tundra: 4, - desert: 5, - lakes: 6, - swamp: 7, -}; - -/** Default biome definitions (can be overridden) */ -export const DEFAULT_BIOMES: Record = { - plains: { - id: "plains", - name: "Plains", - color: 0x7cba5f, - terrainMultiplier: 1.0, - difficultyLevel: 0, - heightRange: [0.1, 0.5], - resourceDensity: 1.0, - }, - forest: { - id: "forest", - name: "Forest", - color: 0x2f7d32, - terrainMultiplier: 1.1, - difficultyLevel: 1, - heightRange: [0.2, 0.6], - resourceDensity: 1.5, - }, - valley: { - id: "valley", - name: "Valley", - color: 0x6b8e23, - terrainMultiplier: 0.8, - difficultyLevel: 1, - heightRange: [0.05, 0.3], - resourceDensity: 1.2, - }, - mountains: { - id: "mountains", - name: "Mountains", - color: 0x808080, - terrainMultiplier: 1.5, - difficultyLevel: 3, - heightRange: [0.5, 1.0], - maxSlope: 0.9, - resourceDensity: 0.7, - }, - desert: { - id: "desert", - name: "Desert", - color: 0xdaa520, - terrainMultiplier: 0.9, - difficultyLevel: 2, - heightRange: [0.1, 0.4], - resourceDensity: 0.3, - }, - swamp: { - id: "swamp", - name: "Swamp", - color: 0x556b2f, - terrainMultiplier: 0.7, - difficultyLevel: 2, - heightRange: [0.0, 0.25], - resourceDensity: 0.8, - }, - tundra: { - id: "tundra", - name: "Tundra", - color: 0xb0c4de, - terrainMultiplier: 1.0, - difficultyLevel: 3, - heightRange: [0.3, 0.8], - resourceDensity: 0.4, - }, - lakes: { - id: "lakes", - name: "Lakes", - color: 0x4682b4, - terrainMultiplier: 0.5, - difficultyLevel: 1, - heightRange: [0.0, 0.15], - resourceDensity: 0.5, - }, -}; - /** * Default biome configuration */ @@ -116,24 +27,6 @@ export const DEFAULT_BIOME_CONFIG: BiomeConfig = { boundaryNoiseAmount: 0.15, }; -/** - * Weighted biome types for random selection - * Plains is dominant with variety from other biomes - */ -const BIOME_TYPE_WEIGHTS = [ - "plains", - "plains", - "plains", - "forest", - "forest", - "valley", - "mountains", - "mountains", - "desert", - "swamp", - "tundra", -]; - /** * BiomeSystem handles biome placement and influence calculations */ @@ -148,7 +41,7 @@ export class BiomeSystem { seed: number, worldSizeMeters: number, config: Partial = {}, - biomeDefinitions: Record = DEFAULT_BIOMES, + biomeDefinitions: Record = {}, ) { this.config = { ...DEFAULT_BIOME_CONFIG, ...config }; this.biomeDefinitions = biomeDefinitions; @@ -200,6 +93,11 @@ export class BiomeSystem { // Use deterministic PRNG for reproducible biome placement const random = createSeededRNG(seed); + const biomeTypes = Object.keys(this.biomeDefinitions); + if (biomeTypes.length === 0) { + return; + } + this.biomeCenters = []; // Grid-jitter placement for even distribution @@ -216,15 +114,15 @@ export class BiomeSystem { const x = baseX + jitterX; const z = baseZ + jitterZ; - // Random biome type and influence - const typeIndex = Math.floor(random() * BIOME_TYPE_WEIGHTS.length); + // Random biome type from provided definitions and influence + const typeIndex = Math.floor(random() * biomeTypes.length); const influenceRange = maxInfluence - minInfluence; const influence = minInfluence + random() * influenceRange; this.biomeCenters.push({ x, z, - type: BIOME_TYPE_WEIGHTS[typeIndex], + type: biomeTypes[typeIndex], influence, }); } @@ -242,14 +140,20 @@ export class BiomeSystem { * Get biome definition by ID */ getBiomeDefinition(biomeId: string): BiomeDefinition { - return this.biomeDefinitions[biomeId] ?? this.biomeDefinitions["plains"]; - } - - /** - * Get all biome definitions - */ - getAllBiomeDefinitions(): Record { - return this.biomeDefinitions; + const def = this.biomeDefinitions[biomeId]; + if (def) return def; + const keys = Object.keys(this.biomeDefinitions); + return keys.length > 0 + ? this.biomeDefinitions[keys[0]] + : { + id: biomeId, + name: biomeId, + color: 0x808080, + terrainMultiplier: 1, + difficultyLevel: 0, + heightRange: [0, 1], + resourceDensity: 1, + }; } /** @@ -312,8 +216,8 @@ export class BiomeSystem { influence.weight /= totalWeight; } } else { - // Fallback to plains if no biome centers are nearby - biomeInfluences.push({ type: "plains", weight: 1.0 }); + const fallback = Object.keys(this.biomeDefinitions)[0] ?? "unknown"; + biomeInfluences.push({ type: fallback, weight: 1.0 }); } // Sort by weight descending @@ -331,24 +235,21 @@ export class BiomeSystem { worldZ, baseHeight, ); - return influences.length > 0 ? influences[0].type : "plains"; + if (influences.length > 0) return influences[0].type; + const keys = Object.keys(this.biomeDefinitions); + return keys.length > 0 ? keys[0] : "unknown"; } /** * Get the dominant biome for a terrain tile (at tile center) */ getBiomeForTile(tileX: number, tileZ: number, tileSize: number): string { - // Get world coordinates for center of tile - const worldX = tileX * tileSize + tileSize / 2; - const worldZ = tileZ * tileSize + tileSize / 2; - - // Use mid-range height for tile-level biome query - return this.getDominantBiome(worldX, worldZ, 0.5); + // Tile geometry is centered at (tileX * tileSize, tileZ * tileSize) + const worldX = tileX * tileSize; + const worldZ = tileZ * tileSize; + return this.getDominantBiome(worldX, worldZ, 0); } - /** @deprecated Use exported BIOME_IDS constant instead */ - static readonly BIOME_IDS = BIOME_IDS; - private biomeIds: Record = {}; private nextBiomeId = 0; diff --git a/packages/procgen/src/terrain/TerrainGenerator.test.ts b/packages/procgen/src/terrain/TerrainGenerator.test.ts index 7321484f1..7c5c93bef 100644 --- a/packages/procgen/src/terrain/TerrainGenerator.test.ts +++ b/packages/procgen/src/terrain/TerrainGenerator.test.ts @@ -7,7 +7,38 @@ import { describe, it, expect, beforeEach } from "vitest"; import { TerrainGenerator, DEFAULT_TERRAIN_CONFIG } from "./TerrainGenerator"; -import { BiomeSystem, DEFAULT_BIOMES } from "./BiomeSystem"; +import { BiomeSystem } from "./BiomeSystem"; +import type { BiomeDefinition } from "./types"; + +const TEST_BIOMES: Record = { + forest: { + id: "forest", + name: "Forest", + color: 0x2f7d32, + terrainMultiplier: 1.1, + difficultyLevel: 1, + heightRange: [0.2, 0.6], + resourceDensity: 1.5, + }, + tundra: { + id: "tundra", + name: "Tundra", + color: 0xb0c4de, + terrainMultiplier: 1.0, + difficultyLevel: 3, + heightRange: [0.3, 0.8], + resourceDensity: 0.4, + }, + canyon: { + id: "canyon", + name: "Canyon", + color: 0xdaa520, + terrainMultiplier: 0.9, + difficultyLevel: 2, + heightRange: [0.1, 0.4], + resourceDensity: 0.3, + }, +}; import { IslandMask } from "./IslandMask"; import { NoiseGenerator, @@ -20,7 +51,7 @@ describe("TerrainGenerator", () => { let generator: TerrainGenerator; beforeEach(() => { - generator = new TerrainGenerator({ seed: 12345 }); + generator = new TerrainGenerator({ seed: 12345 }, TEST_BIOMES); }); describe("initialization", () => { @@ -174,7 +205,7 @@ describe("TerrainGenerator", () => { it("should return valid biome names", () => { const point = generator.queryPoint(150, 150); - const validBiomes = Object.keys(DEFAULT_BIOMES); + const validBiomes = Object.keys(TEST_BIOMES); expect(validBiomes).toContain(point.biome); }); @@ -247,7 +278,7 @@ describe("BiomeSystem", () => { let biomeSystem: BiomeSystem; beforeEach(() => { - biomeSystem = new BiomeSystem(12345, 10000); // 10km world + biomeSystem = new BiomeSystem(12345, 10000, {}, TEST_BIOMES); }); it("should create biome centers", () => { @@ -262,7 +293,7 @@ describe("BiomeSystem", () => { }); it("should return valid biome types", () => { - const validTypes = Object.keys(DEFAULT_BIOMES); + const validTypes = Object.keys(TEST_BIOMES); const influences = biomeSystem.getBiomeInfluencesAtPosition(500, 500, 0.3); for (const influence of influences) { @@ -272,8 +303,8 @@ describe("BiomeSystem", () => { it("should blend colors correctly", () => { const influences = [ - { type: "plains", weight: 0.5 }, { type: "forest", weight: 0.5 }, + { type: "tundra", weight: 0.5 }, ]; const color = biomeSystem.blendBiomeColors(influences); diff --git a/packages/procgen/src/terrain/TerrainGenerator.ts b/packages/procgen/src/terrain/TerrainGenerator.ts index 51676b51a..7c5d72634 100644 --- a/packages/procgen/src/terrain/TerrainGenerator.ts +++ b/packages/procgen/src/terrain/TerrainGenerator.ts @@ -9,7 +9,7 @@ */ import { NoiseGenerator, createTileRNG } from "./NoiseGenerator"; -import { BiomeSystem, DEFAULT_BIOMES } from "./BiomeSystem"; +import { BiomeSystem } from "./BiomeSystem"; import { IslandMask, DEFAULT_ISLAND_CONFIG } from "./IslandMask"; import type { TerrainConfig, @@ -109,7 +109,7 @@ export class TerrainGenerator { constructor( config: Partial = {}, - biomeDefinitions: Record = DEFAULT_BIOMES, + biomeDefinitions: Record = {}, ) { // Merge with defaults this.config = { @@ -440,7 +440,7 @@ export class TerrainGenerator { } // Determine dominant biome for the tile - let tileDominantBiome = "plains"; + let tileDominantBiome = "unknown"; let maxWeight = 0; for (const [biome, weight] of biomeWeights) { if (weight > maxWeight) { diff --git a/packages/procgen/src/terrain/index.ts b/packages/procgen/src/terrain/index.ts index a1fd3995a..4c0591302 100644 --- a/packages/procgen/src/terrain/index.ts +++ b/packages/procgen/src/terrain/index.ts @@ -38,12 +38,7 @@ export { } from "./NoiseGenerator"; // Biome system -export { - BiomeSystem, - BIOME_IDS, - DEFAULT_BIOMES, - DEFAULT_BIOME_CONFIG, -} from "./BiomeSystem"; +export { BiomeSystem, DEFAULT_BIOME_CONFIG } from "./BiomeSystem"; // Island mask export { diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts index e8d43fd04..3131c0ee5 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -4479,25 +4479,9 @@ export class TerrainSystem extends System { } private getBiomeAt(tileX: number, tileZ: number): string { - // Get world coordinates for center of tile - const worldX = tileX * this.CONFIG.TILE_SIZE + this.CONFIG.TILE_SIZE / 2; - const worldZ = tileZ * this.CONFIG.TILE_SIZE + this.CONFIG.TILE_SIZE / 2; - - // Check if near starter towns first (safe zones) - const towns = [ - { x: 0, z: 0, name: "Brookhaven" }, - { x: 10, z: 0, name: "Eastport" }, - { x: -10, z: 0, name: "Westfall" }, - { x: 0, z: 10, name: "Northridge" }, - { x: 0, z: -10, name: "Southmere" }, - ]; - - for (const town of towns) { - const distance = Math.sqrt((tileX - town.x) ** 2 + (tileZ - town.z) ** 2); - if (distance < 3) return DEFAULT_BIOME; - } - - return this.getBiomeAtWorldPosition(worldX, worldZ); + return this.terrainGenerator + .getBiomeSystem() + .getBiomeForTile(tileX, tileZ, this.CONFIG.TILE_SIZE); } private getBiomeInfluencesAtPosition( From c222dff172f2b7043b7b80257e44b63f80a2060d Mon Sep 17 00:00:00 2001 From: lalalune Date: Tue, 10 Mar 2026 05:06:21 -0700 Subject: [PATCH 37/48] fix: remove default PhysicsSystem in streaming mode to unblock world init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The World constructor registers a default PhysicsSystem whose init() calls waitForPhysX() with a 120s timeout. In streaming/spectator mode we skip replacing it with the full Physics system, but the default stub remained registered and stalled the entire init chain — causing the loading screen to freeze at 3% "Initializing world systems...". Now we explicitly removeSystem("physics") in streaming mode so the init pipeline proceeds immediately without waiting for PhysX WASM. --- packages/shared/src/runtime/createClientWorld.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/shared/src/runtime/createClientWorld.ts b/packages/shared/src/runtime/createClientWorld.ts index 1b42d1903..d024fe64a 100644 --- a/packages/shared/src/runtime/createClientWorld.ts +++ b/packages/shared/src/runtime/createClientWorld.ts @@ -257,6 +257,12 @@ export function createClientWorld() { // browser. RigidBody/Collider nodes already guard against missing PHYSX. if (!isStreamingLikeViewport()) { replaceSystem(world, "physics", Physics); + } else { + // The World constructor registers a default PhysicsSystem whose init() + // calls waitForPhysX() with a 120s timeout. If we leave it registered + // the entire init chain stalls waiting for PhysX WASM that will never + // arrive. Remove it so the init pipeline proceeds immediately. + removeSystem(world, "physics"); } // Interaction system - handles clicks, raycasting, context menus From 9711e62a0bcfe13345a43639b868af42fe79d5ec Mon Sep 17 00:00:00 2001 From: Shaw Date: Tue, 10 Mar 2026 05:06:36 -0700 Subject: [PATCH 38/48] fix(shared,procgen): resolve physics race condition and procgen type definitions --- packages/contracts/package.json | 2 +- packages/procgen/package.json | 2 +- .../src/building/viewer/BuildingViewer.tsx | 18 +-- .../src/building/viewer/TownViewer.tsx | 2 +- packages/procgen/src/building/viewer/index.ts | 5 + packages/procgen/tsconfig.json | 1 - .../server/tests/unit/oracle/config.test.ts | 16 ++- .../src/systems/client/ClientNetwork.ts | 2 +- .../src/systems/shared/entities/Entities.ts | 14 +-- turbo.json | 111 ++++++++++++++---- 10 files changed, 126 insertions(+), 47 deletions(-) diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 2b441ee92..3f973ab9c 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -13,7 +13,7 @@ "dev": "mud dev-contracts", "lint": "bunx solhint --disc 'src/**/*.sol'", "tablegen": "mud tablegen", - "test": "if [ -n \"$(find test -name '*.t.sol' -print -quit 2>/dev/null)\" ]; then PRIVATE_KEY=${PRIVATE_KEY:-0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80} bunx mud test; else echo 'No contract tests defined (test/**/*.t.sol not found); skipping mud test'; fi", + "test": "if [ -n \"$(find test -name '*.t.sol' -print -quit 2>/dev/null)\" ]; then export PATH=\"$PWD/node_modules/.bin:/opt/homebrew/bin:/usr/local/bin:$PATH\"; export PRIVATE_KEY=${PRIVATE_KEY:-0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80}; mud test --port 4252; else echo 'No contract tests defined (test/**/*.t.sol not found); skipping mud test'; fi", "worldgen": "mud worldgen" }, "devDependencies": { diff --git a/packages/procgen/package.json b/packages/procgen/package.json index 3a251c71a..fa60670de 100644 --- a/packages/procgen/package.json +++ b/packages/procgen/package.json @@ -121,4 +121,4 @@ "engines": { "node": ">=18.0.0" } -} +} \ No newline at end of file diff --git a/packages/procgen/src/building/viewer/BuildingViewer.tsx b/packages/procgen/src/building/viewer/BuildingViewer.tsx index 8f2b16ce6..175be6fa1 100644 --- a/packages/procgen/src/building/viewer/BuildingViewer.tsx +++ b/packages/procgen/src/building/viewer/BuildingViewer.tsx @@ -40,21 +40,21 @@ import { NavigationVisualizer, type NavigationVisualizerOptions, } from "./NavigationVisualizer"; -import type { TileCoord } from "@hyperscape/shared"; +import type { TileCoord } from "./index"; /** Available material styles for the viewer */ const MATERIAL_OPTIONS: { value: BuildingMaterialType | "vertex-colors"; label: string; }[] = [ - { value: "vertex-colors", label: "Vertex Colors (Classic)" }, - { value: "brick", label: "Brick" }, - { value: "stone-ashlar", label: "Stone (Ashlar)" }, - { value: "stone-rubble", label: "Stone (Rubble)" }, - { value: "wood-plank", label: "Wood Planks" }, - { value: "timber-frame", label: "Timber Frame" }, - { value: "plaster", label: "Plaster/Stucco" }, -]; + { value: "vertex-colors", label: "Vertex Colors (Classic)" }, + { value: "brick", label: "Brick" }, + { value: "stone-ashlar", label: "Stone (Ashlar)" }, + { value: "stone-rubble", label: "Stone (Rubble)" }, + { value: "wood-plank", label: "Wood Planks" }, + { value: "timber-frame", label: "Timber Frame" }, + { value: "plaster", label: "Plaster/Stucco" }, + ]; /** Path info for navigation display */ export interface PathInfo { diff --git a/packages/procgen/src/building/viewer/TownViewer.tsx b/packages/procgen/src/building/viewer/TownViewer.tsx index c29a0fd09..db3877f14 100644 --- a/packages/procgen/src/building/viewer/TownViewer.tsx +++ b/packages/procgen/src/building/viewer/TownViewer.tsx @@ -36,7 +36,7 @@ import { NavigationVisualizer, type NavigationVisualizerOptions, } from "./NavigationVisualizer"; -import type { TileCoord } from "@hyperscape/shared"; +import type { TileCoord } from "./index"; /** Path info for navigation display */ export interface PathInfo { diff --git a/packages/procgen/src/building/viewer/index.ts b/packages/procgen/src/building/viewer/index.ts index 680186e0c..a13f49eff 100644 --- a/packages/procgen/src/building/viewer/index.ts +++ b/packages/procgen/src/building/viewer/index.ts @@ -15,6 +15,11 @@ export { type TownViewerProps, type TownViewerHandle, } from "./TownViewer"; + +export interface TileCoord { + x: number; + z: number; +} export { NavigationVisualizer, type NavigationVisualizerOptions, diff --git a/packages/procgen/tsconfig.json b/packages/procgen/tsconfig.json index 1a68b086c..a1df2f67d 100644 --- a/packages/procgen/tsconfig.json +++ b/packages/procgen/tsconfig.json @@ -47,7 +47,6 @@ "dist", "tests", "viewer", - "src/building/viewer", "**/*.test.ts" ] } \ No newline at end of file diff --git a/packages/server/tests/unit/oracle/config.test.ts b/packages/server/tests/unit/oracle/config.test.ts index a803c1e80..e02e764be 100644 --- a/packages/server/tests/unit/oracle/config.test.ts +++ b/packages/server/tests/unit/oracle/config.test.ts @@ -1,10 +1,22 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getDuelArenaOracleConfig } from "../../../src/oracle/config.js"; const ORIGINAL_ENV = { ...process.env }; +function clearOracleEnv() { + for (const key in process.env) { + if (key.startsWith("DUEL_ARENA_ORACLE_")) { + vi.stubEnv(key, ""); + } + } +} + afterEach(() => { - process.env = { ...ORIGINAL_ENV }; + vi.unstubAllEnvs(); +}); + +beforeEach(() => { + clearOracleEnv(); }); describe("getDuelArenaOracleConfig", () => { diff --git a/packages/shared/src/systems/client/ClientNetwork.ts b/packages/shared/src/systems/client/ClientNetwork.ts index 6dac4ee38..6589d35ed 100644 --- a/packages/shared/src/systems/client/ClientNetwork.ts +++ b/packages/shared/src/systems/client/ClientNetwork.ts @@ -325,7 +325,7 @@ export class ClientNetwork extends SystemBase { constructor(world: World) { super(world, { name: "client-network", - dependencies: { required: [], optional: [] }, + dependencies: { required: ["physics"], optional: [] }, autoCleanup: true, }); this.ids = -1; diff --git a/packages/shared/src/systems/shared/entities/Entities.ts b/packages/shared/src/systems/shared/entities/Entities.ts index 747ff8faf..d6d86f135 100644 --- a/packages/shared/src/systems/shared/entities/Entities.ts +++ b/packages/shared/src/systems/shared/entities/Entities.ts @@ -187,7 +187,7 @@ export class Entities extends SystemBase implements IEntities { constructor(world: World) { super(world, { name: "entities", - dependencies: { required: [], optional: [] }, + dependencies: { required: ["physics"], optional: [] }, autoCleanup: true, }); this.items = new Map(); @@ -304,8 +304,8 @@ export class Entities extends SystemBase implements IEntities { const rawScale = ( data as { scale?: - | { x: number; y: number; z: number } - | [number, number, number]; + | { x: number; y: number; z: number } + | [number, number, number]; } ).scale; let finalScale = { x: 1, y: 1, z: 1 }; @@ -692,11 +692,11 @@ export class Entities extends SystemBase implements IEntities { (data as { resourceType?: string }).resourceType === "tree" ? ResourceType.TREE : (data as { resourceType?: string }).resourceType === - "fishing_spot" + "fishing_spot" ? ResourceType.FISHING_SPOT : (data as { resourceType?: string }).resourceType === - "mining_rock" || - (data as { resourceType?: string }).resourceType === "ore" + "mining_rock" || + (data as { resourceType?: string }).resourceType === "ore" ? ResourceType.MINING_ROCK : ResourceType.TREE, resourceId: @@ -1100,7 +1100,7 @@ export class Entities extends SystemBase implements IEntities { const initPromise = entity.init() as Promise; if (initPromise) { initPromise - .then(() => {}) + .then(() => { }) .catch((err) => { this.logger.error( `Entity ${entity.id} (type: ${data.type}) async init failed:`, diff --git a/turbo.json b/turbo.json index 72b229272..7fbc4297b 100644 --- a/turbo.json +++ b/turbo.json @@ -1,12 +1,21 @@ { "$schema": "https://turbo.build/schema.json", - "globalDependencies": ["**/.env"], + "globalDependencies": [ + "**/.env" + ], "ui": "tui", "concurrency": "10", "tasks": { "build": { - "dependsOn": ["^build"], - "outputs": ["dist/**", "build/**", ".next/**", "tsconfig.tsbuildinfo"], + "dependsOn": [ + "^build" + ], + "outputs": [ + "dist/**", + "build/**", + ".next/**", + "tsconfig.tsbuildinfo" + ], "cache": true }, "extract-bounds": { @@ -27,72 +36,126 @@ "cache": true }, "shared#build": { - "dependsOn": ["^build", "physx-js-webidl#build"], - "outputs": ["dist/**", "build/**", "tsconfig.tsbuildinfo"], + "dependsOn": [ + "^build", + "physx-js-webidl#build" + ], + "outputs": [ + "dist/**", + "build/**", + "tsconfig.tsbuildinfo" + ], "cache": true }, "procgen#build": { - "dependsOn": ["^build", "shared#build"], - "outputs": ["dist/**", "tsconfig.tsbuildinfo"], + "dependsOn": [ + "^build" + ], + "outputs": [ + "dist/**", + "tsconfig.tsbuildinfo" + ], "cache": true }, "client#build": { - "dependsOn": ["^build", "physx-js-webidl#build", "shared#build"], - "outputs": ["dist/**", "build/**", "tsconfig.tsbuildinfo"], + "dependsOn": [ + "^build", + "physx-js-webidl#build", + "shared#build" + ], + "outputs": [ + "dist/**", + "build/**", + "tsconfig.tsbuildinfo" + ], "cache": true }, "asset-forge#build": { - "dependsOn": ["^build", "procgen#build"], - "outputs": ["dist/**", "tsconfig.tsbuildinfo"], + "dependsOn": [ + "^build", + "procgen#build" + ], + "outputs": [ + "dist/**", + "tsconfig.tsbuildinfo" + ], "cache": true }, "plugin-hyperscape#build": { - "dependsOn": ["^build", "shared#build"], - "outputs": ["dist/**", "tsconfig.tsbuildinfo"], + "dependsOn": [ + "^build", + "shared#build" + ], + "outputs": [ + "dist/**", + "tsconfig.tsbuildinfo" + ], "cache": true }, "server#build": { - "dependsOn": ["^build", "shared#build"], - "outputs": ["dist/**", "tsconfig.tsbuildinfo"], + "dependsOn": [ + "^build", + "shared#build" + ], + "outputs": [ + "dist/**", + "tsconfig.tsbuildinfo" + ], "cache": true }, "@hyperscape/app#build": { - "dependsOn": ["@hyperscape/client#build"], + "dependsOn": [ + "@hyperscape/client#build" + ], "outputs": [], "cache": false }, "@hyperscape/contracts#build": { - "dependsOn": ["^build"], + "dependsOn": [ + "^build" + ], "outputs": [], "cache": true }, "@hyperscape/solana-prediction-market#build": { - "dependsOn": ["^build"], + "dependsOn": [ + "^build" + ], "outputs": [], "cache": true }, "dev": { - "dependsOn": ["^build"], + "dependsOn": [ + "^build" + ], "cache": false, "persistent": true }, "app#dev": { - "dependsOn": ["client#build"], + "dependsOn": [ + "client#build" + ], "cache": false, "persistent": true }, "test": { - "dependsOn": ["build"], + "dependsOn": [ + "build" + ], "outputs": [], "cache": true, - "env": ["NODE_ENV"] + "env": [ + "NODE_ENV" + ] }, "lint": { "outputs": [], "cache": true }, "typecheck": { - "dependsOn": ["^build"], + "dependsOn": [ + "^build" + ], "outputs": [], "cache": true }, @@ -100,4 +163,4 @@ "cache": false } } -} +} \ No newline at end of file From 89322093cc3615e94c5075f46e54a84673b6fe92 Mon Sep 17 00:00:00 2001 From: dreaminglucid Date: Tue, 10 Mar 2026 21:43:16 -0400 Subject: [PATCH 39/48] fix(streaming): resolve agent T-pose and re-enable autonomous behavior - Add physics null guards in RigidBody.ts and Collider.ts for stream mode viewports where physics system is removed - Re-enable autonomous behavior (mining, chopping, fishing) for duel bot agents between duels instead of suppressing it - Remove dedicatedDuelBot gates that killed all open-world autonomy - Make shouldRunOpenWorldAutonomy() always return true - Start autonomous behavior on ElizaDuelBot connect - Request bank state on player spawn so goal planner has item data - Relax post-duel restore position from 120-unit lobby radius to 2000-unit world boundary so agents roam freely - Interleave Anthropic/Groq model agents for provider diversity - Bump @elizaos/plugin-elizacloud to 2.0.0-alpha.7 --- bun.lock | 10 +- packages/contracts/deploys/31337/latest.json | 4 +- packages/contracts/worlds.json | 2 +- .../managers/autonomous-behavior-manager.ts | 33 ---- .../src/services/HyperscapeService.ts | 25 +-- packages/server/package.json | 2 +- packages/server/src/eliza/ElizaDuelBot.ts | 6 +- .../server/src/eliza/ModelAgentSpawner.ts | 143 ++++++++---------- .../managers/DuelOrchestrator.ts | 9 +- packages/shared/src/nodes/Collider.ts | 5 + packages/shared/src/nodes/RigidBody.ts | 5 + 11 files changed, 90 insertions(+), 154 deletions(-) diff --git a/bun.lock b/bun.lock index 71ce95feb..211978443 100644 --- a/bun.lock +++ b/bun.lock @@ -390,7 +390,7 @@ "@ai-sdk/openai": "^3.0.30", "@elizaos/core": "alpha", "@elizaos/plugin-anthropic": "alpha", - "@elizaos/plugin-elizacloud": "^1.8.0", + "@elizaos/plugin-elizacloud": "2.0.0-alpha.7", "@elizaos/plugin-groq": "alpha", "@elizaos/plugin-openai": "alpha", "@elizaos/plugin-sql": "alpha", @@ -1240,7 +1240,7 @@ "@elizaos/plugin-anthropic": ["@elizaos/plugin-anthropic@2.0.0-alpha.7", "", { "dependencies": { "@ai-sdk/anthropic": "^3.0.9", "@elizaos/core": "alpha", "ai": "^6.0.23", "jsonrepair": "^3.12.0", "undici": "^7.16.0" } }, "sha512-BqTU9DgLdu+HNAxI5hrvBI4t3Ci2oD9EZ2tuC+IUie3ipB1LL/4A6OhvqKOtjMgJI5pgOXHY71zrjOARVTYniw=="], - "@elizaos/plugin-elizacloud": ["@elizaos/plugin-elizacloud@1.8.0", "", { "dependencies": { "@ai-sdk/openai": "^2.0.88", "@elizaos/core": "^1.7.0", "ai": "^5.0.116", "js-tiktoken": "^1.0.21", "undici": "^7.16.0" }, "peerDependencies": { "zod": "^4.2.1" } }, "sha512-E37eyrZkC34JPkdo72ZlVwcjKQMuQeIlL2xTBY+SWWIDpaq6acxzv/jJrRuxm8E9s8CxWUbLEqaGV09gj+99Bg=="], + "@elizaos/plugin-elizacloud": ["@elizaos/plugin-elizacloud@2.0.0-alpha.7", "", { "dependencies": { "@ai-sdk/openai": "^3.0.9", "@elizaos/core": "2.0.0-alpha.3", "ai": "^6.0.30", "js-tiktoken": "^1.0.21", "undici": "^7.16.0" }, "peerDependencies": { "zod": "^4.3.6" } }, "sha512-9q/QNqnKKidmVq70yIch8uxRfartvdT0dgrcsYfXYJTImuLyEcKG2PRt8kkoRHO6+8jTwkUuSESMuTilfV0Pfw=="], "@elizaos/plugin-groq": ["@elizaos/plugin-groq@2.0.0-alpha.8", "", { "dependencies": { "@ai-sdk/groq": "^3.0.4", "@elizaos/core": "alpha", "ai": "^6.0.0" } }, "sha512-BTpT8Q1YaapmcrfZ8gc9jhd+10r5ZbRvT5uaz+0TtmD5+tL3hstalJsyoEH7zyjI9fIVgP2Z6bO5wKXLzhUblg=="], @@ -6994,7 +6994,7 @@ "@elizaos/core/dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], - "@elizaos/plugin-elizacloud/@ai-sdk/openai": ["@ai-sdk/openai@2.0.98", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-6BI2lpY0WBuLzwz1bVTVkB4gpupisiN5/bFrud0+gHNhgspYOvh5uLf+7sz/NKvAtRB47WEzlBxwDQwGdIpbQw=="], + "@elizaos/plugin-elizacloud/@ai-sdk/openai": ["@ai-sdk/openai@3.0.39", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EZrs4L6kMkPQhpodagpEvqLSryOIK99WgblN0IsVHr1xhajWizQOZ0XMa7c5JpSYgIjV6u8GCpGV6hS3Mk2Bug=="], "@elizaos/plugin-openai/@ai-sdk/openai": ["@ai-sdk/openai@3.0.39", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EZrs4L6kMkPQhpodagpEvqLSryOIK99WgblN0IsVHr1xhajWizQOZ0XMa7c5JpSYgIjV6u8GCpGV6hS3Mk2Bug=="], @@ -8528,10 +8528,6 @@ "@donmccurdy/caporal/table/string-width": ["string-width@3.1.0", "", { "dependencies": { "emoji-regex": "^7.0.1", "is-fullwidth-code-point": "^2.0.0", "strip-ansi": "^5.1.0" } }, "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w=="], - "@elizaos/plugin-elizacloud/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="], - - "@elizaos/plugin-elizacloud/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="], - "@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "@eslint/eslintrc/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], diff --git a/packages/contracts/deploys/31337/latest.json b/packages/contracts/deploys/31337/latest.json index 0b3297766..324751ab1 100644 --- a/packages/contracts/deploys/31337/latest.json +++ b/packages/contracts/deploys/31337/latest.json @@ -1,4 +1,4 @@ { - "worldAddress": "0xF93c1D5C1CFf04256A26778c65a9aCE4FA326E06", - "blockNumber": 13 + "worldAddress": "0x982fF9495E2A797b9C277aD3459590b028Acb2ad", + "blockNumber": 48078 } diff --git a/packages/contracts/worlds.json b/packages/contracts/worlds.json index 652f11999..3f500edae 100644 --- a/packages/contracts/worlds.json +++ b/packages/contracts/worlds.json @@ -4,6 +4,6 @@ "blockNumber": 7 }, "31337": { - "address": "0xF93c1D5C1CFf04256A26778c65a9aCE4FA326E06" + "address": "0x982fF9495E2A797b9C277aD3459590b028Acb2ad" } } diff --git a/packages/plugin-hyperscape/src/managers/autonomous-behavior-manager.ts b/packages/plugin-hyperscape/src/managers/autonomous-behavior-manager.ts index 8c6daef4a..632d41870 100644 --- a/packages/plugin-hyperscape/src/managers/autonomous-behavior-manager.ts +++ b/packages/plugin-hyperscape/src/managers/autonomous-behavior-manager.ts @@ -1076,14 +1076,6 @@ export class AutonomousBehaviorManager { } } - if ( - this.dedicatedDuelBot && - !this.duelPrepPhase && - this.duelPhase === null - ) { - return; - } - // Periodic state refresh — catch any missed push events (dropped packet, race condition) if ( Date.now() - this.lastStateRefreshTime > @@ -5442,17 +5434,6 @@ export class AutonomousBehaviorManager { return false; } - if ( - (player as PlayerEntity & { inStreamingDuel?: boolean }).inStreamingDuel - ) { - if (this.debug) { - logger.debug( - "[AutonomousBehavior] Player is in streaming duel, skipping autonomous tick", - ); - } - return false; - } - // Only skip if explicitly dead - undefined means alive if (player.alive === false) { if (this.debug) @@ -7183,13 +7164,6 @@ export class AutonomousBehaviorManager { this.duelId = null; this.resetDuelCombatState(); - if (this.dedicatedDuelBot) { - this.currentGoal = null; - this.goalStack.length = 0; - this.nextTickFast = true; - return; - } - // Generate post-duel assessment and adjust strategy const assessment = this.assessDuelOutcome(won, opponentName, player); logger.info( @@ -7244,13 +7218,6 @@ export class AutonomousBehaviorManager { player.inCombat = false; } - if (this.dedicatedDuelBot) { - this.currentGoal = null; - this.goalStack.length = 0; - this.nextTickFast = true; - return; - } - // Restore saved goal const restored = this.popGoal(); if (restored) { diff --git a/packages/plugin-hyperscape/src/services/HyperscapeService.ts b/packages/plugin-hyperscape/src/services/HyperscapeService.ts index 12473fddc..0478f7102 100644 --- a/packages/plugin-hyperscape/src/services/HyperscapeService.ts +++ b/packages/plugin-hyperscape/src/services/HyperscapeService.ts @@ -468,23 +468,9 @@ export class HyperscapeService } private shouldRunOpenWorldAutonomy(): boolean { - if (!this.isDuelBotRuntime()) { - return true; - } - - if ( - getRuntimeSettingBoolean( - this.runtime, - "HYPERSCAPE_ENABLE_DUEL_BOT_AUTONOMY", - ) - ) { - return true; - } - - const envOverride = - process.env.HYPERSCAPE_ENABLE_DUEL_BOT_AUTONOMY || - process.env.DUEL_BOT_AUTONOMY_ENABLED; - return envOverride ? /^(1|true|yes|on)$/i.test(envOverride.trim()) : false; + // Duel bots should perform autonomous activities (mining, chopping, fishing) + // between duels to make the world feel alive + return true; } private isPlayerInStreamingDuel(): boolean { @@ -2382,8 +2368,9 @@ Respond with ONLY the action name, nothing else.`; // Server needs socket.player to be set (which happens during enterWorld) // so this is the earliest safe point to request quests. this.requestQuestList(); - logger.debug( - `[HyperscapeService] 📜 Requested quest list after player spawn`, + this.requestBankState(); + logger.info( + `[HyperscapeService] 📜 Requested quest list + bank state after player spawn`, ); } else if (data && data.id) { // Debug: Log mob entity additions with position info diff --git a/packages/server/package.json b/packages/server/package.json index 6a17ab15e..a09378915 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -60,7 +60,7 @@ "@ai-sdk/openai": "^3.0.30", "@elizaos/core": "alpha", "@elizaos/plugin-anthropic": "alpha", - "@elizaos/plugin-elizacloud": "^1.8.0", + "@elizaos/plugin-elizacloud": "2.0.0-alpha.7", "@elizaos/plugin-groq": "alpha", "@elizaos/plugin-openai": "alpha", "@elizaos/plugin-sql": "alpha", diff --git a/packages/server/src/eliza/ElizaDuelBot.ts b/packages/server/src/eliza/ElizaDuelBot.ts index e5a4d3564..b78c474d9 100644 --- a/packages/server/src/eliza/ElizaDuelBot.ts +++ b/packages/server/src/eliza/ElizaDuelBot.ts @@ -273,9 +273,11 @@ export class ElizaDuelBot extends EventEmitter { await this.waitForPlayerSpawnReady(this.config.connectTimeoutMs); + // Start autonomous behavior so agents mine/chop/fish between duels const service = this.getHyperscapeService(); - service?.setAutonomousBehaviorEnabled?.(false); - service?.stopAutonomousBehavior?.(); + if (service?.startAutonomousBehavior) { + service.startAutonomousBehavior(); + } this._id = characterId; this._connected = true; diff --git a/packages/server/src/eliza/ModelAgentSpawner.ts b/packages/server/src/eliza/ModelAgentSpawner.ts index 556a058d2..bc52b359c 100644 --- a/packages/server/src/eliza/ModelAgentSpawner.ts +++ b/packages/server/src/eliza/ModelAgentSpawner.ts @@ -70,111 +70,86 @@ export interface ModelProviderConfig { * AI model configurations for agents */ export const MODEL_AGENTS: ModelProviderConfig[] = [ - // ── Frontier American Models ─────────────────────────────────────────── + // Interleaved so slice(0, N) always gets a mix of providers { - provider: "elizacloud", - model: "openai/gpt-5", - displayName: "GPT-5", - apiKeyEnv: "ELIZAOS_CLOUD_API_KEY", - pluginModule: "@elizaos/plugin-elizacloud", - pluginExport: "elizaOSCloudPlugin", - }, - { - provider: "elizacloud", - model: "anthropic/claude-sonnet-4.6", + provider: "anthropic", + model: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6", - apiKeyEnv: "ELIZAOS_CLOUD_API_KEY", - pluginModule: "@elizaos/plugin-elizacloud", - pluginExport: "elizaOSCloudPlugin", - }, - { - provider: "elizacloud", - model: "anthropic/claude-opus-4.6", - displayName: "Claude Opus 4.6", - apiKeyEnv: "ELIZAOS_CLOUD_API_KEY", - pluginModule: "@elizaos/plugin-elizacloud", - pluginExport: "elizaOSCloudPlugin", + apiKeyEnv: "ANTHROPIC_API_KEY", + pluginModule: "@elizaos/plugin-anthropic", + pluginExport: "anthropicPlugin", }, { - provider: "elizacloud", - model: "google/gemini-3.1-pro-preview", - displayName: "Gemini 3.1 Pro", - apiKeyEnv: "ELIZAOS_CLOUD_API_KEY", - pluginModule: "@elizaos/plugin-elizacloud", - pluginExport: "elizaOSCloudPlugin", + provider: "groq", + model: "meta-llama/llama-4-scout-17b-16e-instruct", + displayName: "Llama 4 Scout", + apiKeyEnv: "GROQ_API_KEY", + pluginModule: "@elizaos/plugin-groq", + pluginExport: "groqPlugin", }, { - provider: "elizacloud", - model: "xai/grok-4", - displayName: "Grok 4", - apiKeyEnv: "ELIZAOS_CLOUD_API_KEY", - pluginModule: "@elizaos/plugin-elizacloud", - pluginExport: "elizaOSCloudPlugin", + provider: "anthropic", + model: "claude-opus-4-6", + displayName: "Claude Opus 4.6", + apiKeyEnv: "ANTHROPIC_API_KEY", + pluginModule: "@elizaos/plugin-anthropic", + pluginExport: "anthropicPlugin", }, { - provider: "elizacloud", - model: "meta/llama-4-maverick", + provider: "groq", + model: "meta-llama/llama-4-maverick-17b-128e-instruct", displayName: "Llama 4 Maverick", - apiKeyEnv: "ELIZAOS_CLOUD_API_KEY", - pluginModule: "@elizaos/plugin-elizacloud", - pluginExport: "elizaOSCloudPlugin", - }, - { - provider: "elizacloud", - model: "mistral/magistral-medium", - displayName: "Magistral Medium", - apiKeyEnv: "ELIZAOS_CLOUD_API_KEY", - pluginModule: "@elizaos/plugin-elizacloud", - pluginExport: "elizaOSCloudPlugin", + apiKeyEnv: "GROQ_API_KEY", + pluginModule: "@elizaos/plugin-groq", + pluginExport: "groqPlugin", }, - // ── Frontier Chinese Models ──────────────────────────────────────────── { - provider: "elizacloud", - model: "deepseek/deepseek-v3.2", - displayName: "DeepSeek V3.2", - apiKeyEnv: "ELIZAOS_CLOUD_API_KEY", - pluginModule: "@elizaos/plugin-elizacloud", - pluginExport: "elizaOSCloudPlugin", + provider: "anthropic", + model: "claude-haiku-4-5-20251001", + displayName: "Claude Haiku 4.5", + apiKeyEnv: "ANTHROPIC_API_KEY", + pluginModule: "@elizaos/plugin-anthropic", + pluginExport: "anthropicPlugin", }, { - provider: "elizacloud", - model: "alibaba/qwen3-max", - displayName: "Qwen 3 Max", - apiKeyEnv: "ELIZAOS_CLOUD_API_KEY", - pluginModule: "@elizaos/plugin-elizacloud", - pluginExport: "elizaOSCloudPlugin", + provider: "groq", + model: "llama-3.3-70b-versatile", + displayName: "Llama 3.3 70B", + apiKeyEnv: "GROQ_API_KEY", + pluginModule: "@elizaos/plugin-groq", + pluginExport: "groqPlugin", }, { - provider: "elizacloud", - model: "minimax/minimax-m2.5", - displayName: "Minimax M2.5", - apiKeyEnv: "ELIZAOS_CLOUD_API_KEY", - pluginModule: "@elizaos/plugin-elizacloud", - pluginExport: "elizaOSCloudPlugin", + provider: "anthropic", + model: "claude-opus-4-20250514", + displayName: "Claude Opus 4", + apiKeyEnv: "ANTHROPIC_API_KEY", + pluginModule: "@elizaos/plugin-anthropic", + pluginExport: "anthropicPlugin", }, { - provider: "elizacloud", - model: "zai/glm-5", - displayName: "GLM-5", - apiKeyEnv: "ELIZAOS_CLOUD_API_KEY", - pluginModule: "@elizaos/plugin-elizacloud", - pluginExport: "elizaOSCloudPlugin", + provider: "groq", + model: "moonshotai/kimi-k2-instruct", + displayName: "Kimi K2", + apiKeyEnv: "GROQ_API_KEY", + pluginModule: "@elizaos/plugin-groq", + pluginExport: "groqPlugin", }, { - provider: "elizacloud", - model: "moonshotai/kimi-k2.5", - displayName: "Kimi K2.5", - apiKeyEnv: "ELIZAOS_CLOUD_API_KEY", - pluginModule: "@elizaos/plugin-elizacloud", - pluginExport: "elizaOSCloudPlugin", + provider: "anthropic", + model: "claude-sonnet-4-20250514", + displayName: "Claude Sonnet 4", + apiKeyEnv: "ANTHROPIC_API_KEY", + pluginModule: "@elizaos/plugin-anthropic", + pluginExport: "anthropicPlugin", }, { - provider: "elizacloud", - model: "bytedance/seed-1.8", - displayName: "Seed 1.8", - apiKeyEnv: "ELIZAOS_CLOUD_API_KEY", - pluginModule: "@elizaos/plugin-elizacloud", - pluginExport: "elizaOSCloudPlugin", + provider: "groq", + model: "qwen/qwen3-32b", + displayName: "Qwen 3 30B", + apiKeyEnv: "GROQ_API_KEY", + pluginModule: "@elizaos/plugin-groq", + pluginExport: "groqPlugin", }, ]; diff --git a/packages/server/src/systems/StreamingDuelScheduler/managers/DuelOrchestrator.ts b/packages/server/src/systems/StreamingDuelScheduler/managers/DuelOrchestrator.ts index 8f5c89b4f..9859c9f03 100644 --- a/packages/server/src/systems/StreamingDuelScheduler/managers/DuelOrchestrator.ts +++ b/packages/server/src/systems/StreamingDuelScheduler/managers/DuelOrchestrator.ts @@ -1807,11 +1807,10 @@ export class DuelOrchestrator { return fallback; } - // Keep post-duel restores near the duel lobby area to avoid origin/out-of-map - // drift from stale respawn state or invalid legacy coordinates. - const lobby = getDuelArenaConfig().lobbySpawnPoint; - const distanceFromLobby = Math.hypot(x - lobby.x, z - lobby.z); - if (distanceFromLobby > 120) { + // Only reject positions that are clearly out-of-world (very far from origin). + // Agents should be free to roam the world between duels. + const distFromOrigin = Math.hypot(x, z); + if (distFromOrigin > 2000) { return fallback; } diff --git a/packages/shared/src/nodes/Collider.ts b/packages/shared/src/nodes/Collider.ts index 92b1dded4..94afc294f 100644 --- a/packages/shared/src/nodes/Collider.ts +++ b/packages/shared/src/nodes/Collider.ts @@ -167,6 +167,11 @@ export class Collider extends Node { return; } + // Guard: physics system may be removed in stream/spectator viewports + if (!this.ctx?.physics) { + return; + } + let geometry; let pmesh; if (this._type === "box") { diff --git a/packages/shared/src/nodes/RigidBody.ts b/packages/shared/src/nodes/RigidBody.ts index 830822785..d8ddbb927 100644 --- a/packages/shared/src/nodes/RigidBody.ts +++ b/packages/shared/src/nodes/RigidBody.ts @@ -169,6 +169,11 @@ export class RigidBody extends Node { ) as PxTransform; } + // Guard: physics system may be removed in stream/spectator viewports + if (!this.ctx?.physics) { + return; + } + // Force decompose using temporary plain vectors const plainPos = new THREE.Vector3(); const plainQuat = new THREE.Quaternion(); From 44d07b40e49832fea82df45238f4b83db589900b Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Thu, 12 Mar 2026 00:21:46 +0800 Subject: [PATCH 40/48] fix(network): batch entity spawns per tile and fix entity leak on tile unload Fixes sparse trees when moving between distant locations. Root causes: 1. Individual entityAdded packets were silently dropped by BandwidthBudget during rapid tile generation (NORMAL priority throttled under load) 2. ResourceEntities were never destroyed in EntityManager on tile unload, causing entity leaks and preventing re-creation on tile revisit Changes: - Batch all entity spawns for a tile into a single entitiesBatchAdded packet - Add sendHighPriority to ServerNetwork (configurable via useHighPriorityBatch) - Add suppressBroadcast option to EntityManager.spawnEntity() for batching - Destroy ResourceEntities in EntityManager when tiles unload - Register entitiesBatchAdded packet and client handler Made-with: Cursor --- .../server/src/systems/ServerNetwork/index.ts | 19 ++++++ .../shared/src/platform/shared/packets.ts | 2 + .../src/systems/client/ClientNetwork.ts | 10 +++ .../systems/shared/entities/EntityManager.ts | 11 +++- .../systems/shared/entities/ResourceSystem.ts | 63 ++++++++++++++++--- 5 files changed, 95 insertions(+), 10 deletions(-) diff --git a/packages/server/src/systems/ServerNetwork/index.ts b/packages/server/src/systems/ServerNetwork/index.ts index d13e6af71..e92701857 100644 --- a/packages/server/src/systems/ServerNetwork/index.ts +++ b/packages/server/src/systems/ServerNetwork/index.ts @@ -117,6 +117,7 @@ import { ActionQueue } from "./action-queue"; import { TickSystem, TickPriority } from "../TickSystem"; import { SocketManager } from "./socket-management"; import { BroadcastManager } from "./broadcast"; +import { PacketPriority } from "./BandwidthBudget"; import { SpatialIndex } from "./SpatialIndex"; import { SaveManager } from "./save-manager"; import { PositionValidator } from "./position-validator"; @@ -3012,6 +3013,24 @@ export class ServerNetwork extends System implements NetworkWithSocket { this.broadcastManager.sendToAll(name, data, ignoreSocketId); } + /** + * Broadcast message with HIGH priority (bypasses bandwidth throttling for + * NORMAL-priority traffic). Use for batched entity spawns that must not be + * silently dropped by the per-connection bandwidth budget. + */ + sendHighPriority( + name: string, + data: T, + ignoreSocketId?: string, + ): void { + this.broadcastManager.sendToAll( + name, + data, + ignoreSocketId, + PacketPriority.HIGH, + ); + } + /** * Broadcast message to spectator sockets only. * diff --git a/packages/shared/src/platform/shared/packets.ts b/packages/shared/src/platform/shared/packets.ts index f07deb2d4..58e317254 100644 --- a/packages/shared/src/platform/shared/packets.ts +++ b/packages/shared/src/platform/shared/packets.ts @@ -418,6 +418,8 @@ const names = [ 'combatEnded', // Server -> Client: PvE combat ended (target dead or disengaged) // Application-level keepalive (prevents Cloudflare/proxy WebSocket idle timeout) 'keepalive', // Client -> Server: periodic heartbeat to keep proxy connection alive + // Batched entity spawn (reduces per-entity bandwidth overhead for tile generation) + 'entitiesBatchAdded', // Server -> Client: batch of newly spawned entities for a tile ] const byName: Record = {}; diff --git a/packages/shared/src/systems/client/ClientNetwork.ts b/packages/shared/src/systems/client/ClientNetwork.ts index 1a7539287..7623d2868 100644 --- a/packages/shared/src/systems/client/ClientNetwork.ts +++ b/packages/shared/src/systems/client/ClientNetwork.ts @@ -1423,6 +1423,16 @@ export class ClientNetwork extends SystemBase { } }; + onEntitiesBatchAdded = (batch: EntityData[]) => { + if (!Array.isArray(batch)) return; + for (const data of batch) { + const newEntity = this.world.entities.add(data); + if (newEntity) { + this.applyPendingModifications(newEntity.id); + } + } + }; + onEntityModified = ( data: { id: string; changes?: Record } & Record< string, diff --git a/packages/shared/src/systems/shared/entities/EntityManager.ts b/packages/shared/src/systems/shared/entities/EntityManager.ts index 064011e95..2dbc263ed 100644 --- a/packages/shared/src/systems/shared/entities/EntityManager.ts +++ b/packages/shared/src/systems/shared/entities/EntityManager.ts @@ -628,7 +628,10 @@ export class EntityManager extends SystemBase { } } - async spawnEntity(config: EntityConfig): Promise { + async spawnEntity( + config: EntityConfig, + options?: { suppressBroadcast?: boolean }, + ): Promise { // CRITICAL: Only server should spawn entities // Clients receive entities via snapshot/network sync if (!this.world.isServer) { @@ -766,8 +769,10 @@ export class EntityManager extends SystemBase { } // Broadcast entityAdded to all clients (server-only) - // Single broadcast point to prevent duplicate packets - if (this.world.isServer) { + // Single broadcast point to prevent duplicate packets. + // When suppressBroadcast is true, the caller is responsible for batching + // and sending entities (e.g. ResourceSystem batches per-tile). + if (this.world.isServer && !options?.suppressBroadcast) { const network = this.world.network; if (network && typeof network.send === "function") { try { diff --git a/packages/shared/src/systems/shared/entities/ResourceSystem.ts b/packages/shared/src/systems/shared/entities/ResourceSystem.ts index fdebf8b31..d054ae05e 100644 --- a/packages/shared/src/systems/shared/entities/ResourceSystem.ts +++ b/packages/shared/src/systems/shared/entities/ResourceSystem.ts @@ -169,6 +169,13 @@ export class ResourceSystem extends SystemBase { // Terrain system reference for height lookups private terrainSystem: TerrainSystem | null = null; + /** + * When true, batched entity spawn packets use HIGH priority to bypass + * per-connection bandwidth throttling. Set to false to revert to NORMAL + * priority (may cause sparse entities during rapid tile generation). + */ + useHighPriorityBatch = true; + // ===== FORESTRY-STYLE RESOURCE TIMERS (OSRS-accurate) ===== /** * Per-resource depletion timer for Forestry-style tree mechanics. @@ -837,7 +844,10 @@ export class ResourceSystem extends SystemBase { // Get EntityManager for spawning const entityManager = this.world.getSystem("entity-manager") as { - spawnEntity?: (config: unknown) => Promise; + spawnEntity?: ( + config: unknown, + options?: { suppressBroadcast?: boolean }, + ) => Promise<{ id?: string; serialize?: () => unknown } | null>; } | null; if (!entityManager?.spawnEntity) { console.error( @@ -847,6 +857,7 @@ export class ResourceSystem extends SystemBase { } let spawned = 0; + const batchedEntityData: unknown[] = []; for (const spawnPoint of spawnPoints) { try { @@ -985,11 +996,15 @@ export class ResourceSystem extends SystemBase { occupiedTiles, }; - const spawnedEntity = (await entityManager.spawnEntity( - resourceConfig, - )) as { id?: string } | null; + // Suppress individual broadcasts; we batch them below + const spawnedEntity = await entityManager.spawnEntity(resourceConfig, { + suppressBroadcast: true, + }); if (spawnedEntity) { spawned++; + if (typeof spawnedEntity.serialize === "function") { + batchedEntityData.push(spawnedEntity.serialize()); + } } } catch (err) { console.error( @@ -999,9 +1014,29 @@ export class ResourceSystem extends SystemBase { } } + // Send all entities for this tile as a single batch packet to avoid + // per-entity bandwidth-budget drops during rapid tile generation. + // useHighPriorityBatch controls whether HIGH or NORMAL priority is used. + if (batchedEntityData.length > 0 && this.world.isServer) { + const network = this.world.network as { + sendHighPriority?: (name: string, data: unknown) => void; + send?: (name: string, data: unknown) => void; + } | null; + if (network) { + if ( + this.useHighPriorityBatch && + typeof network.sendHighPriority === "function" + ) { + network.sendHighPriority("entitiesBatchAdded", batchedEntityData); + } else if (typeof network.send === "function") { + network.send("entitiesBatchAdded", batchedEntityData); + } + } + } + if (spawned > 0) { console.log( - `[ResourceSystem] Spawned ${spawned}/${spawnPoints.length} resource entities${isManifest ? " (manifest)" : ""}`, + `[ResourceSystem] Spawned ${spawned}/${spawnPoints.length} resource entities (batch packet: ${batchedEntityData.length})${isManifest ? " (manifest)" : ""}`, ); } } @@ -1240,13 +1275,19 @@ export class ResourceSystem extends SystemBase { } /** - * Handle terrain tile unloading - remove resources from unloaded tiles - * Note: Manifest resources (from world-areas.json) are protected and never deleted + * Handle terrain tile unloading - remove resources from unloaded tiles. + * Destroys the backing EntityManager entities so they don't leak and can be + * re-broadcast when the tile is loaded again later. + * Note: Manifest resources (from world-areas.json) are protected and never deleted. */ private onTerrainTileUnloaded(data: { tileId: string }): void { // Extract tileX and tileZ from tileId (format: "x,z") const [tileX, tileZ] = data.tileId.split(",").map(Number); + const entityManager = this.world.getSystem("entity-manager") as { + destroyEntity?: (id: string) => boolean; + } | null; + // Remove resources that belong to this tile (but not manifest resources) for (const [resourceId, resource] of this.resources) { // Skip manifest resources - they are permanent and shouldn't be deleted on tile unload @@ -1259,6 +1300,14 @@ export class ResourceSystem extends SystemBase { const resourceTileZ = Math.floor(resource.position.z / 100); if (resourceTileX === tileX && resourceTileZ === tileZ) { + // Destroy the entity in EntityManager so it's removed from the + // entities map and broadcast as entityRemoved to clients. Without + // this, the entity lingers and the duplicate-ID check in + // spawnEntity prevents it from being re-created on tile revisit. + if (entityManager?.destroyEntity) { + entityManager.destroyEntity(resource.id); + } + this.resources.delete(resourceId); // Clean up any active gathering on this resource From 5c34de1382957aeb72ccfa353b0816778414d6c9 Mon Sep 17 00:00:00 2001 From: Shaw Date: Wed, 11 Mar 2026 17:36:40 -0700 Subject: [PATCH 41/48] chore: trigger redeploy to restart agent spawner From 49c0f5c9f2973d6571e8764e347ea4cceba8cfd2 Mon Sep 17 00:00:00 2001 From: Shaw Date: Wed, 11 Mar 2026 19:43:39 -0700 Subject: [PATCH 42/48] chore: use alpha dist-tag for plugin-elizacloud --- packages/server/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/package.json b/packages/server/package.json index a3f0ee829..94304cc79 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -60,7 +60,7 @@ "@ai-sdk/openai": "^3.0.30", "@elizaos/core": "alpha", "@elizaos/plugin-anthropic": "alpha", - "@elizaos/plugin-elizacloud": "^1.8.0", + "@elizaos/plugin-elizacloud": "alpha", "@elizaos/plugin-groq": "alpha", "@elizaos/plugin-openai": "alpha", "@elizaos/plugin-sql": "alpha", From 6c14c8e761bddeda0ff6d6de0906870ac9fd2405 Mon Sep 17 00:00:00 2001 From: Shaw Date: Wed, 11 Mar 2026 19:46:15 -0700 Subject: [PATCH 43/48] perf: optimize TerrainQuadTree per-frame allocation and GLBTreeBatchedInstancer fingerprinting --- .../shared/world/GLBTreeBatchedInstancer.ts | 6 +++++- .../src/systems/shared/world/TerrainQuadTree.ts | 16 +++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts index 4f6291914..8b4e3620f 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts @@ -107,6 +107,8 @@ function extractAllMeshParts(root: THREE.Object3D): MeshPart[] { return parts; } +let _fingerprintId = 0; + /** * Returns a string key that identifies a material's diffuse texture. * Used to match the same material slot across different model variants. @@ -123,7 +125,9 @@ function getTextureFingerprint(mat: THREE.Material): string { return `tex:${img.width}x${img.height}:${img.src ?? img.uuid ?? ""}`; } if (std.name) return `name:${std.name}`; - return `idx:${Math.random()}`; + // Deterministic fallback — monotonic counter avoids random fingerprints + // that would silently prevent variant matching. + return `idx:${_fingerprintId++}`; } /** diff --git a/packages/shared/src/systems/shared/world/TerrainQuadTree.ts b/packages/shared/src/systems/shared/world/TerrainQuadTree.ts index 59f123a39..52b1e7f60 100644 --- a/packages/shared/src/systems/shared/world/TerrainQuadTree.ts +++ b/packages/shared/src/systems/shared/world/TerrainQuadTree.ts @@ -345,7 +345,8 @@ export class TerrainQuadTree { private allNodes = new Map(); private playerX = 0; private playerZ = 0; - private lastChunkKey: string | null = null; + private lastGridX = NaN; + private lastGridZ = NaN; private listener: QuadTreeListener | null = null; private nextNodeId = 0; /** Set true whenever the tree structure changes (split/unsplit). Cleared after neighbour resolution. */ @@ -428,8 +429,11 @@ export class TerrainQuadTree { this.playerX = playerX; this.playerZ = playerZ; - const chunkKey = `${Math.round((playerX / this.config.minSize) * 2 + 0.5)}_${Math.round((playerZ / this.config.minSize) * 2 + 0.5)}`; - if (chunkKey === this.lastChunkKey) { + // Use numeric grid coordinates instead of string comparison to avoid + // per-frame string allocation / GC pressure. + const gridX = Math.round((playerX / this.config.minSize) * 2 + 0.5); + const gridZ = Math.round((playerZ / this.config.minSize) * 2 + 0.5); + if (gridX === this.lastGridX && gridZ === this.lastGridZ) { if (this.structureDirty) { this.updateAllNeighbours(); this.structureDirty = false; @@ -439,7 +443,8 @@ export class TerrainQuadTree { } return false; } - this.lastChunkKey = chunkKey; + this.lastGridX = gridX; + this.lastGridZ = gridZ; // Mark all nodes for re-check for (const node of this.allNodes.values()) { @@ -496,7 +501,8 @@ export class TerrainQuadTree { } this.mainChunks.clear(); this.allNodes.clear(); - this.lastChunkKey = null; + this.lastGridX = NaN; + this.lastGridZ = NaN; } /** Get all leaf nodes that currently have (or need) visual geometry */ From 5f34ccf5ce2477dda3935b4936079cf550ca929a Mon Sep 17 00:00:00 2001 From: Shaw Date: Wed, 11 Mar 2026 19:47:59 -0700 Subject: [PATCH 44/48] fix: remove unused eslint-disable directive in ClientCameraSystem --- bun.lock | 10 ++-------- .../shared/src/systems/client/ClientCameraSystem.ts | 1 - 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/bun.lock b/bun.lock index ada532e06..c84ffa3ae 100644 --- a/bun.lock +++ b/bun.lock @@ -391,7 +391,7 @@ "@ai-sdk/openai": "^3.0.30", "@elizaos/core": "alpha", "@elizaos/plugin-anthropic": "alpha", - "@elizaos/plugin-elizacloud": "^1.8.0", + "@elizaos/plugin-elizacloud": "alpha", "@elizaos/plugin-groq": "alpha", "@elizaos/plugin-openai": "alpha", "@elizaos/plugin-sql": "alpha", @@ -1243,7 +1243,7 @@ "@elizaos/plugin-anthropic": ["@elizaos/plugin-anthropic@2.0.0-alpha.7", "", { "dependencies": { "@ai-sdk/anthropic": "^3.0.9", "@elizaos/core": "alpha", "ai": "^6.0.23", "jsonrepair": "^3.12.0", "undici": "^7.16.0" } }, "sha512-BqTU9DgLdu+HNAxI5hrvBI4t3Ci2oD9EZ2tuC+IUie3ipB1LL/4A6OhvqKOtjMgJI5pgOXHY71zrjOARVTYniw=="], - "@elizaos/plugin-elizacloud": ["@elizaos/plugin-elizacloud@1.8.0", "", { "dependencies": { "@ai-sdk/openai": "^2.0.88", "@elizaos/core": "^1.7.0", "ai": "^5.0.116", "js-tiktoken": "^1.0.21", "undici": "^7.16.0" }, "peerDependencies": { "zod": "^4.2.1" } }, "sha512-E37eyrZkC34JPkdo72ZlVwcjKQMuQeIlL2xTBY+SWWIDpaq6acxzv/jJrRuxm8E9s8CxWUbLEqaGV09gj+99Bg=="], + "@elizaos/plugin-elizacloud": ["@elizaos/plugin-elizacloud@2.0.0-alpha.7", "", { "dependencies": { "@ai-sdk/openai": "^3.0.9", "@elizaos/core": "2.0.0-alpha.3", "ai": "^6.0.30", "js-tiktoken": "^1.0.21", "undici": "^7.16.0" }, "peerDependencies": { "zod": "^4.3.6" } }, "sha512-9q/QNqnKKidmVq70yIch8uxRfartvdT0dgrcsYfXYJTImuLyEcKG2PRt8kkoRHO6+8jTwkUuSESMuTilfV0Pfw=="], "@elizaos/plugin-groq": ["@elizaos/plugin-groq@2.0.0-alpha.8", "", { "dependencies": { "@ai-sdk/groq": "^3.0.4", "@elizaos/core": "alpha", "ai": "^6.0.0" } }, "sha512-BTpT8Q1YaapmcrfZ8gc9jhd+10r5ZbRvT5uaz+0TtmD5+tL3hstalJsyoEH7zyjI9fIVgP2Z6bO5wKXLzhUblg=="], @@ -7111,8 +7111,6 @@ "@elizaos/core/dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], - "@elizaos/plugin-elizacloud/@ai-sdk/openai": ["@ai-sdk/openai@2.0.99", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wwa1/DuO9XThaA+sAi0d3+xfkbEx9nRhZ1USV6kktndmEs8aQRR0DJK/Iec+mwNu06IhfDGd5vMscR1U1q155g=="], - "@elizaos/plugin-sql/dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], "@elizaos/plugin-sql/drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="], @@ -8623,10 +8621,6 @@ "@donmccurdy/caporal/table/string-width": ["string-width@3.1.0", "", { "dependencies": { "emoji-regex": "^7.0.1", "is-fullwidth-code-point": "^2.0.0", "strip-ansi": "^5.1.0" } }, "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w=="], - "@elizaos/plugin-elizacloud/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="], - - "@elizaos/plugin-elizacloud/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="], - "@fastify/static/glob/jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], "@gemini-wallet/core/@metamask/rpc-errors/@metamask/utils": ["@metamask/utils@11.10.0", "", { "dependencies": { "@ethereumjs/tx": "^4.2.0", "@metamask/superstruct": "^3.1.0", "@noble/hashes": "^1.3.1", "@scure/base": "^1.1.3", "@types/debug": "^4.1.7", "@types/lodash": "^4.17.20", "debug": "^4.3.4", "lodash": "^4.17.21", "pony-cause": "^2.1.10", "semver": "^7.5.4", "uuid": "^9.0.1" } }, "sha512-+bWmTOANx1MbBW6RFM8Se4ZoigFYGXiuIrkhjj4XnG5Aez8uWaTSZ76yn9srKKClv+PoEVoAuVtcUOogFEMUNA=="], diff --git a/packages/shared/src/systems/client/ClientCameraSystem.ts b/packages/shared/src/systems/client/ClientCameraSystem.ts index c8eada529..6f1484cdb 100644 --- a/packages/shared/src/systems/client/ClientCameraSystem.ts +++ b/packages/shared/src/systems/client/ClientCameraSystem.ts @@ -2753,7 +2753,6 @@ export class ClientCameraSystem extends SystemBase { } } - // eslint-disable-next-line @typescript-eslint/no-unused-vars private onStreamingStateHP(_state: StreamingCameraStateUpdate): void { // Intentionally empty — all combat-reactive camera effects (punch-in, // shake, dramatic low angle) have been removed for a smooth cinematic From 9104520dee20fc8b093fb1a0c187df8b0c6331fb Mon Sep 17 00:00:00 2001 From: Shaw Date: Wed, 11 Mar 2026 19:54:29 -0700 Subject: [PATCH 45/48] fix: update test expectations to match biome branch changes and fix getRockPresetsForBiome plains fallback --- .../shared/world/BiomeResourceGenerator.ts | 2 +- .../__tests__/BiomeResourceSpawning.test.ts | 28 +++++++++---------- .../BuildingTerrainInteraction.test.ts | 8 +++--- .../__tests__/ProcgenRocksPlants.test.ts | 12 ++++---- .../shared/world/__tests__/TownSystem.test.ts | 5 ++-- 5 files changed, 27 insertions(+), 28 deletions(-) diff --git a/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts b/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts index bc600c584..4bab8bff4 100644 --- a/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts +++ b/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts @@ -491,7 +491,7 @@ export function getRockPresetsForBiome(biomeType: string): { distribution: Record; } { return ( - ROCK_BIOME_DEFAULTS[biomeType.toLowerCase()] ?? ROCK_BIOME_DEFAULTS.plains + ROCK_BIOME_DEFAULTS[biomeType.toLowerCase()] ?? ROCK_BIOME_DEFAULTS.forest ); } 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 469d7d8fa..b116ecabb 100644 --- a/packages/shared/src/systems/shared/world/__tests__/BiomeResourceSpawning.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/BiomeResourceSpawning.test.ts @@ -140,22 +140,22 @@ describe("BiomeResourceGenerator", () => { it("assigns correct level requirements from shared constants", () => { const ctx = createTestContext(0, 0); - const yewConfig: BiomeTreeConfig = { + const mapleConfig: BiomeTreeConfig = { enabled: true, - distribution: { tree_yew: 1.0 }, + distribution: { tree_maple: 1.0 }, density: 5, minSpacing: 5, clustering: false, }; - const trees = generateTrees(ctx, yewConfig); + const trees = generateTrees(ctx, mapleConfig); expect(trees.length).toBeGreaterThan(0); // Level should come from TREE_LEVEL_REQUIREMENTS constant expect( - trees.every((r) => r.requiredLevel === TREE_LEVEL_REQUIREMENTS.yew), + trees.every((r) => r.requiredLevel === TREE_LEVEL_REQUIREMENTS.maple), ).toBe(true); - expect(trees.every((r) => r.requiredLevel === 60)).toBe(true); + expect(trees.every((r) => r.requiredLevel === 45)).toBe(true); }); it("respects minimum spacing between trees", () => { @@ -394,19 +394,19 @@ describe("BiomeResourceGenerator", () => { }); describe("Level Requirements (Single Source of Truth)", () => { - it("tree level requirements match OSRS progression", () => { - // These should match the exported constants - expect(getTreeLevelRequirement("normal")).toBe(1); + it("tree level requirements match progression", () => { + // These should match the actual TREE_TYPES definitions + // Unknown types default to level 1 + expect(getTreeLevelRequirement("fir")).toBe(1); + expect(getTreeLevelRequirement("pine")).toBe(1); expect(getTreeLevelRequirement("oak")).toBe(15); - expect(getTreeLevelRequirement("willow")).toBe(30); - expect(getTreeLevelRequirement("teak")).toBe(35); + expect(getTreeLevelRequirement("birch")).toBe(1); expect(getTreeLevelRequirement("maple")).toBe(45); - expect(getTreeLevelRequirement("mahogany")).toBe(50); - expect(getTreeLevelRequirement("yew")).toBe(60); - expect(getTreeLevelRequirement("magic")).toBe(75); // Verify against exported constant - expect(getTreeLevelRequirement("yew")).toBe(TREE_LEVEL_REQUIREMENTS.yew); + expect(getTreeLevelRequirement("maple")).toBe( + TREE_LEVEL_REQUIREMENTS.maple, + ); }); it("ore level requirements match OSRS progression", () => { 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 d13ebda31..38685d6fb 100644 --- a/packages/shared/src/systems/shared/world/__tests__/BuildingTerrainInteraction.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/BuildingTerrainInteraction.test.ts @@ -925,14 +925,14 @@ describe("TERRAIN_CONSTANTS Centralization", () => { it("should have consistent WATER_THRESHOLD value", async () => { const { TERRAIN_CONSTANTS } = await import("@hyperscape/shared"); - // WATER_THRESHOLD should be 9.0 (as per TerrainSystem) - expect(TERRAIN_CONSTANTS.WATER_THRESHOLD).toBe(9.0); + // WATER_THRESHOLD should be 8.0 (as per TerrainSystem) + expect(TERRAIN_CONSTANTS.WATER_THRESHOLD).toBe(8.0); }); it("should have consistent MAX_WALKABLE_SLOPE value", async () => { const { TERRAIN_CONSTANTS } = await import("@hyperscape/shared"); - // MAX_WALKABLE_SLOPE should be 0.7 (tan of ~35 degrees) - expect(TERRAIN_CONSTANTS.MAX_WALKABLE_SLOPE).toBe(0.7); + // MAX_WALKABLE_SLOPE should be 1.5 + expect(TERRAIN_CONSTANTS.MAX_WALKABLE_SLOPE).toBe(1.5); }); }); diff --git a/packages/shared/src/systems/shared/world/__tests__/ProcgenRocksPlants.test.ts b/packages/shared/src/systems/shared/world/__tests__/ProcgenRocksPlants.test.ts index 06107e512..c5503aaae 100644 --- a/packages/shared/src/systems/shared/world/__tests__/ProcgenRocksPlants.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/ProcgenRocksPlants.test.ts @@ -817,12 +817,12 @@ describe("Error Handling - Invalid Inputs", () => { minSpacing: 2, }; - // Unknown biome should use plains fallback, which has presets + // Unknown biome should use forest fallback, which has presets const rocks = generateRocks(ctx, rockConfig, "unknown_xyz_biome"); expect(rocks.length).toBeGreaterThan(0); - // Should use plains defaults (boulder, pebble, sandstone) - const validPresets = ["boulder", "pebble", "sandstone"]; + // Should use forest defaults (boulder, granite, limestone) + const validPresets = ["boulder", "granite", "limestone"]; for (const rock of rocks) { expect(validPresets).toContain(rock.assetId); } @@ -1462,9 +1462,9 @@ describe("ProcgenPlantCache Exports", () => { } }); - it("includes tropical biome", () => { - expect(BIOME_PLANT_PRESETS.tropical).toBeDefined(); - expect(BIOME_PLANT_PRESETS.tropical.length).toBeGreaterThan(0); + it("includes canyon biome", () => { + expect(BIOME_PLANT_PRESETS.canyon).toBeDefined(); + expect(BIOME_PLANT_PRESETS.canyon.length).toBeGreaterThan(0); }); }); diff --git a/packages/shared/src/systems/shared/world/__tests__/TownSystem.test.ts b/packages/shared/src/systems/shared/world/__tests__/TownSystem.test.ts index 104766603..ed6bbf976 100644 --- a/packages/shared/src/systems/shared/world/__tests__/TownSystem.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/TownSystem.test.ts @@ -12,8 +12,8 @@ const WORLD_SIZE = 10000; const MIN_TOWN_SPACING = 800; const FLATNESS_SAMPLE_RADIUS = 40; const FLATNESS_SAMPLE_COUNT = 16; -// IMPORTANT: This must match TERRAIN_CONSTANTS.WATER_THRESHOLD (9.0) -const WATER_THRESHOLD = 9.0; +// IMPORTANT: This must match TERRAIN_CONSTANTS.WATER_THRESHOLD (8.0) +const WATER_THRESHOLD = 8.0; const OPTIMAL_WATER_DISTANCE_MIN = 30; const OPTIMAL_WATER_DISTANCE_MAX = 150; @@ -550,7 +550,6 @@ describe("TownSystem Algorithms", () => { expect(BIOME_SUITABILITY.tundra).toBeGreaterThan( BIOME_SUITABILITY.canyon, ); - expect(BIOME_SUITABILITY.valley).toBeGreaterThan(BIOME_SUITABILITY.swamp); }); }); From 2751b269029f9cc100200a14cd312f9069e8b596 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Thu, 12 Mar 2026 10:58:25 +0800 Subject: [PATCH 46/48] refactor(trees): biome-aware tree allocation with TreeId enum and per-biome placement - Add TreeId enum to replace hardcoded "tree_" prefix strings - Move per-biome tree configs (distribution, placement, density) to TerrainBiomeTypes.ts alongside BiomeType enum - Add per-biome TreePlacementRules so each tree type can have different placement constraints per biome (e.g. dead tree minHeight differs between canyon and tundra) - Remove spawnWeight from TreeTypeDefinition (now dead code since getTreeConfigForBiome always returns a config) - Remove DEFAULT_TREE_CONFIG from TerrainSystem - Add WindPine tree type using dead_06.glb for tundra biome - Update BiomeResourceGenerator to read placement from biome config instead of global tree type registry Made-with: Cursor --- packages/shared/src/constants/TreeTypes.ts | 151 ++++++++---------- .../shared/world/BiomeResourceGenerator.ts | 76 +++++++-- .../systems/shared/world/TerrainBiomeTypes.ts | 91 ++++++++++- .../src/systems/shared/world/TerrainSystem.ts | 32 ++-- .../shared/src/types/world/world-types.ts | 7 +- 5 files changed, 237 insertions(+), 120 deletions(-) diff --git a/packages/shared/src/constants/TreeTypes.ts b/packages/shared/src/constants/TreeTypes.ts index f67f4bd22..0619699c7 100644 --- a/packages/shared/src/constants/TreeTypes.ts +++ b/packages/shared/src/constants/TreeTypes.ts @@ -4,91 +4,87 @@ * All tree type definitions live here. Every other file that needs tree type * information imports from this module instead of defining its own copy. * - * To add a new tree type: add an entry to TREE_TYPES below. - * To rename a tree type: change the key and update the manifest ID accordingly. - * To remove a tree type: delete the entry. + * To add a new tree type: add an entry to TREE_TYPES and TreeId below. + * To rename a tree type: change the key/enum and update the manifest ID. + * To remove a tree type: delete the entry from both. * - * The manifest (woodcutting.json) must have a matching "id": "tree_" entry - * for each tree type listed here. The manifest holds runtime data (drops, XP, - * model paths) while this file holds the structural/gameplay config. + * The manifest (woodcutting.json) must have a matching "id" entry that equals + * the TreeId enum value for each tree type listed here. + * + * Per-biome tree configs (distribution, placement, density) live in + * TerrainBiomeTypes.ts alongside the BiomeType enum. + */ + +/** + * Enum of all tree IDs — values match the manifest resource IDs. + * Use this instead of hardcoded "tree_xxx" strings everywhere. */ +export enum TreeId { + Fir = "tree_fir", + Pine = "tree_pine", + Oak = "tree_oak", + Birch = "tree_birch", + Bamboo = "tree_bamboo", + ChinaPine = "tree_chinaPine", + Maple = "tree_maple", + Coconut = "tree_coconut", + Palm = "tree_palm", + Dead = "tree_dead", + Cactus = "tree_cactus", + Knotwood = "tree_knotwood", + WindPine = "tree_windPine", +} + +/** Extract the subtype key from a TreeId (e.g. TreeId.Oak → "oak") */ +export function treeIdToSubType(id: string): string { + return id.replace("tree_", ""); +} + +/** Landscape placement rules for a tree type within a biome. */ +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. + */ + waterAffinity?: number; + /** If waterAffinity > 0, the max height above water to consider "near water" */ + waterProximityHeight?: number; + /** Reject placement if position is below this height above water threshold */ + avoidsWaterBelow?: number; + /** Minimum terrain height for spawning (world units) */ + minHeight?: number; + /** Maximum terrain height for spawning (world units) */ + maxHeight?: number; +} export interface TreeTypeDefinition { /** Display name shown in UI (e.g., "Oak Tree") */ name: string; /** Woodcutting level required to chop */ levelRequired: number; - /** Spawn weight in default biome (0 = doesn't spawn by default) */ - spawnWeight: number; } /** * Master tree type registry. * - * Keys are subtypes (e.g., "oak"). The manifest ID is "tree_". - * Add, rename, or remove entries here — everything else derives from this. + * Keys are subtypes (e.g., "oak"). Placement rules live in per-biome configs + * in TerrainBiomeTypes.ts. */ export const TREE_TYPES = { - fir: { - name: "Fir Tree", - levelRequired: 1, - spawnWeight: 10, - }, - pine: { - name: "Pine Tree", - levelRequired: 1, - spawnWeight: 10, - }, - oak: { - name: "Oak Tree", - levelRequired: 15, - spawnWeight: 10, - }, - birch: { - name: "Birch Tree", - levelRequired: 1, - spawnWeight: 10, - }, - bamboo: { - name: "Bamboo Tree", - levelRequired: 1, - spawnWeight: 10, - }, - chinaPine: { - name: "China Pine", - levelRequired: 1, - spawnWeight: 10, - }, - maple: { - name: "Maple Tree", - levelRequired: 45, - spawnWeight: 10, - }, - coconut: { - name: "Coconut Palm", - levelRequired: 1, - spawnWeight: 10, - }, - palm: { - name: "Desert Palm", - levelRequired: 1, - spawnWeight: 10, - }, - dead: { - name: "Dead Tree", - levelRequired: 1, - spawnWeight: 10, - }, - cactus: { - name: "Cactus", - levelRequired: 1, - spawnWeight: 10, - }, - knotwood: { - name: "Knotwood Tree", - levelRequired: 1, - spawnWeight: 80, - }, + fir: { name: "Fir Tree", levelRequired: 1 }, + 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 }, + coconut: { name: "Coconut Palm", levelRequired: 1 }, + palm: { name: "Desert Palm", 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 }, } as const satisfies Record; /** All valid tree subtype keys (e.g., "oak", "willow") */ @@ -107,18 +103,3 @@ export function getTreeLevelRequired(subType: string): number { ?.levelRequired ?? 1 ); } - -/** - * Build the spawn distribution map for BiomeTreeConfig. - * Only includes types with spawnWeight > 0. - * Keys are "tree_" to match manifest IDs. - */ -export function getDefaultTreeDistribution(): Record { - const distribution: Record = {}; - for (const [key, def] of Object.entries(TREE_TYPES)) { - if (def.spawnWeight > 0) { - distribution[`tree_${key}`] = def.spawnWeight; - } - } - return distribution; -} diff --git a/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts b/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts index bc600c584..1ece47330 100644 --- a/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts +++ b/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts @@ -22,7 +22,12 @@ import type { ResourceSubType, } from "../../../types/world/terrain"; import type { VegetationInstance } from "../../../types/world/world-types"; -import { getTreeLevelRequired } from "../../../constants/TreeTypes"; +import { + getTreeLevelRequired, + treeIdToSubType, +} from "../../../constants/TreeTypes"; +import type { TreePlacementRules } from "../../../constants/TreeTypes"; +import { getTreeConfigForBiome } from "./TerrainBiomeTypes"; /** * Context provided by TerrainSystem for resource generation. @@ -42,6 +47,8 @@ export interface ResourceGenerationContext { getHeightAt: (worldX: number, worldZ: number) => number; /** Check if position is on a road */ isOnRoad?: (worldX: number, worldZ: number) => boolean; + /** Get the dominant biome at a world position (for per-tree biome selection) */ + getDominantBiome?: (worldX: number, worldZ: number) => string; /** Deterministic RNG seeded for this tile */ createRng: (salt: string) => () => number; } @@ -145,18 +152,18 @@ export function generateTrees( // Use deterministic RNG for reproducible placement const rng = ctx.createRng("trees"); - // Get distribution weights - const distribution = treeConfig.distribution; - const treeTypes = Object.keys(distribution); - if (treeTypes.length === 0) { + // Pre-compute tile-level distribution as fallback (used when no per-position + // biome callback is available, or as the default for the tile's biome) + const tileDist = treeConfig.distribution; + const tileTreeTypes = Object.keys(tileDist); + if (tileTreeTypes.length === 0) { return []; } - - const totalWeight = Object.values(distribution).reduce( + const tileTotalWeight = Object.values(tileDist).reduce( (sum, w) => sum + w, 0, ); - if (totalWeight === 0) { + if (tileTotalWeight === 0) { return []; } @@ -236,18 +243,65 @@ export function generateTrees( continue; } + // Resolve the tree distribution 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. + let distribution = tileDist; + let treeTypes = tileTreeTypes; + let totalWeight = tileTotalWeight; + let activePlacements: Record | undefined = + treeConfig.placements; + + if (ctx.getDominantBiome) { + const positionBiome = ctx.getDominantBiome(worldX, worldZ); + const posConfig = getTreeConfigForBiome(positionBiome); + if (posConfig && posConfig.distribution !== tileDist) { + distribution = posConfig.distribution; + activePlacements = posConfig.placements; + treeTypes = Object.keys(distribution); + totalWeight = Object.values(distribution).reduce( + (sum, w) => sum + w, + 0, + ); + if (totalWeight === 0 || treeTypes.length === 0) continue; + } + } + // Select tree type based on weighted distribution - let selectedType = "normal"; + // treeType is a TreeId value (e.g. "tree_oak") + let selectedTreeId = treeTypes[0]; const roll = rng() * totalWeight; let cumulative = 0; for (const treeType of treeTypes) { cumulative += distribution[treeType]; if (roll < cumulative) { - // Extract subtype: "tree_oak" -> "oak", "tree_normal" -> "normal" - selectedType = treeType.replace("tree_", ""); + selectedTreeId = treeType; break; } } + const selectedType = treeIdToSubType(selectedTreeId); + + // Apply per-tree placement rules from the active biome config + const rules = activePlacements?.[selectedTreeId]; + if (rules) { + const heightAboveWater = height - ctx.waterThreshold; + + if (rules.minHeight !== undefined && height < rules.minHeight) continue; + if (rules.maxHeight !== undefined && height > rules.maxHeight) continue; + + if ( + rules.avoidsWaterBelow !== undefined && + heightAboveWater < rules.avoidsWaterBelow + ) + continue; + + if (rules.waterAffinity && rules.waterAffinity > 0) { + const proximityLimit = rules.waterProximityHeight ?? 10; + if (heightAboveWater > proximityLimit) { + if (rng() < rules.waterAffinity) continue; + } + } + } // Generate random scale within variation range const [minScale, maxScale] = treeConfig.scaleVariation ?? [0.8, 1.2]; diff --git a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts index 51a29ec74..efb64a317 100644 --- a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts +++ b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts @@ -1,11 +1,14 @@ /** - * Single source of truth for biome type identifiers. + * Single source of truth for biome type identifiers and per-biome configs. * * All terrain files (TerrainSystem, TerrainHeightParams, TerrainShader, * QuadChunkWorker, TerrainWorker, TerrainQuadChunkGenerator) MUST import * from here instead of using string literals. */ +import type { BiomeTreeConfig } from "../../../types/world/world-types"; +import { TreeId } from "../../../constants/TreeTypes"; + export enum BiomeType { Tundra = "tundra", Forest = "forest", @@ -28,3 +31,89 @@ export function buildBiomeConstantsJS(): string { var BT_DEFAULT = BT_FOREST; `; } + +// --------------------------------------------------------------------------- +// Per-biome tree configs +// --------------------------------------------------------------------------- + +const FOREST_TREE_CONFIG: BiomeTreeConfig = { + enabled: true, + distribution: { + [TreeId.Knotwood]: 80, + [TreeId.Oak]: 20, + [TreeId.Birch]: 20, + [TreeId.Maple]: 15, + [TreeId.Fir]: 15, + [TreeId.Pine]: 15, + [TreeId.ChinaPine]: 15, + [TreeId.Bamboo]: 15, + }, + placements: { + [TreeId.Bamboo]: { minHeight: 35 }, + [TreeId.Knotwood]: { maxHeight: 25 }, + [TreeId.Oak]: { maxHeight: 25 }, + [TreeId.Birch]: { maxHeight: 25 }, + [TreeId.Maple]: { maxHeight: 25 }, + [TreeId.Fir]: { maxHeight: 25 }, + [TreeId.Pine]: { maxHeight: 25 }, + [TreeId.ChinaPine]: { minHeight: 30 }, + }, + density: 20, + minSpacing: 8, + clustering: true, + clusterSize: 5, + scaleVariation: [0.8, 1.2], +}; + +const CANYON_TREE_CONFIG: BiomeTreeConfig = { + enabled: true, + distribution: { + [TreeId.Cactus]: 50, + [TreeId.Dead]: 30, + [TreeId.Palm]: 25, + [TreeId.Coconut]: 15, + }, + placements: { + [TreeId.Cactus]: { avoidsWaterBelow: 3 }, + [TreeId.Dead]: { minHeight: 20 }, + [TreeId.Palm]: { waterAffinity: 0.3, waterProximityHeight: 9 }, + [TreeId.Coconut]: { waterAffinity: 0.6, waterProximityHeight: 9 }, + }, + density: 20, + minSpacing: 10, + clustering: false, + scaleVariation: [0.7, 1.3], +}; + +const TUNDRA_TREE_CONFIG: BiomeTreeConfig = { + enabled: true, + distribution: { + [TreeId.WindPine]: 40, + [TreeId.Fir]: 30, + [TreeId.Pine]: 25, + [TreeId.Birch]: 10, + }, + placements: { + [TreeId.WindPine]: { minHeight: 15 }, + [TreeId.Fir]: { minHeight: 10 }, + [TreeId.Pine]: { minHeight: 8 }, + }, + density: 25, + minSpacing: 12, + clustering: false, + scaleVariation: [0.6, 1.0], +}; + +const BIOME_TREE_CONFIGS: Record = { + [BiomeType.Forest]: FOREST_TREE_CONFIG, + [BiomeType.Canyon]: CANYON_TREE_CONFIG, + [BiomeType.Tundra]: TUNDRA_TREE_CONFIG, +}; + +/** + * Get the tree config for a specific biome. + * Falls back to the forest config for unknown biomes. + */ +export function getTreeConfigForBiome(biomeId: string): BiomeTreeConfig { + return BIOME_TREE_CONFIGS[biomeId as BiomeType] ?? FOREST_TREE_CONFIG; +} diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts index 3131c0ee5..891e0baac 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -60,7 +60,6 @@ import { */ import type { BiomeData } from "../../../types/core/core"; -import type { BiomeTreeConfig } from "../../../types/world/world-types"; import type { ResourceNode, TerrainTile, @@ -83,7 +82,7 @@ import { // generatePlants, // DISABLED - plants not working/looking good yet type ResourceGenerationContext, } from "./BiomeResourceGenerator"; -import { getDefaultTreeDistribution } from "../../../constants/TreeTypes"; +import { getTreeConfigForBiome } from "./TerrainBiomeTypes"; import { setProcgenRockWorld, addRockInstance, @@ -4657,35 +4656,22 @@ export class TerrainSystem extends System { // } // } - /** - * Default tree configuration for biomes without explicit tree config. - * Targets ~1 tree per 20m spacing (density 25 = ~10 trees per 64m tile). - * Reduced from 100 to prevent exceeding MAX_GLOBAL_LEAVES capacity (100k leaves, - * ~2000 leaves per tree = ~50 trees max visible at full detail). - */ - private static readonly DEFAULT_TREE_CONFIG: BiomeTreeConfig = { - enabled: true, - distribution: getDefaultTreeDistribution(), - density: 20, // ~8 trees per 64m tile - minSpacing: 18, // Minimum 18m between trees for natural spacing - clustering: true, - clusterSize: 3, - scaleVariation: [0.8, 1.2], - }; - /** * Generate harvestable trees for a tile based on biome configuration. * Uses the extracted BiomeResourceGenerator for the actual algorithm. - * Falls back to DEFAULT_TREE_CONFIG if biome has no tree config. + * getTreeConfigForBiome always returns a config (falls back to forest). */ private generateTreesForTile(tile: TerrainTile, biomeData: BiomeData): void { - // Use biome tree config or fall back to defaults - const treeConfig = biomeData.trees ?? TerrainSystem.DEFAULT_TREE_CONFIG; + const treeConfig = biomeData.trees ?? getTreeConfigForBiome(tile.biome); if (!treeConfig.enabled) { return; } - // Create context for resource generation + // Create context for resource generation. + // getDominantBiome lets generateTrees() resolve the actual biome at each + // tree position instead of using the single tile-center biome, which + // prevents wrong tree types appearing near biome boundaries. + const biomeSystem = this.terrainGenerator.getBiomeSystem(); const ctx: ResourceGenerationContext = { tileX: tile.x, tileZ: tile.z, @@ -4697,6 +4683,8 @@ export class TerrainSystem extends System { ? (worldX, worldZ) => this.roadNetworkSystem!.isOnRoad(worldX, worldZ) : undefined, createRng: (salt) => this.createTileRng(tile.x, tile.z, salt), + getDominantBiome: (worldX, worldZ) => + biomeSystem.getDominantBiome(worldX, worldZ, 0), }; // Generate trees using the extracted algorithm diff --git a/packages/shared/src/types/world/world-types.ts b/packages/shared/src/types/world/world-types.ts index 9954263e9..808f277f4 100644 --- a/packages/shared/src/types/world/world-types.ts +++ b/packages/shared/src/types/world/world-types.ts @@ -253,8 +253,13 @@ export interface ResourceDistribution { export interface BiomeTreeConfig { /** Whether harvestable trees are enabled for this biome */ enabled: boolean; - /** Distribution weights for tree types (IDs from woodcutting.json) */ + /** Distribution weights for tree types keyed by TreeId enum values */ distribution: ResourceDistribution; + /** Per-tree-type placement rules for this biome (keyed by TreeId) */ + placements?: Record< + string, + import("../../constants/TreeTypes").TreePlacementRules + >; /** Trees per 64m tile (base density, modified by resourceDensity) */ density: number; /** Minimum spacing between trees in meters */ From dd8d6adbb9ea386591613d21daa37c723211ddf7 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Thu, 12 Mar 2026 12:10:14 +0800 Subject: [PATCH 47/48] feat(trees): reject tree placement on steep terrain slopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add maxSlope to BiomeTreeConfig — estimates terrain gradient at each candidate position using central differences and skips placement when the slope exceeds the threshold. Defaults: Forest 1.5, Canyon 2.0, Tundra 1.5. Made-with: Cursor --- .../shared/world/BiomeResourceGenerator.ts | 16 +++++++++ .../systems/shared/world/TerrainBiomeTypes.ts | 35 ++++++++++++------- .../shared/src/types/world/world-types.ts | 2 ++ 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts b/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts index 23b8a95ac..4fdfda433 100644 --- a/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts +++ b/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts @@ -149,6 +149,8 @@ export function generateTrees( return []; } + const maxSlope = treeConfig.maxSlope ?? Infinity; + // Use deterministic RNG for reproducible placement const rng = ctx.createRng("trees"); @@ -243,6 +245,20 @@ export function generateTrees( continue; } + // Reject steep slopes — sample 4 neighbors to estimate gradient magnitude + if (maxSlope < Infinity) { + const sd = 1.0; + const dhdx = + (ctx.getHeightAt(worldX + sd, worldZ) - + ctx.getHeightAt(worldX - sd, worldZ)) / + (2 * sd); + const dhdz = + (ctx.getHeightAt(worldX, worldZ + sd) - + ctx.getHeightAt(worldX, worldZ - sd)) / + (2 * sd); + if (dhdx * dhdx + dhdz * dhdz > maxSlope * maxSlope) continue; + } + // Resolve the tree distribution 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. diff --git a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts index efb64a317..b268378b7 100644 --- a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts +++ b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts @@ -39,10 +39,10 @@ export function buildBiomeConstantsJS(): string { const FOREST_TREE_CONFIG: BiomeTreeConfig = { enabled: true, distribution: { - [TreeId.Knotwood]: 80, + [TreeId.Knotwood]: 40, [TreeId.Oak]: 20, [TreeId.Birch]: 20, - [TreeId.Maple]: 15, + [TreeId.Maple]: 40, [TreeId.Fir]: 15, [TreeId.Pine]: 15, [TreeId.ChinaPine]: 15, @@ -58,31 +58,41 @@ const FOREST_TREE_CONFIG: BiomeTreeConfig = { [TreeId.Pine]: { maxHeight: 25 }, [TreeId.ChinaPine]: { minHeight: 30 }, }, - density: 20, + density: 10, minSpacing: 8, clustering: true, clusterSize: 5, scaleVariation: [0.8, 1.2], + maxSlope: 1.5, }; const CANYON_TREE_CONFIG: BiomeTreeConfig = { enabled: true, distribution: { - [TreeId.Cactus]: 50, - [TreeId.Dead]: 30, - [TreeId.Palm]: 25, - [TreeId.Coconut]: 15, + [TreeId.Cactus]: 20, + [TreeId.Dead]: 20, + [TreeId.Palm]: 20, + [TreeId.Coconut]: 10, }, placements: { [TreeId.Cactus]: { avoidsWaterBelow: 3 }, [TreeId.Dead]: { minHeight: 20 }, - [TreeId.Palm]: { waterAffinity: 0.3, waterProximityHeight: 9 }, - [TreeId.Coconut]: { waterAffinity: 0.6, waterProximityHeight: 9 }, + [TreeId.Palm]: { + waterAffinity: 0.3, + waterProximityHeight: 9, + maxHeight: 15, + }, + [TreeId.Coconut]: { + waterAffinity: 0.6, + waterProximityHeight: 9, + maxHeight: 15, + }, }, - density: 20, - minSpacing: 10, + density: 15, + minSpacing: 18, clustering: false, scaleVariation: [0.7, 1.3], + maxSlope: 2.0, }; const TUNDRA_TREE_CONFIG: BiomeTreeConfig = { @@ -98,10 +108,11 @@ const TUNDRA_TREE_CONFIG: BiomeTreeConfig = { [TreeId.Fir]: { minHeight: 10 }, [TreeId.Pine]: { minHeight: 8 }, }, - density: 25, + density: 10, minSpacing: 12, clustering: false, scaleVariation: [0.6, 1.0], + maxSlope: 1.5, }; const BIOME_TREE_CONFIGS: Record = { diff --git a/packages/shared/src/types/world/world-types.ts b/packages/shared/src/types/world/world-types.ts index 808f277f4..990682080 100644 --- a/packages/shared/src/types/world/world-types.ts +++ b/packages/shared/src/types/world/world-types.ts @@ -270,6 +270,8 @@ export interface BiomeTreeConfig { clusterSize?: 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) */ + maxSlope?: number; } /** From 6295345f0861b8b9c1eff6edddf0119caeb35202 Mon Sep 17 00:00:00 2001 From: Ting Chien Meng Date: Thu, 12 Mar 2026 13:49:17 +0800 Subject: [PATCH 48/48] refactor(trees): merge distribution + placements into single trees map in BiomeTreeConfig Eliminates duplicate tree definitions by combining spawn weight and placement rules into a unified TreeSpawnConfig per tree type per biome. Made-with: Cursor --- packages/shared/src/constants/TreeTypes.ts | 6 ++ .../shared/world/BiomeResourceGenerator.ts | 39 +++++------- .../systems/shared/world/TerrainBiomeTypes.ts | 62 +++++++------------ .../__tests__/BiomeResourceSpawning.test.ts | 32 +++++----- .../shared/src/types/world/world-types.ts | 9 +-- 5 files changed, 62 insertions(+), 86 deletions(-) diff --git a/packages/shared/src/constants/TreeTypes.ts b/packages/shared/src/constants/TreeTypes.ts index 0619699c7..33c21e927 100644 --- a/packages/shared/src/constants/TreeTypes.ts +++ b/packages/shared/src/constants/TreeTypes.ts @@ -58,6 +58,12 @@ export interface TreePlacementRules { maxHeight?: number; } +/** Spawn weight + placement rules combined — used in per-biome tree configs. */ +export interface TreeSpawnConfig extends TreePlacementRules { + /** Relative spawn weight (higher = more likely). */ + weight: number; +} + export interface TreeTypeDefinition { /** Display name shown in UI (e.g., "Oak Tree") */ name: string; diff --git a/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts b/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts index 4fdfda433..bc719c7c3 100644 --- a/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts +++ b/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts @@ -154,15 +154,14 @@ export function generateTrees( // Use deterministic RNG for reproducible placement const rng = ctx.createRng("trees"); - // Pre-compute tile-level distribution as fallback (used when no per-position - // biome callback is available, or as the default for the tile's biome) - const tileDist = treeConfig.distribution; - const tileTreeTypes = Object.keys(tileDist); + // Pre-compute tile-level distribution from the merged trees map + const tileTreeMap = treeConfig.trees; + const tileTreeTypes = Object.keys(tileTreeMap); if (tileTreeTypes.length === 0) { return []; } - const tileTotalWeight = Object.values(tileDist).reduce( - (sum, w) => sum + w, + const tileTotalWeight = Object.values(tileTreeMap).reduce( + (sum, cfg) => sum + cfg.weight, 0, ); if (tileTotalWeight === 0) { @@ -259,24 +258,21 @@ export function generateTrees( if (dhdx * dhdx + dhdz * dhdz > maxSlope * maxSlope) continue; } - // Resolve the tree distribution 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. - let distribution = tileDist; + // 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. + let activeTreeMap = tileTreeMap; let treeTypes = tileTreeTypes; let totalWeight = tileTotalWeight; - let activePlacements: Record | undefined = - treeConfig.placements; if (ctx.getDominantBiome) { const positionBiome = ctx.getDominantBiome(worldX, worldZ); const posConfig = getTreeConfigForBiome(positionBiome); - if (posConfig && posConfig.distribution !== tileDist) { - distribution = posConfig.distribution; - activePlacements = posConfig.placements; - treeTypes = Object.keys(distribution); - totalWeight = Object.values(distribution).reduce( - (sum, w) => sum + w, + if (posConfig && posConfig.trees !== tileTreeMap) { + activeTreeMap = posConfig.trees; + treeTypes = Object.keys(activeTreeMap); + totalWeight = Object.values(activeTreeMap).reduce( + (sum, cfg) => sum + cfg.weight, 0, ); if (totalWeight === 0 || treeTypes.length === 0) continue; @@ -284,12 +280,11 @@ export function generateTrees( } // Select tree type based on weighted distribution - // treeType is a TreeId value (e.g. "tree_oak") let selectedTreeId = treeTypes[0]; const roll = rng() * totalWeight; let cumulative = 0; for (const treeType of treeTypes) { - cumulative += distribution[treeType]; + cumulative += activeTreeMap[treeType].weight; if (roll < cumulative) { selectedTreeId = treeType; break; @@ -297,8 +292,8 @@ export function generateTrees( } const selectedType = treeIdToSubType(selectedTreeId); - // Apply per-tree placement rules from the active biome config - const rules = activePlacements?.[selectedTreeId]; + // Apply per-tree placement rules from the merged config + const rules: TreePlacementRules | undefined = activeTreeMap[selectedTreeId]; if (rules) { const heightAboveWater = height - ctx.waterThreshold; diff --git a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts index b268378b7..7d1c523bd 100644 --- a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts +++ b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts @@ -38,51 +38,36 @@ export function buildBiomeConstantsJS(): string { const FOREST_TREE_CONFIG: BiomeTreeConfig = { enabled: true, - distribution: { - [TreeId.Knotwood]: 40, - [TreeId.Oak]: 20, - [TreeId.Birch]: 20, - [TreeId.Maple]: 40, - [TreeId.Fir]: 15, - [TreeId.Pine]: 15, - [TreeId.ChinaPine]: 15, - [TreeId.Bamboo]: 15, + trees: { + [TreeId.Knotwood]: { weight: 40, maxHeight: 25 }, + [TreeId.Oak]: { weight: 20, maxHeight: 25 }, + [TreeId.Birch]: { weight: 20, maxHeight: 25 }, + [TreeId.Maple]: { weight: 40, maxHeight: 25 }, + [TreeId.Fir]: { weight: 15, maxHeight: 25 }, + [TreeId.Pine]: { weight: 15, maxHeight: 25 }, + [TreeId.ChinaPine]: { weight: 15, minHeight: 30, maxHeight: 60 }, + [TreeId.Bamboo]: { weight: 15, minHeight: 35 }, }, - placements: { - [TreeId.Bamboo]: { minHeight: 35 }, - [TreeId.Knotwood]: { maxHeight: 25 }, - [TreeId.Oak]: { maxHeight: 25 }, - [TreeId.Birch]: { maxHeight: 25 }, - [TreeId.Maple]: { maxHeight: 25 }, - [TreeId.Fir]: { maxHeight: 25 }, - [TreeId.Pine]: { maxHeight: 25 }, - [TreeId.ChinaPine]: { minHeight: 30 }, - }, - density: 10, + density: 15, minSpacing: 8, - clustering: true, - clusterSize: 5, + clustering: false, scaleVariation: [0.8, 1.2], maxSlope: 1.5, }; const CANYON_TREE_CONFIG: BiomeTreeConfig = { enabled: true, - distribution: { - [TreeId.Cactus]: 20, - [TreeId.Dead]: 20, - [TreeId.Palm]: 20, - [TreeId.Coconut]: 10, - }, - placements: { - [TreeId.Cactus]: { avoidsWaterBelow: 3 }, - [TreeId.Dead]: { minHeight: 20 }, + trees: { + [TreeId.Cactus]: { weight: 20, avoidsWaterBelow: 3 }, + [TreeId.Dead]: { weight: 20, minHeight: 20 }, [TreeId.Palm]: { + weight: 20, waterAffinity: 0.3, waterProximityHeight: 9, maxHeight: 15, }, [TreeId.Coconut]: { + weight: 10, waterAffinity: 0.6, waterProximityHeight: 9, maxHeight: 15, @@ -97,16 +82,11 @@ const CANYON_TREE_CONFIG: BiomeTreeConfig = { const TUNDRA_TREE_CONFIG: BiomeTreeConfig = { enabled: true, - distribution: { - [TreeId.WindPine]: 40, - [TreeId.Fir]: 30, - [TreeId.Pine]: 25, - [TreeId.Birch]: 10, - }, - placements: { - [TreeId.WindPine]: { minHeight: 15 }, - [TreeId.Fir]: { minHeight: 10 }, - [TreeId.Pine]: { minHeight: 8 }, + trees: { + [TreeId.WindPine]: { weight: 40, minHeight: 15 }, + [TreeId.Fir]: { weight: 30, minHeight: 10 }, + [TreeId.Pine]: { weight: 25, minHeight: 8 }, + [TreeId.Birch]: { weight: 10 }, }, density: 10, minSpacing: 12, 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 b116ecabb..b9d67d735 100644 --- a/packages/shared/src/systems/shared/world/__tests__/BiomeResourceSpawning.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/BiomeResourceSpawning.test.ts @@ -92,10 +92,10 @@ describe("BiomeResourceGenerator", () => { describe("generateTrees", () => { const forestTreeConfig: BiomeTreeConfig = { enabled: true, - distribution: { - tree_normal: 0.5, - tree_oak: 0.35, - tree_willow: 0.15, + trees: { + tree_normal: { weight: 0.5 }, + tree_oak: { weight: 0.35 }, + tree_willow: { weight: 0.15 }, }, density: 8, minSpacing: 8, @@ -142,7 +142,7 @@ describe("BiomeResourceGenerator", () => { const ctx = createTestContext(0, 0); const mapleConfig: BiomeTreeConfig = { enabled: true, - distribution: { tree_maple: 1.0 }, + trees: { tree_maple: { weight: 1.0 } }, density: 5, minSpacing: 5, clustering: false, @@ -162,7 +162,7 @@ describe("BiomeResourceGenerator", () => { const ctx = createTestContext(0, 0); const spacedConfig: BiomeTreeConfig = { enabled: true, - distribution: { tree_normal: 1.0 }, + trees: { tree_normal: { weight: 1.0 } }, density: 50, minSpacing: 20, clustering: false, @@ -537,7 +537,7 @@ describe("BiomeResourceGenerator", () => { const ctx = createTestContext(0, 0); const emptyConfig: BiomeTreeConfig = { enabled: true, - distribution: {}, + trees: {}, density: 10, minSpacing: 5, clustering: false, @@ -551,7 +551,7 @@ describe("BiomeResourceGenerator", () => { const ctx = createTestContext(0, 0); const zeroWeightConfig: BiomeTreeConfig = { enabled: true, - distribution: { tree_normal: 0, tree_oak: 0 }, + trees: { tree_normal: { weight: 0 }, tree_oak: { weight: 0 } }, density: 10, minSpacing: 5, clustering: false, @@ -565,7 +565,7 @@ describe("BiomeResourceGenerator", () => { const ctx = createTestContext(0, 0); const zeroDensityConfig: BiomeTreeConfig = { enabled: true, - distribution: { tree_normal: 1.0 }, + trees: { tree_normal: { weight: 1.0 } }, density: 0, minSpacing: 5, clustering: false, @@ -579,7 +579,7 @@ describe("BiomeResourceGenerator", () => { const ctx = createTestContext(-5, -10); const config: BiomeTreeConfig = { enabled: true, - distribution: { tree_normal: 1.0 }, + trees: { tree_normal: { weight: 1.0 } }, density: 5, minSpacing: 5, clustering: false, @@ -597,7 +597,7 @@ describe("BiomeResourceGenerator", () => { const ctx = createTestContext(0, 0); const noClusterSizeConfig: BiomeTreeConfig = { enabled: true, - distribution: { tree_normal: 1.0 }, + trees: { tree_normal: { weight: 1.0 } }, density: 5, minSpacing: 5, clustering: true, @@ -613,7 +613,7 @@ describe("BiomeResourceGenerator", () => { const ctx = createTestContext(0, 0); const highDensityConfig: BiomeTreeConfig = { enabled: true, - distribution: { tree_normal: 1.0 }, + trees: { tree_normal: { weight: 1.0 } }, density: 100, // Very high minSpacing: 50, // Large spacing limits actual placement clustering: false, @@ -632,7 +632,7 @@ describe("BiomeResourceGenerator", () => { }); const config: BiomeTreeConfig = { enabled: true, - distribution: { tree_normal: 1.0 }, + trees: { tree_normal: { weight: 1.0 } }, density: 10, minSpacing: 5, clustering: false, @@ -648,7 +648,7 @@ describe("BiomeResourceGenerator", () => { }); const config: BiomeTreeConfig = { enabled: true, - distribution: { tree_normal: 1.0 }, + trees: { tree_normal: { weight: 1.0 } }, density: 10, minSpacing: 5, clustering: false, @@ -668,7 +668,7 @@ describe("BiomeResourceGenerator", () => { const config: BiomeTreeConfig = { enabled: true, - distribution: { tree_normal: 1.0 }, + trees: { tree_normal: { weight: 1.0 } }, density: 10, minSpacing: 5, clustering: false, @@ -692,7 +692,7 @@ describe("BiomeResourceGenerator", () => { const config: BiomeTreeConfig = { enabled: true, - distribution: { tree_normal: 1.0 }, + trees: { tree_normal: { weight: 1.0 } }, density: 20, minSpacing: 5, clustering: false, diff --git a/packages/shared/src/types/world/world-types.ts b/packages/shared/src/types/world/world-types.ts index 990682080..70b807b65 100644 --- a/packages/shared/src/types/world/world-types.ts +++ b/packages/shared/src/types/world/world-types.ts @@ -253,13 +253,8 @@ export interface ResourceDistribution { export interface BiomeTreeConfig { /** Whether harvestable trees are enabled for this biome */ enabled: boolean; - /** Distribution weights for tree types keyed by TreeId enum values */ - distribution: ResourceDistribution; - /** Per-tree-type placement rules for this biome (keyed by TreeId) */ - placements?: Record< - string, - import("../../constants/TreeTypes").TreePlacementRules - >; + /** Per-tree spawn weight + placement rules, keyed by TreeId */ + trees: Record; /** Trees per 64m tile (base density, modified by resourceDensity) */ density: number; /** Minimum spacing between trees in meters */