import * as THREE from 'three'; import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils'; import { Sim } from '@shared/sim/Sim'; import { VictoryManager, type VictoryState, type VictoryConfig } from '@shared/sim/VictoryManager'; import { Terra, BiomeType, BIOME_CONFIGS, TerrainType, StrategicFeature, FormationType, AffordanceType, MAP_SIZE_METERS, MAP_SEGMENTS, MAP_TILES, TILE_SIZE, MAP_HALF_SIZE, UNIT_PRODUCTION, MAP_SIZE_CONFIGS, MapSize, getMapMetadata, BUILDING_BLUEPRINTS, UNIT_BLUEPRINTS, type SymmetryGroup, type TerraOptions, deserializeStrategicBundle, deserializeStrategicRoughHeightConditions, type StrategicMapBundle, type StrategicRoughHeightConditions } from '@shared'; import type { HeightmapResolution } from '@shared/terrain/HeightmapConfig'; import { RTSCamera } from './camera/RTSCamera'; import { InputController } from './input/InputController'; import { SelectionManager } from './input/SelectionManager'; import { UIManager } from './ui/UIManager'; import { GameEventNotifications } from './ui/GameEventNotifications'; import { CombatTestPanel } from './ui/CombatTestPanel'; import { ScenarioDirectorPanel } from './ui/ScenarioDirectorPanel'; import { SelectionBoxRenderer } from './ui/SelectionBoxRenderer'; import { ViewportIndicator } from './ui/ViewportIndicator'; import { BuildingPreview } from './render/BuildingPreview'; import { ScreenEffects } from './effects/ScreenEffects'; import { DamageNumbersSystem } from './effects/DamageNumbers'; import { CombatFeedbackSystem } from './effects/CombatFeedback'; import { ConstructionRenderer } from './render/ConstructionRenderer'; import { RallyPointRenderer } from './render/RallyPointRenderer'; import { CameraFocusIndicator } from './render/CameraFocusIndicator'; import { runHeightScaleSanityTest } from './utils/HeightScaleSanityTest'; import { ResourceRenderer } from './render/ResourceRenderer'; import { FormationDebugRenderer } from './render/FormationDebugRenderer'; import { EntityRenderer } from './render/EntityRenderer'; import { StrategyIconRenderer } from './render/StrategyIconRenderer'; import { AAAProjectileRenderer, PERFORMANCE_PRESETS } from './render/AAAProjectileRenderer'; import { ImpactContextBuilder } from './render/effects/impact/ImpactContextBuilder'; import { TerrainMarkerLayer, type TerrainMarkerOverlayLayer } from './ui/TerrainMarkerLayer'; import { BuildingGridOverlay } from './render/BuildingGridOverlay'; import { CliffRenderer } from './render/CliffRenderer'; import { TerrainTextureGenerator } from './assets/terrain'; import { applyCuratedEnvironmentMap } from './materials/CuratedEnvironmentMap'; import { preloadGeneratedSurfaceTexturesFromManifest } from './materials/ProceduralSurfaceTextures'; import { TerrainShaderMaterial } from './render/TerrainShaderMaterial'; import { WaterRenderer } from './render/WaterRenderer'; import { ModernTerrainRenderer } from './render/terrain/ModernTerrainRenderer'; import { WebGPUTerrainChunkManager } from './render/terrain/WebGPUTerrainChunkManager'; import { SaveManager, type CameraState as SaveCameraState, type SaveGameData } from './save/SaveManager'; import { SkyDome } from './render/SkyDome'; import { LightingController } from './render/lighting'; import type { LightingState } from './render/lighting'; import { setGlobalParticleLighting } from './render/ParticleSystem'; import type { Building, BuildingType, Unit, UnitType, TowerProjectileSnapshot, TowerImpactSnapshot, AiPlacementDebugSnapshot } from '@shared'; import { GpuAOGenerator } from './render/GpuAOGenerator'; import { PostProcessing } from './effects/PostProcessing'; import { bootstrapPlatform, getGpuDeviceManager } from './platform/context'; import type { GpuDeviceManager } from './platform/gpuDevice'; import { RendererService } from './render/RendererService'; import { TreePlacementGenerator } from './render/TreePlacementGenerator'; import { ImprovedTreeSystem } from './systems/ImprovedTreeSystem'; import { LoadingProgress } from './ui/LoadingProgress'; import { FRAMEGRAPH_ENABLE_POST, FRAMEGRAPH_TERRAIN_QUALITY, FRAMEGRAPH_TERRAIN_DEBUG_MODE, FRAMEGRAPH_USE_WEBGL_ENTITIES, FRAMEGRAPH_ENTITY_RENDER_MODE, FRAMEGRAPH_ENTITY_SCREENSPACE_PASS, FRAMEGRAPH_CAPTURE_ENTITY_PROXY_OBJECTS, FRAMEGRAPH_GPU_INFLUENCE_RINGS, FRAMEGRAPH_TREE_BILLBOARDS, FRAMEGRAPH_DYNAMIC_QUALITY, FRAMEGRAPH_DYNAMIC_SSAO_HEIGHT, FRAMEGRAPH_DYNAMIC_TREE_HEIGHT, FRAMEGRAPH_DYNAMIC_BLOOM_HEIGHT, FRAMEGRAPH_DYNAMIC_SHADOW_HEIGHT } from './render/framegraph/config'; import { getMeshProxyTextureDebugConfig, getMeshProxyTextureLayerKeys, setMeshProxyTextureDebugConfig } from './render/framegraph/passes'; import { RenderWorldExtractor, RenderWorldManager, CameraSnapshot, LightingSnapshot } from './scene'; import { MeshRegistry } from './render/MeshRegistry'; import type { VisualLanguageSchema } from './render/ProceduralMeshLoader'; import { getAssetIdForBuilding, getAssetIdForUnit } from './render/AssetMapping'; import { MenuManager } from './menu/MenuManager'; import { SettingsManager } from './menu/SettingsManager'; import { ProfileManager } from './menu/ProfileManager'; import { MenuArtRotator, DEFAULT_MENU_ART_ASSETS } from './menu/MenuArtRotator'; import { MenuMusicPlayer } from './menu/MenuMusicPlayer'; import { Mp3Player } from './menu/Mp3Player'; import { Mp3PlayerOverlay } from './menu/Mp3PlayerOverlay'; import type { MatchSettings, MapGeneratorSettings, PlayerSlot } from './menu/MatchSettings'; import { DEFAULT_GENERATOR_SETTINGS, DEFAULT_MATCH_SETTINGS, PLAYER_COLORS } from './menu/MatchSettings'; import { SoundManager } from './audio/SoundManager'; import { AudioOptimizer } from './audio/AudioOptimizer'; import type { LayerPlayRequest, LoopingSoundHandle, PlaySoundOptions } from './audio/types'; import { MusicManager } from './audio/MusicManager'; import { IntensityCalculator } from './audio/IntensityCalculator'; import { DrawCallProfiler } from './profiling/DrawCallProfiler'; import type { PerformanceBenchmark } from './profiling/PerformanceBenchmark'; import { Profiler } from './profiling'; import { TerrainQueryCache } from './terrain/TerrainQueryCache'; import { applyDensityMultiplierPacked, capPackedPositionsUniformlyByHash as capPackedPositionsUniformlyByHashCore, expandAnchorsToDependentScatterPacked as expandAnchorsToDependentScatterPackedCore, extendScatterPositionsPacked as extendScatterPositionsPackedCore, filterPackedByHeight, filterPackedBySlope, filterPackedPositions, mergePackedPositions, packedPositionCount, packPositions, unpackPositions } from './terrain/scatter/PackedScatterUtils'; import { getScatterWorkerManager } from './terrain/ScatterWorkerManager'; import { FPSDisplay } from './ui/FPSDisplay'; import { ProceduralMeshLoader } from './render/ProceduralMeshLoader'; import { ProceduralMeshTester } from './debug/ProceduralMeshTester'; import { FogOfWarRenderer } from './render/FogOfWarRenderer'; import { TerrainDebugPanel } from './ui/TerrainDebugPanel'; import { OverlayHubPanel } from './ui/OverlayHubPanel'; import { DebugToolsPanel } from './ui/DebugToolsPanel'; import { DEBUG_RUNTIME_COMMANDS, DEFAULT_TREE_LIGHTING_TUNING, TREE_TUNING_SPECS, getDebugToolsManifest, resolveShadowCascadeDebugMode, type ShadowCascadeDebugMode, type TreeTuningName } from './debug/DebugToolsRegistry'; import type { TerrainFeatureFlags } from '@shared/maps/stages/TerrainFeatureFlags'; import type { HeightmapTuningOverrides } from '@shared/maps/ProceduralLayoutPlan'; import { buildSystemActivationReport, printSystemActivationReport, type RuntimeOverrideEntry, type SystemActivationReport } from './bootstrap/SystemActivationReport'; declare global { interface Window { __RTS?: { setGpuAoEnabled?: (enabled: boolean) => Promise; refreshGpuAo?: () => void; isGpuAoEnabled?: () => boolean; gpuDeviceManagerReady?: Promise; gpuRendererServiceReady?: Promise; toggleTerrainDebugOverlay?: () => void; refreshTerrainDebugOverlay?: () => void; toggleForestFactorOverlay?: () => void; refreshForestFactorOverlay?: () => void; exportForestFactorOverlay?: () => void; toggleAiPlacementOverlay?: () => void; toggleFeatureOverlay?: () => boolean; setFeatureOverlayVisible?: (enabled: boolean) => boolean; refreshFeatureOverlay?: () => void; toggleStrategicMap?: () => boolean; setStrategicMapVisible?: (enabled: boolean) => boolean; toggleFormationOverlay?: () => boolean; setFormationOverlayVisible?: (enabled: boolean) => boolean; toggleFormationCommandMode?: () => boolean; setFormationCommandMode?: (enabled: boolean) => boolean; getFormationCommandMode?: () => boolean; toggleMoveOrderOrientationDrag?: () => boolean; setMoveOrderOrientationDrag?: (enabled: boolean) => boolean; getMoveOrderOrientationDrag?: () => boolean; toggleFormationHoldOnArrival?: () => boolean; setFormationHoldOnArrival?: (enabled: boolean, persist?: boolean) => boolean; getFormationHoldOnArrival?: () => boolean; toggleStrategicTerrainOverlay?: () => boolean; setStrategicTerrainOverlayVisible?: (enabled: boolean) => boolean; toggleResourceOverlay?: () => boolean; setResourceOverlayVisible?: (enabled: boolean) => boolean; toggleFrontlineOverlay?: () => boolean; setFrontlineOverlayVisible?: (enabled: boolean) => boolean; setTerrainMarkerCloseSuppression?: (enabled: boolean) => boolean; setTerrainMarkerZoomThreshold?: (distance: number) => number; setTerrainDefenseCoverageVisible?: (enabled: boolean) => boolean; setTerrainCommandZonesVisible?: (enabled: boolean) => boolean; getTerrainMarkerOverlayConfig?: () => unknown; exportTerrainHeightmap?: () => void; useGpuInfluenceRings?: boolean; profileDrawCalls?: () => void; enableFrameProfile?: (enabled?: boolean) => void; setFrameProfileInterval?: (ms: number) => void; setFrameProfileSpikeThreshold?: (ms: number) => void; toggleFrameProfileOverlay?: () => void; toggleRendererPerformanceOverlay?: () => void; toggleBenchmarkOverlay?: () => void; togglePerformanceOverlay?: () => void; setGroupAccentRings?: (enabled: boolean) => void; setBuildingStyleRings?: (enabled: boolean) => void; setShieldBubbleVisuals?: (enabled: boolean) => void; setSdfShieldVisuals?: (enabled: boolean) => void; setWebglEntities?: (enabled: boolean) => void; setEntityScreenspacePass?: (enabled: boolean) => void; clearShields?: () => void; getRenderPerfConfig?: () => unknown; getMeshProxyTextureDebugConfig?: () => unknown; getMeshProxyTextureLayerKeys?: () => readonly string[]; setMeshProxyTextureDebugConfig?: (patch: { slotLayerKeys?: Partial>; useSlotOverrides?: boolean; visualizeSlots?: boolean; materialIntensity?: number; verifyMaterialPath?: boolean; }) => unknown; setMeshProxyTextureSlot?: ( slot: 'base' | 'primary' | 'secondary' | 'accent', key: string ) => unknown; setMeshProxySlotOverrides?: (enabled?: boolean) => unknown; setMeshProxySlotVisualization?: (enabled?: boolean) => unknown; setMeshProxyMaterialIntensity?: (value: number) => unknown; setMeshProxyMaterialVerification?: (enabled?: boolean) => unknown; setSsrEnabled?: (enabled: boolean) => void; toggleSsr?: () => void; isSsrEnabled?: () => boolean; setEntityProxyCapture?: (enabled: boolean) => void; setClusteredLighting?: (enabled: boolean) => void; setRenderDecals?: (enabled: boolean) => void; setRenderUiOverlays?: (enabled: boolean) => void; setRenderTacticalOverlays?: (enabled: boolean, persist?: boolean) => boolean; setAnimationDebugOverlay?: (enabled?: boolean, persist?: boolean) => boolean; getAnimationDebugOverlay?: () => unknown; setPreviewInfluenceOverlay?: (enabled: boolean) => void; getRenderCaptureConfig?: () => unknown; setImpactGraphEnabled?: (enabled: boolean) => boolean; setImpactGraphQuality?: (quality: 'high' | 'medium' | 'low') => 'high' | 'medium' | 'low'; setImpactGraphSdfDeflection?: (enabled: boolean) => boolean; getImpactGraphStats?: () => unknown; setLegacyImpactFxEnabled?: (enabled: boolean) => boolean; setUnitSimProfiling?: (enabled?: boolean) => void; getUnitSimPerformance?: () => unknown; getEntityCullingStats?: () => unknown; setEntityFrustumCulling?: (enabled?: boolean) => void; setEntityDistanceCulling?: (enabled?: boolean) => void; setEntityCullDistance?: (meters?: number) => number; getEntityCullingConfig?: () => unknown; getClusteredLightingStats?: () => unknown; getRendererFrameStats?: () => unknown; getBindingUploadStats?: () => unknown; setRenderEnabled?: (enabled?: boolean) => boolean; getRenderEnabled?: () => boolean; setDisabledFramegraphPasses?: (passNames?: string[]) => string[]; getDisabledFramegraphPasses?: () => string[]; getFramegraphPassNames?: () => string[]; setFramegraphFeatureOverrides?: (overrides: { shadows?: boolean | null; ssao?: boolean | null; bloom?: boolean | null; water?: boolean | null; lightCulling?: boolean | null; }) => unknown; getFramegraphFeatureOverrides?: () => unknown; runPerformanceDiagnosis?: (options?: { durationSec?: number; warmupSec?: number; ignoreHidden?: boolean; requireFocus?: boolean; }) => Promise; getLastPerformanceDiagnosis?: () => unknown; printLastPerformanceDiagnosis?: () => string | null; runFrameBudgetBreakdown?: (options?: { durationSec?: number; warmupSec?: number; targetFps?: number; ignoreHidden?: boolean; requireFocus?: boolean; }) => Promise; getLastFrameBudgetBreakdown?: () => unknown; printLastFrameBudgetBreakdown?: () => string | null; runRafPacingProbe?: (options?: { durationSec?: number }) => Promise; runRenderFeatureSweep?: (options?: { durationSec?: number; warmupSec?: number; ignoreHidden?: boolean; requireFocus?: boolean; lockCamera?: boolean; profiles?: string[]; retryInvalidProfiles?: boolean; invalidRetryCount?: number; retryWarmupSec?: number; }) => Promise; getLastRenderFeatureSweep?: () => unknown; getDynamicResolutionState?: () => unknown; setDynamicResolutionEnabled?: (enabled?: boolean, persist?: boolean) => boolean; setDynamicResolutionScale?: (scale?: number, persist?: boolean) => number; setDynamicResolutionTargets?: (options?: { targetMs?: number; minScale?: number; maxScale?: number; stepDown?: number; stepUp?: number; overFrames?: number; recoverFrames?: number; cooldownMs?: number; }) => unknown; getAdaptiveFeatureThrottleState?: () => unknown; setAdaptiveFeatureThrottleEnabled?: (enabled?: boolean, persist?: boolean) => boolean; setAdaptiveFeatureThrottleTargets?: (options?: { targetMs?: number; overFrames?: number; recoverFrames?: number; cooldownMs?: number; }) => unknown; getShadowCasterAutoPolicyState?: () => unknown; setShadowCasterAutoPolicyEnabled?: (enabled?: boolean, persist?: boolean) => boolean; setShadowCasterAutoPolicyTargets?: (options?: { softFrameMs?: number; severeFrameMs?: number; highCameraMeters?: number; recoverFrames?: number; }) => unknown; applyTenPointOptimizationSuite?: (persist?: boolean) => unknown; getTenPointOptimizationSuite?: () => unknown; run10msPerfGate?: (options?: { targetMs?: number; diagnosisDurationSec?: number; diagnosisWarmupSec?: number; runSweep?: boolean; sweepDurationSec?: number; sweepWarmupSec?: number; }) => Promise; toggleOverlayHubPanel?: () => void; showOverlayHubPanel?: () => void; hideOverlayHubPanel?: () => void; toggleDebugToolsPanel?: () => void; showDebugToolsPanel?: () => void; hideDebugToolsPanel?: () => void; listDebugTools?: () => unknown; getDebugToolsRegistry?: () => unknown; captureShadowIncident?: (label?: string) => unknown; compareShadowIncidents?: (a?: number | string, b?: number | string) => unknown; listShadowIncidents?: () => unknown; clearShadowIncidents?: () => number; getShadowDebugStatus?: () => unknown; restoreShadowVisibility?: () => unknown; getShadowPassStats?: () => unknown; setTreeWind?: (strength: number, speed?: number) => { strength: number; speed: number; webglStrength: number; webglSpeed: number; }; getTreeWind?: () => { strength: number; speed: number; webglStrength: number; webglSpeed: number; storage: { strength: string; speed: string; }; }; treeWindStrength?: number; treeWindSpeed?: number; setVegetationQaOverlay?: (enabled?: boolean) => boolean; toggleVegetationQaOverlay?: () => boolean; getVegetationQaOverlay?: () => boolean; textureSource?: 'official' | 'generated' | 'procedural' | null; textureStatus?: 'idle' | 'loading' | 'ready' | 'missing-generated' | 'error'; maplabRoughHeight?: StrategicRoughHeightConditions | null; getMapLabRoughHeight?: () => StrategicRoughHeightConditions | null; getSystemActivationReport?: () => SystemActivationReport; printSystemActivationReport?: () => SystemActivationReport; }; } } // BALANCED GRASS: Request higher buffer limits (use safe value slightly below max) const gpuDeviceManagerReady = bootstrapPlatform({ requiredLimits: { maxBufferSize: 1073741824, // 1GB (safe value that all adapters support) maxStorageBufferBindingSize: 1073741824 // 1GB for large instance buffers } }); window.__RTS = window.__RTS ?? {}; window.__RTS.gpuDeviceManagerReady = gpuDeviceManagerReady; const ensureRTS = (): NonNullable => { if (!window.__RTS) { window.__RTS = {}; } return window.__RTS; }; const readStoredBoolean = (storageKey: string): boolean | null => { if (typeof window === 'undefined') { return null; } try { const raw = window.localStorage.getItem(storageKey); if (raw === '1' || raw === 'true') return true; if (raw === '0' || raw === 'false') return false; } catch { // ignore } return null; }; const readStoredString = (storageKey: string): string | null => { if (typeof window === 'undefined') { return null; } try { return window.localStorage.getItem(storageKey); } catch { // ignore } return null; }; const textureStatusPanel = createTextureStatusIndicator(); function createTextureStatusIndicator(): HTMLDivElement | null { if (typeof document === 'undefined') { return null; } const panel = document.createElement('div'); panel.id = 'terrain-texture-status'; panel.style.position = 'fixed'; panel.style.top = '8px'; panel.style.right = '8px'; panel.style.padding = '6px 10px'; panel.style.fontFamily = 'monospace'; panel.style.fontSize = '12px'; panel.style.backgroundColor = 'rgba(0, 0, 0, 0.55)'; panel.style.color = '#f1f1f1'; panel.style.borderRadius = '4px'; panel.style.zIndex = '9999'; panel.style.pointerEvents = 'none'; panel.textContent = 'Terrain textures: initializing...'; const attachPanel = () => { document.body?.appendChild(panel); }; if (document.body) { attachPanel(); } else { window.addEventListener('DOMContentLoaded', attachPanel, { once: true }); } return panel; } const updateTextureStatusDisplay = (): void => { if (!textureStatusPanel) return; const state = ensureRTS(); const status = state.textureStatus ?? 'idle'; const source = state.textureSource ?? 'official'; textureStatusPanel.textContent = `Terrain textures: ${status} (${source})`; }; updateTextureStatusDisplay(); const FRAME_TIME_QUALITY_KEY = 'rts.framegraph.frameTimeQuality'; const FRAME_TIME_MS_KEY = 'rts.framegraph.frameTimeMs'; const ensureFrameTimeQualityDefaults = (): void => { try { if (window.localStorage.getItem(FRAME_TIME_QUALITY_KEY) == null) { window.localStorage.setItem(FRAME_TIME_QUALITY_KEY, 'true'); } if (window.localStorage.getItem(FRAME_TIME_MS_KEY) == null) { window.localStorage.setItem(FRAME_TIME_MS_KEY, '16'); } } catch { // ignore } }; ensureFrameTimeQualityDefaults(); const ensureRenderSafetyDefaults = (): void => { try { const params = new URLSearchParams(window.location.search); const queryFlag = params.get('entityScreenspacePass'); const explicitQueryEnable = queryFlag === '1' || queryFlag === 'true'; if (!explicitQueryEnable) { const screenspace = window.localStorage.getItem('rts.framegraph.entityScreenspacePass'); if (screenspace === '1' || screenspace === 'true') { window.localStorage.setItem('rts.framegraph.entityScreenspacePass', '0'); console.warn( '[Bootstrap] Forced entity screen-space proxy pass OFF to prevent oversized circles and heavy GPU cost. ' + 'Use ?entityScreenspacePass=1 for one-off debugging.' ); } } } catch { // ignore } }; ensureRenderSafetyDefaults(); const HEIGHTMAP_RESOLUTION_STORAGE_KEY = 'rts.terrain.heightmapResolution'; const HEIGHTMAP_UPGRADE_LEVEL_KEY = 'rts.terrain.resolutionUpgradeLevel'; const DEFAULT_HEIGHTMAP_RESOLUTION: HeightmapResolution = 1024; const ALLOWED_HEIGHTMAP_RESOLUTIONS: HeightmapResolution[] = [ 512, 1024, 2048, 4096, 8192 ]; const MAX_LAYERED_HEIGHTMAP_RESOLUTION: HeightmapResolution = 4096; const HEIGHTMAP_UPGRADE_TIERS = [ { level: 1, label: 'High Detail', resolution: 2048 }, { level: 2, label: 'Ultra Detail', resolution: 4096 }, { level: 3, label: 'Extreme Detail', resolution: 8192 } ] as const; const MAX_HEIGHTMAP_UPGRADE_LEVEL = HEIGHTMAP_UPGRADE_TIERS.length; type OrganicVariationOptions = { scale: number; amplitude: number; octaves: number; domainWarpStrength: number; }; const ORGANIC_VARIATION_OPTIONS_BY_LEVEL: Record = { 0: { scale: 0.015, amplitude: 5.0, octaves: 4, domainWarpStrength: 0.15 }, 1: { scale: 0.017, amplitude: 5.6, octaves: 5, domainWarpStrength: 0.17 }, 2: { scale: 0.02, amplitude: 6.3, octaves: 6, domainWarpStrength: 0.19 }, 3: { scale: 0.022, amplitude: 7.0, octaves: 7, domainWarpStrength: 0.21 } }; function isAllowedHeightmapResolution(value: number): value is HeightmapResolution { return ALLOWED_HEIGHTMAP_RESOLUTIONS.includes(value as HeightmapResolution); } function resolveManualHeightmapResolution(): HeightmapResolution { const raw = window.localStorage.getItem(HEIGHTMAP_RESOLUTION_STORAGE_KEY); const parsed = Number.parseInt(raw ?? '', 10); if (isAllowedHeightmapResolution(parsed)) { return parsed; } return DEFAULT_HEIGHTMAP_RESOLUTION; } function getHeightmapUpgradeLevel(): number { const raw = window.localStorage.getItem(HEIGHTMAP_UPGRADE_LEVEL_KEY) ?? '0'; const parsed = Number.parseInt(raw, 10); if (!Number.isFinite(parsed) || parsed <= 0) { return 0; } return Math.min(MAX_HEIGHTMAP_UPGRADE_LEVEL, Math.round(parsed)); } function getHeightmapUpgradeTier(level: number) { return HEIGHTMAP_UPGRADE_TIERS.find((tier) => tier.level === level) ?? null; } function resolveHeightmapResolution(): HeightmapResolution { const manual = resolveManualHeightmapResolution(); const tier = getHeightmapUpgradeTier(getHeightmapUpgradeLevel()); if (tier && tier.resolution > manual) { return tier.resolution; } return manual; } function getHeightmapResolutionDebugInfo() { const manual = resolveManualHeightmapResolution(); const upgradeLevel = getHeightmapUpgradeLevel(); const upgradeTier = getHeightmapUpgradeTier(upgradeLevel); const effective = resolveHeightmapResolution(); return { manual, upgradeLevel, upgradeLabel: upgradeTier?.label ?? null, upgradeResolution: upgradeTier?.resolution ?? null, effective }; } function getOrganicVariationOptionsForUpgrade(level: number): OrganicVariationOptions { return ORGANIC_VARIATION_OPTIONS_BY_LEVEL[level] ?? ORGANIC_VARIATION_OPTIONS_BY_LEVEL[0]; } // DISABLED: This was causing infinite reload loops with the menu system // The framegraph defaults are already set correctly in config.ts // (function ensureEntitiesEnabled() { // if (FRAMEGRAPH_LOCKED) { // console.log('[Bootstrap] Framegraph is locked, skipping entity check'); // return; // } // // console.log('[Bootstrap] Framegraph check:', { // minimalMode: FRAMEGRAPH_MINIMAL_TERRAIN_MODE, // featureLevel: FRAMEGRAPH_FULL_FEATURE_LEVEL, // needsFix: FRAMEGRAPH_MINIMAL_TERRAIN_MODE || FRAMEGRAPH_FULL_FEATURE_LEVEL < 2 // }); // // if (FRAMEGRAPH_MINIMAL_TERRAIN_MODE || FRAMEGRAPH_FULL_FEATURE_LEVEL < 2) { // // Check if we've already tried to fix this to prevent infinite reload loop // const alreadyFixed = window.localStorage.getItem('rts.framegraph.fixed'); // if (alreadyFixed === 'true') { // console.error('[Bootstrap] Framegraph settings still incorrect after reload. Please check your configuration.'); // console.error('[Bootstrap] Current localStorage:', { // mode: window.localStorage.getItem('rts.framegraph.mode'), // fullLevel: window.localStorage.getItem('rts.framegraph.fullLevel'), // post: window.localStorage.getItem('rts.framegraph.post') // }); // return; // } // // console.warn('[Bootstrap] Framegraph settings disable entities; forcing full mode with entity passes.'); // window.localStorage.setItem('rts.framegraph.mode', 'full'); // window.localStorage.setItem('rts.framegraph.fullLevel', '3'); // window.localStorage.setItem('rts.framegraph.post', '1'); // window.localStorage.setItem('rts.framegraph.terrainDebug', '0'); // window.localStorage.setItem('rts.framegraph.minimalTerrainMesh', '0'); // window.localStorage.setItem('rts.framegraph.fixed', 'true'); // console.log('[Bootstrap] Settings updated, reloading...'); // window.location.reload(); // } else { // // Settings are correct, clear the fix flag // window.localStorage.removeItem('rts.framegraph.fixed'); // console.log('[Bootstrap] Framegraph settings OK, continuing...'); // } // })(); (window.__RTS as any).setFramegraphMode = (mode: 'minimal' | 'full') => { try { window.localStorage.setItem('rts.framegraph.mode', mode); } finally { window.location.reload(); } }; (window.__RTS as any).setFramegraphFullLevel = (level: number) => { try { window.localStorage.setItem('rts.framegraph.fullLevel', String(level | 0)); } finally { window.location.reload(); } }; (window.__RTS as any).setMinimalTerrainMesh = (enabled: boolean) => { try { window.localStorage.setItem('rts.framegraph.minimalTerrainMesh', enabled ? '1' : '0'); } finally { window.location.reload(); } }; (window.__RTS as any).setTerrainDebugMode = (mode: number) => { try { window.localStorage.setItem('rts.framegraph.terrainDebug', String(mode | 0)); } finally { window.location.reload(); } }; (window.__RTS as any).setShadowCascadeDebugMode = ( mode: ShadowCascadeDebugMode | number = 'index' ) => { const resolved = resolveShadowCascadeDebugMode(mode); (window.__RTS as any).setTerrainDebugMode(resolved); }; (window.__RTS as any).setFramegraphTerrainQuality = (level: number) => { const clamped = Math.max(0, Math.min(2, Math.round(level))); try { window.localStorage.setItem('rts.framegraph.terrainQuality', String(clamped)); } finally { window.location.reload(); } }; (window.__RTS as any).getFramegraphInfo = () => { return { post: FRAMEGRAPH_ENABLE_POST, terrainQuality: FRAMEGRAPH_TERRAIN_QUALITY, terrainDebugMode: FRAMEGRAPH_TERRAIN_DEBUG_MODE, webglEntities: FRAMEGRAPH_USE_WEBGL_ENTITIES, entityRenderMode: FRAMEGRAPH_ENTITY_RENDER_MODE, gpuRings: FRAMEGRAPH_GPU_INFLUENCE_RINGS, treeBillboards: FRAMEGRAPH_TREE_BILLBOARDS, dynamicQuality: FRAMEGRAPH_DYNAMIC_QUALITY, ssaoHeight: FRAMEGRAPH_DYNAMIC_SSAO_HEIGHT, treeHeight: FRAMEGRAPH_DYNAMIC_TREE_HEIGHT, bloomHeight: FRAMEGRAPH_DYNAMIC_BLOOM_HEIGHT, shadowHeight: FRAMEGRAPH_DYNAMIC_SHADOW_HEIGHT, frameTimeQuality: (window.localStorage.getItem('rts.framegraph.frameTimeQuality') ?? 'true'), frameTimeMs: window.localStorage.getItem('rts.framegraph.frameTimeMs'), frameTimeHysteresis: window.localStorage.getItem('rts.framegraph.frameTimeHysteresis'), storage: { lock: window.localStorage.getItem('rts.framegraph.lock'), mode: window.localStorage.getItem('rts.framegraph.mode'), fullLevel: window.localStorage.getItem('rts.framegraph.fullLevel'), post: window.localStorage.getItem('rts.framegraph.post'), terrainDebug: window.localStorage.getItem('rts.framegraph.terrainDebug'), terrainQuality: window.localStorage.getItem('rts.framegraph.terrainQuality'), waterQuality: window.localStorage.getItem('rts.framegraph.waterQuality'), waterRTScale: window.localStorage.getItem('rts.framegraph.waterRTScale'), waterFoamResScale: window.localStorage.getItem('rts.framegraph.waterFoamResScale'), waterInteractionResScale: window.localStorage.getItem('rts.framegraph.waterInteractionResScale'), waterDebugView: window.localStorage.getItem('rts.framegraph.waterDebugView'), waterWaveVisibility: window.localStorage.getItem('rts.framegraph.waterWaveVisibility'), waterScatterBoost: window.localStorage.getItem('rts.framegraph.waterScatterBoost'), waterReflectionBreakup: window.localStorage.getItem('rts.framegraph.waterReflectionBreakup'), waterClipmapMultilevel: window.localStorage.getItem('rts.framegraph.waterClipmapMultilevel'), waterClipmapLevels: window.localStorage.getItem('rts.framegraph.waterClipmapLevels'), waterClipmapBaseScale: window.localStorage.getItem('rts.framegraph.waterClipmapBaseScale'), webglEntities: window.localStorage.getItem('rts.framegraph.webglEntities'), gpuRings: window.localStorage.getItem('rts.framegraph.gpuRings'), treeBillboards: window.localStorage.getItem('rts.framegraph.treeBillboards'), dynamicQuality: window.localStorage.getItem('rts.framegraph.dynamicQuality'), ssaoHeight: window.localStorage.getItem('rts.framegraph.ssaoHeight'), treeHeight: window.localStorage.getItem('rts.framegraph.treeHeight'), bloomHeight: window.localStorage.getItem('rts.framegraph.bloomHeight'), shadowHeight: window.localStorage.getItem('rts.framegraph.shadowHeight'), frameTimeQuality: window.localStorage.getItem('rts.framegraph.frameTimeQuality'), frameTimeMs: window.localStorage.getItem('rts.framegraph.frameTimeMs'), frameTimeHysteresis: window.localStorage.getItem('rts.framegraph.frameTimeHysteresis') } }; }; (window.__RTS as any).lockFramegraph = () => { try { window.localStorage.setItem('rts.framegraph.lock', '1'); window.localStorage.setItem('rts.framegraph.mode', 'full'); window.localStorage.setItem('rts.framegraph.fullLevel', '3'); window.localStorage.setItem('rts.framegraph.post', '1'); window.localStorage.setItem('rts.framegraph.terrainDebug', '0'); window.localStorage.setItem('rts.framegraph.minimalTerrainMesh', '0'); } finally { window.location.reload(); } }; (window.__RTS as any).unlockFramegraph = () => { try { window.localStorage.setItem('rts.framegraph.lock', '0'); } finally { window.location.reload(); } }; // ============================================================================ // ENTITY RENDER PIPELINE CONSOLE COMMANDS // ============================================================================ (window.__RTS as any).getEntityRenderMode = () => { const mode = FRAMEGRAPH_ENTITY_RENDER_MODE; const isUnified = mode === 'unified_gpu'; console.log('╔═══════════════════════════════════════════════════════════════╗'); console.log('║ ENTITY RENDERING PIPELINE STATUS ║'); console.log('╚═══════════════════════════════════════════════════════════════╝'); console.log(''); console.log(`Mode: ${isUnified ? '🚀 UNIFIED GPU' : '⚠️ LEGACY WEBGL'}`); console.log(`Flag: FRAMEGRAPH_ENTITY_RENDER_MODE = '${mode}'`); console.log(''); if (isUnified) { console.log('✅ Using modern GPU-driven entity rendering:'); console.log(' • GPU frustum culling (compute shader)'); console.log(' • Indirect draw with compacted visible indices'); console.log(' • Full PBR shading (IBL + shadows + clustered lighting)'); console.log(''); console.log('Pipeline:'); console.log(' 1. GPUCulling - GPU frustum culling'); console.log(' 2. MeshProxyPass - Indirect draw with compacted indices'); console.log(' 3. HiZPass - Hierarchical Z-buffer'); console.log(' 4. ClusterLightAssignmentPass - Forward+ light clustering'); console.log(' 5. LightAccumulationPass - Deferred lighting'); console.log(' 6. EntityCopyPass - Copy entity color to main buffer'); console.log(' 7. EntityDrawPass - Full PBR shading (optional)'); } else { console.log('⚠️ Using legacy WebGL entity rendering:'); console.log(' • CPU-side frustum culling'); console.log(' • Direct draw calls per entity'); console.log(' • Simpler shading model'); console.log(''); console.log('Pipeline:'); console.log(' 1. EntityDepthProxyPass - Simple depth-only cubes'); console.log(' 2. HiZPass - Hierarchical Z-buffer'); console.log(' 3. ClusterLightAssignmentPass - Forward+ light clustering'); console.log(' 4. LightAccumulationPass - Deferred lighting'); console.log(''); console.log('💡 For best performance, switch to unified_gpu mode:'); console.log(' __RTS.setEntityRenderMode("unified")'); } console.log(''); console.log('Commands:'); console.log(' __RTS.setEntityRenderMode("unified") - Force unified GPU mode'); console.log(' __RTS.setEntityRenderMode("legacy") - Legacy mode disabled (kept for compatibility)'); console.log(' __RTS.getEntityRenderMode() - Show this info'); return { mode, isUnified, passes: isUnified ? ['GPUCulling', 'MeshProxyPass', 'HiZPass', 'ClusterLightAssignmentPass', 'LightAccumulationPass', 'EntityCopyPass', 'EntityDrawPass'] : ['EntityDepthProxyPass', 'HiZPass', 'ClusterLightAssignmentPass', 'LightAccumulationPass'] }; }; (window.__RTS as any).setEntityRenderMode = (mode: 'unified' | 'legacy' | 'unified_gpu' | 'legacy_webgl') => { const normalizedMode = 'unified_gpu'; try { window.localStorage.setItem('rts.framegraph.entityMode', normalizedMode); window.localStorage.setItem('rts.framegraph.webglEntities', '0'); if (mode === 'legacy' || mode === 'legacy_webgl') { console.warn('[EntityRenderPipeline] Legacy WebGL entity mode is disabled; using unified_gpu.'); } console.log(`[EntityRenderPipeline] Entity render mode locked to: ${normalizedMode}`); console.log('Reloading page...'); } finally { window.location.reload(); } }; console.info('[EntityRenderPipeline] Console commands available:'); console.info(' __RTS.getEntityRenderMode() - Show entity rendering pipeline status'); console.info(' __RTS.setEntityRenderMode("unified") - Force unified GPU mode'); console.info(' __RTS.setEntityRenderMode("legacy") - Legacy mode disabled (compat alias)'); // ============================================================================ // END ENTITY RENDER PIPELINE CONSOLE COMMANDS // ============================================================================ // AAA: Heightmap resolution controls (window.__RTS as any).setHeightmapResolution = (resolution: HeightmapResolution) => { if (!isAllowedHeightmapResolution(resolution)) { console.error( `[Heightmap] Invalid resolution: ${resolution}. Valid options: ${ALLOWED_HEIGHTMAP_RESOLUTIONS.join(', ')}` ); return; } try { window.localStorage.setItem(HEIGHTMAP_RESOLUTION_STORAGE_KEY, resolution.toString()); console.info(`[Heightmap] Resolution set to ${resolution}x${resolution}. Reloading...`); } finally { window.location.reload(); } }; (window.__RTS as any).getHeightmapResolution = () => { const info = getHeightmapResolutionDebugInfo(); const upgradeNote = info.upgradeLevel > 0 && info.upgradeLabel && info.upgradeResolution ? ` + upgrade ${info.upgradeLevel} (${info.upgradeLabel} ${info.upgradeResolution}x${info.upgradeResolution})` : ''; console.info( `[Heightmap] Current effective resolution: ${info.effective}x${info.effective}${upgradeNote} (manual ${info.manual}x${info.manual})` ); return info.effective; }; (window.__RTS as any).setHeightmapResolutionUpgradeLevel = (level: number) => { const clamped = Math.max(0, Math.min(MAX_HEIGHTMAP_UPGRADE_LEVEL, Math.round(level))); try { window.localStorage.setItem(HEIGHTMAP_UPGRADE_LEVEL_KEY, String(clamped)); const tier = getHeightmapUpgradeTier(clamped); console.info( `[Heightmap] Upgrade level ${clamped} ${ tier ? `(${tier.label} ${tier.resolution}x${tier.resolution})` : '(disabled)' } saved. Reloading...` ); } finally { window.location.reload(); } }; (window.__RTS as any).getHeightmapResolutionUpgradeInfo = () => { const info = getHeightmapResolutionDebugInfo(); const tier = getHeightmapUpgradeTier(info.upgradeLevel); const payload = { level: info.upgradeLevel, label: tier?.label ?? 'None', resolution: tier?.resolution ?? null, manualResolution: info.manual, effectiveResolution: info.effective }; console.info('[Heightmap] Upgrade info:', payload); return payload; }; // AAA: Toggle GPU erosion (enhances terrain with realistic erosion) (window.__RTS as any).toggleGPUErosion = (enabled?: boolean) => { const current = localStorage.getItem('rts.terrain.useGPUHeightmap') !== 'false'; const newValue = enabled !== undefined ? enabled : !current; localStorage.setItem('rts.terrain.useGPUHeightmap', newValue.toString()); console.info(`[AAA Terrain] GPU erosion ${newValue ? 'ENABLED' : 'DISABLED'}`); console.info('[AAA Terrain] This adds realistic erosion to the strategic terrain layout'); console.info('[AAA Terrain] Reload the page to apply changes'); return newValue; }; (window.__RTS as any).isGPUErosionEnabled = () => { const enabled = localStorage.getItem('rts.terrain.useGPUHeightmap') !== 'false'; console.info(`[AAA Terrain] GPU erosion: ${enabled ? 'ENABLED' : 'DISABLED'}`); return enabled; }; // AAA: Set erosion intensity (window.__RTS as any).setErosionIntensity = (intensity: 'none' | 'subtle' | 'moderate' | 'strong' | 'extreme') => { const validIntensities = ['none', 'subtle', 'moderate', 'strong', 'extreme']; if (!validIntensities.includes(intensity)) { console.error(`[AAA Terrain] Invalid intensity. Use: ${validIntensities.join(', ')}`); return; } localStorage.setItem('rts.terrain.erosionIntensity', intensity); console.info(`[AAA Terrain] Erosion intensity set to: ${intensity.toUpperCase()}`); console.info('[AAA Terrain] Reload the page to apply changes'); console.info('[AAA Terrain] Intensities:'); console.info(' - none: No erosion (original terrain)'); console.info(' - subtle: Light erosion (preserves features)'); console.info(' - moderate: Balanced erosion (default)'); console.info(' - strong: Heavy erosion (dramatic features)'); console.info(' - extreme: Maximum erosion (very dramatic)'); }; (window.__RTS as any).getErosionIntensity = () => { const intensity = localStorage.getItem('rts.terrain.erosionIntensity') || 'subtle'; console.info(`[AAA Terrain] Current erosion intensity: ${intensity.toUpperCase()}`); return intensity; }; // AAA: Cache management (window.__RTS as any).clearHeightmapCache = async () => { const { getHeightmapCache } = await import('./render/terrain/HeightmapCache'); const cache = getHeightmapCache(); cache.clear(); console.info('[AAA Terrain] Heightmap cache cleared'); }; (window.__RTS as any).getHeightmapCacheStats = async () => { const { getHeightmapCache } = await import('./render/terrain/HeightmapCache'); const cache = getHeightmapCache(); const stats = cache.getStats(); console.info('[AAA Terrain] Cache Statistics:'); console.info(` Entries: ${stats.entries}`); console.info(` Size: ${stats.sizeMB.toFixed(2)}MB / ${stats.maxSizeMB.toFixed(2)}MB`); console.info(` Usage: ${((stats.sizeMB / stats.maxSizeMB) * 100).toFixed(1)}%`); return stats; }; (window.__RTS as any).setHeightmapCacheEnabled = (enabled: boolean) => { const next = Boolean(enabled); localStorage.setItem('rts.terrain.disableHeightmapCache', (!next).toString()); console.info(`[AAA Terrain] Heightmap cache ${next ? 'ENABLED' : 'DISABLED'}`); console.info('[AAA Terrain] Reload the page to apply changes'); return next; }; (window.__RTS as any).isHeightmapCacheEnabled = () => { const disabled = localStorage.getItem('rts.terrain.disableHeightmapCache') === 'true'; const enabled = !disabled; console.info(`[AAA Terrain] Heightmap cache: ${enabled ? 'ENABLED' : 'DISABLED'}`); return enabled; }; (window.__RTS as any).setLayeredHeightmapEnabled = (enabled: boolean) => { const next = Boolean(enabled); localStorage.setItem('rts.terrain.disableLayeredHeightmap', (!next).toString()); console.info(`[AAA Terrain] Layered heightmap ${next ? 'ENABLED' : 'DISABLED'}`); console.info('[AAA Terrain] Reload the page to apply changes'); return next; }; (window.__RTS as any).isLayeredHeightmapEnabled = () => { const disabled = localStorage.getItem('rts.terrain.disableLayeredHeightmap') === 'true'; const enabled = !disabled; console.info(`[AAA Terrain] Layered heightmap: ${enabled ? 'ENABLED' : 'DISABLED'}`); return enabled; }; // AAA: GPU Heightmap Generation Benchmark (Phase 1.2) (window.__RTS as any).benchmarkHeightmapGeneration = async (resolutions?: number[]) => { console.info('[Benchmark] Starting GPU vs CPU heightmap generation benchmark...'); try { // Dynamically import the benchmark module const { HeightmapBenchmark } = await import('./render/terrain/HeightmapBenchmark'); // Get WebGPU device from renderer service const rendererService = (window.__RTS as any).getRendererService?.(); if (!rendererService || !rendererService.device) { console.error('[Benchmark] WebGPU device not available. Make sure the renderer is initialized.'); return; } const benchmark = new HeightmapBenchmark(rendererService.device); const results = await benchmark.runBenchmark(resolutions); console.info('[Benchmark] Complete! Results:', results); return results; } catch (error) { console.error('[Benchmark] Failed to run benchmark:', error); } }; (window.__RTS as any).testGPUHeightmap = async (resolution: number = 1024) => { console.info(`[GPU Heightmap] Generating ${resolution}x${resolution} heightmap on GPU...`); try { const { GPUHeightmapGenerator } = await import('./render/terrain/GPUHeightmapGenerator'); const rendererService = (window.__RTS as any).getRendererService?.(); if (!rendererService || !rendererService.device) { console.error('[GPU Heightmap] WebGPU device not available.'); return; } const generator = new GPUHeightmapGenerator(rendererService.device); const start = performance.now(); const data = await generator.generate({ resolution, seed: Math.floor(Math.random() * 1000000), octaves: 6, lacunarity: 2.0, persistence: 0.5, scale: 1.0, heightMultiplier: 100.0, domainWarpStrength: 0.1 }); const end = performance.now(); console.info(`[GPU Heightmap] Generated in ${(end - start).toFixed(2)}ms`); console.info(`[GPU Heightmap] Data size: ${(data.length * 4 / 1024 / 1024).toFixed(2)} MB`); // Calculate statistics without stack overflow let min = Infinity, max = -Infinity, sum = 0; for (let i = 0; i < data.length; i++) { const val = data[i]; if (val < min) min = val; if (val > max) max = val; sum += val; } console.info(`[GPU Heightmap] Sample values:`, { min: min, max: max, avg: sum / data.length, first: data[0], last: data[data.length - 1] }); return data; } catch (error) { console.error('[GPU Heightmap] Failed to generate:', error); } }; // AAA: Hydraulic Erosion (Phase 1.3) (window.__RTS as any).testHydraulicErosion = async (resolution: number = 1024, preset: 'LIGHT' | 'MEDIUM' | 'HEAVY' | 'EXTREME' = 'MEDIUM') => { console.info(`[Hydraulic Erosion] Testing ${preset} erosion on ${resolution}x${resolution} heightmap...`); try { const { GPUHeightmapGenerator } = await import('./render/terrain/GPUHeightmapGenerator'); const { HydraulicErosion, EROSION_PRESETS } = await import('./render/terrain/HydraulicErosion'); const rendererService = (window.__RTS as any).getRendererService?.(); if (!rendererService || !rendererService.device) { console.error('[Hydraulic Erosion] WebGPU device not available.'); return; } // Generate initial heightmap console.info('[Hydraulic Erosion] Step 1/2: Generating initial heightmap...'); const generator = new GPUHeightmapGenerator(rendererService.device); const initialHeightmap = await generator.generate({ resolution, seed: 12345, octaves: 6, lacunarity: 2.0, persistence: 0.5, heightMultiplier: 100.0 }); // Apply erosion console.info('[Hydraulic Erosion] Step 2/2: Applying erosion...'); const erosion = new HydraulicErosion(rendererService.device); const start = performance.now(); const erodedHeightmap = await erosion.erode(initialHeightmap, resolution, EROSION_PRESETS[preset]); const end = performance.now(); console.info(`[Hydraulic Erosion] Erosion complete in ${(end - start).toFixed(2)}ms`); console.info(`[Hydraulic Erosion] Preset: ${preset}`, EROSION_PRESETS[preset]); // Calculate statistics for initial heightmap let minInit = Infinity, maxInit = -Infinity, sumInit = 0; for (let i = 0; i < initialHeightmap.length; i++) { const val = initialHeightmap[i]; if (val < minInit) minInit = val; if (val > maxInit) maxInit = val; sumInit += val; } // Calculate statistics for eroded heightmap let minEroded = Infinity, maxEroded = -Infinity, sumEroded = 0; for (let i = 0; i < erodedHeightmap.length; i++) { const val = erodedHeightmap[i]; if (val < minEroded) minEroded = val; if (val > maxEroded) maxEroded = val; sumEroded += val; } console.info(`[Hydraulic Erosion] Before:`, { min: minInit, max: maxInit, avg: sumInit / initialHeightmap.length }); console.info(`[Hydraulic Erosion] After:`, { min: minEroded, max: maxEroded, avg: sumEroded / erodedHeightmap.length }); return { initial: initialHeightmap, eroded: erodedHeightmap }; } catch (error) { console.error('[Hydraulic Erosion] Failed:', error); } }; (window.__RTS as any).benchmarkErosion = async (resolutions: number[] = [512, 1024, 2048]) => { console.info('[Erosion Benchmark] Starting hydraulic erosion benchmark...\n'); try { const { GPUHeightmapGenerator } = await import('./render/terrain/GPUHeightmapGenerator'); const { HydraulicErosion, EROSION_PRESETS } = await import('./render/terrain/HydraulicErosion'); const rendererService = (window.__RTS as any).getRendererService?.(); if (!rendererService || !rendererService.device) { console.error('[Erosion Benchmark] WebGPU device not available.'); return; } const generator = new GPUHeightmapGenerator(rendererService.device); const erosion = new HydraulicErosion(rendererService.device); const results = []; for (const resolution of resolutions) { console.log(`[Erosion Benchmark] Testing ${resolution}x${resolution}...`); // Generate heightmap const heightmap = await generator.generate({ resolution }); // Test MEDIUM preset const start = performance.now(); await erosion.erode(heightmap, resolution, EROSION_PRESETS.MEDIUM); const end = performance.now(); const timeMs = end - start; const particles = EROSION_PRESETS.MEDIUM.numParticles; const particlesPerMs = particles / timeMs; results.push({ resolution, timeMs, particles, particlesPerMs }); console.log(` Time: ${timeMs.toFixed(2)}ms`); console.log(` Particles: ${particles.toLocaleString()}`); console.log(` Throughput: ${particlesPerMs.toFixed(0)} particles/ms\n`); } console.log('=== Erosion Benchmark Summary ===\n'); console.log('Resolution | Time | Particles | Throughput'); console.log('-----------|----------|-----------|------------'); for (const r of results) { console.log( `${r.resolution}x${r.resolution}`.padEnd(11) + `| ${r.timeMs.toFixed(0)}ms`.padEnd(9) + `| ${r.particles.toLocaleString()}`.padEnd(10) + `| ${r.particlesPerMs.toFixed(0)}/ms` ); } return results; } catch (error) { console.error('[Erosion Benchmark] Failed:', error); } }; // AAA: Thermal Erosion (Phase 1.4) (window.__RTS as any).testThermalErosion = async (resolution: number = 1024, preset: 'SUBTLE' | 'MODERATE' | 'AGGRESSIVE' | 'EXTREME' = 'MODERATE') => { console.info(`[Thermal Erosion] Testing ${preset} erosion on ${resolution}x${resolution} heightmap...`); try { const { GPUHeightmapGenerator } = await import('./render/terrain/GPUHeightmapGenerator'); const { ThermalErosion, THERMAL_EROSION_PRESETS } = await import('./render/terrain/ThermalErosion'); const rendererService = (window.__RTS as any).getRendererService?.(); if (!rendererService || !rendererService.device) { console.error('[Thermal Erosion] WebGPU device not available.'); return; } const generator = new GPUHeightmapGenerator(rendererService.device); const erosion = new ThermalErosion(rendererService.device); // Generate base heightmap console.info('[Thermal Erosion] Generating base heightmap...'); const genStart = performance.now(); const heightmap = await generator.generate({ resolution, seed: 12345, octaves: 6, heightMultiplier: 100.0 }); const genEnd = performance.now(); console.info(`[Thermal Erosion] Generated in ${(genEnd - genStart).toFixed(2)}ms`); // Apply thermal erosion console.info(`[Thermal Erosion] Applying ${preset} erosion...`); const erosionStart = performance.now(); const eroded = await erosion.erode(heightmap, resolution, THERMAL_EROSION_PRESETS[preset]); const erosionEnd = performance.now(); console.info(`[Thermal Erosion] Eroded in ${(erosionEnd - erosionStart).toFixed(2)}ms`); // Compare results - calculate statistics without stack overflow let minBase = Infinity, maxBase = -Infinity, sumBase = 0; for (let i = 0; i < heightmap.length; i++) { const val = heightmap[i]; if (val < minBase) minBase = val; if (val > maxBase) maxBase = val; sumBase += val; } let minEroded = Infinity, maxEroded = -Infinity, sumEroded = 0; for (let i = 0; i < eroded.length; i++) { const val = eroded[i]; if (val < minEroded) minEroded = val; if (val > maxEroded) maxEroded = val; sumEroded += val; } const baseStats = { min: minBase.toFixed(2), max: maxBase.toFixed(2), avg: (sumBase / heightmap.length).toFixed(2) }; const erodedStats = { min: minEroded.toFixed(2), max: maxEroded.toFixed(2), avg: (sumEroded / eroded.length).toFixed(2) }; console.info('[Thermal Erosion] Base heightmap:', baseStats); console.info('[Thermal Erosion] Eroded heightmap:', erodedStats); console.info(`[Thermal Erosion] Total time: ${(erosionEnd - genStart).toFixed(2)}ms`); console.info('[Thermal Erosion] ✅ Test complete!'); } catch (error) { console.error('[Thermal Erosion] Failed:', error); } }; (window.__RTS as any).benchmarkThermalErosion = async (resolutions: number[] = [512, 1024, 2048]) => { console.info('[Thermal Erosion Benchmark] Starting benchmark...\n'); try { const { GPUHeightmapGenerator } = await import('./render/terrain/GPUHeightmapGenerator'); const { ThermalErosion, THERMAL_EROSION_PRESETS } = await import('./render/terrain/ThermalErosion'); const rendererService = (window.__RTS as any).getRendererService?.(); if (!rendererService || !rendererService.device) { console.error('[Thermal Erosion Benchmark] WebGPU device not available.'); return; } const generator = new GPUHeightmapGenerator(rendererService.device); const erosion = new ThermalErosion(rendererService.device); const results = []; for (const resolution of resolutions) { console.log(`[Thermal Erosion Benchmark] Testing ${resolution}x${resolution}...`); // Generate heightmap const heightmap = await generator.generate({ resolution }); // Test MODERATE preset const start = performance.now(); await erosion.erode(heightmap, resolution, THERMAL_EROSION_PRESETS.MODERATE); const end = performance.now(); const timeMs = end - start; const iterations = THERMAL_EROSION_PRESETS.MODERATE.iterations; results.push({ resolution, timeMs, iterations }); console.log(` Time: ${timeMs.toFixed(2)}ms`); console.log(` Iterations: ${iterations}`); console.log(` Time/iteration: ${(timeMs / iterations).toFixed(2)}ms\n`); } console.log('=== Thermal Erosion Benchmark Summary ===\n'); console.log('Resolution | Time | Iterations | Time/Iter'); console.log('-----------|----------|------------|----------'); for (const r of results) { console.log( `${r.resolution}x${r.resolution}`.padEnd(11) + `| ${r.timeMs.toFixed(0)}ms`.padEnd(9) + `| ${r.iterations}`.padEnd(11) + `| ${(r.timeMs / r.iterations).toFixed(1)}ms` ); } return results; } catch (error) { console.error('[Thermal Erosion Benchmark] Failed:', error); } }; (window.__RTS as any).testCombinedErosion = async (resolution: number = 1024) => { console.info(`[Combined Erosion] Testing complete erosion pipeline on ${resolution}x${resolution} heightmap...`); try { const { GPUHeightmapGenerator } = await import('./render/terrain/GPUHeightmapGenerator'); const { HydraulicErosion, EROSION_PRESETS } = await import('./render/terrain/HydraulicErosion'); const { ThermalErosion, THERMAL_EROSION_PRESETS } = await import('./render/terrain/ThermalErosion'); const rendererService = (window.__RTS as any).getRendererService?.(); if (!rendererService || !rendererService.device) { console.error('[Combined Erosion] WebGPU device not available.'); return; } const generator = new GPUHeightmapGenerator(rendererService.device); const hydraulic = new HydraulicErosion(rendererService.device); const thermal = new ThermalErosion(rendererService.device); // Step 1: Generate console.info('[Combined Erosion] Step 1: Generating base heightmap...'); const genStart = performance.now(); const heightmap = await generator.generate({ resolution, seed: 12345, octaves: 6, heightMultiplier: 100.0 }); const genEnd = performance.now(); console.info(`[Combined Erosion] Generated in ${(genEnd - genStart).toFixed(2)}ms`); // Step 2: Hydraulic erosion console.info('[Combined Erosion] Step 2: Applying hydraulic erosion...'); const hydraulicStart = performance.now(); const hydraulicEroded = await hydraulic.erode(heightmap, resolution, EROSION_PRESETS.MEDIUM); const hydraulicEnd = performance.now(); console.info(`[Combined Erosion] Hydraulic erosion in ${(hydraulicEnd - hydraulicStart).toFixed(2)}ms`); // Step 3: Thermal erosion console.info('[Combined Erosion] Step 3: Applying thermal erosion...'); const thermalStart = performance.now(); const finalEroded = await thermal.erode(hydraulicEroded, resolution, THERMAL_EROSION_PRESETS.MODERATE); const thermalEnd = performance.now(); console.info(`[Combined Erosion] Thermal erosion in ${(thermalEnd - thermalStart).toFixed(2)}ms`); // Calculate final statistics let minFinal = Infinity, maxFinal = -Infinity, sumFinal = 0; for (let i = 0; i < finalEroded.length; i++) { const val = finalEroded[i]; if (val < minFinal) minFinal = val; if (val > maxFinal) maxFinal = val; sumFinal += val; } // Results const totalTime = thermalEnd - genStart; console.info('\n[Combined Erosion] === Pipeline Summary ==='); console.info(`[Combined Erosion] Generation: ${(genEnd - genStart).toFixed(2)}ms`); console.info(`[Combined Erosion] Hydraulic: ${(hydraulicEnd - hydraulicStart).toFixed(2)}ms`); console.info(`[Combined Erosion] Thermal: ${(thermalEnd - thermalStart).toFixed(2)}ms`); console.info(`[Combined Erosion] Total: ${totalTime.toFixed(2)}ms`); console.info(`[Combined Erosion] Final terrain stats:`, { min: minFinal.toFixed(2), max: maxFinal.toFixed(2), avg: (sumFinal / finalEroded.length).toFixed(2) }); console.info('[Combined Erosion] ✅ Complete AAA terrain pipeline finished!'); return finalEroded; } catch (error) { console.error('[Combined Erosion] Failed:', error); } }; // PERFORMANCE: Scatter density multiplier (0.1 = 10%, 1.0 = 100%) const parseDensityMultiplier = (value: string | null | undefined, fallback: number): number => { const parsed = Number.parseFloat(value ?? ''); return Number.isFinite(parsed) ? parsed : fallback; }; const parseClampedDensityMultiplier = ( value: string | null | undefined, fallback: number, min: number, max: number ): number => { const parsed = parseDensityMultiplier(value, fallback); return Math.max(min, Math.min(max, parsed)); }; const parseBooleanSetting = (value: string | null | undefined, fallback: boolean): boolean => { if (value == null) return fallback; const normalized = value.trim().toLowerCase(); if (normalized.length === 0) return fallback; return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; }; const PERF_TARGET_10MS = parseBooleanSetting( window.localStorage.getItem('rts.perf.target10ms'), true ); const TREE_WIND_STRENGTH_STORAGE_KEY = 'rts.tree.wind.strength'; const TREE_WIND_SPEED_STORAGE_KEY = 'rts.tree.wind.speed'; const TREE_WIND_STRENGTH_MULTIPLIER = parseClampedDensityMultiplier( window.localStorage.getItem(TREE_WIND_STRENGTH_STORAGE_KEY), 1.55, 0.0, 4.0 ); const TREE_WIND_SPEED_MULTIPLIER = parseClampedDensityMultiplier( window.localStorage.getItem(TREE_WIND_SPEED_STORAGE_KEY), 1.18, 0.0, 4.0 ); const WEBGL_TREE_WIND_BASE_STRENGTH = 0.12; const WEBGL_TREE_WIND_BASE_SPEED = 0.84; const toWebglTreeWind = (strengthMultiplier: number, speedMultiplier: number): { strength: number; speed: number } => ({ strength: Math.max(0.0, Math.min(1.0, WEBGL_TREE_WIND_BASE_STRENGTH * strengthMultiplier)), speed: Math.max(0.0, Math.min(3.0, WEBGL_TREE_WIND_BASE_SPEED * speedMultiplier)) }); window.__RTS = window.__RTS ?? {}; window.__RTS.treeWindStrength = TREE_WIND_STRENGTH_MULTIPLIER; window.__RTS.treeWindSpeed = TREE_WIND_SPEED_MULTIPLIER; const SCATTER_DENSITY_MULTIPLIER = Math.min( parseDensityMultiplier( window.localStorage.getItem('rts.scatter.density'), 0.2 // 10ms profile: aggressively reduced scatter density ), PERF_TARGET_10MS ? 0.35 : 2.0 ); const SCATTER_DISTANCE_SCALE_STORAGE_KEY = 'rts.scatter.distanceScale'; const SCATTER_DISTANCE_SCALE = parseClampedDensityMultiplier( window.localStorage.getItem(SCATTER_DISTANCE_SCALE_STORAGE_KEY), 0.55, 0.2, PERF_TARGET_10MS ? 0.9 : 1.6 ); (window.__RTS as any).scatterDistanceScale = SCATTER_DISTANCE_SCALE; (window.__RTS as any).setScatterDistanceScale = ( scale: number, persist: boolean = true ) => { const parsed = Number.isFinite(scale) ? scale : Number.parseFloat(String(scale ?? '')); const clamped = Math.max(0.2, Math.min(1.6, Number.isFinite(parsed) ? parsed : 1.0)); (window.__RTS as any).scatterDistanceScale = clamped; if (persist) { window.localStorage.setItem(SCATTER_DISTANCE_SCALE_STORAGE_KEY, clamped.toFixed(3)); } console.info(`[Scatter] Runtime distance scale set to ${clamped.toFixed(2)}${persist ? ' (persisted)' : ''}`); return clamped; }; (window.__RTS as any).getScatterDistanceScale = () => { const value = Number((window.__RTS as any).scatterDistanceScale ?? SCATTER_DISTANCE_SCALE); console.info(`[Scatter] Runtime distance scale: ${value.toFixed(2)}`); return value; }; // PERFORMANCE: Tree density multiplier (separate from scatter; 1.0 = no reduction) const TREE_DENSITY_MULTIPLIER = parseClampedDensityMultiplier( window.localStorage.getItem('rts.tree.density'), 0.3, 0.1, PERF_TARGET_10MS ? 0.6 : 3.0 ); const TREE_PERF_GUARD = parseBooleanSetting( window.localStorage.getItem('rts.tree.perfGuard'), true ); const TREE_DENSITY_MULTIPLIER_EFFECTIVE = TREE_PERF_GUARD ? Math.min(TREE_DENSITY_MULTIPLIER, 0.35) : TREE_DENSITY_MULTIPLIER; const TREE_DIAGNOSTICS_ENABLED = parseBooleanSetting( window.localStorage.getItem('rts.tree.diagnostics'), false ); const TREE_SEPARATION_CORE_SCALE = parseDensityMultiplier( window.localStorage.getItem('rts.tree.separation.core'), 0.7 ); const TREE_SEPARATION_EDGE_SCALE = parseDensityMultiplier( window.localStorage.getItem('rts.tree.separation.edge'), 1.15 ); const FOREST_PROFILE_RELAXATION = parseDensityMultiplier( window.localStorage.getItem('rts.tree.forest.relax'), 0.03 ); const TREE_SEPARATION_SCALE = parseDensityMultiplier( window.localStorage.getItem('rts.tree.separation.scale'), 1.35 ); const TREE_OVERLAP_PRUNE_ENABLED = parseBooleanSetting( window.localStorage.getItem('rts.tree.overlap.prune'), true ); const TREE_OVERLAP_FACTOR = parseDensityMultiplier( window.localStorage.getItem('rts.tree.overlap.factor'), 1.05 ); type TreeLodPreference = 'auto' | 'high' | 'mid'; const TREE_LOD_PREFERENCE: TreeLodPreference = (() => { const raw = (window.localStorage.getItem('rts.tree.lod') || 'mid').trim().toLowerCase(); if (raw === 'mid') return 'mid'; if (raw === 'auto') return 'auto'; return 'high'; })(); const TREE_MESH_LAB_PARITY = (() => { let explicit: boolean | null = null; try { const params = new URLSearchParams(window.location.search); const raw = params.get('meshLabParity'); if (raw === '1' || raw === 'true') explicit = true; if (raw === '0' || raw === 'false') explicit = false; } catch { // ignore } if (explicit !== null) return explicit; return parseBooleanSetting( window.localStorage.getItem('rts.tree.meshLabParity'), false ); })(); const TREE_MESH_LAB_ART_STRONG_OVERRIDES = { artisticPredictability: 0.985, whorlSpacingMul: 0.76, branchCountMul: 1.38, branchLengthMul: 1.62, crownBaseRatio: 0.11, crownRadiusRatio: 0.52, apicalDominance: 0.9, apicalSuppressionStrength: 0.9, apicalSuppressionDistanceRatio: 0.11, gravitropism: 0.34, phototropism: 0.3, maxAgeOrder1: 30, maxAgeOrder2: 22, maxAgeOrder3: 16, envelopeStallOrder1: 5, envelopeStallOrder2: 4, envelopeStallOrder3: 3, lowerCrownSheddingBand: 0.1, sagBase: 0.013, sagExponent: 0.31, sagAgeFactor: 0.01, } as const; if (TREE_MESH_LAB_PARITY) { (globalThis as { __RTS_TREE_CONIFER_PROFILE_OVERRIDES__?: Record }) .__RTS_TREE_CONIFER_PROFILE_OVERRIDES__ = { ...TREE_MESH_LAB_ART_STRONG_OVERRIDES }; } const TREE_HYBRID_LOD = parseBooleanSetting( window.localStorage.getItem('rts.tree.hybridLod'), false ); const TREE_MESH_LAB_CONIFER_VARIANT = 7; const TREE_CONIFER_AAA_SKELETON = TREE_MESH_LAB_PARITY || (() => { let explicit: boolean | null = null; try { const params = new URLSearchParams(window.location.search); const raw = params.get('coniferAaaSkeleton'); if (raw === '1' || raw === 'true') explicit = true; if (raw === '0' || raw === 'false') explicit = false; } catch { // ignore } if (explicit !== null) return explicit; return parseBooleanSetting( window.localStorage.getItem('rts.tree.coniferAaaSkeleton'), Boolean((import.meta as { env?: { DEV?: boolean } }).env?.DEV) ); })(); const GRASS_DENSITY_MULTIPLIER = parseClampedDensityMultiplier( window.localStorage.getItem('rts.grass.density'), 0.7, 0.1, PERF_TARGET_10MS ? 1.0 : 3.0 ); const GRASS_LOD_RELAX = parseClampedDensityMultiplier( window.localStorage.getItem('rts.grass.lod'), 0.85, 0.35, 3.0 ); const BUSH_DENSITY_MULTIPLIER = parseClampedDensityMultiplier( window.localStorage.getItem('rts.bush.density'), 0.55, 0.1, PERF_TARGET_10MS ? 0.8 : 3.0 ); const GRASS_CHUNK_SIZE = Math.max( 128, Math.min( 4096, Math.round( parseDensityMultiplier( window.localStorage.getItem('rts.grass.chunkSize'), 720 ) ) ) ); const GRASS_MAX_CHUNKS = Math.max( 16, Math.min( 1024, Math.round( parseDensityMultiplier( window.localStorage.getItem('rts.grass.maxChunks'), 96 ) ) ) ); // Helper to reduce positions based on density multiplier const applyDensityMultiplier = (positions: T[], multiplier: number): T[] => { if (multiplier >= 1.0) return positions; if (multiplier <= 0) return []; const targetCount = Math.floor(positions.length * multiplier); if (targetCount >= positions.length) return positions; // Deterministic sampling keeps spatial distribution stable across runs. const result: T[] = []; const step = positions.length / targetCount; for (let i = 0; i < targetCount; i++) { const jitter = Math.sin((i + 1) * 12.9898 + positions.length * 0.0001) * 43758.5453; const jitter01 = jitter - Math.floor(jitter); const index = Math.floor(i * step + jitter01 * step * 0.5); result.push(positions[Math.min(index, positions.length - 1)]); } return result; }; // Helper to reduce scatter positions based on density multiplier const applyScatterDensity = (positions: T[]): T[] => applyDensityMultiplier(positions, SCATTER_DENSITY_MULTIPLIER); // Helper to reduce tree positions based on density multiplier const applyTreeDensity = (positions: T[]): T[] => applyDensityMultiplier(positions, TREE_DENSITY_MULTIPLIER_EFFECTIVE); // Helper to reduce grass positions based on density multiplier const applyGrassDensity = (positions: T[]): T[] => applyDensityMultiplier(positions, GRASS_DENSITY_MULTIPLIER); // Helper to reduce bush positions based on density multiplier const applyBushDensity = (positions: T[]): T[] => applyDensityMultiplier(positions, BUSH_DENSITY_MULTIPLIER); const applyScatterDensityPacked = (positions: Float32Array): Float32Array => applyDensityMultiplierPacked(positions, SCATTER_DENSITY_MULTIPLIER); const applyGrassDensityPacked = (positions: Float32Array): Float32Array => applyDensityMultiplierPacked(positions, GRASS_DENSITY_MULTIPLIER); const applyBushDensityPacked = (positions: Float32Array): Float32Array => applyDensityMultiplierPacked(positions, BUSH_DENSITY_MULTIPLIER); const capPackedPositionsUniformlyByHash = ( positions: Float32Array, targetCount: number, seed: number ): Float32Array => capPackedPositionsUniformlyByHashCore(positions, targetCount, seed, hash2); const extendScatterPositionsPacked = ( base: Float32Array, copies: number, jitter: number ): Float32Array => extendScatterPositionsPackedCore(base, copies, jitter, clampToMap); const expandAnchorsToDependentScatterPacked = ( anchors: Float32Array, options: { seed: number; dependentsMin: number; dependentsMax: number; radiusMin: number; radiusMax: number; densitySampler?: (x: number, z: number) => number; densityPower?: number; includeAnchor?: boolean; maxPositions?: number; } ): Float32Array => expandAnchorsToDependentScatterPackedCore(anchors, options, { hash2, clamp01, clampToMap }); // Cap very large scatter sets using world-space hashing so retained instances stay spatially uniform. const capPositionsUniformlyByHash = ( positions: T[], targetCount: number, seed: number ): T[] => { if (positions.length <= targetCount) return positions; if (targetCount <= 0) return []; const keepRatio = targetCount / positions.length; const kept: T[] = []; const dropped: T[] = []; for (const pos of positions) { const gate = hash2(pos.x * 0.173, pos.z * 0.197, seed); if (gate < keepRatio) { kept.push(pos); } else { dropped.push(pos); } } if (kept.length < targetCount && dropped.length > 0) { const refillRatio = Math.min(1, (targetCount - kept.length) / dropped.length); for (const pos of dropped) { if (hash2(pos.x * 0.071, pos.z * 0.113, seed ^ 0x9e3779b9) < refillRatio) { kept.push(pos); } } } if (kept.length <= targetCount) { return kept; } const trimRatio = targetCount / kept.length; const trimmed: T[] = []; for (const pos of kept) { if (hash2(pos.x * 0.047, pos.z * 0.089, seed ^ 0x85ebca6b) < trimRatio) { trimmed.push(pos); } } if (trimmed.length <= targetCount) { return trimmed; } // Rare overflow path: deterministic trim to exact target count. const scored = trimmed.map((pos) => ({ pos, score: hash2(pos.x * 0.019, pos.z * 0.023, seed ^ 0xc2b2ae35) })); scored.sort((a, b) => a.score - b.score); return scored.slice(0, targetCount).map((entry) => entry.pos); }; const getTreeInstanceCap = (mapSizeMeters: number): number => { const raw = window.localStorage.getItem('rts.tree.instanceCap'); const parsed = raw != null ? Number.parseInt(raw, 10) : Number.NaN; const maxAbsoluteCap = PERF_TARGET_10MS ? 35_000 : 120_000; if (Number.isFinite(parsed)) { return Math.max(4000, Math.min(maxAbsoluteCap, parsed)); } const mapAreaKm2 = (mapSizeMeters * mapSizeMeters) / 1_000_000; const perKm2 = parseDensityMultiplier( window.localStorage.getItem('rts.tree.instanceCapPerKm2'), 28 ); const effectivePerKm2 = TREE_PERF_GUARD ? Math.min(perKm2, 35) : perKm2; const scaled = Math.round(mapAreaKm2 * effectivePerKm2); return Math.max(4000, Math.min(maxAbsoluteCap, scaled)); }; const getGrassInstanceCap = (mapSizeMeters: number): number => { const raw = window.localStorage.getItem('rts.grass.instanceCap'); const parsed = raw != null ? Number.parseInt(raw, 10) : Number.NaN; const maxAbsoluteCap = PERF_TARGET_10MS ? 320_000 : 1_200_000; if (Number.isFinite(parsed)) { return Math.max(50_000, Math.min(maxAbsoluteCap, parsed)); } const mapAreaKm2 = (mapSizeMeters * mapSizeMeters) / 1_000_000; const perKm2 = parseDensityMultiplier( window.localStorage.getItem('rts.grass.instanceCapPerKm2'), 750 ); const scaled = Math.round(mapAreaKm2 * perKm2); return Math.max(50_000, Math.min(maxAbsoluteCap, scaled)); }; const getBushInstanceCap = (mapSizeMeters: number): number => { const raw = window.localStorage.getItem('rts.bush.instanceCap'); const parsed = raw != null ? Number.parseInt(raw, 10) : Number.NaN; const maxAbsoluteCap = PERF_TARGET_10MS ? 110_000 : 300_000; if (Number.isFinite(parsed)) { return Math.max(8000, Math.min(maxAbsoluteCap, parsed)); } const mapAreaKm2 = (mapSizeMeters * mapSizeMeters) / 1_000_000; const perKm2 = parseDensityMultiplier( window.localStorage.getItem('rts.bush.instanceCapPerKm2'), 70 ); const scaled = Math.round(mapAreaKm2 * perKm2); return Math.max(8000, Math.min(maxAbsoluteCap, scaled)); }; const capTreePlacementsByDensity = ( placements: T[], targetCount: number, forestSampler: ForestSamplerFn, seed: number ): T[] => { if (placements.length <= targetCount) { return placements; } const patchSeeds = forestSampler.getPatchSeeds?.() ?? []; if (patchSeeds.length === 0 || !forestSampler.getPatchId) { const scored = placements.map((placement) => { const forestFactor = forestSampler(placement.x, placement.z); const jitter = hash2(placement.x, placement.z, seed) * 0.25; return { placement, score: forestFactor + jitter }; }); scored.sort((a, b) => b.score - a.score); return scored.slice(0, targetCount).map((entry) => entry.placement); } const patchWeightMap = new Map(); for (const seedEntry of patchSeeds) { patchWeightMap.set(seedEntry.id, seedEntry.radius * seedEntry.radius * seedEntry.strength); } const patchGroups = new Map>(); for (const placement of placements) { const patchId = forestSampler.getPatchId(placement.x, placement.z); if (patchId < 0) { continue; } const forestFactor = forestSampler(placement.x, placement.z); const jitter = hash2(placement.x, placement.z, seed) * 0.25; const score = forestFactor + jitter; const bucket = patchGroups.get(patchId); if (bucket) { bucket.push({ placement, score }); } else { patchGroups.set(patchId, [{ placement, score }]); } } const patchEntries = Array.from(patchGroups.entries()).map(([id, items]) => ({ id, items, weight: patchWeightMap.get(id) ?? 1 })); if (patchEntries.length === 0) { return placements.slice(0, targetCount); } const minPerPatch = Math.max(50, Math.round(targetCount * 0.005)); const quotas = new Map(); let remaining = targetCount; let baseTotal = 0; for (const entry of patchEntries) { const base = Math.min(entry.items.length, minPerPatch); quotas.set(entry.id, base); baseTotal += base; remaining -= base; } if (remaining < 0) { const scale = targetCount / Math.max(1, baseTotal); remaining = targetCount; for (const entry of patchEntries) { const scaled = Math.min(entry.items.length, Math.max(1, Math.floor((quotas.get(entry.id) ?? 0) * scale))); quotas.set(entry.id, scaled); remaining -= scaled; } } if (remaining > 0) { const totalWeight = patchEntries.reduce((sum, entry) => sum + entry.weight, 0) || patchEntries.length; for (const entry of patchEntries) { const current = quotas.get(entry.id) ?? 0; const capacity = entry.items.length - current; if (capacity <= 0) continue; const share = Math.min(capacity, Math.floor(remaining * (entry.weight / totalWeight))); quotas.set(entry.id, current + share); remaining -= share; } if (remaining > 0) { const ordered = [...patchEntries].sort((a, b) => b.weight - a.weight); let cursor = 0; while (remaining > 0 && ordered.length > 0) { const entry = ordered[cursor % ordered.length]; const current = quotas.get(entry.id) ?? 0; if (current < entry.items.length) { quotas.set(entry.id, current + 1); remaining -= 1; } else { ordered.splice(cursor % ordered.length, 1); if (ordered.length === 0) break; cursor = 0; continue; } cursor += 1; } } } const selected: T[] = []; for (const entry of patchEntries) { const quota = quotas.get(entry.id) ?? 0; if (quota <= 0) continue; entry.items.sort((a, b) => b.score - a.score); for (let i = 0; i < Math.min(quota, entry.items.length); i++) { selected.push(entry.items[i].placement); } } if (selected.length >= targetCount) { return selected.slice(0, targetCount); } const remainingPool: Array<{ placement: T; score: number }> = []; for (const entry of patchEntries) { const quota = quotas.get(entry.id) ?? 0; if (entry.items.length > quota) { for (let i = quota; i < entry.items.length; i++) { remainingPool.push(entry.items[i]); } } } remainingPool.sort((a, b) => b.score - a.score); for (let i = 0; i < remainingPool.length && selected.length < targetCount; i++) { selected.push(remainingPool[i].placement); } return selected; }; const capTreeCandidatesByPatch = ( candidates: TreeCandidate[], targetCount: number, forestSampler: ForestSamplerFn, seed: number ): TreeCandidate[] => { if (candidates.length <= targetCount) { return candidates; } const patchSeeds = forestSampler.getPatchSeeds?.() ?? []; if (patchSeeds.length === 0) { return candidates.slice(0, targetCount); } const patchWeightMap = new Map(); for (const seedEntry of patchSeeds) { patchWeightMap.set(seedEntry.id, seedEntry.radius * seedEntry.radius * seedEntry.strength); } const groups = new Map>(); for (const candidate of candidates) { if (candidate.patchId < 0) continue; const jitter = hash2(candidate.x, candidate.z, seed) * 0.25; const score = candidate.forestFactor + jitter; const bucket = groups.get(candidate.patchId); if (bucket) { bucket.push({ placement: candidate, score }); } else { groups.set(candidate.patchId, [{ placement: candidate, score }]); } } const patchEntries = Array.from(groups.entries()).map(([id, items]) => ({ id, items, weight: patchWeightMap.get(id) ?? 1 })); if (patchEntries.length === 0) { return candidates.slice(0, targetCount); } const minPerPatch = Math.max(50, Math.round(targetCount * 0.005)); const quotas = new Map(); let remaining = targetCount; let baseTotal = 0; for (const entry of patchEntries) { const base = Math.min(entry.items.length, minPerPatch); quotas.set(entry.id, base); baseTotal += base; remaining -= base; } if (remaining < 0) { const scale = targetCount / Math.max(1, baseTotal); remaining = targetCount; for (const entry of patchEntries) { const scaled = Math.min(entry.items.length, Math.max(1, Math.floor((quotas.get(entry.id) ?? 0) * scale))); quotas.set(entry.id, scaled); remaining -= scaled; } } if (remaining > 0) { const totalWeight = patchEntries.reduce((sum, entry) => sum + entry.weight, 0) || patchEntries.length; for (const entry of patchEntries) { const current = quotas.get(entry.id) ?? 0; const capacity = entry.items.length - current; if (capacity <= 0) continue; const share = Math.min(capacity, Math.floor(remaining * (entry.weight / totalWeight))); quotas.set(entry.id, current + share); remaining -= share; } if (remaining > 0) { const ordered = [...patchEntries].sort((a, b) => b.weight - a.weight); let cursor = 0; while (remaining > 0 && ordered.length > 0) { const entry = ordered[cursor % ordered.length]; const current = quotas.get(entry.id) ?? 0; if (current < entry.items.length) { quotas.set(entry.id, current + 1); remaining -= 1; } else { ordered.splice(cursor % ordered.length, 1); if (ordered.length === 0) break; cursor = 0; continue; } cursor += 1; } } } const selected: TreeCandidate[] = []; for (const entry of patchEntries) { const quota = quotas.get(entry.id) ?? 0; if (quota <= 0) continue; entry.items.sort((a, b) => b.score - a.score); for (let i = 0; i < Math.min(quota, entry.items.length); i++) { selected.push(entry.items[i].placement); } } if (selected.length >= targetCount) { return selected.slice(0, targetCount); } const remainingPool: Array<{ placement: TreeCandidate; score: number }> = []; for (const entry of patchEntries) { const quota = quotas.get(entry.id) ?? 0; if (entry.items.length > quota) { for (let i = quota; i < entry.items.length; i++) { remainingPool.push(entry.items[i]); } } } remainingPool.sort((a, b) => b.score - a.score); for (let i = 0; i < remainingPool.length && selected.length < targetCount; i++) { selected.push(remainingPool[i].placement); } return selected; }; const samplePositions = ( positions: T[], ratio: number, weightFn?: (pos: T) => number ): T[] => { if (ratio >= 1 || positions.length === 0) { return [...positions]; } const clampedRatio = Math.max(0, Math.min(1, ratio)); const targetCount = Math.floor(positions.length * clampedRatio); if (targetCount === 0) { return []; } const result: T[] = []; const step = positions.length / targetCount; const weights = weightFn ? new Float32Array(positions.length) : null; let maxWeight = 1; if (weightFn && weights) { for (let i = 0; i < positions.length; i += 1) { const weight = Math.max(0, weightFn(positions[i])); weights[i] = weight; if (weight > maxWeight) { maxWeight = weight; } } } for (let i = 0; i < positions.length && result.length < targetCount; i++) { const pos = positions[i]; const weight = weights ? weights[i] : 1; const probability = clampedRatio * (0.4 + 0.6 * (weight / maxWeight)); if (Math.random() < Math.min(probability, 1)) { result.push(pos); } } if (result.length < targetCount) { // fallback fill to ensure enough samples for (let i = 0; i < positions.length && result.length < targetCount; i++) { const index = Math.floor(i * step + Math.random() * step * 0.5); result.push(positions[Math.min(index, positions.length - 1)]); } } return result; }; const filterPositionsBySlope = ( positions: { x: number; z: number }[], terra: Terra, minSlope: number, cache?: TerrainQueryCache ): { x: number; z: number }[] => { const useCache = !!cache; const filtered: { x: number; z: number }[] = []; for (let i = 0; i < positions.length; i += 1) { const pos = positions[i]; const slope = useCache ? cache!.getSlopeFast(pos.x, pos.z) : terra.getSlopeWorld(pos.x, pos.z).slopeDegrees; if (slope >= minSlope) { filtered.push(pos); } } return filtered; }; // PHASE 1: Terrain Quality Controls (window.__RTS as any).setTerrainQuality = (preset: 'low' | 'medium' | 'high' | 'ultra') => { const presets = { low: { textureQuality: 'LOW', heightmapSize: '256', normalStrength: '0.4' }, medium: { textureQuality: 'MEDIUM', heightmapSize: '512', normalStrength: '0.5' }, high: { textureQuality: 'HIGH', heightmapSize: '1024', normalStrength: '0.75' }, ultra: { textureQuality: 'ULTRA', heightmapSize: '2048', normalStrength: '1.0' } }; const config = presets[preset]; if (!config) { console.error(`[TerrainQuality] Invalid preset: ${preset}`); return; } try { window.localStorage.setItem('rts.terrain.textureQuality', config.textureQuality); window.localStorage.setItem('rts.terrain.heightmapSize', config.heightmapSize); window.localStorage.setItem('rts.terrain.normalStrength', config.normalStrength); console.info(`[TerrainQuality] Set to ${preset.toUpperCase()}`, config); } finally { window.location.reload(); } }; // PERFORMANCE: Scatter density control (window.__RTS as any).setScatterDensity = (multiplier: number) => { const clamped = Math.max(0.05, Math.min(2.0, multiplier)); try { window.localStorage.setItem('rts.scatter.density', String(clamped)); console.info(`[Performance] Scatter density set to ${(clamped * 100).toFixed(0)}%`); } finally { window.location.reload(); } }; (window.__RTS as any).getTerrainQuality = () => { return { textureQuality: window.localStorage.getItem('rts.terrain.textureQuality') || 'HIGH', heightmapSize: window.localStorage.getItem('rts.terrain.heightmapSize') || '1024', normalStrength: window.localStorage.getItem('rts.terrain.normalStrength') || '0.75' }; }; (window.__RTS as any).resetTerrainQuality = () => { try { window.localStorage.removeItem('rts.terrain.textureQuality'); window.localStorage.removeItem('rts.terrain.heightmapSize'); window.localStorage.removeItem('rts.terrain.normalStrength'); console.info('[TerrainQuality] Reset to defaults (HIGH)'); } finally { window.location.reload(); } }; console.info('[Framegraph]', { post: FRAMEGRAPH_ENABLE_POST }); console.info('[TerrainQuality]', (window.__RTS as any).getTerrainQuality()); const RENDER_TERRAIN_UNITS_BUILDINGS_ONLY = false; const ENABLE_SCATTER = !RENDER_TERRAIN_UNITS_BUILDINGS_ONLY; const ENABLE_WATER = !RENDER_TERRAIN_UNITS_BUILDINGS_ONLY; const ENABLE_ATMOSPHERE = !RENDER_TERRAIN_UNITS_BUILDINGS_ONLY; const ENABLE_CLIFFS = !RENDER_TERRAIN_UNITS_BUILDINGS_ONLY; const ENABLE_TERRAIN_MARKERS = true; // keep strategic/resource/frontline overlays available when GPU overlay path is disabled const ENABLE_RESOURCE_RENDERER = !RENDER_TERRAIN_UNITS_BUILDINGS_ONLY; const ENABLE_RALLY_RENDERER = !RENDER_TERRAIN_UNITS_BUILDINGS_ONLY; const ENABLE_SELECTION_BOX = !RENDER_TERRAIN_UNITS_BUILDINGS_ONLY; const ENABLE_GRID_HELPERS = !RENDER_TERRAIN_UNITS_BUILDINGS_ONLY; const scene = new THREE.Scene(); // NOTE: No background color - entities render with transparency over WebGPU terrain // scene.background = new THREE.Color(0x4a9fd8); // WebGPU canvas - renders the final composited output (terrain + scatter overlay) const gpuCanvas = document.createElement('canvas'); gpuCanvas.width = window.innerWidth; gpuCanvas.height = window.innerHeight; gpuCanvas.style.position = 'fixed'; gpuCanvas.style.top = '0'; gpuCanvas.style.left = '0'; gpuCanvas.style.width = '100%'; gpuCanvas.style.height = '100%'; gpuCanvas.style.pointerEvents = 'none'; // Disabled until game starts (menu is active) gpuCanvas.style.zIndex = '0'; gpuCanvas.style.display = 'none'; // Hidden until game starts gpuCanvas.id = 'gpu-canvas'; // Add ID for menu system to control document.body.appendChild(gpuCanvas); // WebGL canvas - renders scatter objects (trees, rocks, grass) to an offscreen texture // This texture is then composited on top of the WebGPU terrain in the final blit const WEBGL_OVERLAY_SCALE = (() => { const raw = window.localStorage.getItem('rts.webgl.overlayScale') ?? '0.8'; const parsed = Number.parseFloat(raw); if (!Number.isFinite(parsed)) { return 0.8; } return Math.max(0.6, Math.min(1.0, parsed)); })(); const renderer = new THREE.WebGLRenderer({ antialias: false, // Disable antialiasing for performance alpha: true, // Enable transparency premultipliedAlpha: false // Disable premultiplied alpha for proper compositing }); // Aggregate all WebGL sub-passes for the whole frame; we reset manually in the frame loop. renderer.info.autoReset = false; renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5) * WEBGL_OVERLAY_SCALE); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setClearColor(0x000000, 0); // Clear with transparent black renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.0; applyCuratedEnvironmentMap(scene, renderer); const configureWebglCanvas = (attachToDom: boolean): void => { renderer.domElement.style.position = 'fixed'; renderer.domElement.style.top = '0'; renderer.domElement.style.left = '0'; renderer.domElement.style.width = '100%'; renderer.domElement.style.height = '100%'; renderer.domElement.style.pointerEvents = 'none'; renderer.domElement.style.zIndex = '1'; if (attachToDom) { if (!renderer.domElement.isConnected) { document.body.appendChild(renderer.domElement); } return; } if (renderer.domElement.isConnected) { renderer.domElement.remove(); } }; // Depth precision was too poor with a huge clip range (0.1..100000), // causing far water/terrain occlusion artifacts. Use tighter defaults. const CAMERA_NEAR_PLANE = (() => { const raw = window.localStorage.getItem('rts.camera.near'); const parsed = raw == null ? Number.NaN : Number(raw); return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 0.05), 10) : 0.5; })(); const CAMERA_FAR_PLANE = (() => { const raw = window.localStorage.getItem('rts.camera.far'); const parsed = raw == null ? Number.NaN : Number(raw); return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 4000), 100000) : 35000; })(); const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, CAMERA_NEAR_PLANE, CAMERA_FAR_PLANE ); const rtsCamera = new RTSCamera(camera, gpuCanvas); // Use GPU canvas for input (WebGL canvas is offscreen) // Expose rtsCamera early for menu system to control window.__RTS = window.__RTS ?? {}; (window.__RTS as any).rtsCamera = rtsCamera; // Camera starts disabled - menu system will enable it when game starts rtsCamera.setEnabled(false); console.log('[Main] Camera created and disabled. Check with: __RTS.rtsCamera.isEnabled()'); // Debug function to check pointer-events configuration (window as any).__RTS.checkPointerEvents = () => { const layers = [ { name: 'gpu-canvas', id: 'gpu-canvas' }, { name: 'game-container', id: 'game-container' }, { name: 'terrain-marker-layer', id: 'terrain-marker-layer' }, { name: 'ui-overlay', id: 'ui-overlay' } ]; console.log('=== Pointer Events Configuration ==='); layers.forEach(layer => { const el = document.getElementById(layer.id); if (el) { const style = window.getComputedStyle(el); console.log(`${layer.name}:`, { zIndex: style.zIndex, pointerEvents: style.pointerEvents, display: style.display, position: style.position }); } else { console.log(`${layer.name}: NOT FOUND`); } }); }; const builtinHeightmapUrl = new URL('./heightmapper-1766096447141.png', import.meta.url).href; const heightmapUrl = (import.meta.env as Record).VITE_TERRAIN_HEIGHTMAP_URL; const defaultMapPlan = 'aaa-20km'; const MAPLAB_LATEST_BUNDLE_KEY = 'rts.maplab.latestBundle'; const MAPLAB_LATEST_BUNDLE_REF_KEY = 'rts.maplab.latestBundleRef'; const MAPLAB_LATEST_BUNDLE_DB_KEY = 'latest'; const MAPLAB_LATEST_ROUGH_KEY = 'rts.maplab.latestRough'; const MAPLAB_STREAM_CHANNEL = 'rts.maplab.stream'; const MAPLAB_AUTOSTART_KEY = 'rts.maplab.autostart'; const MAPLAB_AUTOSTART_MAX_AGE_MS = 2 * 60 * 1000; const MAPLAB_LAUNCH_DB_NAME = 'rts.maplab.launch.db'; const MAPLAB_LAUNCH_DB_STORE = 'launchBundles'; type MapLabAutoStartRequest = { version: 1; source: 'maplab'; createdAt: number; mapName?: string; sourceHash?: string; seed?: number; playerCount?: number; biome?: BiomeType; mapSize?: MapSize; bundleTransport?: 'localStorage' | 'indexeddb'; launchBundleKey?: string; }; type MapLabLatestBundleRef = { version: 1; transport: 'localStorage' | 'indexeddb'; key?: string; createdAt: number; hash?: string; }; const terrainMapPlan = (import.meta.env as Record).VITE_TERRAIN_MAP_PLAN ?? defaultMapPlan; if (!(import.meta.env as Record).VITE_TERRAIN_MAP_PLAN) { console.info('[Terrain] VITE_TERRAIN_MAP_PLAN not set; defaulting to', defaultMapPlan); } const readLatestMapLabRoughConditions = (): StrategicRoughHeightConditions | null => { try { const raw = window.localStorage.getItem(MAPLAB_LATEST_ROUGH_KEY); if (!raw) return null; return deserializeStrategicRoughHeightConditions(raw); } catch (error) { console.warn('[MapLab] Failed to parse latest rough conditions:', error); return null; } }; const applyRuntimeMapLabRoughConditions = (conditions: StrategicRoughHeightConditions | null): void => { const rts = ensureRTS(); rts.maplabRoughHeight = conditions; }; const installMapLabRealtimeBridge = (): void => { applyRuntimeMapLabRoughConditions(readLatestMapLabRoughConditions()); const rts = ensureRTS(); rts.getMapLabRoughHeight = () => ensureRTS().maplabRoughHeight ?? null; if (typeof BroadcastChannel === 'undefined') { return; } try { const channel = new BroadcastChannel(MAPLAB_STREAM_CHANNEL); channel.addEventListener('message', (event) => { const payload = event.data; if (!payload || typeof payload !== 'object') return; const type = (payload as { type?: string }).type; if (type === 'maplab-listener-presence-request') { const sourceId = (payload as { sourceId?: unknown }).sourceId; if (typeof sourceId !== 'string' || !sourceId) return; channel.postMessage({ type: 'maplab-listener-presence-response', sourceId, sentAt: Date.now() }); return; } if (type !== 'maplab-rough-height') return; const rough = (payload as { payload?: StrategicRoughHeightConditions }).payload; if (!rough) return; applyRuntimeMapLabRoughConditions(rough); }); } catch (error) { console.warn('[MapLab] Failed to subscribe to realtime rough stream:', error); } }; installMapLabRealtimeBridge(); const openMapLabLaunchDb = (): Promise => { return new Promise((resolve, reject) => { if (typeof indexedDB === 'undefined') { reject(new Error('IndexedDB is not available.')); return; } const request = indexedDB.open(MAPLAB_LAUNCH_DB_NAME, 1); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(MAPLAB_LAUNCH_DB_STORE)) { db.createObjectStore(MAPLAB_LAUNCH_DB_STORE, { keyPath: 'key' }); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error ?? new Error('Failed to open MapLab launch database.')); }); }; const readIndexedDbMapLabBundle = async ( key?: string, options?: { consume?: boolean } ): Promise => { if (!key) return null; let db: IDBDatabase | null = null; try { db = await openMapLabLaunchDb(); const consume = options?.consume === true; const record = await new Promise<{ key: string; bundle: StrategicMapBundle } | null>((resolve, reject) => { const tx = db!.transaction(MAPLAB_LAUNCH_DB_STORE, consume ? 'readwrite' : 'readonly'); const store = tx.objectStore(MAPLAB_LAUNCH_DB_STORE); const getReq = store.get(key); getReq.onsuccess = () => { const value = (getReq.result ?? null) as { key: string; bundle: StrategicMapBundle } | null; resolve(value); }; getReq.onerror = () => reject(getReq.error ?? new Error('Failed to read launch bundle from IndexedDB.')); if (consume) { store.delete(key); } tx.onabort = () => reject(tx.error ?? new Error('Launch bundle transaction aborted.')); tx.onerror = () => reject(tx.error ?? new Error('Launch bundle transaction failed.')); }); return record?.bundle ?? null; } catch (error) { console.warn('[MapLab] Failed to consume IndexedDB launch bundle:', error); return null; } finally { db?.close(); } }; const consumeIndexedDbLaunchBundle = async (key?: string): Promise => { return readIndexedDbMapLabBundle(key, { consume: true }); }; const readLatestMapLabBundleRef = (): MapLabLatestBundleRef | null => { try { const raw = window.localStorage.getItem(MAPLAB_LATEST_BUNDLE_REF_KEY); if (!raw) return null; const parsed = JSON.parse(raw) as Partial; if (parsed.transport !== 'indexeddb' && parsed.transport !== 'localStorage') { return null; } return { version: 1, transport: parsed.transport, key: typeof parsed.key === 'string' && parsed.key ? parsed.key : undefined, createdAt: Number.isFinite(parsed.createdAt) ? Number(parsed.createdAt) : Date.now(), hash: typeof parsed.hash === 'string' ? parsed.hash : undefined }; } catch (error) { console.warn('[MapLab] Failed to parse latest bundle ref:', error); return null; } }; const consumeMapLabAutoStartRequest = (): MapLabAutoStartRequest | null => { const url = new URL(window.location.href); if (url.searchParams.get('maplabStart') !== '1') { return null; } let payload: MapLabAutoStartRequest | null = null; try { const raw = window.sessionStorage.getItem(MAPLAB_AUTOSTART_KEY) ?? window.localStorage.getItem(MAPLAB_AUTOSTART_KEY); if (raw) { payload = JSON.parse(raw) as MapLabAutoStartRequest; } } catch (error) { console.warn('[MapLab] Failed to parse autostart payload:', error); } finally { window.sessionStorage.removeItem(MAPLAB_AUTOSTART_KEY); window.localStorage.removeItem(MAPLAB_AUTOSTART_KEY); } url.searchParams.delete('maplabStart'); const query = url.searchParams.toString(); const nextUrl = `${url.pathname}${query ? `?${query}` : ''}${url.hash}`; window.history.replaceState({}, document.title, nextUrl); if (!payload || payload.source !== 'maplab' || payload.version !== 1) { return null; } const createdAt = Number(payload.createdAt) || 0; if (createdAt <= 0 || Date.now() - createdAt > MAPLAB_AUTOSTART_MAX_AGE_MS) { return null; } return payload; }; const createMapLabAutoStartPlayerSlots = (playerCount: number): PlayerSlot[] => { const slots: PlayerSlot[] = []; for (let index = 0; index < playerCount; index += 1) { const isLocal = index === 0; slots.push({ slotId: index, playerName: isLocal ? 'Player 1' : `AI Opponent ${index}`, team: index, faction: 'UEF', color: PLAYER_COLORS[index % PLAYER_COLORS.length], isAI: !isLocal, aiDifficulty: isLocal ? undefined : 'medium', isOpen: false }); } return slots; }; const buildSettingsFromMapLabAutoStart = (payload: MapLabAutoStartRequest): MatchSettings => { const mapName = payload.mapName?.trim() || 'MapLab Generated'; const playerCountRaw = Number(payload.playerCount); const playerCount = Math.max(2, Math.min(8, Number.isFinite(playerCountRaw) ? Math.floor(playerCountRaw) : 4)); const playerSlots = createMapLabAutoStartPlayerSlots(playerCount); const seed = Number(payload.seed); const mapSize = payload.mapSize && payload.mapSize in MAP_SIZE_CONFIGS ? payload.mapSize : DEFAULT_GENERATOR_SETTINGS.mapSize; const biome = payload.biome && payload.biome in BIOME_CONFIGS ? payload.biome : DEFAULT_GENERATOR_SETTINGS.biome; return { ...DEFAULT_MATCH_SETTINGS, mapName, isGeneratedMap: true, generatedTerrainSource: 'maplab', maxPlayers: playerCount, playerSlots, generatorSettings: { ...DEFAULT_GENERATOR_SETTINGS, seed: Number.isFinite(seed) ? Math.floor(seed) : DEFAULT_GENERATOR_SETTINGS.seed, mapSize, biome, playerCount } }; }; const mapLabAutoStartRequest = consumeMapLabAutoStartRequest(); const mapLabAutoStartIndexedBundlePromise: Promise | null = mapLabAutoStartRequest?.bundleTransport === 'indexeddb' ? consumeIndexedDbLaunchBundle(mapLabAutoStartRequest.launchBundleKey) : null; type TerrainResolveDiagnostics = { mapId: string; mapPlan: string; biome: BiomeType; useLayeredHeightmap: boolean; useGPUErosion: boolean; useGPUTerrainPipeline: boolean; forceGPUErosion: boolean; heightmapResolution: HeightmapResolution; erosionResolution: HeightmapResolution; }; let lastTerrainResolveDiagnostics: TerrainResolveDiagnostics | null = null; const TERRAIN_DETAIL_SETTINGS = { plateauSharpness: 1.45, erosionStrength: 2.0, // Increased from 1.0 to 2.0 for more dramatic erosion features microDetailStrength: 0.25 // Increased from 0.12 to 0.25 for more surface variation }; // Biome selection: Use env var or random const getBiomeForTerrain = (): BiomeType => { const biomeEnv = (import.meta.env as Record).VITE_TERRAIN_BIOME; if (biomeEnv) { const biomeMap: Record = { 'temperate': BiomeType.TEMPERATE, 'desert': BiomeType.DESERT, 'arctic': BiomeType.ARCTIC, 'volcanic': BiomeType.VOLCANIC, 'alien': BiomeType.ALIEN, 'urban_ruins': BiomeType.URBAN_RUINS }; const biome = biomeMap[biomeEnv.toLowerCase()]; if (biome) { console.info('[Terrain] Using biome from env:', biome); return biome; } } // Random biome selection const biomes = [ BiomeType.TEMPERATE, BiomeType.DESERT, BiomeType.ARCTIC, BiomeType.VOLCANIC, BiomeType.ALIEN, BiomeType.URBAN_RUINS ]; const randomBiome = biomes[Math.floor(Math.random() * biomes.length)]; console.info('[Terrain] Using random biome:', randomBiome); return randomBiome; }; /** * AAA Phase 1.5: Create Terra with GPU-accelerated heightmap pipeline * This runs the complete GPU pipeline (generation + hydraulic + thermal erosion) * and passes the result to Terra for rendering. */ async function createTerraWithGPUPipeline( width: number, height: number, tileSize: number, options: TerraOptions & { seed?: number } ): Promise { try { // Get GPU device const deviceManager = await getGpuDeviceManager(); if (!deviceManager) { console.warn('[GPU Terra] WebGPU not available, falling back to CPU generation'); return new Terra(width, height, tileSize, options); } const device = deviceManager.logicalDevice; // Check if device is lost const lostInfo = await Promise.race([ device.lost, new Promise((resolve) => setTimeout(() => resolve(null), 100)) ]); if (lostInfo) { console.warn('[GPU Terra] WebGPU device is lost, falling back to CPU generation'); return new Terra(width, height, tileSize, options); } // Run GPU pipeline const { runGPUTerrainPipeline } = await import('./terrain/GPUTerrainPipeline'); const resolution = (options.heightmapResolution ?? 1024) as HeightmapResolution; const mapSize = width * tileSize; const rawBiome = options.biome ?? 'temperate'; const layoutBiome = typeof rawBiome === 'string' && rawBiome in BIOME_CONFIGS ? (rawBiome as BiomeType) : BiomeType.TEMPERATE; const layoutSeed = options.layoutSeed ?? options.seed ?? Math.floor(Math.random() * 1000000); const layoutSpec = { seed: layoutSeed, playerCount: options.layoutPlayerCount ?? 4, mapSize, symmetry: (options.layoutSymmetry ?? 'rotational') as SymmetryGroup, biome: layoutBiome, terrainRoughness: options.terrainRoughness ?? 0.5, waterCoverage: options.waterCoverage ?? 0.5, plateauDensity: options.plateauDensity ?? 0.5, enforceSymmetry: options.enforceSymmetry ?? false }; console.log(`[GPU Terra] Running GPU terrain pipeline (${resolution}x${resolution})...`); const result = await runGPUTerrainPipeline(device, { resolution, seed: options.seed ?? options.layoutSeed ?? Math.floor(Math.random() * 1000000), octaves: 6, lacunarity: 2.0, persistence: 0.5, scale: 1.0, domainWarpStrength: 0.5, hydraulicPreset: 'LIGHT', thermalPreset: 'SUBTLE', enableFeaturePasses: true, softNoiseAmplitude: 0.08, softNoiseOctaves: 2, softNoisePersistence: 0.22, softNoiseScale: 0.14, softNoiseWarpStrength: 0.004, smoothPasses: 1, smoothStrength: 0.2, canyonDepthScale: 0.85, terraceStrength: 0.75, terraceStep: 9.0, layoutSpec }); console.log(`[GPU Terra] GPU pipeline complete in ${result.timings.total.toFixed(1)}ms`); const computeSeaLevelForCoverage = (data: Float32Array, coverage: number) => { const total = data.length; if (!total) { return { seaLevel: 0, min: 0, max: 0, avg: 0, span: 0 }; } let min = Infinity; let max = -Infinity; let sum = 0; for (let i = 0; i < total; i++) { const v = data[i]; if (v < min) min = v; if (v > max) max = v; sum += v; } const avg = sum / total; const span = Math.max(1e-4, max - min); const targetCoverage = Math.max(0, Math.min(1, coverage)); if (targetCoverage <= 0) { return { seaLevel: min - span * 0.05, min, max, avg, span }; } if (targetCoverage >= 1) { return { seaLevel: max + span * 0.05, min, max, avg, span }; } const bins = 512; const hist = new Uint32Array(bins); for (let i = 0; i < total; i++) { const normalized = Math.max(0, Math.min(1, (data[i] - min) / span)); const bin = Math.min(bins - 1, Math.floor(normalized * bins)); hist[bin] += 1; } const targetCount = Math.round(targetCoverage * total); let cumulative = 0; let quantile = min; for (let bin = 0; bin < bins; bin++) { cumulative += hist[bin]; if (cumulative >= targetCount) { quantile = min + (bin / bins) * span; break; } } const margin = span * 0.02; const seaLevel = Math.max(min + margin, Math.min(max - margin, quantile)); return { seaLevel, min, max, avg, span }; }; const applyShorelineErosionToHeightmap = ( data: Float32Array, mapWidth: number, mapHeight: number, seaLevel: number, mapSizeMeters: number ) => { if (!Number.isFinite(seaLevel)) return; const total = mapWidth * mapHeight; if (total <= 0) return; const cellSize = mapSizeMeters / Math.max(mapWidth - 1, 1); const beachBand = Math.max(8, cellSize * 3.5); const baseMask = new Uint8Array(total); for (let idx = 0; idx < total; idx++) { if (Math.abs(data[idx] - seaLevel) <= beachBand) { baseMask[idx] = 1; } } const expanded = new Uint8Array(total); for (let idx = 0; idx < total; idx++) { if (!baseMask[idx]) continue; const i = idx % mapWidth; const k = (idx - i) / mapWidth; for (let dk = -3; dk <= 3; dk++) { const nk = k + dk; if (nk < 0 || nk >= mapHeight) continue; const row = nk * mapWidth; for (let di = -3; di <= 3; di++) { const ni = i + di; if (ni < 0 || ni >= mapWidth) continue; expanded[row + ni] = 1; } } } const indices: number[] = []; for (let idx = 0; idx < total; idx++) { if (expanded[idx]) indices.push(idx); } if (indices.length === 0) return; const blurOnce = (input: Float32Array, output: Float32Array) => { for (const idx of indices) { const i = idx % mapWidth; const k = (idx - i) / mapWidth; const iW = i > 0 ? i - 1 : i; const iE = i < mapWidth - 1 ? i + 1 : i; const kN = k > 0 ? k - 1 : k; const kS = k < mapHeight - 1 ? k + 1 : k; const kNRow = kN * mapWidth; const kRow = k * mapWidth; const kSRow = kS * mapWidth; const sum = input[idx] * 4 + (input[kRow + iW] + input[kRow + iE] + input[kNRow + i] + input[kSRow + i]) * 2 + input[kNRow + iW] + input[kNRow + iE] + input[kSRow + iW] + input[kSRow + iE]; output[idx] = sum / 16; } }; const blurred = new Float32Array(total); blurOnce(data, blurred); const blurred2 = new Float32Array(total); blurOnce(blurred, blurred2); const blend = 0.85; const carveMax = Math.max(0.35, cellSize * 0.22); const falloffBand = beachBand * 1.6; const shoreFade = Math.max(1e-4, beachBand * 0.35); for (const idx of indices) { const baseHeight = data[idx]; const dist = Math.abs(baseHeight - seaLevel); const t = Math.max(0, Math.min(1, 1 - dist / Math.max(falloffBand, 1e-4))); if (t <= 0) { continue; } const base = baseHeight + (blurred2[idx] - baseHeight) * (blend * t); // Only carve below/near sea level to avoid crevices at the sand/grass boundary. const above = base - seaLevel; const wetFactor = Math.max(0, Math.min(1, 1 - above / shoreFade)); const carve = t * t * carveMax * wetFactor; data[idx] = base - carve; } // Fill small local minima in the beach band to avoid shoreline crevices. // This is a lightweight "depression fill" inspired by erosion sink handling. const pitAllowance = Math.max(0.05, cellSize * 0.02); const clampBandMin = seaLevel - beachBand * 0.15; for (let pass = 0; pass < 2; pass++) { for (const idx of indices) { const h = data[idx]; if (h < clampBandMin) continue; const i = idx % mapWidth; const k = (idx - i) / mapWidth; const iW = i > 0 ? i - 1 : i; const iE = i < mapWidth - 1 ? i + 1 : i; const kN = k > 0 ? k - 1 : k; const kS = k < mapHeight - 1 ? k + 1 : k; const kNRow = kN * mapWidth; const kRow = k * mapWidth; const kSRow = kS * mapWidth; const minNeighbor = Math.min( data[kRow + iW], data[kRow + iE], data[kNRow + i], data[kSRow + i] ); const target = minNeighbor - pitAllowance; if (h < target) { data[idx] = target; } } } // Clamp steep drops in the beach band (prevents sharp crevices at grass/sand edges). const maxDrop = Math.max(0.12, cellSize * 0.18); for (let pass = 0; pass < 2; pass++) { for (const idx of indices) { const h = data[idx]; const i = idx % mapWidth; const k = (idx - i) / mapWidth; const iW = i > 0 ? i - 1 : i; const iE = i < mapWidth - 1 ? i + 1 : i; const kN = k > 0 ? k - 1 : k; const kS = k < mapHeight - 1 ? k + 1 : k; const kNRow = kN * mapWidth; const kRow = k * mapWidth; const kSRow = kS * mapWidth; const neighborMax = Math.max( data[kRow + iW], data[kRow + iE], data[kNRow + i], data[kSRow + i] ); const minAllowed = neighborMax - maxDrop; if (h < minAllowed) { data[idx] = minAllowed; } } } // SDF-based seabed profile: smooth depth increases with distance from shore. const INF = 1e9; const dist = new Float32Array(total); let minHeight = Infinity; let maxHeight = -Infinity; for (let idx = 0; idx < total; idx++) { const h = data[idx]; if (h < minHeight) minHeight = h; if (h > maxHeight) maxHeight = h; dist[idx] = h > seaLevel ? 0 : INF; } const diag = 1.41421356237; for (let k = 0; k < mapHeight; k++) { const kBase = k * mapWidth; for (let i = 0; i < mapWidth; i++) { const idx = kBase + i; let best = dist[idx]; if (best <= 0) continue; if (i > 0) best = Math.min(best, dist[idx - 1] + 1); if (k > 0) best = Math.min(best, dist[idx - mapWidth] + 1); if (i > 0 && k > 0) best = Math.min(best, dist[idx - mapWidth - 1] + diag); if (i < mapWidth - 1 && k > 0) best = Math.min(best, dist[idx - mapWidth + 1] + diag); dist[idx] = best; } } for (let k = mapHeight - 1; k >= 0; k--) { const kBase = k * mapWidth; for (let i = mapWidth - 1; i >= 0; i--) { const idx = kBase + i; let best = dist[idx]; if (best <= 0) continue; if (i < mapWidth - 1) best = Math.min(best, dist[idx + 1] + 1); if (k < mapHeight - 1) best = Math.min(best, dist[idx + mapWidth] + 1); if (i < mapWidth - 1 && k < mapHeight - 1) best = Math.min(best, dist[idx + mapWidth + 1] + diag); if (i > 0 && k < mapHeight - 1) best = Math.min(best, dist[idx + mapWidth - 1] + diag); dist[idx] = best; } } const heightSpan = Math.max(1e-4, maxHeight - minHeight); const availableDepth = Math.max(0, seaLevel - minHeight); const maxDepth = Math.min(availableDepth, Math.max(heightSpan * 0.35, cellSize * 8)); const slopeLength = Math.max(cellSize * 10, mapSizeMeters * 0.02); const depthScale = Math.max(slopeLength, 1e-4); for (let idx = 0; idx < total; idx++) { const h = data[idx]; if (h >= seaLevel) continue; const distMeters = dist[idx] * cellSize; const depthTarget = maxDepth * (1 - Math.exp(-distMeters / depthScale)); const targetHeight = seaLevel - depthTarget; const influence = 0.35 + 0.55 * smoothstep(0, slopeLength * 0.75, distMeters); data[idx] = h + (targetHeight - h) * influence; } }; const coverage = layoutSpec.waterCoverage ?? 0.5; const seaStats = computeSeaLevelForCoverage(result.heightmap, coverage); applyShorelineErosionToHeightmap(result.heightmap, resolution, resolution, seaStats.seaLevel, mapSize); const pipelineResult = { totalDuration: result.timings.total, stages: [], finalStats: { min: seaStats.min, max: seaStats.max, avg: seaStats.avg, span: seaStats.span }, seaLevel: seaStats.seaLevel, beachMask: undefined }; console.info( `[GPU Terra] Calibrated sea level ${seaStats.seaLevel.toFixed(2)} for water coverage ${(coverage * 100).toFixed(0)}%` ); // Create Terra with GPU heightmap data const terra = new Terra(width, height, tileSize, { ...options, useGPUHeightmap: true, gpuHeightmapData: result.heightmap, pipelineResult }); (terra as any).updateWaterLevel?.(); return terra; } catch (error) { console.error('[GPU Terra] GPU pipeline failed, falling back to CPU generation:', error); return new Terra(width, height, tileSize, options); } } const createStrategicBundleFromRoughConditions = ( rough: StrategicRoughHeightConditions ): StrategicMapBundle | null => { const width = Math.max(1, Math.floor(rough.width)); const height = Math.max(1, Math.floor(rough.height)); const expected = width * height; if (!Array.isArray(rough.normalizedHeightData) || rough.normalizedHeightData.length !== expected) { return null; } const minHeight = Number.isFinite(rough.minHeight) ? rough.minHeight : 0; const maxHeight = Number.isFinite(rough.maxHeight) ? rough.maxHeight : minHeight + 1; const span = Math.max(1e-6, maxHeight - minHeight); const heightData = new Float32Array(expected); for (let i = 0; i < expected; i += 1) { const normalized = Math.min(1, Math.max(0, Number(rough.normalizedHeightData[i] ?? 0))); heightData[i] = minHeight + normalized * span; } const mapSizeMeters = rough.worldMetrics?.mapSizeMeters ?? rough.recipe.mapSizeMeters ?? 20_000; const mapHalfSize = mapSizeMeters * 0.5; return { recipe: rough.recipe, heightData, width, height, worldMetrics: { mapSizeMeters, mapHalfSize, minX: -mapHalfSize, maxX: mapHalfSize, minZ: -mapHalfSize, maxZ: mapHalfSize, resolution: width }, hash: `rough-${rough.sourceBundleHash}`, generatedAt: rough.generatedAt }; }; const resolveTerrain = async (settings?: MatchSettings): Promise => { const generatorSettings = settings?.isGeneratedMap ? (settings.generatorSettings ?? null) : null; const isProceduralMap = settings?.isGeneratedMap ?? false; const usingMapLabSource = isProceduralMap && settings?.generatedTerrainSource === 'maplab'; let strategicBundle: StrategicMapBundle | null = null; if (usingMapLabSource) { if (mapLabAutoStartIndexedBundlePromise) { strategicBundle = await mapLabAutoStartIndexedBundlePromise; if (strategicBundle) { console.info('[Terrain] Loaded strategic MapLab bundle from IndexedDB autostart payload'); } } try { const raw = strategicBundle ? null : window.localStorage.getItem(MAPLAB_LATEST_BUNDLE_KEY); if (!strategicBundle && raw) { strategicBundle = deserializeStrategicBundle(raw); console.info('[Terrain] Loaded strategic MapLab bundle from localStorage'); } if (!strategicBundle) { const ref = readLatestMapLabBundleRef(); if (ref?.transport === 'indexeddb') { const key = ref.key ?? MAPLAB_LATEST_BUNDLE_DB_KEY; strategicBundle = await readIndexedDbMapLabBundle(key); if (strategicBundle) { console.info('[Terrain] Loaded strategic MapLab bundle from IndexedDB latest reference'); } } } if (!strategicBundle) { const roughConditions = readLatestMapLabRoughConditions(); if (roughConditions) { strategicBundle = createStrategicBundleFromRoughConditions(roughConditions); if (strategicBundle) { console.info('[Terrain] Loaded realtime rough MapLab conditions as strategic seed bundle'); } else { console.warn('[Terrain] MapLab rough conditions could not be converted, falling back to generator.'); } } else { console.warn('[Terrain] MapLab source selected without a saved bundle, falling back to generator.'); } } } catch (error) { const roughConditions = readLatestMapLabRoughConditions(); if (roughConditions) { strategicBundle = createStrategicBundleFromRoughConditions(roughConditions); if (strategicBundle) { console.warn('[Terrain] MapLab bundle parse failed, using realtime rough conditions instead:', error); } else { console.warn('[Terrain] Failed to deserialize MapLab bundle, falling back to generator:', error); } } else { console.warn('[Terrain] Failed to deserialize MapLab bundle, falling back to generator:', error); } } } const generatedMapSizeMeters = strategicBundle?.worldMetrics?.mapSizeMeters ?? (generatorSettings?.mapSize ? MAP_SIZE_CONFIGS[generatorSettings.mapSize as MapSize]?.dimensions ?? 20000 : 20000); const terrainTileSize = isProceduralMap ? generatedMapSizeMeters / MAP_TILES : TILE_SIZE; const terrainSizeMeters = terrainTileSize * MAP_TILES; const usingStrategicBundle = strategicBundle !== null; // Determine biome from settings or use default let biome = getBiomeForTerrain(); if (strategicBundle?.recipe?.biome) { biome = strategicBundle.recipe.biome as BiomeType; } // If using procedural generation, use the biome from generator settings if (settings?.isGeneratedMap && settings.generatorSettings?.biome && settings.generatorSettings.biome !== 'random') { biome = settings.generatorSettings.biome as BiomeType; console.info('[Terrain] Using biome from generator settings:', biome); } // AAA: Get heightmap resolution from localStorage or default to 1024 const baseResolution = resolveHeightmapResolution(); let heightmapResolution = baseResolution; if (strategicBundle?.worldMetrics?.resolution) { heightmapResolution = strategicBundle.worldMetrics.resolution as HeightmapResolution; } if (generatorSettings?.mapSize) { const sizeConfig = MAP_SIZE_CONFIGS[generatorSettings.mapSize as MapSize]; const target = sizeConfig?.gridSize ?? heightmapResolution; const nearest = ALLOWED_HEIGHTMAP_RESOLUTIONS.find((option) => target <= option) ?? ALLOWED_HEIGHTMAP_RESOLUTIONS[ALLOWED_HEIGHTMAP_RESOLUTIONS.length - 1]; heightmapResolution = (heightmapResolution >= nearest ? heightmapResolution : nearest) as HeightmapResolution; } // PERFORMANCE FIX: Cap heightmap resolution at 2048 for procedural generation (18s → ~5s) // Procedural terrain doesn't benefit much from 4096 resolution, but cost increases 4x // 2048 provides excellent quality while being 4x faster than 4096 if (isProceduralMap && !usingStrategicBundle) { heightmapResolution = Math.min(heightmapResolution, 2048) as HeightmapResolution; } const forceGPUErosion = localStorage.getItem('rts.terrain.forceGPUErosion') === 'true'; const forceFullGPUErosion = localStorage.getItem('rts.terrain.forceGPUErosionFull') === 'true'; const fastGPUErosion = isProceduralMap && forceGPUErosion && !forceFullGPUErosion; // PERFORMANCE FIX: Cap erosion resolution for fast loading (60+ sec → ~10 sec) // Erosion quality doesn't benefit much from higher resolutions, but cost increases 4x per doubling const erosionResolutionCap = fastGPUErosion ? 512 : 1024; const erosionResolution = Math.min(heightmapResolution, erosionResolutionCap) as HeightmapResolution; const resolutionInfo = getHeightmapResolutionDebugInfo(); const upgradeNote = resolutionInfo.upgradeLevel > 0 && resolutionInfo.upgradeLabel && resolutionInfo.upgradeResolution ? ` + upgrade ${resolutionInfo.upgradeLevel} (${resolutionInfo.upgradeLabel} ${resolutionInfo.upgradeResolution}x${resolutionInfo.upgradeResolution})` : ''; const mapNote = heightmapResolution > resolutionInfo.effective ? ` (map target forced ${heightmapResolution})` : ''; const erosionNoteParts: string[] = []; if (erosionResolution < heightmapResolution) { erosionNoteParts.push(`erosion capped at ${erosionResolution} for speed`); } if (fastGPUErosion) { erosionNoteParts.push('fast erosion profile'); } const erosionNote = erosionNoteParts.length ? ` (${erosionNoteParts.join(', ')})` : ''; console.info( `[Terrain] Using heightmap resolution: ${heightmapResolution}x${heightmapResolution}${upgradeNote}${mapNote}${erosionNote}` ); // AAA: Check if GPU erosion is enabled (enhances terrain with realistic erosion) // PERFORMANCE FIX: Disable GPU erosion for procedural maps (they already have excellent quality) const useGPUErosion = (forceGPUErosion || !isProceduralMap) && localStorage.getItem('rts.terrain.useGPUHeightmap') !== 'false'; // Default: enabled for non-procedural maps only // NEW: Check if layered heightmap generation is enabled (default: true for hybrid approach) const layeredDisabled = localStorage.getItem('rts.terrain.disableLayeredHeightmap') === 'true'; const useLayeredHeightmap = !layeredDisabled && localStorage.getItem('rts.terrain.useLayeredHeightmap') !== 'false'; // Default: enabled // AAA Phase 1.5: Check if GPU terrain pipeline is enabled (complete GPU generation + erosion) // This is the new integrated approach that runs the full pipeline before creating Terra const useGPUTerrainPipeline = localStorage.getItem('rts.terrain.useGPUPipeline') === 'true'; // Default: disabled (experimental) const pipelineAlreadyEroded = useGPUTerrainPipeline; if (isProceduralMap && !forceGPUErosion) { console.info('[Terrain] Procedural map detected - skipping GPU erosion (already has AAA quality terrain)'); } else if (isProceduralMap && forceGPUErosion) { console.info( `[Terrain] Procedural map detected - forcing GPU erosion via rts.terrain.forceGPUErosion${fastGPUErosion ? ' (fast profile)' : ''}` ); } if (useGPUTerrainPipeline) { console.info('[Terrain] GPU terrain pipeline enabled - will use complete GPU generation + erosion'); } // Determine which map to load const mapId = settings?.mapId ?? (settings?.isGeneratedMap ? 'aaa-20km' : terrainMapPlan); lastTerrainResolveDiagnostics = { mapId, mapPlan: terrainMapPlan, biome, useLayeredHeightmap, useGPUErosion, useGPUTerrainPipeline, forceGPUErosion, heightmapResolution, erosionResolution }; const resolveLayoutSeed = (gen: MapGeneratorSettings | null): number | undefined => { if (!gen) return undefined; return Number.isFinite(gen.seed) ? gen.seed : undefined; }; const resolveLayoutSymmetry = (gen: MapGeneratorSettings | null): SymmetryGroup | undefined => { if (!gen) return undefined; if (gen.symmetry === 'random') { const options: SymmetryGroup[] = ['point', 'axis-x', 'axis-z', 'rotational']; const idx = Math.abs(gen.seed ?? 0) % options.length; return options[idx]; } if (gen.symmetry === 'axis') { return 'axis-x'; } return gen.symmetry as SymmetryGroup; }; const layoutSeed = resolveLayoutSeed(generatorSettings); const layoutPlayerCount = generatorSettings?.playerCount; const layoutSymmetry = resolveLayoutSymmetry(generatorSettings); const resourceDensity = generatorSettings?.resourceDensity; const terrainRoughness = generatorSettings?.terrainRoughness; const waterCoverage = generatorSettings?.waterCoverage; const plateauDensity = generatorSettings?.plateauDensity; const enforceSymmetry = generatorSettings?.enforceSymmetry ?? false; const baseTerraOptions = { detailSettings: TERRAIN_DETAIL_SETTINGS, biome, heightmapResolution, fastLoad: false, // DISABLED: Use full AAA terrain with SDF features (was: true for performance) ...(layoutSeed !== undefined ? { layoutSeed } : {}), ...(layoutPlayerCount !== undefined ? { layoutPlayerCount } : {}), ...(layoutSymmetry ? { layoutSymmetry } : {}), ...(resourceDensity ? { resourceDensity } : {}), ...(terrainRoughness !== undefined ? { terrainRoughness } : {}), ...(waterCoverage !== undefined ? { waterCoverage } : {}), ...(plateauDensity !== undefined ? { plateauDensity } : {}), ...(enforceSymmetry ? { enforceSymmetry } : {}) }; if (isProceduralMap && strategicBundle) { const terra = new Terra(MAP_TILES, MAP_TILES, terrainTileSize, { ...baseTerraOptions, biome: strategicBundle.recipe.biome as BiomeType, heightmapResolution: strategicBundle.worldMetrics.resolution as HeightmapResolution, strategicBundle, layoutSeed: strategicBundle.recipe.seed, layoutPlayerCount: strategicBundle.recipe.players, layoutSymmetry: strategicBundle.recipe.symmetry as SymmetryGroup, enforceSymmetry: strategicBundle.recipe.competitiveMode }); return terra; } // Handle specific authored map plans const explicitMapPlan = mapId === 'frontlinie-aegis' || terrainMapPlan === 'frontlinie-aegis' ? 'frontlinie-aegis' : mapId === 'combat-proving-grounds' || terrainMapPlan === 'combat-proving-grounds' ? 'combat-proving-grounds' : null; if (!settings?.isGeneratedMap && explicitMapPlan) { console.info('[Terrain] Loading map:', mapId); const terra = new Terra(MAP_TILES, MAP_TILES, TILE_SIZE, { mapPlan: explicitMapPlan, ...baseTerraOptions }); // NEW: Try layered heightmap first, then fall back to GPU erosion if (useLayeredHeightmap) { try { await applyLayeredHeightmapToTerrain(terra, erosionResolution, 20000); } catch (error) { console.warn('[Terrain] Layered heightmap failed, using GPU erosion:', error); if (useGPUErosion) { await applyGPUHeightmapToTerrain(terra, erosionResolution, { fast: fastGPUErosion }); } } } else if (useGPUErosion) { await applyGPUHeightmapToTerrain(terra, erosionResolution, { fast: fastGPUErosion }); } return terra; } // Handle other curated maps (crimson-wastes, frozen-tundra) // For now, these use procedural generation with biome-specific settings if (mapId === 'crimson-wastes') { console.info('[Terrain] Loading map: Crimson Wastes (Desert biome)'); const terra = new Terra(MAP_TILES, MAP_TILES, TILE_SIZE, { ...baseTerraOptions, biome: BiomeType.DESERT }); // NEW: Try layered heightmap first, then fall back to GPU erosion if (useLayeredHeightmap) { try { await applyLayeredHeightmapToTerrain(terra, erosionResolution, 20000); } catch (error) { console.warn('[Terrain] Layered heightmap failed, using GPU erosion:', error); if (useGPUErosion) { await applyGPUHeightmapToTerrain(terra, erosionResolution, { fast: fastGPUErosion }); } } } else if (useGPUErosion) { await applyGPUHeightmapToTerrain(terra, erosionResolution, { fast: fastGPUErosion }); } return terra; } if (mapId === 'frozen-tundra') { console.info('[Terrain] Loading map: Frozen Tundra (Arctic biome)'); const terra = new Terra(MAP_TILES, MAP_TILES, TILE_SIZE, { ...baseTerraOptions, biome: BiomeType.ARCTIC }); // NEW: Try layered heightmap first, then fall back to GPU erosion if (useLayeredHeightmap) { try { await applyLayeredHeightmapToTerrain(terra, erosionResolution, 20000); } catch (error) { console.warn('[Terrain] Layered heightmap failed, using GPU erosion:', error); if (useGPUErosion) { await applyGPUHeightmapToTerrain(terra, erosionResolution, { fast: fastGPUErosion }); } } } else if (useGPUErosion) { await applyGPUHeightmapToTerrain(terra, erosionResolution, { fast: fastGPUErosion }); } return terra; } if (terrainMapPlan === 'heightmap') { const resolvedUrl = heightmapUrl ?? builtinHeightmapUrl; try { console.info('Loading heightmap terrain from', resolvedUrl); return await Terra.loadHeightmapFromImage(resolvedUrl, MAP_TILES, MAP_TILES, TILE_SIZE, { normalized: true, heightScale: 48, detailSettings: TERRAIN_DETAIL_SETTINGS, heightmapResolution }); } catch (error) { console.warn('Heightmap load failed, falling back to procedural terrain.', error); const terra = new Terra(MAP_TILES, MAP_TILES, TILE_SIZE, { ...baseTerraOptions }); // NEW: Try layered heightmap first, then fall back to GPU erosion if (useLayeredHeightmap) { try { await applyLayeredHeightmapToTerrain(terra, heightmapResolution, 20000); return terra; } catch (layeredError) { console.warn('[Terrain] Layered heightmap failed, using GPU erosion:', layeredError); } } // Final fallback: GPU erosion if (useGPUErosion) { await applyGPUHeightmapToTerrain(terra, erosionResolution, { fast: fastGPUErosion }); } return terra; } } // Create Terra with initial generation console.time('[PERF] Terra construction'); let terra: Terra; // AAA Phase 1.5: Use GPU terrain pipeline if enabled if (useGPUTerrainPipeline) { console.info('[Terrain] Using GPU terrain pipeline for complete generation + erosion'); terra = await createTerraWithGPUPipeline(MAP_TILES, MAP_TILES, terrainTileSize, { ...baseTerraOptions, seed: layoutSeed }); } else { terra = new Terra(MAP_TILES, MAP_TILES, terrainTileSize, { ...baseTerraOptions }); } console.timeEnd('[PERF] Terra construction'); console.info('[Terrain] Terra construction complete, starting post-processing...'); // LIGHTWEIGHT EROSION: Apply fast thermal erosion + micro-detail to procedural maps for visual polish const shouldApplyLightweightErosion = isProceduralMap && !useGPUErosion && !pipelineAlreadyEroded; if (shouldApplyLightweightErosion) { try { console.info('[Terrain] Applying hybrid lightweight erosion (thermal + micro-detail)...'); console.time('[PERF] Hybrid lightweight erosion'); const { ThermalErosion, THERMAL_EROSION_PRESETS } = await import('./render/terrain/ThermalErosion'); const { MicroDetailEnhancer, MICRO_DETAIL_PRESETS } = await import('./render/terrain/MicroDetailEnhancer'); const deviceManager = await getGpuDeviceManager(); const device = deviceManager ? deviceManager.logicalDevice : null; if (device) { // Extract heightmap from Terra (world units) const heightmapData = (terra as any).extractHeightmapData(heightmapResolution); // Normalize to 0..1 for GPU erosion/detail (these shaders assume normalized heights) let hMin = Infinity; let hMax = -Infinity; for (let i = 0; i < heightmapData.length; i++) { const v = heightmapData[i]; if (v < hMin) hMin = v; if (v > hMax) hMax = v; } if (!Number.isFinite(hMin) || !Number.isFinite(hMax)) { hMin = 0; hMax = 1; } const hSpan = Math.max(1e-5, hMax - hMin); const normalizedHeightmap = new Float32Array(heightmapData.length); for (let i = 0; i < heightmapData.length; i++) { normalizedHeightmap[i] = (heightmapData[i] - hMin) / hSpan; } // Step 1: Apply ultra-fast thermal erosion (2 iterations) console.time('[PERF] Thermal erosion'); const thermal = new ThermalErosion(device); const polished = await thermal.erode( normalizedHeightmap, heightmapResolution, THERMAL_EROSION_PRESETS.PROCEDURAL_POLISH ); console.timeEnd('[PERF] Thermal erosion'); // Step 2: Add micro-detail noise for weathering patterns console.time('[PERF] Micro-detail enhancement'); const enhancer = new MicroDetailEnhancer(device); const detailed = await enhancer.enhance( polished, heightmapResolution, MICRO_DETAIL_PRESETS.PROCEDURAL_WEATHERING ); console.timeEnd('[PERF] Micro-detail enhancement'); // Rescale back to world units and clamp to the original range const rescaled = new Float32Array(detailed.length); for (let i = 0; i < detailed.length; i++) { const v = Math.max(0, Math.min(1, detailed[i])); rescaled[i] = hMin + v * hSpan; } // Apply enhanced heightmap back to Terra terra.applyGPUHeightmap(rescaled, heightmapResolution); console.timeEnd('[PERF] Hybrid lightweight erosion'); console.info('[Terrain] Hybrid erosion complete - added weathering + micro-detail'); } else { console.warn('[Terrain] GPU device not available, skipping lightweight erosion'); } } catch (error) { console.warn('[Terrain] Lightweight erosion failed, continuing without it:', error); } } // NEW: Try layered heightmap generation first (hybrid approach) // PERFORMANCE FIX: Skip layered heightmap for procedural maps - they already have AAA terrain! // The layered heightmap would just overwrite the expensive AAA terrain we just generated. if (useLayeredHeightmap && !isProceduralMap) { try { // Determine map size from generator settings or use default const mapSize = generatorSettings?.mapSize ? MAP_SIZE_CONFIGS[generatorSettings.mapSize as MapSize]?.dimensions ?? terrainSizeMeters : terrainSizeMeters; console.info(`[Terrain] Using layered heightmap generation (hybrid mode) for ${mapSize}m map`); console.time('[PERF] Layered heightmap generation'); await applyLayeredHeightmapToTerrain(terra, erosionResolution, mapSize); console.timeEnd('[PERF] Layered heightmap generation'); // Optionally apply GPU erosion on top for additional realism if (useGPUErosion) { console.info('[Terrain] Applying GPU erosion to layered terrain for additional detail...'); console.time('[PERF] GPU erosion on layered terrain'); await applyGPUHeightmapToTerrain(terra, erosionResolution, { fast: fastGPUErosion }); console.timeEnd('[PERF] GPU erosion on layered terrain'); } console.info('[Terrain] Layered heightmap complete, returning terra'); return terra; } catch (error) { console.warn('[Terrain] Layered heightmap generation failed, falling back to GPU erosion:', error); // Fall through to GPU erosion fallback } } else if (isProceduralMap) { console.info('[Terrain] Skipping layered heightmap for procedural map (already has AAA terrain)'); } // Fallback: Apply GPU erosion to enhance the procedural terrain if (useGPUErosion && !pipelineAlreadyEroded) { console.info('[Terrain] Applying GPU erosion (fallback mode)...'); console.time('[PERF] GPU erosion fallback'); await applyGPUHeightmapToTerrain(terra, erosionResolution, { fast: fastGPUErosion }); console.timeEnd('[PERF] GPU erosion fallback'); } else if (pipelineAlreadyEroded && useGPUErosion) { console.info('[Terrain] Skipping GPU erosion fallback (GPU pipeline already includes erosion)'); } console.info('[Terrain] Terrain generation complete, returning terra'); return terra; }; const normalizeMatchSettings = (settings?: MatchSettings): MatchSettings | undefined => { if (!settings) { return settings; } let maxPlayers = settings.maxPlayers; if (settings.isGeneratedMap) { if (settings.generatorSettings?.playerCount) { maxPlayers = settings.generatorSettings.playerCount; } } else if (settings.mapId) { const meta = getMapMetadata(settings.mapId); if (meta?.playerCount?.length) { maxPlayers = Math.max(...meta.playerCount); } } const next: MatchSettings = { ...settings, maxPlayers, playerSlots: [...settings.playerSlots] }; if (next.playerSlots.length > maxPlayers) { next.playerSlots = next.playerSlots.slice(0, maxPlayers); } return next; }; const initializePlayersFromSettings = (sim: Sim, settings?: MatchSettings): void => { if (!settings?.playerSlots?.length) { return; } const activeSlots = settings.playerSlots.filter((slot) => !slot.isOpen).map((slot) => ({ ...slot })); if (!activeSlots.length) { return; } const ensureOpponentSlot = (): void => { const existingTeams = new Set(activeSlots.map((slot) => slot.team)); if (activeSlots.length >= 2 && existingTeams.size >= 2) { return; } const first = activeSlots[0]; const nextSlotId = activeSlots.reduce((max, slot) => Math.max(max, slot.slotId), -1) + 1; const fallbackTeam = ((first?.team ?? 0) + 1) % Math.max(2, settings.maxPlayers ?? 2); activeSlots.push({ slotId: nextSlotId, playerName: 'AI Opponent 1', team: fallbackTeam, faction: first?.faction ?? 'UEF', color: PLAYER_COLORS[1 % PLAYER_COLORS.length], isAI: true, aiDifficulty: 'medium', isOpen: false }); console.warn('[Bootstrap] Injected fallback AI opponent to prevent single-team instant victory.'); }; ensureOpponentSlot(); const maxPlayers = Math.min(settings.maxPlayers ?? activeSlots.length, activeSlots.length); const selectedSlots = activeSlots.slice(0, maxPlayers); const seed = settings.generatorSettings?.seed ?? 0; const playerConfigs = selectedSlots.map((slot) => ({ slotId: slot.slotId, name: slot.playerName, teamId: slot.team, color: slot.color, faction: slot.faction, isAI: slot.isAI, aiDifficulty: slot.aiDifficulty })); const localSlotId = selectedSlots.find((slot) => !slot.isAI)?.slotId ?? selectedSlots[0]?.slotId; if (typeof sim.configurePlayers !== 'function' || typeof sim.getPlayers !== 'function') { console.error( '[Bootstrap] Sim API mismatch (configurePlayers/getPlayers missing). ' + 'Rebuild shared and restart the dev server.' ); return; } sim.configurePlayers(playerConfigs, { localSlotId, shareResources: settings.shareResources, shareVision: settings.shareVision }); const playerIds = sim.getPlayers().sort((a, b) => a.id - b.id).map((player) => player.id); const assignments = sim.assignSpawns(playerIds, { randomize: settings.randomSpawns, seed }); if (!assignments.length) { return; } const validation = sim.validateSpawnAssignments(assignments); if (!validation.ok) { console.warn('[Spawn] Some player spawns failed validation:', validation.issues); } const localPlayer = sim.getLocalPlayer(); const localPlayerId = localPlayer?.id ?? null; const localTeamId = localPlayer?.teamId ?? null; const playersById = new Map(sim.getPlayers().map((player) => [player.id, player])); const offset = sim.terra.tileSize * 6; // ========== SPAWN COMMANDERS AT STARTING POSITIONS ========== console.info('[Bootstrap] Spawning Commander units at starting positions...'); for (const assignment of assignments) { const player = playersById.get(assignment.playerId); if (!player) { continue; } const spawn = assignment.spawn; const isLocalPlayer = player.id === localPlayerId; const isAllied = localTeamId != null && player.teamId === localTeamId; const owner = isLocalPlayer || isAllied ? 'player' : 'enemy'; const faction = owner; // Spawn Commander (ACU) at starting position const commanderId = sim.units.spawn( 'Commander', spawn.x, spawn.z, undefined, faction, player.id, player.teamId ); console.info(`[Bootstrap] Spawned Commander for ${player.name} (ID: ${player.id}, Team: ${player.teamId}) at (${spawn.x.toFixed(1)}, ${spawn.z.toFixed(1)})`); // OPTIONAL: Spawn some starting units around the commander // Uncomment if you want starting units in addition to the commander /* sim.units.spawn( 'Engineer', spawn.x - offset * 0.3, spawn.z + offset * 0.3, undefined, faction, player.id, player.teamId ); sim.units.spawn( 'Combat', spawn.x + offset * 0.3, spawn.z - offset * 0.4, undefined, faction, player.id, player.teamId ); */ } console.info(`[Bootstrap] Spawned ${assignments.length} Commander units`); }; /** * Generate layered heightmap and apply to Terra * Uses the new hybrid approach (layered structure + noise detail) */ async function applyLayeredHeightmapToTerrain(terra: Terra, resolution: number, mapSize: number): Promise { try { const targetResolution = Math.min(resolution, MAX_LAYERED_HEIGHTMAP_RESOLUTION) as HeightmapResolution; if (targetResolution !== resolution) { console.warn( `[Layered Terrain] Requested ${resolution}x${resolution} but capping to ` + `${targetResolution}x${targetResolution} for hybrid generation. ` + `Lower the heightmap upgrade level if you need faster loads.` ); } console.info(`[Layered Terrain] Generating hybrid heightmap (${targetResolution}x${targetResolution})...`); const startTime = performance.now(); // Import the layered terrain generation modules // Note: These will be available after building the shared module const terrainHelpers = await import('@shared/terrain'); const generateLayeredHeightmap = (terrainHelpers as any).generateLayeredHeightmap; const addOrganicVariation = (terrainHelpers as any).addOrganicVariation; // Get seed from Terra const terraAny = terra as any; const seed = terraAny.getLayoutSeed?.() || Math.floor(Math.random() * 1000000); // Generate layered base heightmap const heightmap = generateLayeredHeightmap({ mapSize, resolution: targetResolution, seaLevel: 0, seed, debug: false // Set to true for layer-by-layer debugging }); // IMPROVED: Add stronger organic noise detail for natural look and elevation changes const variationOptions = getOrganicVariationOptionsForUpgrade(getHeightmapUpgradeLevel()); addOrganicVariation(heightmap, targetResolution, targetResolution, seed + 2000, variationOptions); const genTime = performance.now() - startTime; console.info(`[Layered Terrain] Hybrid heightmap generated in ${genTime.toFixed(2)}ms`); // Apply the heightmap to Terra terraAny.applyGPUHeightmap(heightmap, targetResolution); const totalTime = performance.now() - startTime; console.info( `[Layered Terrain] ✅ Hybrid terrain applied (${totalTime.toFixed(2)}ms total @ ${targetResolution}x${targetResolution})` ); } catch (error) { console.error('[Layered Terrain] Failed to generate layered heightmap:', error); throw error; // Re-throw to trigger fallback } } /** * AAA: Apply GPU erosion to existing Terra heightmap * This enhances the strategic terrain layout with realistic erosion */ // If GPU erosion ever fails once (adapter lost, unsupported), skip subsequent attempts for this session let gpuErosionFailedOnce = false; async function applyGPUHeightmapToTerrain( terra: Terra, resolution: number, options?: { fast?: boolean } ): Promise { if (gpuErosionFailedOnce) { console.warn('[AAA Terrain] GPU erosion previously failed; skipping for this session'); return; } try { // Wait for GPU device to be available const gpuDeviceManager = await getGpuDeviceManager(); if (!gpuDeviceManager) { console.warn('[AAA Terrain] WebGPU not available, skipping erosion'); return; } // Check if device is lost (await the promise) const lostInfo = await gpuDeviceManager.logicalDevice.lost; if (lostInfo) { console.warn('[AAA Terrain] WebGPU device is lost, skipping erosion'); return; } const { getBiomeAdjustedErosionConfig, scaleHydraulicParams, scaleThermalParams } = await import('./render/terrain/ErosionConfig'); const { getHeightmapCache } = await import('./render/terrain/HeightmapCache'); const { HydraulicErosion, EROSION_PRESETS } = await import('./render/terrain/HydraulicErosion'); const { ThermalErosion, THERMAL_EROSION_PRESETS } = await import('./render/terrain/ThermalErosion'); const fastMode = options?.fast ?? false; const erosionQuality: 'fast' | 'full' = fastMode ? 'fast' : 'full'; const erosionSmoothRadius = fastMode ? 1 : 2; // Get biome from Terra const terraAny = terra as any; const biome = terraAny.biome || null; const erosionConfig = getBiomeAdjustedErosionConfig(biome); // Skip erosion if intensity is 'none' if (erosionConfig.intensity === 'none') { console.info('[AAA Terrain] Erosion disabled (intensity: none)'); return; } // Log biome-specific erosion if (biome) { console.info(`[AAA Terrain] Biome: ${biome} (hydraulic: ${erosionConfig.biomeModifiers.hydraulic}x, thermal: ${erosionConfig.biomeModifiers.thermal}x)`); } // Check cache first const cache = getHeightmapCache(); const cacheKey = { resolution, erosionIntensity: erosionConfig.intensity, mapPlan: terraAny.getMapPlan?.() || 'default', seed: terraAny.getLayoutSeed?.() || 0, layoutVersion: 5, biome: biome || 'none', mapSize: (terraAny.width ?? 0) * (terraAny.tileSize ?? 0), quality: erosionQuality }; const cachedEntry = cache.get(cacheKey); if (cachedEntry) { console.info(`[AAA Terrain] Using cached eroded heightmap (${erosionQuality})`); let normalMap = cachedEntry.normalMap; let erosionAnalysis = cachedEntry.erosionAnalysis; // Regenerate derived maps for older cache entries if needed (skip in fast mode) if (!fastMode && (!normalMap || !erosionAnalysis)) { const baseHeightmap = extractAndUpsampleHeightmap(terra, resolution); const normalizeHeightmap = (data: Float32Array): Float32Array => { let min = Infinity; let max = -Infinity; for (let i = 0; i < data.length; i++) { const v = data[i]; if (v < min) min = v; if (v > max) max = v; } const span = Math.max(1e-5, max - min); const normalized = new Float32Array(data.length); for (let i = 0; i < data.length; i++) { normalized[i] = (data[i] - min) / span; } return normalized; }; const { NormalMapGenerator } = await import('./render/terrain/NormalMapGenerator'); const normalGen = new NormalMapGenerator(gpuDeviceManager.logicalDevice); normalMap = await normalGen.generateNormalMap(normalizeHeightmap(cachedEntry.heightmap), resolution, 0.5); normalGen.destroy(); const { ErosionAnalyzer } = await import('./render/terrain/ErosionAnalyzer'); erosionAnalysis = ErosionAnalyzer.analyze(baseHeightmap, cachedEntry.heightmap, resolution); erosionAnalysis.erosionMap = ErosionAnalyzer.smoothMap(erosionAnalysis.erosionMap, resolution, erosionSmoothRadius); erosionAnalysis.depositionMap = ErosionAnalyzer.smoothMap(erosionAnalysis.depositionMap, resolution, erosionSmoothRadius); erosionAnalysis.slopeMap = ErosionAnalyzer.smoothMap(erosionAnalysis.slopeMap, resolution, 1); } terraAny.applyGPUHeightmap(cachedEntry.heightmap, resolution); if (normalMap) { terraAny.gpuNormalMap = normalMap; terraAny.gpuNormalMapResolution = resolution; } if (erosionAnalysis) { terraAny.gpuErosionAnalysis = erosionAnalysis; } cache.set(cacheKey, { heightmap: cachedEntry.heightmap, normalMap: normalMap ?? undefined, erosionAnalysis: erosionAnalysis ?? undefined, resolution }); console.info(`[AAA Terrain] Cached erosion (${erosionConfig.intensity}) applied to terrain`); return; } console.info(`[AAA Terrain] Applying GPU erosion (${erosionConfig.intensity}, ${erosionQuality}) to terrain (${resolution}x${resolution})...`); const thermal = new ThermalErosion(gpuDeviceManager.logicalDevice); const startTime = performance.now(); // Step 1: Extract current heightmap from Terra and upsample to target resolution (row-major) const currentHeightmap = extractAndUpsampleHeightmap(terra, resolution); let minHeight = Infinity; let maxHeight = -Infinity; for (let i = 0; i < currentHeightmap.length; i++) { const v = currentHeightmap[i]; if (v < minHeight) minHeight = v; if (v > maxHeight) maxHeight = v; } if (!Number.isFinite(minHeight) || !Number.isFinite(maxHeight)) { minHeight = 0; maxHeight = 1; } const baselineRange = terraAny._baselineHeightRange ?? terraAny._aaaHeightRange ?? terra.getHeightRange(); terraAny._baselineHeightRange = baselineRange; let baselineMin = baselineRange.minHeight; let baselineMax = baselineRange.maxHeight; const baselineSpan = Math.max(0.0001, baselineMax - baselineMin); if (!Number.isFinite(baselineMin) || !Number.isFinite(baselineMax) || baselineSpan > 1000) { // DRAMATIC TERRAIN: Default to full AAA range instead of 0-220 baselineMin = -50; baselineMax = 300; } const currentSpan = Math.max(0.0001, maxHeight - minHeight); let sourceHeightmap = currentHeightmap; // DRAMATIC TERRAIN: Allow up to 400m span for dramatic mountains and valleys if (currentSpan > 400) { console.warn('[AAA Terrain] Height span out of bounds; clamping to baseline range.', { currentMin: minHeight, currentMax: maxHeight, currentSpan: currentSpan.toFixed(2), baselineMin, baselineMax }); minHeight = baselineMin; maxHeight = baselineMax; sourceHeightmap = new Float32Array(currentHeightmap.length); for (let i = 0; i < currentHeightmap.length; i++) { sourceHeightmap[i] = clampValue(currentHeightmap[i], minHeight, maxHeight); } } else if (baselineSpan <= 400 && baselineMin >= -50 && baselineMax <= 300) { // DRAMATIC TERRAIN: Accept full AAA range (-50 to 300m) minHeight = baselineMin; maxHeight = baselineMax; } const span = Math.max(1e-5, maxHeight - minHeight); const normalizedCurrent = new Float32Array(currentHeightmap.length); for (let i = 0; i < sourceHeightmap.length; i++) { normalizedCurrent[i] = clamp01((sourceHeightmap[i] - minHeight) / span); } const normalizedBase = boxBlurHeightmap(normalizedCurrent, resolution, 1, 1); let erodedHeightmap: Float32Array; let finalNormalized: Float32Array; if (fastMode) { // FAST PATH: Thermal-only + simple blend (skips hydraulic + slope analysis) const baseThermalPreset = THERMAL_EROSION_PRESETS[erosionConfig.thermalPreset]; const thermalPreset = scaleThermalParams(baseThermalPreset, erosionConfig.biomeModifiers.thermal); thermalPreset.iterations = Math.max(1, Math.floor(thermalPreset.iterations * 0.35)); thermalPreset.transferRate = Math.min(thermalPreset.transferRate, 0.45); console.info('[AAA Terrain] Fast erosion path: thermal-only blend'); erodedHeightmap = await thermal.erode(normalizedBase, resolution, thermalPreset); const blend = Math.min(0.35, erosionConfig.blendFactor * 0.6); finalNormalized = new Float32Array(resolution * resolution); for (let i = 0; i < finalNormalized.length; i++) { finalNormalized[i] = normalizedBase[i] * (1.0 - blend) + erodedHeightmap[i] * blend; } } else { // Step 2: Apply hydraulic erosion (valleys, drainage) with biome scaling const hydraulic = new HydraulicErosion(gpuDeviceManager.logicalDevice); const baseHydraulicPreset = EROSION_PRESETS[erosionConfig.hydraulicPreset]; const hydraulicPreset = scaleHydraulicParams(baseHydraulicPreset, erosionConfig.biomeModifiers.hydraulic); const hydraulicEroded = await hydraulic.erode(normalizedBase, resolution, hydraulicPreset); // Step 3: Apply thermal erosion (slope collapse, talus) with biome scaling const baseThermalPreset = THERMAL_EROSION_PRESETS[erosionConfig.thermalPreset]; const thermalPreset = scaleThermalParams(baseThermalPreset, erosionConfig.biomeModifiers.thermal); erodedHeightmap = await thermal.erode(hydraulicEroded, resolution, thermalPreset); // Step 3.5: Blend eroded heightmap with original to preserve macro features const blendFactor = erosionConfig.blendFactor; finalNormalized = new Float32Array(resolution * resolution); const slopeMask = new Float32Array(finalNormalized.length); const concavityMask = new Float32Array(finalNormalized.length); const smoothstepLocal = (edge0: number, edge1: number, x: number): number => { const t = clamp01((x - edge0) / Math.max(1e-5, edge1 - edge0)); return t * t * (3 - 2 * t); }; for (let y = 0; y < resolution; y++) { const yBase = y * resolution; const yN = Math.max(0, y - 1) * resolution; const yS = Math.min(resolution - 1, y + 1) * resolution; for (let x = 0; x < resolution; x++) { const xW = Math.max(0, x - 1); const xE = Math.min(resolution - 1, x + 1); const hL = normalizedBase[yBase + xW]; const hR = normalizedBase[yBase + xE]; const hN = normalizedBase[yN + x]; const hS = normalizedBase[yS + x]; const slope = Math.max(Math.abs(hR - hL), Math.abs(hS - hN)); slopeMask[yBase + x] = smoothstepLocal(0.01, 0.06, slope); const hC = normalizedBase[yBase + x]; const avg = (hL + hR + hN + hS) * 0.25; const concavity = clamp01((avg - hC) / 0.03); concavityMask[yBase + x] = concavity; } } for (let i = 0; i < finalNormalized.length; i++) { const valleyBoost = clamp01(slopeMask[i] * 0.75 + concavityMask[i] * 0.65); const localBlend = lerpValue(0.15, blendFactor, valleyBoost); // Linear interpolation: result = original * (1 - blend) + eroded * blend finalNormalized[i] = normalizedBase[i] * (1.0 - localBlend) + erodedHeightmap[i] * localBlend; } console.info(`[AAA Terrain] Blended erosion with original (blend: ${(blendFactor * 100).toFixed(0)}%)`); } const fineDenoise = boxBlurHeightmap(finalNormalized, resolution, 1, 1); const macroRadius = fastMode ? 1 : 2; const macroDenoise = boxBlurHeightmap(finalNormalized, resolution, macroRadius, 1); for (let i = 0; i < finalNormalized.length; i++) { // Keep more of the eroded signal; use lighter blurs for detail retention finalNormalized[i] = finalNormalized[i] * 0.75 + fineDenoise[i] * 0.15 + macroDenoise[i] * 0.1; } let normalizedMin = Infinity; let normalizedMax = -Infinity; for (let i = 0; i < finalNormalized.length; i++) { const value = finalNormalized[i]; normalizedMin = Math.min(normalizedMin, value); normalizedMax = Math.max(normalizedMax, value); } const normalizedSpan = Math.max(1e-5, normalizedMax - normalizedMin); for (let i = 0; i < finalNormalized.length; i++) { finalNormalized[i] = clamp01((finalNormalized[i] - normalizedMin) / normalizedSpan); } const finalHeightmap = new Float32Array(finalNormalized.length); let finalMin = Infinity; let finalMax = -Infinity; for (let i = 0; i < finalNormalized.length; i++) { const value = minHeight + finalNormalized[i] * span; const clamped = clampValue(value, minHeight, maxHeight); finalHeightmap[i] = clamped; finalMin = Math.min(finalMin, clamped); finalMax = Math.max(finalMax, clamped); } const finalSpan = finalMax - finalMin; const preSpan = maxHeight - minHeight; let rmsDiff = 0; for (let i = 0; i < currentHeightmap.length; i++) { const delta = finalHeightmap[i] - currentHeightmap[i]; rmsDiff += delta * delta; } rmsDiff = Math.sqrt(rmsDiff / Math.max(1, currentHeightmap.length)); if (finalSpan < 0.5) { console.warn('[AAA Terrain] Erosion collapsed to near-constant height; skipping application'); return; } const baselineSpanActual = Math.max(0.0001, baselineMax - baselineMin); const baselineRangeLabel = `${baselineMin.toFixed(2)}..${baselineMax.toFixed(2)}`; console.info( `[AAA Terrain] Candidate erosion range: ${finalMin.toFixed(2)}..${finalMax.toFixed(2)} (span ${finalSpan.toFixed(2)}) vs baseline ${baselineRangeLabel} (span ${baselineSpanActual.toFixed(2)})` ); if (finalSpan < baselineSpanActual * 0.65) { console.warn('[AAA Terrain] Erosion produced a tighter span than baseline; keeping original terrain'); return; } console.info('[AAA Terrain] Erosion debug stats:', { preMin: minHeight.toFixed(2), preMax: maxHeight.toFixed(2), preSpan: preSpan.toFixed(2), postMin: finalMin.toFixed(2), postMax: finalMax.toFixed(2), postSpan: finalSpan.toFixed(2), spanGain: (finalSpan - preSpan).toFixed(2), rmsDiff: rmsDiff.toFixed(3) }); let normalMap: Float32Array | undefined; let erosionAnalysis: | { erosionMap: Float32Array; depositionMap: Float32Array; slopeMap: Float32Array } | undefined; if (!fastMode) { // Step 4: Generate high-quality normal map from eroded heightmap const { NormalMapGenerator } = await import('./render/terrain/NormalMapGenerator'); const normalGen = new NormalMapGenerator(gpuDeviceManager.logicalDevice); normalMap = await normalGen.generateNormalMap(finalNormalized, resolution, 1.25); normalGen.destroy(); // Step 5: Analyze erosion to generate erosion/deposition maps // Use the fully eroded heightmap for analysis (before blending) to get accurate erosion data const { ErosionAnalyzer } = await import('./render/terrain/ErosionAnalyzer'); const erodedWorld = new Float32Array(erodedHeightmap.length); for (let i = 0; i < erodedHeightmap.length; i++) { erodedWorld[i] = minHeight + erodedHeightmap[i] * span; } erosionAnalysis = ErosionAnalyzer.analyze(currentHeightmap, erodedWorld, resolution); // Smooth the maps for better texture blending erosionAnalysis.erosionMap = ErosionAnalyzer.smoothMap(erosionAnalysis.erosionMap, resolution, erosionSmoothRadius); erosionAnalysis.depositionMap = ErosionAnalyzer.smoothMap(erosionAnalysis.depositionMap, resolution, erosionSmoothRadius); erosionAnalysis.slopeMap = ErosionAnalyzer.smoothMap(erosionAnalysis.slopeMap, resolution, 1); } const endTime = performance.now(); console.info(`[AAA Terrain] Erosion complete in ${(endTime - startTime).toFixed(2)}ms`); // Cache the result cache.set(cacheKey, { heightmap: finalHeightmap, normalMap, erosionAnalysis, resolution }); // Apply eroded heightmap back to Terra terraAny.applyGPUHeightmap(finalHeightmap, resolution); // Store normal map and erosion data in Terra for later application if (normalMap) { terraAny.gpuNormalMap = normalMap; terraAny.gpuNormalMapResolution = resolution; } if (erosionAnalysis) { terraAny.gpuErosionAnalysis = erosionAnalysis; } console.info('[AAA Terrain] Normal map and erosion data stored in Terra (will be applied when renderer is created)'); console.info(`[AAA Terrain] ✅ GPU erosion (${erosionConfig.intensity}) applied to terrain`); } catch (error) { gpuErosionFailedOnce = true; const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error); console.warn('[AAA Terrain] Failed to apply GPU erosion, disabling for this session:', message); console.warn('[AAA Terrain] Continuing with original terrain'); } } /** * Update terrain renderer with erosion splatting maps */ function updateTerrainRendererErosionSplatting( erosionAnalysis: { erosionMap: Float32Array; depositionMap: Float32Array; slopeMap: Float32Array }, resolution: number ): void { try { const terrainRenderer = (window.__RTS as any).terrainRenderer; if (!terrainRenderer) { console.warn('[AAA Terrain] Terrain renderer not found, cannot apply erosion splatting'); return; } // Helper to create texture from Float32Array const createTextureFromFloat = (data: Float32Array, res: number): THREE.Texture => { // Convert to RGBA format (grayscale in R channel) const rgba = new Uint8Array(res * res * 4); for (let i = 0; i < res * res; i++) { const value = Math.floor(data[i] * 255); rgba[i * 4 + 0] = value; // R rgba[i * 4 + 1] = value; // G rgba[i * 4 + 2] = value; // B rgba[i * 4 + 3] = 255; // A } const texture = new THREE.DataTexture(rgba, res, res, THREE.RGBAFormat, THREE.UnsignedByteType); texture.needsUpdate = true; texture.minFilter = THREE.LinearFilter; texture.magFilter = THREE.LinearFilter; texture.wrapS = THREE.ClampToEdgeWrapping; texture.wrapT = THREE.ClampToEdgeWrapping; return texture; }; // Create textures const erosionTexture = createTextureFromFloat(erosionAnalysis.erosionMap, resolution); const depositionTexture = createTextureFromFloat(erosionAnalysis.depositionMap, resolution); const slopeTexture = createTextureFromFloat(erosionAnalysis.slopeMap, resolution); // Update terrain renderer terrainRenderer.updateErosionSplatting(erosionTexture, depositionTexture, slopeTexture); console.info('[AAA Terrain] Erosion splatting maps applied to terrain renderer'); } catch (error) { console.error('[AAA Terrain] Failed to apply erosion splatting:', error); } } /** * Update terrain renderer with GPU-generated normal map */ function updateTerrainRendererNormalMap(normalMap: Float32Array, resolution: number): void { try { const terrainRenderer = (window.__RTS as any).terrainRenderer; if (!terrainRenderer) { console.warn('[AAA Terrain] Terrain renderer not found, cannot apply normal map'); return; } // Convert Float32Array to Uint8Array for THREE.js DataTexture (RGBA format) const normalMapRGBA = new Uint8Array(resolution * resolution * 4); for (let i = 0; i < resolution * resolution; i++) { const srcIdx = i * 3; const dstIdx = i * 4; // Convert from [-1, 1] to [0, 255] normalMapRGBA[dstIdx + 0] = Math.floor((normalMap[srcIdx + 0] * 0.5 + 0.5) * 255); normalMapRGBA[dstIdx + 1] = Math.floor((normalMap[srcIdx + 1] * 0.5 + 0.5) * 255); normalMapRGBA[dstIdx + 2] = Math.floor((normalMap[srcIdx + 2] * 0.5 + 0.5) * 255); normalMapRGBA[dstIdx + 3] = 255; // Alpha } // Create THREE.js DataTexture const normalTexture = new THREE.DataTexture( normalMapRGBA, resolution, resolution, THREE.RGBAFormat, THREE.UnsignedByteType ); normalTexture.needsUpdate = true; normalTexture.wrapS = THREE.ClampToEdgeWrapping; normalTexture.wrapT = THREE.ClampToEdgeWrapping; normalTexture.minFilter = THREE.LinearFilter; normalTexture.magFilter = THREE.LinearFilter; // Update terrain renderer with normal map terrainRenderer.updateTerrainNormalMap(normalTexture, 1.0); console.info('[AAA Terrain] Normal map applied to terrain renderer'); } catch (error) { console.error('[AAA Terrain] Failed to update terrain normal map:', error); } } /** * Extract heightmap from Terra and upsample to target resolution */ function extractAndUpsampleHeightmap(terra: Terra, targetResolution: number): Float32Array { const terraAny = terra as any; const gridWidth = terraAny.width; const gridHeight = terraAny.height; const heights = terraAny.heights as Float32Array; const upsampled = new Float32Array(targetResolution * targetResolution); for (let y = 0; y < targetResolution; y++) { for (let x = 0; x < targetResolution; x++) { // Map target coordinates to source grid const u = x / (targetResolution - 1); const v = y / (targetResolution - 1); const srcX = u * (gridWidth - 1); const srcY = v * (gridHeight - 1); const x0 = Math.floor(srcX); const y0 = Math.floor(srcY); const x1 = Math.min(x0 + 1, gridWidth - 1); const y1 = Math.min(y0 + 1, gridHeight - 1); const fx = srcX - x0; const fy = srcY - y0; // Bilinear interpolation const h00 = heights[x0 * gridHeight + y0]; const h10 = heights[x1 * gridHeight + y0]; const h01 = heights[x0 * gridHeight + y1]; const h11 = heights[x1 * gridHeight + y1]; const h0 = h00 * (1 - fx) + h10 * fx; const h1 = h01 * (1 - fx) + h11 * fx; const height = h0 * (1 - fy) + h1 * fy; upsampled[y * targetResolution + x] = height; } } return upsampled; } const boxBlurHeightmap = ( heightmap: Float32Array, resolution: number, radius: number, iterations: number ): Float32Array => { if (radius <= 0 || iterations <= 0) { return new Float32Array(heightmap) as Float32Array; } let source: Float32Array = heightmap; let target: Float32Array = new Float32Array(heightmap.length) as Float32Array; const span = radius * 2 + 1; const kernelArea = span * span; for (let pass = 0; pass < iterations; pass++) { for (let y = 0; y < resolution; y++) { const yMin = Math.max(0, y - radius); const yMax = Math.min(resolution - 1, y + radius); for (let x = 0; x < resolution; x++) { const xMin = Math.max(0, x - radius); const xMax = Math.min(resolution - 1, x + radius); let sum = 0; for (let yy = yMin; yy <= yMax; yy++) { const base = yy * resolution; for (let xx = xMin; xx <= xMax; xx++) { sum += source[base + xx]; } } const count = (xMax - xMin + 1) * (yMax - yMin + 1); target[y * resolution + x] = sum / (count || kernelArea); } } if (pass < iterations - 1) { const swap = source; source = target; target = swap === heightmap ? (new Float32Array(heightmap.length) as Float32Array) : swap; } } return target; }; const clampValue = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value)); const lerpValue = (a: number, b: number, t: number): number => a + (b - a) * t; const getTerrainColor = (terrainType: TerrainType, isRamp: boolean = false): THREE.Color => { let color: THREE.Color; switch (terrainType) { case TerrainType.GRASS: color = new THREE.Color(0x4a7c3a); break; case TerrainType.ROCK: color = new THREE.Color(0x808080); break; case TerrainType.SAND: color = new THREE.Color(0xc9b882); break; case TerrainType.DIRT: color = new THREE.Color(0x6b5638); break; default: color = new THREE.Color(0x4a7c3a); } if (isRamp) { color.multiplyScalar(1.2); } return color; }; type BiomePalette = { primary: THREE.Color; secondary: THREE.Color; accent: THREE.Color; }; const buildBiomePalette = (biomeType: BiomeType): BiomePalette => { const config = BIOME_CONFIGS[biomeType] ?? BIOME_CONFIGS[BiomeType.TEMPERATE]; return { primary: new THREE.Color(config.colorPalette.primary), secondary: new THREE.Color(config.colorPalette.secondary), accent: new THREE.Color(config.colorPalette.accent) }; }; // WebGL to WebGPU projection matrix conversion // WebGL uses Z range [-1, 1], WebGPU uses [0, 1] // This matrix transforms: Z_webgpu = Z_webgl * 0.5 + 0.5 const WEBGL_TO_WEBGPU_PROJECTION = new THREE.Matrix4().set( 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0.5, 0.5, 0, 0, 0, 1 ); const captureCameraSnapshot = (camera: THREE.PerspectiveCamera): CameraSnapshot => { camera.updateMatrixWorld(); camera.updateProjectionMatrix(); // Convert Three.js projection matrix (WebGL) to WebGPU coordinate system const webgpuProjectionMatrix = new THREE.Matrix4().multiplyMatrices( WEBGL_TO_WEBGPU_PROJECTION, camera.projectionMatrix ); const viewProjMatrix = new THREE.Matrix4().multiplyMatrices( webgpuProjectionMatrix, camera.matrixWorldInverse ); return { viewMatrix: new Float32Array(camera.matrixWorldInverse.elements), projectionMatrix: new Float32Array(webgpuProjectionMatrix.elements), viewProjectionMatrix: new Float32Array(viewProjMatrix.elements), position: new Float32Array([camera.position.x, camera.position.y, camera.position.z]) }; }; let lightingSnapshotLogOnce = false; const createLightingSnapshot = (state: LightingState): LightingSnapshot => { // 🔍 DIAGNOSTIC: Log lighting values once if (!lightingSnapshotLogOnce) { console.info('[createLightingSnapshot] First snapshot - LightingState:', { sunDirection: `(${state.sunDirection.x.toFixed(3)}, ${state.sunDirection.y.toFixed(3)}, ${state.sunDirection.z.toFixed(3)})`, sunColor: `(${state.sunColor.r.toFixed(3)}, ${state.sunColor.g.toFixed(3)}, ${state.sunColor.b.toFixed(3)})`, ambientColor: `(${state.ambientColor.r.toFixed(3)}, ${state.ambientColor.g.toFixed(3)}, ${state.ambientColor.b.toFixed(3)})`, skyColor: `(${state.skyColor.r.toFixed(3)}, ${state.skyColor.g.toFixed(3)}, ${state.skyColor.b.toFixed(3)})` }); lightingSnapshotLogOnce = true; } return { sunDirection: new Float32Array([state.sunDirection.x, state.sunDirection.y, state.sunDirection.z]), sunColor: new Float32Array([state.sunColor.r, state.sunColor.g, state.sunColor.b]), ambientColor: new Float32Array([state.ambientColor.r, state.ambientColor.g, state.ambientColor.b]), skyColor: new Float32Array([state.skyColor.r, state.skyColor.g, state.skyColor.b]), hemisphereGroundColor: new Float32Array([ state.hemisphereGroundColor.r, state.hemisphereGroundColor.g, state.hemisphereGroundColor.b ]), reflectionColor: new Float32Array([ state.reflectionColor.r, state.reflectionColor.g, state.reflectionColor.b ]) }; }; const nextTick = (): Promise => new Promise((resolve) => window.setTimeout(resolve, 0)); const debugProfilingEnabled = (import.meta.env as Record).VITE_DEBUG_TERRAIN_PROFILE === '1'; const frameProfileEnabled = (import.meta.env as Record).VITE_DEBUG_FRAME_PROFILE === '1'; type FrameStageStats = { total: number; count: number; max: number }; type FrameStageSnapshot = { label: string; duration: number }; type FrameProfileRecord = { timestamp: number; frameDuration: number; stages: FrameStageSnapshot[]; unaccountedMs: number; }; const frameProfiler = (() => { let enabled = frameProfileEnabled; let logIntervalMs = 2000; let spikeThresholdMs = 100; let lastLogTime = performance.now(); let frameCount = 0; let frameTimeSum = 0; let maxFrameTime = 0; const stageStats = new Map(); const currentStages = new Map(); const recentFrames: FrameProfileRecord[] = []; const maxRecentFrames = 1200; let overlay: HTMLDivElement | null = null; let overlayVisible = frameProfileEnabled; let lastSpikeText = ''; const ensureOverlay = (): void => { if (overlay || typeof document === 'undefined') return; const panel = document.createElement('div'); panel.id = 'frame-profile-overlay'; panel.style.position = 'fixed'; panel.style.left = '12px'; panel.style.top = '12px'; panel.style.background = 'rgba(10, 12, 16, 0.9)'; panel.style.color = '#d7f7ff'; panel.style.fontFamily = 'Consolas, \"Courier New\", monospace'; panel.style.fontSize = '12px'; panel.style.padding = '10px 12px'; panel.style.border = '1px solid #3b6b75'; panel.style.borderRadius = '6px'; panel.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.45)'; panel.style.zIndex = '10003'; panel.style.minWidth = '280px'; panel.style.maxWidth = '360px'; panel.style.display = overlayVisible ? 'block' : 'none'; panel.style.pointerEvents = 'none'; const attach = () => document.body?.appendChild(panel); if (document.body) { attach(); } else { window.addEventListener('DOMContentLoaded', attach, { once: true }); } overlay = panel; }; const setOverlayVisible = (visible: boolean): void => { overlayVisible = visible; ensureOverlay(); if (overlay) { overlay.style.display = overlayVisible ? 'block' : 'none'; } }; const resetAggregate = (): void => { frameCount = 0; frameTimeSum = 0; maxFrameTime = 0; stageStats.clear(); }; const beginFrame = (): void => { if (!enabled) return; currentStages.clear(); }; const recordStage = (label: string, duration: number): void => { if (!enabled) return; currentStages.set(label, duration); const stats = stageStats.get(label) ?? { total: 0, count: 0, max: 0 }; stats.total += duration; stats.count += 1; stats.max = Math.max(stats.max, duration); stageStats.set(label, stats); }; const logSpike = (frameDuration: number): void => { const entries: FrameStageSnapshot[] = Array.from(currentStages.entries()) .map(([label, duration]) => ({ label, duration })) .sort((a, b) => b.duration - a.duration); const top = entries.slice(0, 6) .map((entry) => `${entry.label}=${entry.duration.toFixed(1)}ms`) .join(' '); const accounted = entries.reduce((sum, entry) => sum + entry.duration, 0); const unaccounted = Math.max(0, frameDuration - accounted); const extra = unaccounted > 1 ? ` other=${unaccounted.toFixed(1)}ms` : ''; lastSpikeText = `Spike ${frameDuration.toFixed(1)}ms | ${top}${extra}`; console.warn(`[FrameProfile] spike ${frameDuration.toFixed(1)}ms | ${top}${extra}`); }; const logSummary = (): void => { const avgFrame = frameCount > 0 ? frameTimeSum / frameCount : 0; const avgFps = avgFrame > 0 ? 1000 / avgFrame : 0; const entries = Array.from(stageStats.entries()) .map(([label, stats]) => ({ label, avg: stats.total / Math.max(1, stats.count), max: stats.max })) .sort((a, b) => b.avg - a.avg); const top = entries.slice(0, 6) .map((entry) => `${entry.label}=${entry.avg.toFixed(1)}ms(max ${entry.max.toFixed(1)}ms)`) .join(' '); console.info( `[FrameProfile] samples=${frameCount} avg=${avgFrame.toFixed(1)}ms (${avgFps.toFixed(1)} FPS)` + ` max=${maxFrameTime.toFixed(1)}ms | ${top}` ); updateOverlay(avgFrame, avgFps, top); }; const updateOverlay = (avgFrame: number, avgFps: number, top: string): void => { if (!overlayVisible) return; ensureOverlay(); if (!overlay) return; const maxFrame = maxFrameTime.toFixed(1); const spikeLine = lastSpikeText ? `
${lastSpikeText}
` : ''; overlay.innerHTML = `
Frame Profile
Avg: ${avgFrame.toFixed(1)}ms (${avgFps.toFixed(1)} FPS)
Max: ${maxFrame}ms
Top stages
${top}
${spikeLine} `; }; const endFrame = (frameDuration: number): void => { if (!enabled) return; const stageEntries: FrameStageSnapshot[] = Array.from(currentStages.entries()) .map(([label, duration]) => ({ label, duration })) .sort((a, b) => b.duration - a.duration); const accounted = stageEntries.reduce((sum, entry) => sum + entry.duration, 0); const unaccountedMs = Math.max(0, frameDuration - accounted); recentFrames.push({ timestamp: performance.now(), frameDuration, stages: stageEntries, unaccountedMs }); while (recentFrames.length > maxRecentFrames) { recentFrames.shift(); } frameCount += 1; frameTimeSum += frameDuration; maxFrameTime = Math.max(maxFrameTime, frameDuration); if (frameDuration >= spikeThresholdMs) { logSpike(frameDuration); } const now = performance.now(); if (now - lastLogTime >= logIntervalMs) { logSummary(); lastLogTime = now; resetAggregate(); } }; const setEnabled = (value: boolean): void => { enabled = value; if (enabled) { lastLogTime = performance.now(); resetAggregate(); } }; const setLogInterval = (ms: number): void => { logIntervalMs = Math.max(250, ms); }; const setSpikeThreshold = (ms: number): void => { spikeThresholdMs = Math.max(16, ms); }; const getRecentFrames = (count = 240): FrameProfileRecord[] => { const sampleCount = Math.max(1, Math.floor(count)); return recentFrames.slice(-sampleCount).map((frame) => ({ timestamp: frame.timestamp, frameDuration: frame.frameDuration, unaccountedMs: frame.unaccountedMs, stages: frame.stages.map((stage) => ({ ...stage })) })); }; const clearRecentFrames = (): void => { recentFrames.length = 0; }; return { beginFrame, recordStage, endFrame, setEnabled, setLogInterval, setSpikeThreshold, getRecentFrames, clearRecentFrames, isEnabled: () => enabled, setOverlayVisible }; })(); const rtsProfiler = ensureRTS(); rtsProfiler.enableFrameProfile = (enabled = true) => { frameProfiler.setEnabled(enabled); frameProfiler.setOverlayVisible(enabled); console.info(`[FrameProfile] ${enabled ? 'Enabled' : 'Disabled'}`); }; rtsProfiler.setFrameProfileInterval = (ms: number) => { frameProfiler.setLogInterval(ms); console.info(`[FrameProfile] Summary interval set to ${ms}ms`); }; rtsProfiler.setFrameProfileSpikeThreshold = (ms: number) => { frameProfiler.setSpikeThreshold(ms); console.info(`[FrameProfile] Spike threshold set to ${ms}ms`); }; rtsProfiler.toggleFrameProfileOverlay = () => { frameProfiler.setOverlayVisible(!(document.getElementById('frame-profile-overlay')?.style.display === 'block')); const state = document.getElementById('frame-profile-overlay')?.style.display === 'block'; console.info(`[FrameProfile] Overlay ${state ? 'enabled' : 'disabled'}`); }; if (typeof rtsProfiler.toggleRendererPerformanceOverlay !== 'function') { rtsProfiler.toggleRendererPerformanceOverlay = () => { console.warn('[RendererOverlay] Not ready yet. Start a match and wait for renderer initialization.'); }; } if (typeof rtsProfiler.toggleBenchmarkOverlay !== 'function') { rtsProfiler.toggleBenchmarkOverlay = () => { console.warn('[BenchmarkOverlay] Not ready yet. Start a match and wait for benchmark initialization.'); }; } if (typeof rtsProfiler.togglePerformanceOverlay !== 'function') { rtsProfiler.togglePerformanceOverlay = rtsProfiler.toggleBenchmarkOverlay; } const logDebugProfile = (label: string, duration: number, context?: string): void => { if (!debugProfilingEnabled) { return; } const normalized = duration.toFixed(1); console.debug(`[PROFILE] ${label}: ${normalized}ms${context ? ` ${context}` : ''}`); }; const occlusionMapCache = new WeakMap(); let reusableColorBuffer: Float32Array | null = null; let reusableAoBuffer: Float32Array | null = null; const generateOcclusionMap = (terra: Terra): { data: Float32Array; size: number } => { const cached = occlusionMapCache.get(terra); if (cached) { return cached; } const mapSize = Math.min(512, MAP_SEGMENTS + 1); const result = new Float32Array(mapSize * mapSize); const sampleRings = [ { radius: 2.8, samples: 6, weight: 1.0 }, { radius: 5.5, samples: 8, weight: 0.65 }, { radius: 9.0, samples: 10, weight: 0.35 } ]; const occlusionScale = Math.max(1, terra.getVerticalScale() * 0.35); for (let row = 0; row < mapSize; row++) { const worldZ = (row + 0.5) / mapSize * MAP_SIZE_METERS - MAP_HALF_SIZE; for (let col = 0; col < mapSize; col++) { const worldX = (col + 0.5) / mapSize * MAP_SIZE_METERS - MAP_HALF_SIZE; const baseHeight = terra.getHeightWorld(worldX, worldZ); let totalOcclusion = 0; let totalWeight = 0; for (const ring of sampleRings) { let ringOcclusion = 0; const angleStep = (Math.PI * 2) / ring.samples; for (let idx = 0; idx < ring.samples; idx++) { const angle = idx * angleStep; const sampleX = worldX + Math.cos(angle) * ring.radius; const sampleZ = worldZ + Math.sin(angle) * ring.radius; const sampleHeight = terra.getHeightWorld(sampleX, sampleZ); const diff = sampleHeight - baseHeight; const contribution = Math.max(0, Math.min(1, diff / occlusionScale)); ringOcclusion += contribution; } ringOcclusion /= ring.samples; totalOcclusion += ringOcclusion * ring.weight; totalWeight += ring.weight; } const occlusion = totalWeight > 0 ? Math.min(0.6, totalOcclusion / totalWeight) : 0; result[row * mapSize + col] = 1.0 - occlusion; } } const final = { data: result, size: mapSize }; occlusionMapCache.set(terra, final); return final; }; const sampleOcclusionValue = ( occlusionMap: { data: Float32Array; size: number }, worldX: number, worldZ: number ): number => { const u = THREE.MathUtils.clamp((worldX + MAP_HALF_SIZE) / MAP_SIZE_METERS, 0, 0.9999); const v = THREE.MathUtils.clamp((worldZ + MAP_HALF_SIZE) / MAP_SIZE_METERS, 0, 0.9999); const col = Math.floor(u * occlusionMap.size); const row = Math.floor(v * occlusionMap.size); return occlusionMap.data[row * occlusionMap.size + col]; }; const applyHeightmap = async ( geometry: THREE.PlaneGeometry, terra: Terra, options?: { quiet?: boolean; enableAO?: boolean } ): Promise => { const quiet = options?.quiet ?? false; const enableAO = options?.enableAO ?? true; const profileStart = performance.now(); const positions = geometry.attributes.position as THREE.BufferAttribute; const vertexCount = positions.count; const colors = (() => { if (!reusableColorBuffer || reusableColorBuffer.length < vertexCount * 3) { reusableColorBuffer = new Float32Array(vertexCount * 3); } return reusableColorBuffer.subarray(0, vertexCount * 3); })(); const aoValues = (() => { if (!reusableAoBuffer || reusableAoBuffer.length < vertexCount) { reusableAoBuffer = new Float32Array(vertexCount); } return reusableAoBuffer.subarray(0, vertexCount); })(); const progressInterval = Math.max(1, Math.floor(vertexCount / 10)); let lastLoggedProgress = -1; const occlusionMap = enableAO ? generateOcclusionMap(terra) : null; if (!quiet) { if (enableAO) { console.info(`Applying vertex AO texture for ${vertexCount} terrain vertices...`); } else { console.info(`Applying terrain heights and colors without AO for ${vertexCount} vertices...`); } } const startTime = enableAO && !quiet ? performance.now() : 0; const chunkSize = 4096; for (let start = 0; start < vertexCount; start += chunkSize) { const end = Math.min(vertexCount, start + chunkSize); for (let index = start; index < end; index += 1) { const x = positions.getX(index); const z = positions.getZ(index); const height = terra.getHeightWorld(x, z); positions.setY(index, height); if (enableAO && occlusionMap) { const ao = sampleOcclusionValue(occlusionMap, x, z); aoValues[index] = ao; } else { aoValues[index] = 1; } const { i, k } = terra.worldToTile(x, z); const idx = Math.min(Math.max(i * terra.height + k, 0), terra.terrainTypes.length - 1); const terrainType = terra.terrainTypes[idx]; const isRamp = terra.isRampTile(i, k); const color = getTerrainColor(terrainType, isRamp); colors[index * 3] = color.r; colors[index * 3 + 1] = color.g; colors[index * 3 + 2] = color.b; if (enableAO && !quiet && index % progressInterval === 0) { const percent = Math.floor((index / vertexCount) * 100); if (percent !== lastLoggedProgress) { lastLoggedProgress = percent; console.info(` Vertex AO progress: ${percent}%`); } } } if (end < vertexCount) { await nextTick(); } } if (!quiet) { if (enableAO) { const endTime = performance.now(); console.info(`Vertex AO computed in ${(endTime - startTime).toFixed(0)}ms`); } else { console.info('Terrain heights applied; vertex AO skipped.'); } } geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); geometry.setAttribute('ao', new THREE.BufferAttribute(aoValues, 1)); geometry.computeVertexNormals(); positions.needsUpdate = true; geometry.attributes.position.needsUpdate = true; geometry.attributes.color.needsUpdate = true; geometry.attributes.ao.needsUpdate = true; logDebugProfile('applyHeightmap', performance.now() - profileStart, `vertices=${vertexCount} AO=${enableAO}`); }; const configureTerrainTexture = (texture: THREE.Texture, useSRGB = true, repeat = 1): void => { texture.wrapS = THREE.RepeatWrapping; texture.wrapT = THREE.RepeatWrapping; texture.minFilter = THREE.LinearMipmapLinearFilter; texture.magFilter = THREE.LinearFilter; texture.anisotropy = renderer.capabilities.getMaxAnisotropy(); texture.colorSpace = useSRGB ? THREE.SRGBColorSpace : THREE.LinearSRGBColorSpace; texture.repeat.set(repeat, repeat); texture.needsUpdate = true; }; const createTerrainHeightTexture = (terra: Terra, size = 512): THREE.DataTexture => { const data = new Float32Array(size * size); for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { const u = x / (size - 1); const v = y / (size - 1); const worldX = THREE.MathUtils.lerp(-MAP_HALF_SIZE, MAP_HALF_SIZE, u); const worldZ = THREE.MathUtils.lerp(-MAP_HALF_SIZE, MAP_HALF_SIZE, v); data[y * size + x] = terra.getHeightWorld(worldX, worldZ); } } const texture = new THREE.DataTexture(data, size, size, THREE.RedFormat, THREE.FloatType); texture.wrapS = THREE.ClampToEdgeWrapping; texture.wrapT = THREE.ClampToEdgeWrapping; // PHASE 2.3: Enable mipmaps for distance-based LOD sampling texture.generateMipmaps = true; texture.minFilter = THREE.LinearMipmapLinearFilter; texture.magFilter = THREE.LinearFilter; texture.needsUpdate = true; return texture; }; const clampToMap = (value: number): number => { // CRITICAL: Clamp to slightly less than map bounds to prevent grid coordinate overflow // When converting world coords to grid coords: gridX = floor((x + mapHalfSize) / cellSize) // If x == mapHalfSize exactly, gridX can equal gridSize, which is out of bounds [0, gridSize-1] // Solution: Clamp to mapHalfSize - epsilon to ensure gridX < gridSize const mapHalfSize = MAP_SIZE_METERS / 2; const epsilon = 0.01; // Small offset to prevent boundary overflow return Math.max(-mapHalfSize + epsilon, Math.min(value, mapHalfSize - epsilon)); }; const clamp01 = (value: number): number => THREE.MathUtils.clamp(value, 0, 1); const smoothstep = (edge0: number, edge1: number, x: number): number => { if (edge0 === edge1) { return x < edge0 ? 0 : 1; } const t = clamp01((x - edge0) / (edge1 - edge0)); return t * t * (3 - 2 * t); }; const hash2 = (x: number, z: number, seed: number): number => { const value = Math.sin(x * 12.9898 + z * 78.233 + seed) * 43758.5453; return value - Math.floor(value); }; const valueNoise2D = (x: number, z: number, scale: number, seed: number): number => { const fx = x * scale; const fz = z * scale; const ix = Math.floor(fx); const iz = Math.floor(fz); const tx = fx - ix; const tz = fz - iz; const v00 = hash2(ix, iz, seed); const v10 = hash2(ix + 1, iz, seed); const v01 = hash2(ix, iz + 1, seed); const v11 = hash2(ix + 1, iz + 1, seed); const sx = tx * tx * (3 - 2 * tx); const sz = tz * tz * (3 - 2 * tz); const nx0 = v00 + (v10 - v00) * sx; const nx1 = v01 + (v11 - v01) * sx; return nx0 + (nx1 - nx0) * sz; }; const fbmNoise2D = (x: number, z: number, seed: number, baseScale: number): number => { let sum = 0; let amplitude = 0.6; let scale = baseScale; let total = 0; for (let i = 0; i < 3; i += 1) { sum += valueNoise2D(x, z, scale, seed + i * 131) * amplitude; total += amplitude; amplitude *= 0.5; scale *= 2; } return total > 0 ? sum / total : 0; }; type Season = 'spring' | 'summer' | 'autumn' | 'winter' | 'dry'; type TreeBiomePresetName = 'temperate' | 'arctic' | 'desert' | 'alien'; type SeasonPalette = { leafBase: number; leafTint: number; dead: number; healthBias: number; }; const SEASON_PALETTES: Record = { spring: { leafBase: 0x3c8a4b, leafTint: 0x7bd86a, dead: 0x5a4a33, healthBias: 0.12 }, summer: { leafBase: 0x2f7a3f, leafTint: 0x5bb95b, dead: 0x5b4a33, healthBias: 0.08 }, autumn: { leafBase: 0x8b6b2e, leafTint: 0xc56b2a, dead: 0x6b4a2c, healthBias: -0.05 }, winter: { leafBase: 0x5f6b63, leafTint: 0xa3b7b0, dead: 0x6f6a5f, healthBias: -0.18 }, dry: { leafBase: 0x6d6a3c, leafTint: 0xb49a4a, dead: 0x6b5a3a, healthBias: -0.12 } }; const parseSeasonOverride = (): Season | null => { try { const params = new URLSearchParams(window.location.search); const raw = params.get('season') ?? window.localStorage.getItem('rts.season'); if (!raw) { return null; } const normalized = raw.toLowerCase(); if (normalized === 'spring' || normalized === 'summer' || normalized === 'autumn' || normalized === 'winter' || normalized === 'dry') { return normalized; } } catch { // ignore } return null; }; const getSeasonForBiome = (biome: BiomeType | null): Season => { const override = parseSeasonOverride(); if (override) { return override; } switch (biome ?? BiomeType.TEMPERATE) { case BiomeType.DESERT: return 'dry'; case BiomeType.ARCTIC: return 'winter'; case BiomeType.VOLCANIC: return 'autumn'; case BiomeType.ALIEN: return 'spring'; case BiomeType.URBAN_RUINS: return 'autumn'; case BiomeType.TEMPERATE: default: return 'summer'; } }; type ForestProfile = { patchScale: number; patchEdgeLow: number; patchEdgeHigh: number; densityScale: number; minForestFactor: number; minSeparation: number; }; type GrassDensityProfile = { grassDensityScale: number; grassMapInfluence: number; grassThreshold: number; grassPower: number; grassLodThreshold: number; grassLodPower: number; tallDensityScale: number; tallMapInfluence: number; tallThreshold: number; tallPower: number; tallLodThreshold: number; tallLodPower: number; }; const getGrassDensityProfileForBiome = (biome: BiomeType | null): GrassDensityProfile => { switch (biome ?? BiomeType.TEMPERATE) { case BiomeType.DESERT: return { grassDensityScale: 0.62, grassMapInfluence: 0.92, grassThreshold: 0.26, grassPower: 1.5, grassLodThreshold: 0.30, grassLodPower: 1.45, tallDensityScale: 0.55, tallMapInfluence: 0.82, tallThreshold: 0.34, tallPower: 1.6, tallLodThreshold: 0.38, tallLodPower: 1.55 }; case BiomeType.ARCTIC: return { grassDensityScale: 0.74, grassMapInfluence: 0.96, grassThreshold: 0.22, grassPower: 1.35, grassLodThreshold: 0.24, grassLodPower: 1.35, tallDensityScale: 0.62, tallMapInfluence: 0.88, tallThreshold: 0.30, tallPower: 1.45, tallLodThreshold: 0.33, tallLodPower: 1.45 }; case BiomeType.VOLCANIC: return { grassDensityScale: 0.66, grassMapInfluence: 0.94, grassThreshold: 0.24, grassPower: 1.5, grassLodThreshold: 0.28, grassLodPower: 1.45, tallDensityScale: 0.54, tallMapInfluence: 0.84, tallThreshold: 0.34, tallPower: 1.6, tallLodThreshold: 0.38, tallLodPower: 1.55 }; case BiomeType.URBAN_RUINS: return { grassDensityScale: 0.78, grassMapInfluence: 0.98, grassThreshold: 0.20, grassPower: 1.3, grassLodThreshold: 0.22, grassLodPower: 1.3, tallDensityScale: 0.66, tallMapInfluence: 0.90, tallThreshold: 0.28, tallPower: 1.42, tallLodThreshold: 0.32, tallLodPower: 1.42 }; case BiomeType.ALIEN: return { grassDensityScale: 1.26, grassMapInfluence: 1.14, grassThreshold: 0.08, grassPower: 1.05, grassLodThreshold: 0.10, grassLodPower: 1.08, tallDensityScale: 1.22, tallMapInfluence: 1.04, tallThreshold: 0.14, tallPower: 1.14, tallLodThreshold: 0.16, tallLodPower: 1.18 }; case BiomeType.TEMPERATE: default: return { grassDensityScale: 1.14, grassMapInfluence: 1.08, grassThreshold: 0.10, grassPower: 1.12, grassLodThreshold: 0.12, grassLodPower: 1.16, tallDensityScale: 1.08, tallMapInfluence: 0.98, tallThreshold: 0.16, tallPower: 1.24, tallLodThreshold: 0.20, tallLodPower: 1.28 }; } }; const getTreeBiomePresetForBiome = (biome: BiomeType | null): TreeBiomePresetName => { switch (biome ?? BiomeType.TEMPERATE) { case BiomeType.ARCTIC: return 'arctic'; case BiomeType.DESERT: return 'desert'; case BiomeType.ALIEN: return 'alien'; case BiomeType.VOLCANIC: return 'desert'; case BiomeType.TEMPERATE: case BiomeType.URBAN_RUINS: default: return 'temperate'; } }; type ForestPatchSeed = { id: number; x: number; z: number; radius: number; strength: number; }; type ForestSamplerFn = ((x: number, z: number) => number) & { getPatchId?: (x: number, z: number) => number; getPatchSeeds?: () => ForestPatchSeed[]; }; const createSeededRng = (seed: number): (() => number) => { let state = seed >>> 0; return () => { state += 0x6D2B79F5; let t = Math.imul(state ^ (state >>> 15), 1 | state); t ^= t + Math.imul(t ^ (t >>> 7), 61 | t); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }; const getTargetPatchCount = (profile: ForestProfile, mapSize: number): number => { const scale = Math.max(0.00015, Math.min(0.0006, profile.patchScale)); const base = Math.round((0.00035 / scale) * 3); const mapScale = Math.max(0.7, Math.min(1.4, mapSize / 20000)); return Math.max(2, Math.round(base * mapScale)); }; const generateForestPatchSeeds = (terra: Terra, seed: number, profile: ForestProfile): ForestPatchSeed[] => { // PHASE 3: Use Terra.js forest patches if available (generated during terrain generation) const terraWithPatches = terra as Terra & { forestPatches?: Array<{x: number; z: number; radius: number; strength: number}> }; if (terraWithPatches.forestPatches && terraWithPatches.forestPatches.length > 0) { console.info(`[ForestPatch] Using ${terraWithPatches.forestPatches.length} patches from Terra.js`); // Convert Terra.js patches (tile coordinates) to world coordinates (meters) const seeds: ForestPatchSeed[] = terraWithPatches.forestPatches.map((patch, index) => { // Convert tile coordinates to world coordinates // Terra tiles are centered at (width/2, height/2) = (0, 0) in world space const worldX = (patch.x - terra.width / 2) * terra.tileSize; const worldZ = (patch.z - terra.height / 2) * terra.tileSize; const worldRadius = patch.radius * terra.tileSize; return { id: index, x: worldX, z: worldZ, radius: worldRadius, strength: patch.strength }; }); // Log patch statistics const avgRadius = seeds.reduce((sum, s) => sum + s.radius, 0) / seeds.length; const avgStrength = seeds.reduce((sum, s) => sum + s.strength, 0) / seeds.length; console.info(`[ForestPatch] Converted patches: avg radius=${avgRadius.toFixed(1)}m, avg strength=${avgStrength.toFixed(2)}`); return seeds; } // FALLBACK: Generate patches client-side if Terra.js patches not available console.warn('[ForestPatch] Terra.js patches not available, generating client-side patches (fallback)'); const mapSize = terra.width * terra.tileSize; const targetCount = getTargetPatchCount(profile, mapSize); const rng = createSeededRng(seed ^ 0x9e3779b9); const baseRadius = (mapSize * 0.32) / Math.sqrt(targetCount); const minSpacing = baseRadius * 0.9; const maxAttempts = targetCount * 40; const seeds: ForestPatchSeed[] = []; for (let attempt = 0; attempt < maxAttempts && seeds.length < targetCount; attempt++) { const x = (rng() * 2 - 1) * MAP_HALF_SIZE; const z = (rng() * 2 - 1) * MAP_HALF_SIZE; let tooClose = false; for (const seedEntry of seeds) { const dist = Math.hypot(seedEntry.x - x, seedEntry.z - z); if (dist < minSpacing) { tooClose = true; break; } } if (tooClose) { continue; } const radius = baseRadius * (0.85 + rng() * 0.35); const strength = 0.85 + rng() * 0.3; seeds.push({ id: seeds.length, x, z, radius, strength }); } while (seeds.length < targetCount) { const x = (rng() * 2 - 1) * MAP_HALF_SIZE; const z = (rng() * 2 - 1) * MAP_HALF_SIZE; const radius = baseRadius * (0.85 + rng() * 0.35); const strength = 0.85 + rng() * 0.3; seeds.push({ id: seeds.length, x, z, radius, strength }); } return seeds; }; const applyForestProfileTuning = (profile: ForestProfile): ForestProfile => { const relax = Math.max(0, FOREST_PROFILE_RELAXATION); if (relax <= 0) { return profile; } let patchEdgeLow = clamp01(profile.patchEdgeLow - relax); let patchEdgeHigh = clamp01(profile.patchEdgeHigh - relax); if (patchEdgeHigh < patchEdgeLow + 0.02) { patchEdgeHigh = clamp01(patchEdgeLow + 0.02); } const minForestFactor = clamp01(profile.minForestFactor - relax * 0.8); return { ...profile, patchEdgeLow, patchEdgeHigh, minForestFactor }; }; const getForestProfileForBiome = (biome: BiomeType | null): ForestProfile => { let profile: ForestProfile; switch (biome ?? BiomeType.TEMPERATE) { case BiomeType.DESERT: profile = { patchScale: 0.0004, // EXTREME: Huge oasis patches, 2-3 total patchEdgeLow: 0.78, // EXTREME: Only top 22% - very rare oases patchEdgeHigh: 0.86, // EXTREME: Sharp boundaries densityScale: 2.6, minForestFactor: 0.30, // EXTREME: Only strong oases minSeparation: 8.0 // Sparse desert vegetation }; break; case BiomeType.ARCTIC: profile = { patchScale: 0.0003, // EXTREME: Huge taiga patches, 2-4 total patchEdgeLow: 0.74, // EXTREME: Only top 26% - sparse forests patchEdgeHigh: 0.82, // EXTREME: Sharp boundaries densityScale: 3.2, minForestFactor: 0.28, // EXTREME: Only strong forests minSeparation: 2.5 // Sparse arctic forests }; break; case BiomeType.VOLCANIC: profile = { patchScale: 0.0003, // EXTREME: Huge volcanic patches, 2-4 total patchEdgeLow: 0.75, // EXTREME: Only top 25% - scattered forests patchEdgeHigh: 0.83, // EXTREME: Sharp boundaries densityScale: 3.0, minForestFactor: 0.28, // EXTREME: Only strong forests minSeparation: 7.2 // Moderate volcanic forests }; break; case BiomeType.ALIEN: profile = { patchScale: 0.0002, // EXTREME: MASSIVE alien zones, 4-6 total patchEdgeLow: 0.65, // EXTREME: Top 35% - more coverage than others patchEdgeHigh: 0.75, // EXTREME: Sharp boundaries densityScale: 5.6, minForestFactor: 0.20, // EXTREME: Moderate threshold for alien minSeparation: 5.5 // Dense alien vegetation }; break; case BiomeType.URBAN_RUINS: profile = { patchScale: 0.0004, // EXTREME: Large overgrown patches, 2-3 total patchEdgeLow: 0.76, // EXTREME: Only top 24% - scattered overgrowth patchEdgeHigh: 0.84, // EXTREME: Sharp boundaries densityScale: 2.8, minForestFactor: 0.30, // EXTREME: Only strong overgrowth minSeparation: 6.8 // Overgrown ruins }; break; case BiomeType.TEMPERATE: default: profile = { patchScale: 0.0003, // EXTREME: Huge patches, only 3-5 islands patchEdgeLow: 0.72, // EXTREME: Only top 28% of noise becomes forest patchEdgeHigh: 0.80, // EXTREME: Very sharp boundaries densityScale: 4.8, minForestFactor: 0.25, // EXTREME: Only strong forest areas minSeparation: 6.0 // Dense temperate forests }; break; } return applyForestProfileTuning(profile); }; const createForestFactorSampler = ( terra: Terra, seed: number, profile: ForestProfile ): ForestSamplerFn => { let debugSampleCount = 0; let debugForestCount = 0; const patchSeeds = generateForestPatchSeeds(terra, seed, profile); const edgeStart = clamp01(1 - profile.patchEdgeLow); const edgeEnd = clamp01(1 - profile.patchEdgeHigh); const edgeLow = Math.min(edgeStart, edgeEnd); const edgeHigh = Math.max(edgeStart, edgeEnd); const getPatchInfo = (x: number, z: number): { id: number; factor: number } => { if (patchSeeds.length === 0) { return { id: -1, factor: 0 }; } let bestSeed: ForestPatchSeed | null = null; let bestDist = Infinity; for (const seedEntry of patchSeeds) { const dist = Math.hypot(seedEntry.x - x, seedEntry.z - z); if (dist < bestDist) { bestDist = dist; bestSeed = seedEntry; } } if (!bestSeed) { return { id: -1, factor: 0 }; } const shapeNoise = fbmNoise2D(x, z, seed + 77, profile.patchScale * 1.4); const shapeFactor = 0.85 + shapeNoise * 0.15; const effectiveRadius = Math.max(1, bestSeed.radius * shapeFactor); const normalized = clamp01(1 - bestDist / effectiveRadius); const mask = smoothstep(edgeLow, edgeHigh, normalized) * bestSeed.strength; return { id: bestSeed.id, factor: clamp01(mask) }; }; const sampler = ((x: number, z: number): number => { // STEP 1: Macro patch seeding (Poisson-like) with distance falloff const patchInfo = getPatchInfo(x, z); const forestMask = patchInfo.factor; // Debug logging (sample every 1000th call) debugSampleCount++; if (debugSampleCount % 1000 === 0) { if (forestMask > 0.01) debugForestCount++; if (debugSampleCount === 10000) { console.log(`[ForestSampler] ${debugForestCount}/10 samples had forestMask > 0.01 (${(debugForestCount/10).toFixed(1)}%)`); } } // If not in a forest patch, return 0 immediately if (forestMask < 0.01) { return 0; } // STEP 2: Filter by terrain suitability (biome-aware) // Terra's vegetation density includes: terrain type, slope, elevation const terrainDensity = clamp01(terra.getVegetationDensityAt(x, z)); // Reduce forest in unsuitable terrain (rock, steep slopes) // But don't eliminate completely - allow some sparse trees const terrainFilter = 0.3 + terrainDensity * 0.7; // STEP 3: Add detail variation within patches const detailNoise = fbmNoise2D(x, z, seed + 1000, profile.patchScale * 4.0); const detailFactor = detailNoise * 0.5 + 0.5; // Combine: patch creates islands, terrain filters, detail adds variation const finalFactor = forestMask * terrainFilter * (0.6 + detailFactor * 0.4); return clamp01(finalFactor); }) as ForestSamplerFn; sampler.getPatchId = (x: number, z: number): number => getPatchInfo(x, z).id; sampler.getPatchSeeds = (): ForestPatchSeed[] => patchSeeds; return sampler; }; type TreeSpecies = 'conifer' | 'broadleaf'; type TreeSubtype = 'fir' | 'pine' | 'spruce' | 'oak' | 'poplar' | 'birch' | 'mixed'; const getTreeSubtypeMaterialSeed = (subtype: string): number => { switch (subtype) { case 'fir': return 0; case 'pine': return 1; case 'spruce': return 2; case 'oak': return 0; case 'poplar': return 3; case 'birch': return 2; default: return 1; } }; const getTreeSubtypeColorProfile = ( subtype: string, species: TreeSpecies, season: Season ): { hueShift: number; saturationShift: number; lightnessShift: number; deadBias: number } => { const winterOrDry = season === 'winter' || season === 'dry'; switch (subtype) { case 'fir': return { hueShift: -0.01, saturationShift: -0.08, lightnessShift: winterOrDry ? -0.08 : -0.06, deadBias: 0.72 }; case 'pine': return { hueShift: 0.018, saturationShift: 0.09, lightnessShift: winterOrDry ? -0.03 : 0.015, deadBias: 0.58 }; case 'spruce': return { hueShift: -0.022, saturationShift: -0.02, lightnessShift: winterOrDry ? -0.06 : -0.02, deadBias: 0.64 }; case 'oak': return { hueShift: 0.012, saturationShift: 0.02, lightnessShift: winterOrDry ? -0.045 : -0.01, deadBias: 0.92 }; case 'poplar': return { hueShift: 0.028, saturationShift: 0.08, lightnessShift: winterOrDry ? -0.02 : 0.03, deadBias: 0.8 }; case 'birch': return { hueShift: -0.004, saturationShift: -0.16, lightnessShift: winterOrDry ? 0.01 : 0.05, deadBias: 1.05 }; default: return { hueShift: species === 'conifer' ? -0.006 : 0.006, saturationShift: species === 'conifer' ? -0.03 : 0.03, lightnessShift: species === 'conifer' ? -0.03 : 0.01, deadBias: 0.9 }; } }; const getConiferWeightForBiome = (biome: BiomeType | null, season: Season): number => { let weight: number; switch (biome ?? BiomeType.TEMPERATE) { case BiomeType.DESERT: weight = 0.15; break; case BiomeType.ARCTIC: weight = 0.75; break; case BiomeType.VOLCANIC: weight = 0.45; break; case BiomeType.ALIEN: weight = 0.25; break; case BiomeType.URBAN_RUINS: weight = 0.35; break; case BiomeType.TEMPERATE: default: weight = 0.4; break; } if (season === 'winter' || season === 'dry') { weight += 0.12; } return clamp01(weight); }; const splitTreePlacementsBySpecies = ( placements: TreePlacement[], sampler: (x: number, z: number) => number, seed: number, coniferWeight: number ): { conifer: TreePlacement[]; broadleaf: TreePlacement[] } => { const conifer: TreePlacement[] = []; const broadleaf: TreePlacement[] = []; for (const placement of placements) { const forestFactor = sampler(placement.x, placement.z); const localBias = coniferWeight + (1 - forestFactor) * 0.08; const pick = hash2(placement.x, placement.z, seed); if (pick < clamp01(localBias)) { conifer.push(placement); } else { broadleaf.push(placement); } } return { conifer, broadleaf }; }; const buildTreeInstanceColors = ( placements: TreePlacement[], palette: SeasonPalette, season: Season, seed: number, species: TreeSpecies, subtype: TreeSubtype = 'mixed' ): THREE.Color[] => { const leafBase = new THREE.Color(palette.leafBase); const leafTint = new THREE.Color(palette.leafTint); const dead = new THREE.Color(palette.dead); const subtypeProfile = getTreeSubtypeColorProfile(subtype, species, season); return placements.map((placement) => { const noise = hash2(placement.x, placement.z, seed); const secondaryNoise = hash2(placement.x * 0.47, placement.z * 0.53, seed + 61); const tertiaryNoise = hash2(placement.x * 0.21, placement.z * 0.39, seed + 127); const tintMix = species === 'conifer' ? 0.22 + noise * 0.48 : 0.34 + noise * 0.56; const color = leafBase.clone().lerp(leafTint, tintMix); // Species-specific palette shaping for stronger biome readability. if (species === 'conifer') { color.offsetHSL((secondaryNoise - 0.5) * 0.012, -0.05, -0.07 + placement.age * 0.05); } else { color.offsetHSL((secondaryNoise - 0.5) * 0.04, 0.06, -0.02 + (1 - placement.age) * 0.06); } color.offsetHSL( subtypeProfile.hueShift + (tertiaryNoise - 0.5) * 0.014, subtypeProfile.saturationShift + (tertiaryNoise - 0.5) * 0.06, subtypeProfile.lightnessShift + (noise - 0.5) * 0.04 ); let deadMix = 1 - placement.health; if (species === 'conifer') { deadMix *= 0.45; } else if (season === 'winter') { deadMix = clamp01(deadMix + 0.25); } else if (season === 'dry') { deadMix = clamp01(deadMix + 0.15); } // Older broadleaf trees in dry/winter seasons skew slightly drier. if (species === 'broadleaf' && (season === 'winter' || season === 'dry')) { deadMix = clamp01(deadMix + (1 - placement.age) * 0.08); } deadMix = clamp01(deadMix * subtypeProfile.deadBias); color.lerp(dead, deadMix); return color; }); }; /** * Build instance colors for rock/boulder/pebble scatter objects * Adds natural variation in gray-brown tones based on position */ const buildRockInstanceColors = ( positions: { x: number; z: number }[], baseColor: THREE.Color, seed: number, variationAmount = 0.25 ): THREE.Color[] => { // Natural rock color variations: gray, brown, tan, dark const rockTints = [ new THREE.Color(0.50, 0.48, 0.44), // Light gray new THREE.Color(0.42, 0.38, 0.32), // Brown-gray new THREE.Color(0.55, 0.50, 0.42), // Tan new THREE.Color(0.35, 0.32, 0.28), // Dark gray ]; return positions.map((pos, idx) => { const noise1 = hash2(pos.x, pos.z, seed); const noise2 = hash2(pos.x * 1.7, pos.z * 1.3, seed + 100); const tintIdx = Math.floor(noise1 * rockTints.length) % rockTints.length; const color = baseColor.clone(); // Blend with tint based on noise color.lerp(rockTints[tintIdx], noise2 * variationAmount); // Add slight brightness variation const brightness = 0.9 + noise1 * 0.2; color.multiplyScalar(brightness); return color; }); }; /** * Build instance colors for vegetation scatter objects (bushes, grass, ferns) * Uses season palette for consistency with trees */ const buildVegetationInstanceColors = ( positions: { x: number; z: number }[], palette: SeasonPalette, seed: number, saturationBoost = 0.0 ): THREE.Color[] => { const leafBase = new THREE.Color(palette.leafBase); const leafTint = new THREE.Color(palette.leafTint); const dead = new THREE.Color(palette.dead); return positions.map((pos) => { const noise = hash2(pos.x, pos.z, seed); const healthNoise = hash2(pos.x * 0.5, pos.z * 0.5, seed + 200); // Blend between base and tint const color = leafBase.clone().lerp(leafTint, 0.2 + noise * 0.6); // Add slight dead/brown tint for variation const deadMix = (1 - healthNoise) * 0.15; color.lerp(dead, deadMix); // Optional saturation boost for vibrant vegetation if (saturationBoost > 0) { const hsl = { h: 0, s: 0, l: 0 }; color.getHSL(hsl); hsl.s = Math.min(1, hsl.s + saturationBoost); color.setHSL(hsl.h, hsl.s, hsl.l); } return color; }); }; const densifyForestPositions = ( positions: { x: number; z: number }[], sampler: (x: number, z: number) => number, options: { jitter: number; densityScale: number; minForestFactor: number; } ): { x: number; z: number }[] => { const result: { x: number; z: number }[] = []; for (const pos of positions) { const factor = sampler(pos.x, pos.z); const threshold = clamp01(options.minForestFactor); if (factor < threshold) { continue; } const normalized = clamp01((factor - threshold) / Math.max(0.0001, 1 - threshold)); const clustered = normalized * normalized; const copies = Math.max(1, Math.round((0.6 + clustered * 1.9) * options.densityScale)); const localJitter = options.jitter * (0.45 + (1 - clustered) * 0.7); for (let i = 0; i < copies; i += 1) { result.push({ x: clampToMap(pos.x + (Math.random() - 0.5) * localJitter), z: clampToMap(pos.z + (Math.random() - 0.5) * localJitter) }); } } return result; }; const extendScatterPositions = ( base: { x: number; z: number }[], copies: number, jitter: number ): { x: number; z: number }[] => { const result: { x: number; z: number }[] = []; base.forEach((pos) => { result.push(pos); for (let i = 0; i < copies; i++) { result.push({ x: clampToMap(pos.x + (Math.random() - 0.5) * jitter), z: clampToMap(pos.z + (Math.random() - 0.5) * jitter) }); } }); return result; }; // Generate uniform grid of positions across the entire map const generateUniformGridPositions = ( gridSpacing: number, jitter: number ): { x: number; z: number }[] => { const result: { x: number; z: number }[] = []; const halfSize = MAP_HALF_SIZE; console.info(`[generateUniformGrid] halfSize=${halfSize}, gridSpacing=${gridSpacing}`); console.info(`[generateUniformGrid] Range: ${-halfSize} to ${halfSize}`); let xCount = 0; let zCount = 0; for (let x = -halfSize + gridSpacing / 2; x < halfSize; x += gridSpacing) { xCount++; zCount = 0; for (let z = -halfSize + gridSpacing / 2; z < halfSize; z += gridSpacing) { zCount++; // Add jittered position result.push({ x: clampToMap(x + (Math.random() - 0.5) * jitter), z: clampToMap(z + (Math.random() - 0.5) * jitter) }); } } console.info(`[generateUniformGrid] Generated ${result.length} positions (${xCount} x ${zCount})`); if (result.length > 0) { console.info(`[generateUniformGrid] Sample positions:`, result.slice(0, 5)); } return result; }; type RawTreePlacement = { x: number; z: number; age: number }; type TreePlacement = RawTreePlacement & { health: number; season: Season }; type TreeCandidate = { x: number; z: number; forestFactor: number; patchId: number }; const generateTreePlacements = async ( positions: Array<{ x: number; z: number; forestFactor?: number }>, terra: Terra, options: { minSeparation?: number; cohesionChance?: number; jitterMultiplier?: number; minSeparationCore?: number; minSeparationEdge?: number; } = {} ): Promise => { const minSeparation = options.minSeparation ?? 4.5; let minSeparationCore = options.minSeparationCore ?? minSeparation * 0.7; let minSeparationEdge = options.minSeparationEdge ?? minSeparation * 1.15; minSeparationCore = Math.max(0.1, minSeparationCore); minSeparationEdge = Math.max(0.1, minSeparationEdge); if (minSeparationCore > minSeparationEdge) { const temp = minSeparationCore; minSeparationCore = minSeparationEdge; minSeparationEdge = temp; } const cellSize = minSeparationCore / Math.SQRT2; const cohesionChance = options.cohesionChance ?? 0.15; const jitterMultiplier = options.jitterMultiplier ?? 0.3; const placements: Array = []; const grid = new Map(); const clampAge = (value: number): number => Math.max(0.55, Math.min(1.25, value)); const rootAge = (x: number, z: number): number => { const slope = terra.getSlopeWorld(x, z).slopeDegrees; const slopeFactor = Math.max(0, 1 - slope / 40); const base = 0.75 + slopeFactor * 0.25; return clampAge(base + (Math.random() - 0.5) * 0.25); }; const toGridCoord = (value: number): number => Math.floor((value + MAP_HALF_SIZE) / cellSize); const neighborsRange = Math.max(2, Math.ceil((minSeparationEdge * 1.5) / cellSize)); const getAdaptiveMinSeparation = (forestFactor?: number): number => { const factor = Math.min(1, Math.max(0, forestFactor ?? 0)); return THREE.MathUtils.lerp(minSeparationEdge, minSeparationCore, factor); }; const addPlacement = (x: number, z: number, forestFactor?: number): boolean => { const candidateMinSep = getAdaptiveMinSeparation(forestFactor); const gx = toGridCoord(x); const gz = toGridCoord(z); for (let dx = -neighborsRange; dx <= neighborsRange; dx++) { for (let dz = -neighborsRange; dz <= neighborsRange; dz++) { const key = `${gx + dx},${gz + dz}`; const neighbor = grid.get(key); if (!neighbor) continue; const deltaX = neighbor.x - x; const deltaZ = neighbor.z - z; const requiredSep = Math.max(candidateMinSep, neighbor.minSep); if (deltaX * deltaX + deltaZ * deltaZ < requiredSep * requiredSep) { return false; } } } const placement: RawTreePlacement & { minSep: number; forestFactor?: number } = { x, z, age: rootAge(x, z), minSep: candidateMinSep, forestFactor }; placements.push(placement); grid.set(`${gx},${gz}`, placement); return true; }; const jitter = (value: number): number => (Math.random() - 0.5) * minSeparation * jitterMultiplier; const attemptCohesion = (): void => { if (placements.length === 0) return; const anchor = placements[Math.floor(Math.random() * placements.length)]; const angle = Math.random() * Math.PI * 2; const radius = anchor.minSep * (1 + Math.random() * 0.25); const candidateX = clampToMap(anchor.x + Math.cos(angle) * radius); const candidateZ = clampToMap(anchor.z + Math.sin(angle) * radius); addPlacement(candidateX, candidateZ, anchor.forestFactor); }; const shuffled = [...positions].sort(() => Math.random() - 0.5); const batchSize = 1000; // Process 1000 positions at a time for (let i = 0; i < shuffled.length; i++) { const pos = shuffled[i]; const candidateX = clampToMap(pos.x + jitter(pos.x)); const candidateZ = clampToMap(pos.z + jitter(pos.z)); if (!addPlacement(candidateX, candidateZ, pos.forestFactor) && Math.random() < cohesionChance) { attemptCohesion(); } // Yield control back to the browser every batchSize iterations if (i % batchSize === 0 && i > 0) { await new Promise(resolve => setTimeout(resolve, 0)); } } return placements.map(({ minSep, forestFactor, ...placement }) => placement); }; const enrichTreePlacements = ( placements: RawTreePlacement[], terra: Terra, forestSampler: (x: number, z: number) => number, season: Season, palette: SeasonPalette, seed: number ): TreePlacement[] => { const heightRange = terra.getHeightRange(); const heightSpan = Math.max(1, heightRange.maxHeight - heightRange.minHeight); return placements.map((placement) => { const forestFactor = forestSampler(placement.x, placement.z); const slope = terra.getSlopeWorld(placement.x, placement.z).slopeDegrees; const slopeFactor = clamp01(1 - slope / 45); const height = terra.getHeightWorld(placement.x, placement.z); const heightNorm = clamp01((height - heightRange.minHeight) / heightSpan); const heightFactor = 0.6 + (1 - Math.abs(heightNorm - 0.5)) * 0.5; const noise = hash2(placement.x, placement.z, seed); const baseAge = clamp01(0.6 + forestFactor * 0.5 + slopeFactor * 0.15 + (noise - 0.5) * 0.2); const blendedAge = placement.age * 0.45 + baseAge * 0.55 + forestFactor * 0.1; const age = THREE.MathUtils.clamp(blendedAge, 0.55, 1.35); const moisture = clamp01(0.4 + forestFactor * 0.6 + heightFactor * 0.2 + (noise - 0.5) * 0.15 - slope / 80); const health = clamp01(0.45 + moisture * 0.55 + palette.healthBias + (noise - 0.5) * 0.2); return { x: placement.x, z: placement.z, age, health, season }; }); }; const densifyScatterPositions = ( positions: { x: number; z: number }[], terra: Terra, options: { jitter: number; slopeModifier?: (slopeDegrees: number) => number; densityScale?: number; densityMapInfluence?: number; minCopies?: number; densityThreshold?: number; densityPower?: number; maxPositions?: number; cache?: TerrainQueryCache; // Optional cache for 10-20x speedup } ): { x: number; z: number }[] => { const result: { x: number; z: number }[] = []; const maxPositions = options.maxPositions ?? Number.POSITIVE_INFINITY; const useCache = !!options.cache; const slopeModifier = options.slopeModifier; const scale = options.densityScale ?? 1; const mapInfluence = options.densityMapInfluence ?? 0.8; const minCopies = Math.max(0, Math.floor(options.minCopies ?? 1)); const densityThreshold = clamp01(options.densityThreshold ?? 0); const densityPower = Math.max(0.05, options.densityPower ?? 1); const densityDenom = Math.max(0.0001, 1 - densityThreshold); const jitter = options.jitter; // AAA OPTIMIZATION: Spatial hashing to avoid duplicate positions in same cell const cellSize = Math.max(1, jitter * 0.5); // Half jitter size const mapHalfSize = (terra.width * terra.tileSize) * 0.5; const cellOrigin = Math.ceil(mapHalfSize / cellSize) + 2; const cellStride = cellOrigin * 2 + 3; const spatialHash = new Map(); // Track density per cell const maxPerCell = 8; // Max positions per cell const getCellKey = (x: number, z: number): number => { const cx = Math.floor(x / cellSize); const cz = Math.floor(z / cellSize); return (cx + cellOrigin) * cellStride + (cz + cellOrigin); }; outer: for (let p = 0; p < positions.length; p += 1) { const pos = positions[p]; if (result.length >= maxPositions) { break; } // Use cache if available (10-20x faster) const slope = useCache ? options.cache!.getSlopeFast(pos.x, pos.z) : terra.getSlopeWorld(pos.x, pos.z).slopeDegrees; const densityValue = slopeModifier ? slopeModifier(slope) : 1; const terrainDensity = useCache ? options.cache!.getDensityFast(pos.x, pos.z) : (typeof terra.getVegetationDensityAt === 'function' ? terra.getVegetationDensityAt(pos.x, pos.z) : 0); const clampedTerrainDensity = clamp01(terrainDensity); const normalizedTerrainDensity = clampedTerrainDensity <= densityThreshold ? 0 : (clampedTerrainDensity - densityThreshold) / densityDenom; const shapedTerrainDensity = Math.pow(clamp01(normalizedTerrainDensity), densityPower); const finalDensity = Math.max(0, Math.min(3, densityValue * (1 + shapedTerrainDensity * mapInfluence))); const copies = Math.max(minCopies, Math.round(finalDensity * scale)); if (copies <= 0) { continue; } for (let i = 0; i < copies; i++) { if (result.length >= maxPositions) { break outer; } const newX = clampToMap(pos.x + (Math.random() - 0.5) * jitter); const newZ = clampToMap(pos.z + (Math.random() - 0.5) * jitter); const cellKey = getCellKey(newX, newZ); const cellCount = spatialHash.get(cellKey) ?? 0; // AAA OPTIMIZATION: Skip if cell is too dense if (cellCount >= maxPerCell) { continue; } result.push({ x: newX, z: newZ }); spatialHash.set(cellKey, cellCount + 1); } } return result; }; /** * AAA: Poisson disk sampling for natural, evenly-spaced scatter distribution * with slope-based density (more objects on steep slopes) */ const generatePoissonScatter = ( terra: Terra, options: { minDistance: number; // Minimum distance between objects maxAttempts?: number; // Max attempts per sample (default: 30) slopeDensityModifier?: (slopeDegrees: number) => number; // Density based on slope maxPositions?: number; // Maximum number of positions cache?: TerrainQueryCache; // Optional cache for performance } ): { x: number; z: number }[] => { const mapSize = terra.width * terra.tileSize; const halfSize = mapSize / 2; const minDist = options.minDistance; const maxAttempts = options.maxAttempts ?? 30; const maxPositions = options.maxPositions ?? 100000; const useCache = !!options.cache; // Grid for fast spatial lookup const cellSize = minDist / Math.sqrt(2); const gridWidth = Math.ceil(mapSize / cellSize); const grid: (number | null)[][] = Array(gridWidth).fill(null).map(() => Array(gridWidth).fill(null)); const result: { x: number; z: number }[] = []; const activeList: { x: number; z: number }[] = []; // Helper: Convert world coords to grid coords const worldToGrid = (x: number, z: number): [number, number] => { const gx = Math.floor((x + halfSize) / cellSize); const gz = Math.floor((z + halfSize) / cellSize); return [Math.max(0, Math.min(gridWidth - 1, gx)), Math.max(0, Math.min(gridWidth - 1, gz))]; }; // Helper: Check if position is valid (far enough from existing points) const isValidPosition = (x: number, z: number): boolean => { const [gx, gz] = worldToGrid(x, z); // Check neighboring cells for (let dx = -2; dx <= 2; dx++) { for (let dz = -2; dz <= 2; dz++) { const nx = gx + dx; const nz = gz + dz; if (nx < 0 || nx >= gridWidth || nz < 0 || nz >= gridWidth) continue; const idx = grid[nx][nz]; if (idx !== null) { const other = result[idx]; const distSq = (x - other.x) ** 2 + (z - other.z) ** 2; if (distSq < minDist * minDist) { return false; } } } } return true; }; // Start with random seed point const startX = (Math.random() - 0.5) * mapSize; const startZ = (Math.random() - 0.5) * mapSize; result.push({ x: startX, z: startZ }); activeList.push({ x: startX, z: startZ }); const [gx0, gz0] = worldToGrid(startX, startZ); grid[gx0][gz0] = 0; // Generate points using Poisson disk sampling while (activeList.length > 0 && result.length < maxPositions) { const randomIndex = Math.floor(Math.random() * activeList.length); const point = activeList[randomIndex]; // Get slope at this point to determine local density const slope = useCache ? options.cache!.getSlopeFast(point.x, point.z) : terra.getSlopeWorld(point.x, point.z).slopeDegrees; const densityMod = options.slopeDensityModifier ? options.slopeDensityModifier(slope) : 1.0; const localMinDist = minDist / Math.max(0.5, Math.min(2.0, densityMod)); // Adjust spacing based on slope const localMaxAttempts = Math.ceil(maxAttempts * densityMod); // More attempts on steep slopes let found = false; for (let attempt = 0; attempt < localMaxAttempts; attempt++) { // Generate random point around current point const angle = Math.random() * Math.PI * 2; const radius = localMinDist + Math.random() * localMinDist; const newX = point.x + Math.cos(angle) * radius; const newZ = point.z + Math.sin(angle) * radius; // Check if in bounds if (Math.abs(newX) > halfSize || Math.abs(newZ) > halfSize) continue; // Check if valid position if (isValidPosition(newX, newZ)) { const [gx, gz] = worldToGrid(newX, newZ); grid[gx][gz] = result.length; result.push({ x: newX, z: newZ }); activeList.push({ x: newX, z: newZ }); found = true; break; } } // Remove point from active list if no valid neighbors found if (!found) { activeList.splice(randomIndex, 1); } } return result; }; /** * Expand sparse anchor points into "strand" clusters using deterministic dependent nodes. * This is cheaper than full terrain-query densification and avoids expensive per-copy slope sampling. */ const expandAnchorsToDependentScatter = ( anchors: { x: number; z: number }[], options: { seed: number; dependentsMin: number; dependentsMax: number; radiusMin: number; radiusMax: number; densitySampler?: (x: number, z: number) => number; densityPower?: number; includeAnchor?: boolean; maxPositions?: number; } ): { x: number; z: number }[] => { const result: { x: number; z: number }[] = []; const includeAnchor = options.includeAnchor ?? true; const maxPositions = options.maxPositions ?? Number.POSITIVE_INFINITY; const dependentsMin = Math.max(0, Math.floor(options.dependentsMin)); const dependentsMax = Math.max(dependentsMin, Math.floor(options.dependentsMax)); const radiusMin = Math.max(0.05, options.radiusMin); const radiusMax = Math.max(radiusMin, options.radiusMax); const densityPower = Math.max(0.1, options.densityPower ?? 1); for (let anchorIndex = 0; anchorIndex < anchors.length; anchorIndex++) { if (result.length >= maxPositions) { break; } const anchor = anchors[anchorIndex]; const sampledDensity = options.densitySampler ? clamp01(options.densitySampler(anchor.x, anchor.z)) : 1; if (sampledDensity <= 0.005) { continue; } const densityFactor = Math.pow(sampledDensity, densityPower); const dependentCount = Math.round( THREE.MathUtils.lerp(dependentsMin, dependentsMax, densityFactor) ); if (includeAnchor) { result.push(anchor); if (result.length >= maxPositions) { break; } } for (let i = 0; i < dependentCount; i++) { if (result.length >= maxPositions) { break; } const angleNoise = hash2( anchor.x * 0.061 + i * 0.137, anchor.z * 0.073 + anchorIndex * 0.019, options.seed + i * 53 ); const radiusNoise = hash2( anchor.x * 0.097 + i * 0.193, anchor.z * 0.041 + anchorIndex * 0.029, options.seed ^ 0x9e3779b9 ); const jitterXNoise = hash2( anchor.x * 0.149 + i * 0.083, anchor.z * 0.157 + anchorIndex * 0.047, options.seed ^ 0x85ebca6b ); const jitterZNoise = hash2( anchor.x * 0.127 + i * 0.101, anchor.z * 0.167 + anchorIndex * 0.059, options.seed ^ 0xc2b2ae35 ); const angle = angleNoise * Math.PI * 2; const radialT = Math.sqrt(radiusNoise); const radiusSpread = THREE.MathUtils.lerp(radiusMin, radiusMax, radialT); const lowDensitySpreadBoost = 0.85 + (1 - densityFactor) * 0.55; const radius = radiusSpread * lowDensitySpreadBoost; const jitterAmplitude = radiusMin * 0.45; const x = clampToMap( anchor.x + Math.cos(angle) * radius + (jitterXNoise - 0.5) * jitterAmplitude ); const z = clampToMap( anchor.z + Math.sin(angle) * radius + (jitterZNoise - 0.5) * jitterAmplitude ); result.push({ x, z }); } } return result; }; /** * Weighted Poisson disk sampling where local spacing and spawn acceptance are driven by * vegetation density (and optionally slope). This avoids grid-like placement artifacts. */ const generateWeightedPoissonScatter = ( terra: Terra, options: { minDistance: number; maxAttempts?: number; maxPositions?: number; initialSeedCount?: number; initialSeedAttempts?: number; densityThreshold?: number; densityPower?: number; denseSpacingScale?: number; // spacing multiplier in dense zones (smaller = denser) sparseSpacingScale?: number; // spacing multiplier in sparse zones (larger = sparser) baseAcceptance?: number; // minimum chance to accept a candidate slopeDensityModifier?: (slopeDegrees: number) => number; densitySampler?: (x: number, z: number) => number; cache?: TerrainQueryCache; random?: () => number; } ): { x: number; z: number }[] => { type WeightedPoint = { x: number; z: number; radius: number }; const mapSize = terra.width * terra.tileSize; const halfSize = mapSize / 2; const minDistance = Math.max(0.5, options.minDistance); const maxAttempts = Math.max(4, options.maxAttempts ?? 18); const maxPositions = Math.max(1, options.maxPositions ?? 120000); const initialSeedCount = Math.max(1, Math.floor(options.initialSeedCount ?? 1)); const initialSeedAttempts = Math.max( initialSeedCount * 16, Math.floor(options.initialSeedAttempts ?? (initialSeedCount * 96)) ); const random = options.random ?? Math.random; const useCache = !!options.cache; const densityThreshold = clamp01(options.densityThreshold ?? 0.12); const densityPower = Math.max(0.1, options.densityPower ?? 1.2); const denseSpacingScale = Math.max(0.25, options.denseSpacingScale ?? 0.72); const sparseSpacingScale = Math.max(denseSpacingScale, options.sparseSpacingScale ?? 1.32); const baseAcceptance = clamp01(options.baseAcceptance ?? 0.03); const defaultDensitySampler = (x: number, z: number): number => { if (useCache) return options.cache!.getDensityFast(x, z); return typeof terra.getVegetationDensityAt === 'function' ? terra.getVegetationDensityAt(x, z) : 0; }; const densitySampler = options.densitySampler ?? defaultDensitySampler; const minPossibleRadius = minDistance * Math.min(denseSpacingScale, sparseSpacingScale) * 0.6; const cellSize = Math.max(0.5, minPossibleRadius / Math.sqrt(2)); const gridWidth = Math.max(1, Math.ceil(mapSize / cellSize)); const grid = new Int32Array(gridWidth * gridWidth); grid.fill(-1); const points: WeightedPoint[] = []; const activeList: number[] = []; const worldToGridX = (x: number): number => { const gx = Math.floor((x + halfSize) / cellSize); return gx < 0 ? 0 : (gx >= gridWidth ? gridWidth - 1 : gx); }; const worldToGridZ = (z: number): number => { const gz = Math.floor((z + halfSize) / cellSize); return gz < 0 ? 0 : (gz >= gridWidth ? gridWidth - 1 : gz); }; const getDensityWeight = (x: number, z: number): number => { const raw = clamp01(densitySampler(x, z)); if (raw <= densityThreshold) { return 0; } const normalized = (raw - densityThreshold) / Math.max(0.0001, 1 - densityThreshold); return Math.pow(clamp01(normalized), densityPower); }; const evaluateCandidate = (x: number, z: number): { weight: number; radius: number; acceptance: number } => { const weight = getDensityWeight(x, z); const spacingScale = THREE.MathUtils.lerp(sparseSpacingScale, denseSpacingScale, weight); let slopeMod = 1; if (options.slopeDensityModifier) { const slope = useCache ? options.cache!.getSlopeFast(x, z) : terra.getSlopeWorld(x, z).slopeDegrees; slopeMod = Math.max(0.35, Math.min(2.25, options.slopeDensityModifier(slope))); } const radius = Math.max(0.35, minDistance * spacingScale / slopeMod); const acceptance = Math.min(1, baseAcceptance + weight * (1 - baseAcceptance)); return { weight, radius, acceptance }; }; const isValid = (x: number, z: number, radius: number): boolean => { const gx = worldToGridX(x); const gz = worldToGridZ(z); const searchRadius = Math.max(2, Math.ceil((radius * 2) / cellSize)); for (let dz = -searchRadius; dz <= searchRadius; dz++) { for (let dx = -searchRadius; dx <= searchRadius; dx++) { const nx = gx + dx; const nz = gz + dz; if (nx < 0 || nx >= gridWidth || nz < 0 || nz >= gridWidth) continue; const neighborIdx = grid[nz * gridWidth + nx]; if (neighborIdx < 0) continue; const neighbor = points[neighborIdx]; const requiredDist = Math.max(radius, neighbor.radius); const distSq = (x - neighbor.x) * (x - neighbor.x) + (z - neighbor.z) * (z - neighbor.z); if (distSq < requiredDist * requiredDist) { return false; } } } return true; }; const addPoint = (x: number, z: number, radius: number): void => { const pointIndex = points.length; points.push({ x, z, radius }); activeList.push(pointIndex); const gx = worldToGridX(x); const gz = worldToGridZ(z); grid[gz * gridWidth + gx] = pointIndex; }; // Seed multiple initial points to avoid "single expanding disk" artifacts on large maps. for (let seedAttempt = 0; seedAttempt < initialSeedAttempts && points.length < initialSeedCount; seedAttempt++) { const x = (random() * 2 - 1) * halfSize; const z = (random() * 2 - 1) * halfSize; const candidate = evaluateCandidate(x, z); if (candidate.weight <= 0) continue; if (random() > candidate.acceptance) continue; if (!isValid(x, z, candidate.radius)) continue; addPoint(x, z, candidate.radius); } if (points.length === 0) { return []; } while (activeList.length > 0 && points.length < maxPositions) { const activeIdx = Math.floor(random() * activeList.length); const pointIndex = activeList[activeIdx]; const origin = points[pointIndex]; let found = false; for (let attempt = 0; attempt < maxAttempts; attempt++) { const angle = random() * Math.PI * 2; const radius = origin.radius * (1 + random()); const x = origin.x + Math.cos(angle) * radius; const z = origin.z + Math.sin(angle) * radius; if (Math.abs(x) > halfSize || Math.abs(z) > halfSize) continue; const candidate = evaluateCandidate(x, z); if (candidate.weight <= 0) continue; if (random() > candidate.acceptance) continue; if (!isValid(x, z, candidate.radius)) continue; addPoint(x, z, candidate.radius); found = true; break; } if (!found) { const last = activeList.length - 1; activeList[activeIdx] = activeList[last]; activeList.pop(); } } return points.map((point) => ({ x: point.x, z: point.z })); }; const ensureUvAttribute = (geometry: THREE.BufferGeometry): void => { if (geometry.getAttribute('uv')) { return; } geometry.computeBoundingBox(); const bbox = geometry.boundingBox; if (!bbox) { return; } const size = new THREE.Vector3(); bbox.getSize(size); const position = geometry.getAttribute('position') as THREE.BufferAttribute; const uv = new Float32Array(position.count * 2); const denomX = size.x !== 0 ? 1 / size.x : 0; const denomZ = size.z !== 0 ? 1 / size.z : 0; for (let i = 0; i < position.count; i++) { const x = position.getX(i) - bbox.min.x; const z = position.getZ(i) - bbox.min.z; uv[i * 2] = denomX === 0 ? 0 : x * denomX; uv[i * 2 + 1] = denomZ === 0 ? 0 : z * denomZ; } geometry.setAttribute('uv', new THREE.BufferAttribute(uv, 2)); }; const normalizeGeometryForMerge = (geometry: THREE.BufferGeometry): THREE.BufferGeometry => { let result = geometry; if (result.index) { result = result.toNonIndexed(); } if (!result.getAttribute('normal')) { result.computeVertexNormals(); } ensureUvAttribute(result); return result; }; const mergeTreeGeometries = (geometries: THREE.BufferGeometry[], label: string): THREE.BufferGeometry => { const prepared = geometries.map(normalizeGeometryForMerge); const merged = mergeGeometries(prepared, true); if (!merged || !merged.getAttribute('position')) { console.warn(`[Trees] ${label} geometry merge failed; using fallback geometry.`); return prepared[0]; } return merged; }; // Import AAA-quality tree geometry generator import { ImprovedTreeGeometry } from './geometry/ImprovedTreeGeometry'; const createTreeGeometry = (): THREE.BufferGeometry => { // Use AAA-quality conifer geometry (variant 0 for default tree) return ImprovedTreeGeometry.createConifer(0); }; // Create conifer with random variation for natural look const createConiferGeometry = (variant: number = 0): THREE.BufferGeometry => { // Use AAA-quality conifer geometry with variant return ImprovedTreeGeometry.createConifer(variant); }; // Create broadleaf with random variation for natural look const createBroadleafGeometry = (variant: number = 0): THREE.BufferGeometry => { // Use AAA-quality broadleaf geometry with variant return ImprovedTreeGeometry.createBroadleaf(variant); }; const configureVegetationTexture = (texture: THREE.Texture, repeat = 6): void => { texture.wrapS = THREE.RepeatWrapping; texture.wrapT = THREE.RepeatWrapping; texture.repeat.set(repeat, repeat); texture.generateMipmaps = true; texture.minFilter = THREE.LinearMipMapLinearFilter; texture.magFilter = THREE.LinearFilter; }; const createPlantTexture = (baseColor: number, variation = 30, repeats = 5): THREE.Texture => { // PBR UPGRADE: Higher resolution for better detail (256x256 instead of 128x128) const size = 256; const canvas = document.createElement('canvas'); canvas.width = canvas.height = size; const ctx = canvas.getContext('2d', { willReadFrequently: true })!; const color = new THREE.Color(baseColor); ctx.fillStyle = color.getStyle(); ctx.fillRect(0, 0, size, size); const imageData = ctx.getImageData(0, 0, size, size); const data = imageData.data; // PBR: Multi-scale noise for realistic organic variation for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { const idx = (y * size + x) * 4; // Large-scale variation (leaf clusters) const largeNoise = Math.sin(x * 0.05) * Math.cos(y * 0.05) * variation * 0.5; // Medium-scale variation (individual leaves) const mediumNoise = (Math.sin(x * 0.2) + Math.cos(y * 0.2)) * variation * 0.3; // Fine-scale variation (texture detail) const fineNoise = (Math.random() - 0.5) * variation; const totalNoise = largeNoise + mediumNoise + fineNoise; data[idx] = Math.max(0, Math.min(255, data[idx] + totalNoise)); data[idx + 1] = Math.max(0, Math.min(255, data[idx + 1] + totalNoise * 0.85)); data[idx + 2] = Math.max(0, Math.min(255, data[idx + 2] + totalNoise * 0.75)); } } ctx.putImageData(imageData, 0, 0); const texture = new THREE.CanvasTexture(canvas); configureVegetationTexture(texture, repeats); return texture; }; const createPlantNormalTexture = (texture: THREE.Texture): THREE.Texture => { if (!texture.image) { return texture.clone(); } const srcCanvas = texture.image as HTMLCanvasElement; const width = srcCanvas.width; const height = srcCanvas.height; const srcCtx = srcCanvas.getContext('2d', { willReadFrequently: true })!; const srcData = srcCtx.getImageData(0, 0, width, height); const destCanvas = document.createElement('canvas'); destCanvas.width = width; destCanvas.height = height; const destCtx = destCanvas.getContext('2d', { willReadFrequently: true })!; const destImage = destCtx.createImageData(width, height); const sampleGray = (x: number, y: number): number => { const cx = Math.max(0, Math.min(width - 1, x)); const cy = Math.max(0, Math.min(height - 1, y)); const idx = (cy * width + cx) * 4; return srcData.data[idx] * 0.299 + srcData.data[idx + 1] * 0.587 + srcData.data[idx + 2] * 0.114; }; // PBR UPGRADE: Stronger normal detail for better lighting response const scale = 0.025; // Increased from 0.015 for more pronounced normals for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { // Sample surrounding pixels for gradient calculation const left = sampleGray(x - 1, y); const right = sampleGray(x + 1, y); const top = sampleGray(x, y + 1); const bottom = sampleGray(x, y - 1); // Calculate normal vector from height gradients const dx = (left - right) * scale; const dy = (bottom - top) * scale; // Normalize to 0-1 range for RGB encoding const nx = THREE.MathUtils.clamp(dx + 0.5, 0, 1); const ny = THREE.MathUtils.clamp(dy + 0.5, 0, 1); // Calculate Z component (pointing outward) const nz = THREE.MathUtils.clamp( Math.sqrt(Math.max(0, 1 - (nx - 0.5) ** 2 - (ny - 0.5) ** 2)), 0, 1 ); const idx = (y * width + x) * 4; destImage.data[idx] = Math.round(nx * 255); // R = X normal destImage.data[idx + 1] = Math.round(ny * 255); // G = Y normal destImage.data[idx + 2] = Math.round(nz * 255); // B = Z normal destImage.data[idx + 3] = 255; // A = full opacity } } destCtx.putImageData(destImage, 0, 0); const normalTexture = new THREE.CanvasTexture(destCanvas); configureVegetationTexture(normalTexture, texture.repeat.x); return normalTexture; }; // PBR: Create detailed bark texture with procedural noise const createBarkTexture = (): THREE.Texture => { const size = 256; const canvas = document.createElement('canvas'); canvas.width = canvas.height = size; const ctx = canvas.getContext('2d', { willReadFrequently: true })!; // Base bark color (dark brown) const baseColor = new THREE.Color(0x3d2817); ctx.fillStyle = baseColor.getStyle(); ctx.fillRect(0, 0, size, size); const imageData = ctx.getImageData(0, 0, size, size); const data = imageData.data; // Add vertical bark grain pattern for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { const idx = (y * size + x) * 4; // Vertical grain with noise const grainNoise = Math.sin(x * 0.3 + Math.random() * 2) * 15; const verticalPattern = Math.sin(x * 0.1) * 10; // Add random bark texture variation const noise = (Math.random() - 0.5) * 40; const totalVariation = grainNoise + verticalPattern + noise; data[idx] = Math.max(0, Math.min(255, data[idx] + totalVariation)); data[idx + 1] = Math.max(0, Math.min(255, data[idx + 1] + totalVariation * 0.8)); data[idx + 2] = Math.max(0, Math.min(255, data[idx + 2] + totalVariation * 0.6)); } } ctx.putImageData(imageData, 0, 0); const texture = new THREE.CanvasTexture(canvas); configureVegetationTexture(texture, 4); return texture; }; // PBR: Create bark normal map with deep grooves const createBarkNormalMap = (): THREE.Texture => { const size = 256; const canvas = document.createElement('canvas'); canvas.width = canvas.height = size; const ctx = canvas.getContext('2d', { willReadFrequently: true })!; const imageData = ctx.createImageData(size, size); const data = imageData.data; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { const idx = (y * size + x) * 4; // Vertical bark grooves const grooveDepth = Math.sin(x * 0.3) * 0.5 + 0.5; const noise = Math.random() * 0.2; // Normal map: X (red), Y (green), Z (blue) const nx = grooveDepth * 0.3 + noise; const ny = 0.5 + noise * 0.5; const nz = 0.7 + grooveDepth * 0.3; data[idx] = Math.round(nx * 255); data[idx + 1] = Math.round(ny * 255); data[idx + 2] = Math.round(nz * 255); data[idx + 3] = 255; } } ctx.putImageData(imageData, 0, 0); const texture = new THREE.CanvasTexture(canvas); configureVegetationTexture(texture, 4); return texture; }; // PBR: Create roughness map (bark is rough, leaves less so) const createRoughnessMap = (baseRoughness: number, variation: number): THREE.Texture => { const size = 128; const canvas = document.createElement('canvas'); canvas.width = canvas.height = size; const ctx = canvas.getContext('2d', { willReadFrequently: true })!; const imageData = ctx.createImageData(size, size); const data = imageData.data; for (let i = 0; i < data.length; i += 4) { const roughness = baseRoughness + (Math.random() - 0.5) * variation; const value = Math.round(THREE.MathUtils.clamp(roughness, 0, 1) * 255); data[i] = value; data[i + 1] = value; data[i + 2] = value; data[i + 3] = 255; } ctx.putImageData(imageData, 0, 0); const texture = new THREE.CanvasTexture(canvas); configureVegetationTexture(texture, 3); return texture; }; // PBR: Create ambient occlusion map for depth const createAOMap = (intensity: number): THREE.Texture => { const size = 128; const canvas = document.createElement('canvas'); canvas.width = canvas.height = size; const ctx = canvas.getContext('2d', { willReadFrequently: true })!; const imageData = ctx.createImageData(size, size); const data = imageData.data; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { const idx = (y * size + x) * 4; // Darker in crevices, lighter on surfaces const ao = intensity + (Math.random() - 0.5) * 0.3; const value = Math.round(THREE.MathUtils.clamp(ao, 0, 1) * 255); data[idx] = value; data[idx + 1] = value; data[idx + 2] = value; data[idx + 3] = 255; } } ctx.putImageData(imageData, 0, 0); const texture = new THREE.CanvasTexture(canvas); configureVegetationTexture(texture, 3); return texture; }; const createPebbleGeometry = (): THREE.BufferGeometry => { const geometry = new THREE.IcosahedronGeometry(0.35, 0); geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0.15, 0)); return geometry; }; const createInstancedScatter = ( positions: { x: number; z: number }[], geometry: THREE.BufferGeometry, material: THREE.Material, terra: Terra, options: { heightOffset?: number; scaleRange?: [number, number]; slopeScale?: number; // DEPRECATED: Use for rocks/debris only. Trees/vegetation should NOT use slope scaling scaleVariance?: number; instanceAge?: number[]; ageInfluence?: number; instanceHealth?: number[]; healthInfluence?: number; instanceColors?: THREE.Color[]; allowTilt?: boolean; frustumCulled?: boolean; cache?: TerrainQueryCache; } = {} ): THREE.InstancedMesh => { const mesh = new THREE.InstancedMesh(geometry, material, positions.length); mesh.userData.scatterHeightOffset = options.heightOffset ?? 0; mesh.frustumCulled = options.frustumCulled ?? true; mesh.castShadow = false; // Disable shadows for performance mesh.receiveShadow = false; if (options.instanceColors) { mesh.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(positions.length * 3), 3); } const matrix = new THREE.Matrix4(); const quaternion = new THREE.Quaternion(); const up = new THREE.Vector3(0, 1, 0); const tempScale = new THREE.Vector3(); const tiltAxis = new THREE.Vector3(); const yawRotation = new THREE.Quaternion(); const tiltQuat = new THREE.Quaternion(); const normalVec = new THREE.Vector3(); const tempPosition = new THREE.Vector3(); const allowTilt = options.allowTilt === true; const useSlopeScale = (options.slopeScale ?? 0) > 0; const slopeScale = options.slopeScale ?? 0; const scaleMin = options.scaleRange?.[0] ?? 0.8; const scaleMax = options.scaleRange?.[1] ?? 1.3; const scaleVariance = options.scaleVariance ?? 0.35; const ageInfluence = options.ageInfluence ?? 0.5; const healthInfluence = options.healthInfluence ?? 0.35; const heightOffset = options.heightOffset ?? 0; const instanceColors = options.instanceColors; const cache = options.cache; const useCache = !!cache; for (let index = 0; index < positions.length; index += 1) { const pos = positions[index]; const height = (useCache ? cache!.getHeightFast(pos.x, pos.z) : terra.getHeightWorld(pos.x, pos.z)) + heightOffset; // Rotation: Random Y-axis rotation with optional terrain alignment for rocks yawRotation.setFromAxisAngle(up, Math.random() * Math.PI * 2); if (allowTilt) { const normal = useCache ? cache!.getSurfaceNormalFast(pos.x, pos.z) : terra.getSurfaceNormal(pos.x, pos.z); // Align to terrain normal (good for rocks/debris that should sit on slopes) normalVec.set(normal.x, normal.y, normal.z); quaternion.setFromUnitVectors(up, normalVec); quaternion.multiply(yawRotation); tiltAxis.set(normal.z, 0, -normal.x); if (tiltAxis.lengthSq() < 1e-4) { tiltAxis.set(1, 0, 0); } tiltAxis.normalize(); const tiltAngle = (Math.random() - 0.5) * 0.24; tiltQuat.setFromAxisAngle(tiltAxis, tiltAngle); quaternion.multiply(tiltQuat); } else { quaternion.copy(yawRotation); } // Base scale with random variation const scaleValue = THREE.MathUtils.lerp(scaleMin, scaleMax, Math.random()); tempScale.setScalar(scaleValue); // DEPRECATED: Slope-based scaling (only use for rocks/debris, not vegetation) // For realistic forests, trees should NOT be scaled by slope if (useSlopeScale) { const slopeDegrees = useCache ? cache!.getSlopeFast(pos.x, pos.z) : terra.getSlopeWorld(pos.x, pos.z).slopeDegrees; const slopeFactor = Math.min(1, Math.max(0, slopeDegrees / 40)); const slopeInfluence = 1 - slopeFactor * slopeScale; tempScale.multiplyScalar(slopeInfluence); } tempScale.x *= 1 + (Math.random() - 0.5) * scaleVariance; tempScale.z *= 1 + (Math.random() - 0.5) * scaleVariance; tempScale.y *= options.scaleRange ? 1 + Math.random() * 0.4 : 1; const age = options.instanceAge?.[index] ?? 1; const ageFactor = 1 + (age - 1) * ageInfluence; const health = options.instanceHealth?.[index] ?? 1; const healthFactor = 1 - (1 - health) * healthInfluence; tempScale.multiplyScalar(ageFactor * healthFactor); tempPosition.set(pos.x, height, pos.z); matrix.compose(tempPosition, quaternion, tempScale); mesh.setMatrixAt(index, matrix); if (instanceColors) { const color = instanceColors[index]; if (color) { mesh.setColorAt(index, color); } } } mesh.instanceMatrix.needsUpdate = true; if (mesh.instanceColor) { mesh.instanceColor.needsUpdate = true; } if (mesh.frustumCulled && positions.length > 0) { mesh.computeBoundingBox(); mesh.computeBoundingSphere(); } return mesh; }; const createChunkedInstancedScatter = ( positions: { x: number; z: number }[], geometry: THREE.BufferGeometry, material: THREE.Material, terra: Terra, options: { keyPrefix: string; chunkSize: number; maxChunkCount?: number; heightOffset?: number; scaleRange?: [number, number]; slopeScale?: number; scaleVariance?: number; instanceAge?: number[]; ageInfluence?: number; instanceHealth?: number[]; healthInfluence?: number; instanceColors?: THREE.Color[]; allowTilt?: boolean; cache?: TerrainQueryCache; } ): THREE.Group => { const baseChunkSize = Math.max(32, options.chunkSize); const maxChunkCount = Math.max(1, Math.floor(options.maxChunkCount ?? 128)); type ChunkData = { cx: number; cz: number; positions: { x: number; z: number }[]; colors?: THREE.Color[]; }; const chunkOrigin = Math.ceil(MAP_HALF_SIZE / baseChunkSize) + 2; const chunkStride = chunkOrigin * 2 + 3; const chunkMap = new Map(); for (let i = 0; i < positions.length; i++) { const pos = positions[i]; const cx = Math.floor((pos.x + MAP_HALF_SIZE) / baseChunkSize); const cz = Math.floor((pos.z + MAP_HALF_SIZE) / baseChunkSize); const key = (cx + chunkOrigin) * chunkStride + (cz + chunkOrigin); let chunk = chunkMap.get(key); if (!chunk) { chunk = { cx, cz, positions: [], colors: options.instanceColors ? [] : undefined }; chunkMap.set(key, chunk); } chunk.positions.push(pos); if (chunk.colors && options.instanceColors) { chunk.colors.push(options.instanceColors[i]); } } let mergedChunks = [...chunkMap.values()]; let mergeFactor = 1; while (mergedChunks.length > maxChunkCount && mergeFactor < 32) { const nextMergeFactor = mergeFactor * 2; const mergedMap = new Map(); for (const chunk of mergedChunks) { const mergedCx = Math.floor(chunk.cx / nextMergeFactor); const mergedCz = Math.floor(chunk.cz / nextMergeFactor); const mergedKey = (mergedCx + chunkOrigin) * chunkStride + (mergedCz + chunkOrigin); let merged = mergedMap.get(mergedKey); if (!merged) { merged = { cx: mergedCx, cz: mergedCz, positions: [], colors: options.instanceColors ? [] : undefined }; mergedMap.set(mergedKey, merged); } for (const position of chunk.positions) { merged.positions.push(position); } if (merged.colors && chunk.colors) { for (const color of chunk.colors) { merged.colors.push(color); } } } mergedChunks = [...mergedMap.values()]; mergeFactor = nextMergeFactor; } const chunkGroup = new THREE.Group(); chunkGroup.name = options.keyPrefix; const chunkSize = baseChunkSize * mergeFactor; const chunks = mergedChunks.sort((a, b) => (a.cz - b.cz) || (a.cx - b.cx)); const avgChunkInstances = chunks.length > 0 ? Math.round(positions.length / chunks.length) : 0; console.info( `[Scatter] ${options.keyPrefix}: ${positions.length} instances in ${chunks.length} chunks ` + `(chunkSize=${chunkSize}, avg=${avgChunkInstances}, maxChunks=${maxChunkCount})` ); for (const chunk of chunks) { const mesh = createInstancedScatter(chunk.positions, geometry, material, terra, { heightOffset: options.heightOffset, scaleRange: options.scaleRange, slopeScale: options.slopeScale, scaleVariance: options.scaleVariance, instanceAge: options.instanceAge, ageInfluence: options.ageInfluence, instanceHealth: options.instanceHealth, healthInfluence: options.healthInfluence, instanceColors: chunk.colors, allowTilt: options.allowTilt, frustumCulled: true, cache: options.cache }); mesh.name = `${options.keyPrefix}-chunk-${chunk.cx}-${chunk.cz}`; mesh.userData.scatterChunk = { cx: chunk.cx, cz: chunk.cz, chunkSize, count: chunk.positions.length }; chunkGroup.add(mesh); } return chunkGroup; }; const createFernGeometry = (): THREE.BufferGeometry => { const width = 0.9; const height = 0.6; const basePlane = new THREE.PlaneGeometry(width, height, 1, 2); basePlane.applyMatrix4(new THREE.Matrix4().makeTranslation(0, height * 0.5, 0)); const crossA = basePlane.clone(); const crossB = basePlane.clone(); crossB.rotateY(Math.PI / 2); return mergeGeometries([crossA, crossB], true)!; }; const createFeatureOverlay = (color: number): THREE.LineSegments => { const geometry = new THREE.BufferGeometry(); const material = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.55, depthWrite: false }); const line = new THREE.LineSegments(geometry, material); line.frustumCulled = false; return line; }; const updateFeatureOverlay = ( line: THREE.LineSegments, highlights: { x: number; z: number; feature?: StrategicFeature }[], heightFunc: (x: number, z: number) => number, size: number ): void => { if (highlights.length === 0) { line.geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(0), 3)); return; } const positions = new Float32Array(highlights.length * 4 * 3); let ptr = 0; for (const highlight of highlights) { const y = heightFunc(highlight.x, highlight.z) + 0.2; const pairs = [ { dx: -size, dz: 0 }, { dx: size, dz: 0 }, { dx: 0, dz: -size }, { dx: 0, dz: size } ]; for (let i = 0; i < pairs.length; i += 2) { const a = pairs[i]; const b = pairs[i + 1]; positions[ptr++] = highlight.x + a.dx; positions[ptr++] = y; positions[ptr++] = highlight.z + a.dz; positions[ptr++] = highlight.x + b.dx; positions[ptr++] = y; positions[ptr++] = highlight.z + b.dz; } } line.geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); line.geometry.attributes.position.needsUpdate = true; line.geometry.computeBoundingSphere(); }; const createAiPlacementOverlay = (scene: THREE.Scene, terra: Terra) => { const group = new THREE.Group(); group.name = 'ai-placement-overlay'; group.visible = false; const candidateLine = new THREE.LineSegments( new THREE.BufferGeometry(), new THREE.LineBasicMaterial({ color: 0x63c6ff, transparent: true, opacity: 0.55, depthWrite: false }) ); candidateLine.frustumCulled = false; const chosenLine = new THREE.LineSegments( new THREE.BufferGeometry(), new THREE.LineBasicMaterial({ color: 0x6dff91, transparent: true, opacity: 0.85, depthWrite: false }) ); chosenLine.frustumCulled = false; group.add(candidateLine, chosenLine); scene.add(group); let lastUpdate = 0; let lastSignature = ''; const updateLine = (line: THREE.LineSegments, points: Array<{ x: number; z: number }>, size: number): void => { if (points.length === 0) { line.geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(0), 3)); return; } const positions = new Float32Array(points.length * 4 * 3); let ptr = 0; for (const point of points) { const y = terra.getHeightWorld(point.x, point.z) + 0.35; positions[ptr++] = point.x - size; positions[ptr++] = y; positions[ptr++] = point.z; positions[ptr++] = point.x + size; positions[ptr++] = y; positions[ptr++] = point.z; positions[ptr++] = point.x; positions[ptr++] = y; positions[ptr++] = point.z - size; positions[ptr++] = point.x; positions[ptr++] = y; positions[ptr++] = point.z + size; } line.geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); line.geometry.attributes.position.needsUpdate = true; line.geometry.computeBoundingSphere(); }; const update = (snapshots: AiPlacementDebugSnapshot[]): void => { if (!group.visible) return; const now = performance.now(); if (now - lastUpdate < 220) return; lastUpdate = now; const candidates: Array<{ x: number; z: number }> = []; const chosen: Array<{ x: number; z: number }> = []; let newest = 0; for (const snapshot of snapshots) { newest = Math.max(newest, snapshot.timestamp); for (const candidate of snapshot.candidates) { candidates.push({ x: candidate.x, z: candidate.z }); } if (snapshot.chosen) { chosen.push({ x: snapshot.chosen.x, z: snapshot.chosen.z }); } } const signature = `${newest}:${candidates.length}:${chosen.length}`; if (signature === lastSignature) { return; } lastSignature = signature; updateLine(candidateLine, candidates, 5); updateLine(chosenLine, chosen, 9); }; const toggle = (): void => { group.visible = !group.visible; }; return { toggle, update, group }; }; const ENABLE_FEATURE_OVERLAY = false; const modulateOverlayOpacity = (line: THREE.LineSegments, value: number): void => { const material = line.material as THREE.LineBasicMaterial; material.opacity = value; material.needsUpdate = true; }; type TerrainDebugMode = 'bands' | 'height'; type TerrainDebugRenderOptions = { mode: TerrainDebugMode; showContours: boolean; showFeatures: boolean; contourSteps?: number; }; const TERRAIN_BAND_COLORS: Array<[number, number, number]> = [ [18, 32, 64], [38, 70, 94], [72, 104, 96], [104, 124, 92], [142, 138, 88], [178, 144, 90], [204, 150, 98], [226, 160, 112] ]; const TERRAIN_DEBUG_COLORS = { ramp: [92, 210, 255] as [number, number, number], ridge: [255, 166, 80] as [number, number, number], basin: [40, 70, 130] as [number, number, number], choke: [255, 96, 140] as [number, number, number], contour: [15, 20, 28] as [number, number, number] }; const blendColor = ( base: [number, number, number], overlay: [number, number, number], alpha: number ): [number, number, number] => { const clamped = Math.min(1, Math.max(0, alpha)); return [ Math.round(base[0] + (overlay[0] - base[0]) * clamped), Math.round(base[1] + (overlay[1] - base[1]) * clamped), Math.round(base[2] + (overlay[2] - base[2]) * clamped) ]; }; const lerpColor = ( from: [number, number, number], to: [number, number, number], t: number ): [number, number, number] => blendColor(from, to, t); const mapPixelToTerrainCoordinate = ( pixelIndex: number, pixelCount: number, terrainSize: number ): number => { if (terrainSize <= 1 || pixelCount <= 1) { return 0; } const normalized = Math.max(0, Math.min(1, pixelIndex / (pixelCount - 1))); const mapped = Math.round(normalized * (terrainSize - 1)); return Math.min(terrainSize - 1, Math.max(0, mapped)); }; const computeHeightRange = (heights: Float32Array): { min: number; max: number; span: number } => { let min = Infinity; let max = -Infinity; for (let i = 0; i < heights.length; i++) { const value = heights[i]; if (value < min) min = value; if (value > max) max = value; } if (!Number.isFinite(min) || !Number.isFinite(max)) { min = 0; max = 1; } const span = Math.max(0.0001, max - min); return { min, max, span }; }; const renderTerrainDebugCanvas = ( canvas: HTMLCanvasElement, terra: Terra, options: TerrainDebugRenderOptions ): void => { const width = Math.max(1, canvas.width); const height = Math.max(1, canvas.height); if (canvas.width !== width) canvas.width = width; if (canvas.height !== height) canvas.height = height; const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.imageSmoothingEnabled = false; const { min, span } = computeHeightRange(terra.heights); const data = ctx.createImageData(width, height); const heights = terra.heights; const terrainWidth = terra.width; const terrainHeight = terra.height; const bandCount = TERRAIN_BAND_COLORS.length; const contourSteps = Math.max(4, options.contourSteps ?? 12); const contourStep = span / contourSteps; const contourBand = 0.04; const basinThreshold = min + span * 0.22; const tileSize = terra.tileSize; for (let k = 0; k < height; k++) { for (let i = 0; i < width; i++) { const sampleX = mapPixelToTerrainCoordinate(i, width, terrainWidth); const sampleZ = mapPixelToTerrainCoordinate(k, height, terrainHeight); const idx = sampleX * terrainHeight + sampleZ; const heightValue = heights[idx]; const normalized = (heightValue - min) / span; let color: [number, number, number]; if (options.mode === 'height') { const shade = Math.round(normalized * 255); color = [shade, shade, shade]; } else { const bandIndex = Math.min(bandCount - 1, Math.max(0, Math.floor(normalized * bandCount))); color = TERRAIN_BAND_COLORS[bandIndex]; } if (options.showFeatures) { if (heightValue <= basinThreshold) { color = blendColor(color, TERRAIN_DEBUG_COLORS.basin, 0.6); } const affordance = terra.affordances.getAt(sampleX, sampleZ); if (affordance?.type === AffordanceType.RAMP) { color = TERRAIN_DEBUG_COLORS.ramp; } else { const worldX = sampleX * tileSize + tileSize * 0.5 - MAP_HALF_SIZE; const worldZ = sampleZ * tileSize + tileSize * 0.5 - MAP_HALF_SIZE; const feature = terra.getStrategicFeatureAt(worldX, worldZ); if (feature & StrategicFeature.HIGH_GROUND) { color = blendColor(color, TERRAIN_DEBUG_COLORS.ridge, 0.55); } if (feature & StrategicFeature.CHOKE_POINT) { color = blendColor(color, TERRAIN_DEBUG_COLORS.choke, 0.5); } } } if (options.showContours && contourStep > 0) { const phase = (heightValue - min) / contourStep; const frac = phase - Math.floor(phase); if (frac < contourBand || frac > 1 - contourBand) { color = TERRAIN_DEBUG_COLORS.contour; } } const offset = (k * width + i) * 4; data.data[offset] = color[0]; data.data[offset + 1] = color[1]; data.data[offset + 2] = color[2]; data.data[offset + 3] = 255; } } ctx.putImageData(data, 0, 0); }; const downloadCanvas = (canvas: HTMLCanvasElement, filename: string): void => { canvas.toBlob((blob) => { if (!blob) { const fallbackUrl = canvas.toDataURL('image/png'); const fallbackLink = document.createElement('a'); fallbackLink.href = fallbackUrl; fallbackLink.download = filename; fallbackLink.click(); return; } const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; link.click(); URL.revokeObjectURL(url); }, 'image/png'); }; const exportTerrainHeightmap = (terra: Terra): void => { const rawCanvas = document.createElement('canvas'); rawCanvas.width = terra.width; rawCanvas.height = terra.height; renderTerrainDebugCanvas(rawCanvas, terra, { mode: 'height', showContours: false, showFeatures: false }); downloadCanvas(rawCanvas, 'terrain_heightmap_raw.png'); const contourCanvas = document.createElement('canvas'); contourCanvas.width = terra.width; contourCanvas.height = terra.height; renderTerrainDebugCanvas(contourCanvas, terra, { mode: 'height', showContours: true, showFeatures: false }); downloadCanvas(contourCanvas, 'terrain_heightmap_contours.png'); }; const createTerrainDebugOverlay = (terra: Terra) => { const OVERLAY_MAX_DIMENSION = 256; // keep the overlay lightweight by not sampling every tile const scale = Math.max( 1, Math.ceil(Math.max(terra.width, terra.height) / OVERLAY_MAX_DIMENSION) ); const canvasWidth = Math.max(1, Math.ceil(terra.width / scale)); const canvasHeight = Math.max(1, Math.ceil(terra.height / scale)); const canvas = document.createElement('canvas'); canvas.width = canvasWidth; canvas.height = canvasHeight; canvas.style.position = 'fixed'; canvas.style.right = '16px'; canvas.style.bottom = '16px'; canvas.style.width = '320px'; canvas.style.height = '320px'; canvas.style.border = '1px solid rgba(120, 190, 220, 0.45)'; canvas.style.background = 'rgba(6, 12, 18, 0.65)'; canvas.style.zIndex = '30'; canvas.style.display = 'none'; canvas.style.pointerEvents = 'none'; canvas.style.imageRendering = 'pixelated'; document.body.appendChild(canvas); const renderOptions: TerrainDebugRenderOptions = { mode: 'bands', showContours: true, showFeatures: true }; const render = (): void => { renderTerrainDebugCanvas(canvas, terra, renderOptions); }; let visible = false; const toggle = (): void => { visible = !visible; canvas.style.display = visible ? 'block' : 'none'; if (visible) { render(); } }; return { canvas, render, toggle }; }; const renderForestFactorCanvas = ( canvas: HTMLCanvasElement, terra: Terra, sampler: (x: number, z: number) => number ): void => { const width = Math.max(1, canvas.width); const height = Math.max(1, canvas.height); if (canvas.width !== width) canvas.width = width; if (canvas.height !== height) canvas.height = height; const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.imageSmoothingEnabled = false; const data = ctx.createImageData(width, height); const terrainWidth = terra.width; const terrainHeight = terra.height; const tileSize = terra.tileSize; const lowColor: [number, number, number] = [10, 14, 18]; const highColor: [number, number, number] = [80, 220, 120]; for (let k = 0; k < height; k++) { for (let i = 0; i < width; i++) { const sampleX = mapPixelToTerrainCoordinate(i, width, terrainWidth); const sampleZ = mapPixelToTerrainCoordinate(k, height, terrainHeight); const worldX = sampleX * tileSize + tileSize * 0.5 - MAP_HALF_SIZE; const worldZ = sampleZ * tileSize + tileSize * 0.5 - MAP_HALF_SIZE; const factor = Math.min(1, Math.max(0, sampler(worldX, worldZ))); const color = lerpColor(lowColor, highColor, factor); const offset = (k * width + i) * 4; data.data[offset] = color[0]; data.data[offset + 1] = color[1]; data.data[offset + 2] = color[2]; data.data[offset + 3] = 255; } } ctx.putImageData(data, 0, 0); }; const computeForestPatchStats = ( terra: Terra, sampler: (x: number, z: number) => number, threshold: number ): { patchCount: number; avgPatchAreaKm2: number; maxPatchAreaKm2: number; forestCoveragePct: number; sampleResolution: string; } => { const maxDimension = 256; const scale = Math.max(1, Math.ceil(Math.max(terra.width, terra.height) / maxDimension)); const width = Math.max(4, Math.ceil(terra.width / scale)); const height = Math.max(4, Math.ceil(terra.height / scale)); const mask = new Uint8Array(width * height); const visited = new Uint8Array(width * height); const tileSize = terra.tileSize; const clampedThreshold = clamp01(threshold); let forestCells = 0; for (let k = 0; k < height; k++) { for (let i = 0; i < width; i++) { const sampleX = mapPixelToTerrainCoordinate(i, width, terra.width); const sampleZ = mapPixelToTerrainCoordinate(k, height, terra.height); const worldX = sampleX * tileSize + tileSize * 0.5 - MAP_HALF_SIZE; const worldZ = sampleZ * tileSize + tileSize * 0.5 - MAP_HALF_SIZE; const factor = sampler(worldX, worldZ); const idx = k * width + i; if (factor >= clampedThreshold) { mask[idx] = 1; forestCells += 1; } } } const stack: number[] = []; let patchCount = 0; let maxPatchCells = 0; let totalPatchCells = 0; const pushIfValid = (x: number, z: number): void => { if (x < 0 || z < 0 || x >= width || z >= height) return; const idx = z * width + x; if (mask[idx] === 0 || visited[idx] === 1) return; visited[idx] = 1; stack.push(idx); }; for (let k = 0; k < height; k++) { for (let i = 0; i < width; i++) { const idx = k * width + i; if (mask[idx] === 0 || visited[idx] === 1) continue; patchCount += 1; let patchCells = 0; visited[idx] = 1; stack.length = 0; stack.push(idx); while (stack.length > 0) { const current = stack.pop()!; patchCells += 1; const x = current % width; const z = Math.floor(current / width); pushIfValid(x - 1, z); pushIfValid(x + 1, z); pushIfValid(x, z - 1); pushIfValid(x, z + 1); } totalPatchCells += patchCells; if (patchCells > maxPatchCells) { maxPatchCells = patchCells; } } } const cellArea = (MAP_SIZE_METERS / width) * (MAP_SIZE_METERS / height); const areaFactor = cellArea / 1_000_000; const totalForestAreaKm2 = forestCells * areaFactor; const avgPatchAreaKm2 = patchCount > 0 ? (totalPatchCells * areaFactor) / patchCount : 0; const maxPatchAreaKm2 = maxPatchCells * areaFactor; const forestCoveragePct = (forestCells / (width * height)) * 100; return { patchCount, avgPatchAreaKm2, maxPatchAreaKm2, forestCoveragePct, sampleResolution: `${width}x${height}` }; }; const createForestFactorOverlay = ( terra: Terra, sampler: (x: number, z: number) => number ) => { const OVERLAY_MAX_DIMENSION = 256; const scale = Math.max(1, Math.ceil(Math.max(terra.width, terra.height) / OVERLAY_MAX_DIMENSION)); const canvasWidth = Math.max(1, Math.ceil(terra.width / scale)); const canvasHeight = Math.max(1, Math.ceil(terra.height / scale)); const canvas = document.createElement('canvas'); canvas.width = canvasWidth; canvas.height = canvasHeight; canvas.style.position = 'fixed'; canvas.style.left = '16px'; canvas.style.bottom = '16px'; canvas.style.width = '280px'; canvas.style.height = '280px'; canvas.style.border = '1px solid rgba(120, 220, 140, 0.5)'; canvas.style.background = 'rgba(6, 12, 18, 0.65)'; canvas.style.zIndex = '30'; canvas.style.display = 'none'; canvas.style.pointerEvents = 'none'; canvas.style.imageRendering = 'pixelated'; document.body.appendChild(canvas); const render = (): void => { renderForestFactorCanvas(canvas, terra, sampler); }; let visible = false; const toggle = (): void => { visible = !visible; canvas.style.display = visible ? 'block' : 'none'; if (visible) { render(); } }; const exportOverlay = (): void => { render(); downloadCanvas(canvas, 'forest_factor_overlay.png'); }; return { canvas, render, toggle, export: exportOverlay }; }; /** * Create a procedural texture for terrain */ const createProceduralTerrainTexture = (baseColor: THREE.Color, variation: number = 0.1): THREE.Texture => { const quality = localStorage.getItem('rts.terrain.textureQuality') || 'HIGH'; const qualityMap: Record = { LOW: 256, MEDIUM: 512, HIGH: 1024, ULTRA: 2048 }; const size = qualityMap[quality] ?? 1024; const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d')!; const imageData = ctx.createImageData(size, size); for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { const i = (y * size + x) * 4; const noise = (Math.random() - 0.5) * variation; imageData.data[i] = Math.floor((baseColor.r + noise) * 255); imageData.data[i + 1] = Math.floor((baseColor.g + noise) * 255); imageData.data[i + 2] = Math.floor((baseColor.b + noise) * 255); imageData.data[i + 3] = 255; } } ctx.putImageData(imageData, 0, 0); const texture = new THREE.CanvasTexture(canvas); texture.wrapS = THREE.RepeatWrapping; texture.wrapT = THREE.RepeatWrapping; texture.generateMipmaps = true; texture.minFilter = THREE.LinearMipmapLinearFilter; texture.magFilter = THREE.LinearFilter; return texture; }; /** * Create a flat normal map */ const createFlatNormalMap = (): THREE.Texture => { const size = 64; const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d')!; // Normal pointing up: RGB(128, 128, 255) = normal(0, 0, 1) ctx.fillStyle = 'rgb(128, 128, 255)'; ctx.fillRect(0, 0, size, size); const texture = new THREE.CanvasTexture(canvas); texture.wrapS = THREE.RepeatWrapping; texture.wrapT = THREE.RepeatWrapping; texture.generateMipmaps = true; texture.minFilter = THREE.LinearMipmapLinearFilter; texture.magFilter = THREE.LinearFilter; return texture; }; /** * Create and setup modern terrain renderer with procedural textures */ const createModernTerrainRenderer = ( terra: Terra, scene: THREE.Scene, renderer: THREE.WebGLRenderer ): ModernTerrainRenderer => { console.log('[ModernTerrain] Creating procedural textures...'); // Create procedural textures const grassTexture = createProceduralTerrainTexture(new THREE.Color(0x4a7c3a), 0.1); const rockTexture = createProceduralTerrainTexture(new THREE.Color(0x808080), 0.15); const sandTexture = createProceduralTerrainTexture(new THREE.Color(0xc9b882), 0.08); const dirtTexture = createProceduralTerrainTexture(new THREE.Color(0x6b5638), 0.12); // Configure textures for quality [grassTexture, rockTexture, sandTexture, dirtTexture].forEach(tex => { tex.anisotropy = renderer.capabilities.getMaxAnisotropy(); }); // Create flat normal maps const flatNormal = createFlatNormalMap(); console.log('[ModernTerrain] Creating terrain renderer...'); // Create terrain renderer const terrainRenderer = new ModernTerrainRenderer( terra, { grassTexture, rockTexture, sandTexture, dirtTexture, grassNormal: flatNormal, rockNormal: flatNormal, sandNormal: flatNormal, dirtNormal: flatNormal, textureScale: 0.05, normalStrength: 0.0 // No normal mapping with flat normals }, { tileSize: terra.tileSize, verticalScale: terra.getVerticalScale(), enableShadows: true, shadowBias: -0.0001, normalBias: 0.02, // 🔥 PHASE 1: Enable terrain chunking for 50-75% triangle reduction enableChunking: true, chunkGridSize: 8, // 8x8 grid = 64 chunks enableLOD: true, lodLevels: 4, lodDistances: [140, 320, 640], lodUpdatesPerFrame: 4 } ); // Add to scene const mesh = terrainRenderer.getMesh(); scene.add(mesh); // Store terrain renderer reference for normal map updates (window.__RTS as any).terrainRenderer = terrainRenderer; // Store textures for terrain regeneration (window.__RTS as any).terrainTextures = { grassTexture, rockTexture, sandTexture, dirtTexture, flatNormal }; // Swap in terrain textures (official assets or pre-rendered procedural PNGs) once loaded. const textureLoader = new THREE.TextureLoader(); const loadTerrainTexture = (url: string, useSRGB: boolean = true): Promise => { return new Promise((resolve, reject) => { textureLoader.load( url, (texture) => { texture.wrapS = THREE.RepeatWrapping; texture.wrapT = THREE.RepeatWrapping; texture.generateMipmaps = true; texture.minFilter = THREE.LinearMipmapLinearFilter; texture.magFilter = THREE.LinearFilter; texture.anisotropy = renderer.capabilities.getMaxAnisotropy(); texture.colorSpace = useSRGB ? THREE.SRGBColorSpace : THREE.NoColorSpace; resolve(texture); }, undefined, (err) => reject(err) ); }); }; const basePath = `${import.meta.env.BASE_URL}assets/terrain`; const generatedFolder = `${basePath}generated`; const generatedReadyUrl = `${generatedFolder}/ready.json`; const useGeneratedTextures = typeof window !== 'undefined' && window.localStorage?.getItem('rts.useGeneratedTerrainTextures') === 'true'; const terrainTextureState = ensureRTS(); terrainTextureState.textureSource = null; terrainTextureState.textureStatus = 'idle'; updateTextureStatusDisplay(); const checkGeneratedReady = async () => { if (!useGeneratedTextures) return false; try { const response = await fetch(generatedReadyUrl, { method: 'GET', cache: 'no-store' }); if (!response.ok) { return false; } const payload = await response.json(); return payload?.generated === true; } catch { return false; } }; const swapTextures = async () => { const rtsState = ensureRTS(); rtsState.textureStatus = 'loading'; updateTextureStatusDisplay(); let textureSource: 'generated' | 'official' = useGeneratedTextures ? 'generated' : 'official'; if (textureSource === 'generated') { const ready = await checkGeneratedReady(); if (!ready) { textureSource = 'official'; rtsState.textureStatus = 'missing-generated'; updateTextureStatusDisplay(); console.warn('[ModernTerrain] Generated textures missing, falling back to official assets'); } } rtsState.textureSource = textureSource; updateTextureStatusDisplay(); const folder = textureSource === 'generated' ? generatedFolder : basePath; const suffix = textureSource === 'generated' ? '_albedo.png' : '/albedo.jpg'; try { const [grass, rock, sand, dirt] = await Promise.all([ loadTerrainTexture(`${folder}/grass${suffix}`, true), loadTerrainTexture(`${folder}/rock${suffix}`, true), loadTerrainTexture(`${folder}/sand${suffix}`, true), loadTerrainTexture(`${folder}/dirt${suffix}`, true) ]); terrainRenderer.updateTerrainTextures({ grassTexture: grass, rockTexture: rock, sandTexture: sand, dirtTexture: dirt, textureScale: 0.05 }); rtsState.textureStatus = 'ready'; updateTextureStatusDisplay(); console.info(`[ModernTerrain] ? Swapped to ${textureSource} textures`); } catch (err) { rtsState.textureStatus = 'error'; rtsState.textureSource = 'procedural'; updateTextureStatusDisplay(); console.warn('[ModernTerrain] Texture swap failed, using procedural textures', err); } }; swapTextures(); // === ORGANIC TERRAIN ENHANCEMENT CONTROLS === (window.__RTS as any).enableOrganicTerrain = (enabled: boolean = true) => { const tr = (window.__RTS as any).terrainRenderer; if (!tr) { console.error('[OrganicTerrain] Terrain renderer not available'); return; } tr.setOrganicEnhancementsEnabled(enabled); console.info(`[OrganicTerrain] Organic enhancements ${enabled ? 'ENABLED' : 'DISABLED'}`); }; (window.__RTS as any).configureOrganicTerrain = (config: { enabled?: boolean; heightBlendContrast?: number; breakupUVStrength?: number; breakupWeightStrength?: number; macroTintStrength?: number; curvatureRoughnessBoost?: number; curvatureDarkenStrength?: number; terrainAOStrength?: number; }) => { const tr = (window.__RTS as any).terrainRenderer; if (!tr) { console.error('[OrganicTerrain] Terrain renderer not available'); return; } tr.configureOrganicEnhancements(config); console.info('[OrganicTerrain] Configuration applied:', config); }; // Enable organic enhancements by default after first render requestAnimationFrame(() => { requestAnimationFrame(() => { // Wait 2 frames for shader to compile terrainRenderer.setOrganicEnhancementsEnabled(true); console.info('[OrganicTerrain] ✨ Organic terrain enhancements ENABLED by default'); console.info('[OrganicTerrain] Techniques active:'); console.info(' 1. Height-Blend Splatting - Natural material transitions'); console.info(' 2. Procedural Breakup - Reduces tiling patterns'); console.info(' 3. Terrain-Aware AO - Contact shadows in valleys'); console.info(' 4. Macro Albedo Tinting - Distance color variation'); console.info(' 5. Curvature Micro-Variation - Weathered ridges, darker valleys'); console.info('[OrganicTerrain] Configure with: __RTS.configureOrganicTerrain({ ... })'); }); }); // Apply GPU-generated normal map and erosion splatting if available const terraAny = terra as any; if (terraAny.gpuNormalMap && terraAny.gpuNormalMapResolution) { console.log('[ModernTerrain] Applying GPU-generated normal map...'); updateTerrainRendererNormalMap(terraAny.gpuNormalMap, terraAny.gpuNormalMapResolution); } if (terraAny.gpuErosionAnalysis) { console.log('[ModernTerrain] Applying erosion-driven texture splatting...'); updateTerrainRendererErosionSplatting(terraAny.gpuErosionAnalysis, terraAny.gpuNormalMapResolution); } console.log('[ModernTerrain] Terrain mesh added to scene!'); // Log statistics (works for both single mesh and chunked mode) if (terrainRenderer.isChunked()) { const stats = terrainRenderer.getStatistics(); console.log('[ModernTerrain] Chunking enabled:', stats); } else { // Single mesh mode if ('geometry' in mesh) { console.log('[ModernTerrain] Vertices:', mesh.geometry.attributes.position.count); console.log('[ModernTerrain] Triangles:', mesh.geometry.index ? mesh.geometry.index.count / 3 : 0); } } return terrainRenderer; }; // Game speed multiplier (controlled by match settings) let gameSpeedMultiplier = 1.0; const bootstrap = async (settings?: MatchSettings): Promise => { // Initialize window.__RTS for storing references if (!(window as any).__RTS) { (window as any).__RTS = {}; } let loadingProgress: LoadingProgress | null = null; const finishLoading = () => { if (loadingProgress) { try { loadingProgress.complete(); } catch { // ignore } } }; try { const normalizedSettings = normalizeMatchSettings(settings); // Create loading progress indicator loadingProgress = new LoadingProgress(); const stageLog = (label: string, pct?: number) => { const message = pct !== undefined ? `${pct}% - ${label}` : label; console.info(`[LoadingStage] ${message}`); }; let startupTreePlacementMode: 'gpu' | 'cpu' = 'cpu'; const allowGeneratedTextureBootstrapPreload = /\brtsTexturePreload=1\b/i.test(window.location.search); if (allowGeneratedTextureBootstrapPreload) { const runGeneratedTexturePreload = () => { void preloadGeneratedSurfaceTexturesFromManifest().catch((error) => { console.warn('[Bootstrap] Generated unit texture preload failed; falling back to procedural synthesis.', error); }); }; const win = window as Window & { requestIdleCallback?: ( callback: () => void, options?: { timeout?: number } ) => number; }; if (typeof win.requestIdleCallback === 'function') { win.requestIdleCallback(runGeneratedTexturePreload, { timeout: 3000 }); } else { window.setTimeout(runGeneratedTexturePreload, 1200); } } else { console.info('[Bootstrap] Generated unit texture preload is disabled by default (use ?rtsTexturePreload=1 to enable).'); } console.log('[Bootstrap] Starting game with settings:', normalizedSettings ?? settings); // Configure game speed from settings if (normalizedSettings?.gameSpeed) { const speedMap = { slow: 0.5, medium: 1.0, fast: 1.5 }; gameSpeedMultiplier = speedMap[normalizedSettings.gameSpeed]; console.log('[Bootstrap] Game speed set to:', normalizedSettings.gameSpeed, `(${gameSpeedMultiplier}x)`); } loadingProgress.setProgress(5, 'Terrain: generating base heightmap (fast mode)...'); stageLog('Terrain generation start', 5); const terra = await resolveTerrain(normalizedSettings ?? settings); loadingProgress.setProgress(10, 'Terrain: applying erosion & detail enhancement...'); stageLog('Terrain base generated', 10); const runtimeMapHalfSize = terra.getMapHalfSize?.() ?? MAP_HALF_SIZE; const runtimeMapSizeMeters = terra.getMapSizeMeters?.() ?? MAP_SIZE_METERS; rtsCamera.setMapHalfSize(runtimeMapHalfSize); rtsCamera.config.maxDistance = Math.max(rtsCamera.config.maxDistance, runtimeMapSizeMeters * 0.95); rtsCamera.config.tacticalDistance = Math.max(2500, runtimeMapSizeMeters * 0.25); // If terrain was created in fast-load mode, finish heavy passes before simulation starts if ((terra as any).finalizeTerrainGeneration && !(terra as any).isTerrainFinalized?.()) { loadingProgress.setProgress(12, 'Terrain: generating resources & features...'); stageLog('Finalizing terrain (resources, features, scatter)', 12); console.time('[PERF] Terrain finalization'); (terra as any).finalizeTerrainGeneration(); console.timeEnd('[PERF] Terrain finalization'); loadingProgress.setProgress(14, 'Terrain: finalization complete'); stageLog('Terrain finalized', 14); } // CRITICAL FIX: Verify terrain heights are set correctly after GPU erosion const heightRange = terra.getHeightRange(); console.info(`[Bootstrap] Terrain height range after erosion: ${heightRange.minHeight.toFixed(2)} to ${heightRange.maxHeight.toFixed(2)}`); if (heightRange.minHeight === Infinity || heightRange.maxHeight === -Infinity) { console.error('[Bootstrap] CRITICAL: Terrain heights not initialized! Forcing recompute...'); (terra as any).recomputeMinMax?.(); const fixedRange = terra.getHeightRange(); console.info(`[Bootstrap] Fixed terrain height range: ${fixedRange.minHeight.toFixed(2)} to ${fixedRange.maxHeight.toFixed(2)}`); } loadingProgress.setProgress(15, 'Simulation: initializing world/players...'); // 🔍 DIAGNOSTIC: Log terra dimensions before Sim creation console.info('[Bootstrap] Terra BEFORE Sim creation:', { width: terra.width, height: terra.height, tileSize: terra.tileSize, heightsLength: terra.heights?.length, heightRange: terra.getHeightRange() }); const sim = new Sim(terra); // 🔍 DIAGNOSTIC: Log sim.terra dimensions after Sim creation console.info('[Bootstrap] sim.terra AFTER Sim creation:', { width: sim.terra.width, height: sim.terra.height, tileSize: sim.terra.tileSize, heightsLength: sim.terra.heights?.length, sameReference: sim.terra === terra }); // Store references for terrain debug panel (window.__RTS as any).sim = sim; (window.__RTS as any).scene = scene; // Initialize sound system const soundManager = SoundManager.getInstance(); await soundManager.initialize(); console.log('[Bootstrap] Sound system initialized'); // Start ambient background sound soundManager.playLoopingSound('game_ambient', 'ambient', { volume: 0.3 }); console.log('[Bootstrap] Ambient sound started'); // Initialize dynamic music system const intensityCalculator = new IntensityCalculator(); const musicManager = new MusicManager(soundManager); musicManager.initialize(); musicManager.start(); console.log('[Bootstrap] Dynamic music system initialized'); initializePlayersFromSettings(sim, normalizedSettings); if (normalizedSettings?.startingResources) { console.log('[Bootstrap] Applying starting resources:', normalizedSettings.startingResources); const updated = new Set(); const players = sim.getPlayers(); if (players.length) { for (const player of players) { if (updated.has(player.economy)) { continue; } player.economy.mass = normalizedSettings.startingResources.mass; player.economy.energy = normalizedSettings.startingResources.energy; updated.add(player.economy); } } else { sim.economy.mass = normalizedSettings.startingResources.mass; sim.economy.energy = normalizedSettings.startingResources.energy; } } const spawnPoints = sim.getSpawnPoints(); const primarySpawn = spawnPoints.find(spawn => spawn.team === 0) ?? spawnPoints[0]; // 🔥 PHASE 2: Initialize Victory Manager for win/lose detection const victoryConfig: Partial = { condition: (normalizedSettings?.victoryCondition as VictoryConfig['condition']) ?? 'annihilation', timeLimit: (normalizedSettings?.timeLimit ?? 0) * 60, // Convert minutes to seconds economicThreshold: 100000, dominationPercent: 75 }; const victoryManager = new VictoryManager(victoryConfig); victoryManager.start(); console.info('[Bootstrap] ✅ Victory Manager initialized:', victoryConfig.condition, victoryConfig.timeLimit ? `(${victoryConfig.timeLimit}s limit)` : '(no time limit)'); // Expose for debugging (window.__RTS as any).victoryManager = victoryManager; (window.__RTS as any).checkVictory = () => { const state = victoryManager.checkVictory(sim); console.info('[Victory] Game Over:', state.isGameOver, state.isGameOver ? `Winner: Team ${state.winningTeamId} (${state.reason})` : 'Game in progress'); return state; }; (window.__RTS as any).getMatchStats = () => { const stats = victoryManager.getMatchStatistics(sim); console.info('[Match Stats]', stats); return stats; }; // 🔥 PHASE 2: Initialize Save Manager for save/load functionality const saveManager = SaveManager.getInstance(); await saveManager.initialize(); console.info('[Bootstrap] ✅ Save Manager initialized'); // Helper to get current camera state for saving const getCameraStateForSave = (): SaveCameraState => { const pivot = rtsCamera.getPivotPosition(); return { position: { x: camera.position.x, y: camera.position.y, z: camera.position.z }, target: { x: pivot.x, y: pivot.y, z: pivot.z }, distance: rtsCamera.getDistance(), yaw: 0, // RTSCamera doesn't expose yaw/pitch getters pitch: 60 }; }; // Expose save/load commands for console and UI (window.__RTS as any).saveManager = saveManager; (window.__RTS as any).quickSave = async () => { const gameTime = victoryManager.getGameTime(); const settingsToSave = normalizedSettings ?? settings ?? DEFAULT_MATCH_SETTINGS; const result = await saveManager.quickSave(sim, settingsToSave, getCameraStateForSave(), gameTime); console.info('[Save]', result.message); return result; }; (window.__RTS as any).quickLoad = async () => { const result = await saveManager.quickLoad(); if (result.success && result.data) { console.info('[Load]', result.message); console.info('[Load] To apply this save, the game needs to reload. Use: __RTS.applyLoadedSave()'); (window.__RTS as any).pendingLoad = result.data; } else { console.warn('[Load]', result.message); } return result; }; (window.__RTS as any).saveToSlot = async (slot: number, name?: string) => { const gameTime = victoryManager.getGameTime(); const settingsToSave = normalizedSettings ?? settings ?? DEFAULT_MATCH_SETTINGS; const result = await saveManager.saveToSlot(sim, settingsToSave, getCameraStateForSave(), gameTime, slot, name); console.info('[Save]', result.message); return result; }; (window.__RTS as any).loadFromSlot = async (slot: number) => { const result = await saveManager.loadFromSlot(slot); if (result.success && result.data) { console.info('[Load]', result.message); (window.__RTS as any).pendingLoad = result.data; } else { console.warn('[Load]', result.message); } return result; }; (window.__RTS as any).listSaves = async () => { const saves = await saveManager.getAllSaveMetadata(); console.table(saves.map(s => ({ slot: s.slotNumber, name: s.name, map: s.mapName, time: SaveManager.formatGameTime(s.gameTime), saved: SaveManager.formatTimestamp(s.timestamp) }))); return saves; }; // Keep the RTS camera pivot anchored to the actual terrain height (heightmaps are not centered at y=0). rtsCamera.setGroundHeightProvider((x, z) => sim.terra.getHeightWorld(x, z)); // Initialize camera at a safe position inside terrain bounds const initialYawDeg = 0; const initialPitchDeg = 60; const initialDistance = 200; const pivotX = primarySpawn?.x ?? 0; const pivotZ = primarySpawn?.z ?? 0; rtsCamera.focusOn(pivotX, pivotZ, true); rtsCamera.setRotation(initialYawDeg, initialPitchDeg, true); rtsCamera.setDistance(initialDistance, true); type MeshScaleRuntimeConfig = { globalScale: number; unitScale: number; buildingScale: number; normalizeToSchemaBounds: boolean; }; const readMeshScaleNumber = (key: string, fallback: number, min: number, max: number): number => { const raw = window.localStorage.getItem(key); if (raw == null) { return fallback; } const parsed = Number.parseFloat(raw); if (!Number.isFinite(parsed)) { return fallback; } return Math.max(min, Math.min(max, parsed)); }; const readMeshScaleBool = (key: string, fallback: boolean): boolean => { const raw = window.localStorage.getItem(key); if (raw == null) { return fallback; } if (raw === '1' || raw === 'true') { return true; } if (raw === '0' || raw === 'false') { return false; } return fallback; }; const DEFAULT_GLOBAL_MESH_SCALE = 4.0; let meshScaleBootstrapConfig: MeshScaleRuntimeConfig = { globalScale: readMeshScaleNumber('rts.mesh.scale.global', DEFAULT_GLOBAL_MESH_SCALE, 0.05, 64), unitScale: readMeshScaleNumber('rts.mesh.scale.units', 1.0, 0.05, 64), buildingScale: readMeshScaleNumber('rts.mesh.scale.buildings', 6.0, 0.05, 128), normalizeToSchemaBounds: readMeshScaleBool('rts.mesh.scale.normalizeToSchemaBounds', true) }; const persistMeshScaleConfig = (config: MeshScaleRuntimeConfig): void => { window.localStorage.setItem('rts.mesh.scale.global', config.globalScale.toString()); window.localStorage.setItem('rts.mesh.scale.units', config.unitScale.toString()); window.localStorage.setItem('rts.mesh.scale.buildings', config.buildingScale.toString()); window.localStorage.setItem('rts.mesh.scale.normalizeToSchemaBounds', config.normalizeToSchemaBounds ? '1' : '0'); }; // One-time migration: apply requested global x2 mesh scale to existing saved configs. try { const migrationKey = 'rts.mesh.scale.migration.globalx2.20260228b'; if (window.localStorage.getItem(migrationKey) !== '1') { const rawGlobal = window.localStorage.getItem('rts.mesh.scale.global'); if (rawGlobal != null) { const parsedGlobal = Number.parseFloat(rawGlobal); if (Number.isFinite(parsedGlobal)) { meshScaleBootstrapConfig.globalScale = Math.max(0.05, Math.min(64, parsedGlobal * 2)); persistMeshScaleConfig(meshScaleBootstrapConfig); console.info('[MeshRegistry] Applied one-time global mesh scale x2 migration.', { previousGlobalScale: parsedGlobal, nextGlobalScale: meshScaleBootstrapConfig.globalScale }); } } window.localStorage.setItem(migrationKey, '1'); } } catch { // ignore localStorage errors } (window.__RTS as any).getMeshScaleConfig = (): MeshScaleRuntimeConfig => ({ ...meshScaleBootstrapConfig }); (window.__RTS as any).setMeshScaleConfig = (config: Partial = {}): MeshScaleRuntimeConfig & { reloadRequired: boolean } => { const next: MeshScaleRuntimeConfig = { globalScale: Number.isFinite(config.globalScale) ? Math.max(0.05, Math.min(64, Number(config.globalScale))) : meshScaleBootstrapConfig.globalScale, unitScale: Number.isFinite(config.unitScale) ? Math.max(0.05, Math.min(64, Number(config.unitScale))) : meshScaleBootstrapConfig.unitScale, buildingScale: Number.isFinite(config.buildingScale) ? Math.max(0.05, Math.min(128, Number(config.buildingScale))) : meshScaleBootstrapConfig.buildingScale, normalizeToSchemaBounds: typeof config.normalizeToSchemaBounds === 'boolean' ? config.normalizeToSchemaBounds : meshScaleBootstrapConfig.normalizeToSchemaBounds }; meshScaleBootstrapConfig = next; persistMeshScaleConfig(next); console.info('[MeshRegistry] Mesh scale config saved. Reload required to rebuild GPU mesh buffers.', next); return { ...next, reloadRequired: true }; }; (window.__RTS as any).setBuildingMeshScale = (scale: number): MeshScaleRuntimeConfig & { reloadRequired: boolean } => (window.__RTS as any).setMeshScaleConfig({ buildingScale: scale }); // Phase B: helper function to initialize mesh registry with visual schemas. const initializeMeshRegistry = async (device: GPUDevice): Promise => { console.info('[MeshRegistry] Phase B: initializing mesh registry'); const manifestUrls = [ `${import.meta.env.BASE_URL}assets_visual_manifest.json`, '/assets_visual_manifest.json' ]; let manifest: { units: VisualLanguageSchema[]; buildings: VisualLanguageSchema[] } | null = null; let loadedManifestUrl: string | null = null; const manifestErrors: string[] = []; for (const manifestUrl of manifestUrls) { try { const response = await fetch(manifestUrl, { cache: 'no-store' }); if (!response.ok) { manifestErrors.push(`${manifestUrl} -> HTTP ${response.status}`); continue; } const parsed = await response.json() as { units?: VisualLanguageSchema[]; buildings?: VisualLanguageSchema[]; }; if (!Array.isArray(parsed.units) || !Array.isArray(parsed.buildings)) { manifestErrors.push(`${manifestUrl} -> invalid manifest shape`); continue; } manifest = { units: parsed.units, buildings: parsed.buildings }; loadedManifestUrl = manifestUrl; break; } catch (error) { manifestErrors.push(`${manifestUrl} -> ${(error as Error)?.message ?? String(error)}`); } } if (!manifest) { throw new Error(`[MeshRegistry] Failed to load MeshLab manifest. Attempts: ${manifestErrors.join(' | ')}`); } console.info( `[MeshRegistry] Loaded MeshLab manifest from ${loadedManifestUrl} ` + `(${manifest.units.length} units, ${manifest.buildings.length} buildings)` ); const schemaOverridesEnabled = new URLSearchParams(window.location.search).get('schemaOverrides') === '1'; if (schemaOverridesEnabled) { type SchemaOverrideRecord = { placementOverrides?: VisualLanguageSchema['placementOverrides']; volumeHierarchyOverrides?: VisualLanguageSchema['volumeHierarchyOverrides']; }; type SchemaOverrideManifest = { schemaOverrides: Record; }; const mergePlacementOverrides = ( base: VisualLanguageSchema['placementOverrides'], next: VisualLanguageSchema['placementOverrides'] ): VisualLanguageSchema['placementOverrides'] => { const baseEntries = base?.entries ?? []; const nextEntries = next?.entries ?? []; if (baseEntries.length === 0 && nextEntries.length === 0) return undefined; if (baseEntries.length === 0) return { version: 1 as const, entries: [...nextEntries] }; if (nextEntries.length === 0) return { version: 1 as const, entries: [...baseEntries] }; return { version: 1 as const, entries: [...baseEntries, ...nextEntries] }; }; const mergeVolumeHierarchyOverrides = ( base: VisualLanguageSchema['volumeHierarchyOverrides'], next: VisualLanguageSchema['volumeHierarchyOverrides'] ): VisualLanguageSchema['volumeHierarchyOverrides'] => { const baseReplace = base?.replace ?? []; const nextReplace = next?.replace ?? []; if (baseReplace.length === 0 && nextReplace.length === 0) return undefined; return { replace: [...baseReplace, ...nextReplace] }; }; const applyOverridesToSchema = (schema: VisualLanguageSchema, override: SchemaOverrideRecord): VisualLanguageSchema => ({ ...schema, placementOverrides: mergePlacementOverrides(schema.placementOverrides, override.placementOverrides), volumeHierarchyOverrides: mergeVolumeHierarchyOverrides( schema.volumeHierarchyOverrides, override.volumeHierarchyOverrides ) }); try { const overrideResponse = await fetch('/assets_visual_manifest.override.json', { cache: 'no-store' }); if (overrideResponse.ok) { const overrideManifest = await overrideResponse.json() as SchemaOverrideManifest; if (overrideManifest?.schemaOverrides) { const applyToList = (schemas: VisualLanguageSchema[]) => schemas.map((schema) => { const override = overrideManifest.schemaOverrides[schema.assetId]; return override ? applyOverridesToSchema(schema, override) : schema; }); manifest = { units: applyToList(manifest.units), buildings: applyToList(manifest.buildings) }; console.info('[MeshRegistry] Applied schema overrides from assets_visual_manifest.override.json.'); } } } catch (overrideError) { console.warn('[MeshRegistry] Schema override load failed.', overrideError); } } const resolvedManifest = manifest; // Create registry console.info('[MeshRegistry] Scale config:', meshScaleBootstrapConfig); const registry = new MeshRegistry(device, { globalScale: meshScaleBootstrapConfig.globalScale, unitScale: meshScaleBootstrapConfig.unitScale, buildingScale: meshScaleBootstrapConfig.buildingScale, normalizeToSchemaBounds: meshScaleBootstrapConfig.normalizeToSchemaBounds }); const unitSchemasByAssetId = new Map(resolvedManifest.units.map((schema) => [schema.assetId, schema])); const buildingSchemasByAssetId = new Map(resolvedManifest.buildings.map((schema) => [schema.assetId, schema])); const missingUnitSchemas: string[] = []; const missingBuildingSchemas: string[] = []; // Register unit meshes for every known unit type. let registeredUnits = 0; for (const unitType of Object.keys(UNIT_BLUEPRINTS) as UnitType[]) { const assetId = getAssetIdForUnit(unitType); const schema = unitSchemasByAssetId.get(assetId); if (!schema) { missingUnitSchemas.push(`${unitType}:${assetId}`); continue; } registry.registerUnit(unitType, schema); registeredUnits++; } // Register building meshes for every known building type. let registeredBuildings = 0; for (const buildingType of Object.keys(BUILDING_BLUEPRINTS) as BuildingType[]) { const assetId = getAssetIdForBuilding(buildingType); const schema = buildingSchemasByAssetId.get(assetId); if (!schema) { missingBuildingSchemas.push(`${buildingType}:${assetId}`); continue; } registry.registerBuilding(buildingType, schema); registeredBuildings++; } if (missingUnitSchemas.length > 0 || missingBuildingSchemas.length > 0) { console.error('[MeshRegistry] MeshLab schema coverage failure', { missingUnits: missingUnitSchemas, missingBuildings: missingBuildingSchemas }); throw new Error( '[MeshRegistry] Missing MeshLab schemas for mapped entities. ' + `Missing units=${missingUnitSchemas.length}, buildings=${missingBuildingSchemas.length}` ); } const stats = registry.getStats(); console.info( `[MeshRegistry] initialized with ${stats.totalMeshes} meshes ` + `(${registeredUnits} units, ${registeredBuildings} buildings), ` + `${stats.totalVertices} vertices, ${(stats.gpuMemoryBytes / 1024 / 1024).toFixed(2)} MB GPU memory` ); return registry; }; const renderWorldManager = new RenderWorldManager(); // RenderWorldExtractor will be created after GPU device is ready (to pass mesh registry) // CRITICAL FIX: Force terrain height update to ensure renderer gets correct data console.info('[Bootstrap] Forcing terrain height update before renderer initialization...'); (sim.terra as any).markHeightDirty?.(); const preRenderRange = sim.terra.getHeightRange(); console.info(`[Bootstrap] Pre-render terrain range: ${preRenderRange.minHeight.toFixed(2)} to ${preRenderRange.maxHeight.toFixed(2)}`); // Phase B: initialize mesh registry before creating RendererService. let meshRegistry: MeshRegistry | null = null; const gpuDeviceManager = await gpuDeviceManagerReady; if (gpuDeviceManager) { try { loadingProgress.setProgress(20, 'Renderer: loading entity meshes...'); stageLog('Mesh registry init start', 20); meshRegistry = await initializeMeshRegistry(gpuDeviceManager.logicalDevice); console.info('[Bootstrap] Mesh registry initialized successfully'); } catch (error) { console.error('[MeshRegistry] Failed to initialize mesh registry:', error); throw new Error('[MeshRegistry] Startup aborted: MeshLab unit/building mesh set is required.'); } } loadingProgress.setProgress(21, 'Renderer: initializing GPU device & framegraph...'); stageLog('Renderer init start', 21); const rendererService = new RendererService(gpuCanvas, renderWorldManager, meshRegistry ?? undefined); const rendererServiceReady = rendererService.initialize(); window.__RTS = window.__RTS ?? {}; window.__RTS.gpuRendererServiceReady = rendererServiceReady; const webgpuReady = await rendererServiceReady; if (!webgpuReady) { console.warn('[Bootstrap] WebGPU renderer unavailable; falling back to WebGL-only rendering.'); loadingProgress.setProgress(22, 'Renderer: WebGPU unavailable, falling back to WebGL...'); stageLog('WebGPU unavailable -> fallback', 22); } // 🔥 PHASE B: Create RenderWorldExtractor with mesh registry const renderWorldExtractor = new RenderWorldExtractor(sim, renderWorldManager, meshRegistry ?? undefined); const captureEntityProxyOverride = readStoredBoolean('rts.render.captureEntityProxyObjects'); const captureLightsOverride = readStoredBoolean('rts.render.captureLights'); const captureDecalsOverride = readStoredBoolean('rts.render.captureDecals'); const captureUiOverlaysOverride = readStoredBoolean('rts.render.captureUiOverlays'); const captureTacticalOverlaysOverride = readStoredBoolean('rts.render.captureTacticalOverlays'); let effectiveCaptureDecalsOverride = captureDecalsOverride; let effectiveCaptureUiOverlaysOverride = captureUiOverlaysOverride; // One-time migration: older sessions may have persisted oversized decal circles enabled. // Reset to off once to keep units/buildings readable by default. if (captureDecalsOverride === true) { try { const migrationKey = 'rts.render.captureDecals.migration.20260211'; if (window.localStorage.getItem(migrationKey) !== '1') { effectiveCaptureDecalsOverride = false; window.localStorage.setItem('rts.render.captureDecals', '0'); window.localStorage.setItem(migrationKey, '1'); console.warn( '[Bootstrap] Resetting stale decal-capture flag to OFF to prevent oversized colored circles. ' + 'Use __RTS.setRenderDecals(true) to re-enable.' ); } } catch { // ignore } } // One-time migration: reset stale UI overlay capture to OFF so expensive ring overlays // do not stay enabled across sessions unless the user explicitly opts in. if (captureUiOverlaysOverride === true) { try { const migrationKey = 'rts.render.captureUiOverlays.migration.20260222'; if (window.localStorage.getItem(migrationKey) !== '1') { effectiveCaptureUiOverlaysOverride = false; window.localStorage.setItem('rts.render.captureUiOverlays', '0'); window.localStorage.setItem(migrationKey, '1'); console.warn( '[Bootstrap] Resetting stale UI-overlay capture flag to OFF for performance. ' + 'Use __RTS.setRenderUiOverlays(true) to re-enable.' ); } } catch { // ignore } } const entityFrustumCullingOverride = readStoredBoolean('rts.render.entityFrustumCulling'); const entityDistanceCullingOverride = readStoredBoolean('rts.render.entityDistanceCulling'); const entityCullDistanceRaw = readStoredString('rts.render.entityCullDistance'); const parsedEntityCullDistance = entityCullDistanceRaw != null ? Number.parseFloat(entityCullDistanceRaw) : Number.NaN; const entityCullDistance = Number.isFinite(parsedEntityCullDistance) ? Math.max(300, Math.min(9000, parsedEntityCullDistance)) : 2600; // Safety: if WebGL entities are disabled, force proxy capture so WebGPU entity passes // always receive object data even if a stale localStorage flag disabled capture. let captureEntityProxyObjects = webgpuReady && (captureEntityProxyOverride ?? (FRAMEGRAPH_CAPTURE_ENTITY_PROXY_OBJECTS || !FRAMEGRAPH_USE_WEBGL_ENTITIES)); if (webgpuReady && !FRAMEGRAPH_USE_WEBGL_ENTITIES && !captureEntityProxyObjects) { captureEntityProxyObjects = true; console.warn( '[Bootstrap] Invalid entity render config detected (webglEntities=off + proxyCapture=off). ' + 'Forcing proxy capture on so units/buildings remain visible.' ); try { window.localStorage.setItem('rts.render.captureEntityProxyObjects', '1'); } catch { // ignore } } const captureLights = webgpuReady && rendererService.hasFramegraphPass('LightAccumulation') && (captureLightsOverride ?? true); const captureInfluenceRings = false; // Decals are large colored circles in current UX; keep off by default. const captureDecals = webgpuReady && rendererService.hasFramegraphPass('DecalPass') && (effectiveCaptureDecalsOverride ?? false); // Construction/tactical overlays can be expensive and visually noisy; keep off by default. const captureUiOverlays = webgpuReady && rendererService.hasFramegraphPass('UiOverlay') && (effectiveCaptureUiOverlaysOverride ?? false); const captureTacticalOverlays = webgpuReady && rendererService.hasFramegraphPass('UiOverlay') && (captureTacticalOverlaysOverride ?? true); // Default to frustum culling when proxy capture is active to avoid paying full entity // cost at all camera states; users can still override explicitly in localStorage. const entityFrustumCulling = entityFrustumCullingOverride ?? captureEntityProxyObjects; const entityDistanceCulling = entityDistanceCullingOverride ?? captureEntityProxyObjects; renderWorldExtractor.setCaptureEntityProxyObjects(captureEntityProxyObjects); renderWorldExtractor.setCaptureLights(captureLights); renderWorldExtractor.setCaptureInfluenceRings(captureInfluenceRings); renderWorldExtractor.setCaptureDecals(captureDecals); renderWorldExtractor.setCaptureUiOverlays(captureUiOverlays); renderWorldExtractor.setCaptureTacticalCommandOverlays(captureTacticalOverlays); renderWorldExtractor.setFrustumCulling(entityFrustumCulling); renderWorldExtractor.setDistanceCulling(entityDistanceCulling); renderWorldExtractor.setDistanceCullMaxRange(entityCullDistance); console.info( `[Bootstrap] Framegraph entity proxy capture ${captureEntityProxyObjects ? 'enabled' : 'disabled'}` ); console.info( `[Bootstrap] Framegraph light capture ${captureLights ? 'enabled' : 'disabled'}` ); console.info( `[Bootstrap] Framegraph influence ring capture ${captureInfluenceRings ? 'enabled' : 'disabled'}` ); console.info( `[Bootstrap] Framegraph decal capture ${captureDecals ? 'enabled' : 'disabled'}` ); console.info( `[Bootstrap] Framegraph UI overlay capture ${captureUiOverlays ? 'enabled' : 'disabled'}` ); console.info( `[Bootstrap] Tactical command overlay capture ${captureTacticalOverlays ? 'enabled' : 'disabled'}` ); console.info( `[Bootstrap] Framegraph entity frustum culling ${entityFrustumCulling ? 'enabled' : 'disabled'}` ); console.info( `[Bootstrap] Entity distance culling ${entityDistanceCulling ? 'enabled' : 'disabled'} (max ${Math.round(entityCullDistance)}m)` ); window.__RTS.useGpuInfluenceRings = captureInfluenceRings; loadingProgress.setProgress(24, 'Renderer: configuring pipelines & lighting...'); stageLog('Renderer configured', 24); // Expose renderer service for console commands (AAA Heightmap) (window.__RTS as any).getRendererService = () => { return rendererService; }; // 🔍 DIAGNOSTIC: Expose renderer components for debugging (window.__RTS as any).renderer = rendererService; (window.__RTS as any).webgpuReady = webgpuReady; const readInitialRuntimeTerrainQualityTier = (): 0 | 1 | 2 => { const raw = window.localStorage.getItem('rts.framegraph.terrainQuality'); const parsed = raw != null ? Number.parseInt(raw, 10) : Number.NaN; if (!Number.isFinite(parsed)) return 0; return Math.max(0, Math.min(2, Math.round(parsed))) as 0 | 1 | 2; }; const initialRuntimeTerrainQualityTier = readInitialRuntimeTerrainQualityTier(); if (webgpuReady) { rendererService.setRuntimeTerrainQualityTier(initialRuntimeTerrainQualityTier); } (window.__RTS as any).runtimeTerrainQualityTier = initialRuntimeTerrainQualityTier; // 🔥 PHASE 1: Create terrain chunk manager for WebGPU frustum culling (50-75% GPU reduction) let terrainChunkManager: WebGPUTerrainChunkManager | null = null; if (webgpuReady) { const terrainSize = sim.terra.width * sim.terra.tileSize; terrainChunkManager = new WebGPUTerrainChunkManager(sim.terra, { gridSize: 32, // 32x32 grid = 1024 chunks chunkSize: terrainSize / 32, // Each chunk covers 1/32 of terrain terrainSize: terrainSize, verticalScale: sim.terra.getVerticalScale() }); console.info('[Bootstrap] ✅ Terrain chunk manager created for GPU frustum culling'); console.info('[Bootstrap] Grid: 32x32 chunks, Terrain size:', terrainSize, 'Chunk size:', terrainSize / 32); // Expose for debugging (window.__RTS as any).terrainChunkManager = terrainChunkManager; (window.__RTS as any).getChunkStats = () => { if (!terrainChunkManager) return null; const stats = terrainChunkManager.getStats(); console.info('[Terrain Chunks] Visible:', stats.visibleChunks, '/', stats.totalChunks, '(' + stats.cullPercentage.toFixed(1) + '% culled)'); return stats; }; } // 🔥 PHASE 4: Create Fog of War renderer for visibility system let fogOfWarRenderer: FogOfWarRenderer | null = null; if (webgpuReady && rendererService.device) { fogOfWarRenderer = new FogOfWarRenderer(rendererService.device, { resolution: 256, // Match VisionManager grid resolution enabled: settings?.fogOfWar ?? true }); fogOfWarRenderer.initialize(); console.info('[Bootstrap] ✅ Fog of War renderer initialized (256x256 grid)'); // Expose for debugging (window.__RTS as any).fogOfWarRenderer = fogOfWarRenderer; (window.__RTS as any).toggleFogOfWar = () => { if (!fogOfWarRenderer) return; const newState = !fogOfWarRenderer.isEnabled(); fogOfWarRenderer.setEnabled(newState); sim.setFogOfWarEnabled(newState); console.info(`[FogOfWar] ${newState ? 'Enabled' : 'Disabled'}`); return newState; }; (window.__RTS as any).revealMap = () => { sim.revealMap(); console.info('[FogOfWar] Map revealed'); }; } // 🔥 PHASE 4: GPU Particle System console commands const resolveImpactGraphQuality = (value: string | null): 'high' | 'medium' | 'low' => { if (value === 'high' || value === 'low') { return value; } return 'medium'; }; let impactGraphEnabled = readStoredBoolean('rts.impactGraph.enabled') ?? true; let impactGraphQuality: 'high' | 'medium' | 'low' = resolveImpactGraphQuality(readStoredString('rts.impactGraph.quality')); let impactGraphSdfDeflection = readStoredBoolean('rts.impactGraph.sdfDeflection') ?? false; const applyImpactGraphSettings = (): boolean => { const effectsManager = rendererService.getEffectsManager(); if (!effectsManager) { return false; } effectsManager.setImpactGraphEnabled(impactGraphEnabled); effectsManager.setImpactGraphQuality(impactGraphQuality); effectsManager.setImpactGraphSdfDeflection(impactGraphSdfDeflection); return true; }; applyImpactGraphSettings(); (window.__RTS as any).spawnTestExplosion = (x?: number, z?: number) => { const effectsManager = rendererService.getEffectsManager(); if (!effectsManager) { console.error('[Particles] Effects manager not available'); return; } const posX = x ?? 0; const posZ = z ?? 0; const posY = terra.getHeightWorld(posX, posZ) + 3.0; effectsManager.spawnExplosion([posX, posY, posZ], 8.0); console.info(`[Particles] Spawned explosion at (${posX.toFixed(1)}, ${posY.toFixed(1)}, ${posZ.toFixed(1)})`); }; (window.__RTS as any).spawnTestImpact = (x?: number, z?: number, type?: 'kinetic' | 'laser') => { const effectsManager = rendererService.getEffectsManager(); if (!effectsManager) { console.error('[Particles] Effects manager not available'); return; } const posX = x ?? 0; const posZ = z ?? 0; const posY = terra.getHeightWorld(posX, posZ) + 0.5; const impactType = type ?? 'kinetic'; if (effectsManager.isImpactGraphEnabled()) { effectsManager.spawnImpactGraphFromLegacy([posX, posY, posZ], [0, 1, 0], impactType, 'ground'); } else { effectsManager.spawnImpact([posX, posY, posZ], [0, 1, 0], impactType); } console.info(`[Particles] Spawned ${impactType} impact at (${posX.toFixed(1)}, ${posY.toFixed(1)}, ${posZ.toFixed(1)})`); }; (window.__RTS as any).getParticleStats = () => { const effectsManager = rendererService.getEffectsManager(); if (!effectsManager) { console.error('[Particles] Effects manager not available'); return null; } const stats = effectsManager.getStats(); console.info(`[Particles] Active: ${stats.particles} particles, ${stats.decals} decals`); return stats; }; (window.__RTS as any).setImpactGraphEnabled = (enabled: boolean = true) => { impactGraphEnabled = Boolean(enabled); try { window.localStorage.setItem('rts.impactGraph.enabled', impactGraphEnabled ? '1' : '0'); } catch { // ignore } if (!applyImpactGraphSettings()) { console.error('[ImpactGraph] Effects manager not available'); } console.info(`[ImpactGraph] ${impactGraphEnabled ? 'enabled' : 'disabled'}`); return impactGraphEnabled; }; (window.__RTS as any).setImpactGraphQuality = (quality: 'high' | 'medium' | 'low' = 'medium') => { impactGraphQuality = resolveImpactGraphQuality(quality); try { window.localStorage.setItem('rts.impactGraph.quality', impactGraphQuality); } catch { // ignore } if (!applyImpactGraphSettings()) { console.error('[ImpactGraph] Effects manager not available'); } console.info(`[ImpactGraph] quality=${impactGraphQuality}`); return impactGraphQuality; }; (window.__RTS as any).setImpactGraphSdfDeflection = (enabled: boolean = true) => { impactGraphSdfDeflection = Boolean(enabled); try { window.localStorage.setItem('rts.impactGraph.sdfDeflection', impactGraphSdfDeflection ? '1' : '0'); } catch { // ignore } if (!applyImpactGraphSettings()) { console.error('[ImpactGraph] Effects manager not available'); } console.info(`[ImpactGraph] sdfDeflection=${impactGraphSdfDeflection ? 'on' : 'off'}`); return impactGraphSdfDeflection; }; (window.__RTS as any).getImpactGraphStats = () => { const effectsManager = rendererService.getEffectsManager(); if (!effectsManager) { console.error('[ImpactGraph] Effects manager not available'); return null; } const stats = effectsManager.getImpactGraphStats(); console.info('[ImpactGraph] stats', stats); return stats; }; console.info('[Bootstrap] ✅ GPU Particle System console commands registered'); const enableWebglWorld = !webgpuReady; // WebGPU-first policy: keep only a minimal WebGL overlay scene for visuals that do not yet // have a WebGPU path (projectile/tracer overlays, construction helpers). // You can disable it via ?webglOverlayScene=0 or localStorage for pure WebGPU runs. const forceWebglOverlayScene = (() => { try { const query = new URLSearchParams(window.location.search).get('webglOverlayScene'); if (query === '1' || query === 'true') return true; if (query === '0' || query === 'false') return false; const stored = window.localStorage.getItem('rts.webgl.overlayScene'); if (stored === '1' || stored === 'true') return true; if (stored === '0' || stored === 'false') return false; return true; } catch { return true; } })(); const enableWebglOverlayScene = webgpuReady && !FRAMEGRAPH_USE_WEBGL_ENTITIES && forceWebglOverlayScene; const enableWebglEntityScene = enableWebglWorld || FRAMEGRAPH_USE_WEBGL_ENTITIES; const enableWebglScene = enableWebglEntityScene || enableWebglOverlayScene; const useWebglOverlayOnlyScene = enableWebglScene && !enableWebglEntityScene; const webglOverlayScene = useWebglOverlayOnlyScene ? new THREE.Scene() : scene; const webglRenderScene = useWebglOverlayOnlyScene ? webglOverlayScene : scene; configureWebglCanvas(enableWebglScene); if (webgpuReady && !enableWebglScene) { console.info('[Bootstrap] WebGPU-only render path active (WebGL scene disabled).'); } const lightingController = new LightingController(scene, renderer, { backgroundEnabled: enableWebglWorld, dayCycleStartHour: 10, dayCycleEndHour: 17, dayCycleDurationSeconds: 10 * 60, autoDayCycle: true }); const terrainRange = sim.terra.getHeightRange(); lightingController.setTerrainHeightRange(terrainRange.minHeight, terrainRange.maxHeight); const sunMarkerMaterial = new THREE.MeshBasicMaterial({ color: 0x00ff55, depthTest: false, depthWrite: false, transparent: false, opacity: 1.0, toneMapped: false }); const sunMarkerBaseRadius = 12; const sunMarker = new THREE.Mesh(new THREE.SphereGeometry(sunMarkerBaseRadius, 20, 14), sunMarkerMaterial); sunMarker.name = 'sun-debug-marker'; sunMarker.renderOrder = 4096; sunMarker.frustumCulled = false; scene.add(sunMarker); const sunMarkerPosition = new THREE.Vector3(); const sunMarkerTarget = new THREE.Vector3(); const sunMarkerDefaultEnabled = (() => { try { const params = new URLSearchParams(window.location.search); const queryValue = params.get('debugSunMarker'); if (queryValue !== null) { return queryValue === '1' || queryValue.toLowerCase() === 'true'; } const stored = window.localStorage.getItem('rts.debug.sunMarker'); return stored === '1' || stored === 'true'; } catch { return false; } })(); let sunMarkerEnabled = sunMarkerDefaultEnabled; sunMarker.visible = sunMarkerEnabled; const updateSunMarker = (): void => { if (!sunMarkerEnabled) { return; } lightingController.getSunLightPosition(sunMarkerPosition); sunMarker.position.copy(sunMarkerPosition); const cameraDistance = Math.max(camera.position.distanceTo(sunMarkerPosition), 1); const desiredRadius = THREE.MathUtils.clamp(cameraDistance * 0.03, sunMarkerBaseRadius, 240); const scale = desiredRadius / sunMarkerBaseRadius; sunMarker.scale.setScalar(scale); }; const setSunMarkerVisible = (enabled: boolean): void => { sunMarkerEnabled = Boolean(enabled); sunMarker.visible = sunMarkerEnabled; try { window.localStorage.setItem('rts.debug.sunMarker', sunMarkerEnabled ? '1' : '0'); } catch { // Ignore storage failures (private mode / blocked storage). } if (sunMarkerEnabled) { updateSunMarker(); } }; if (sunMarkerEnabled) { updateSunMarker(); } // 🔍 DIAGNOSTIC: Expose lightingController for debugging (window.__RTS as any).lightingController = lightingController; (window.__RTS as any).setSunPitch = (pitch: number) => { lightingController.setSunPitch(Number.isFinite(pitch) ? pitch : 55); return lightingController.getSunPitch(); }; (window.__RTS as any).getSunPitch = () => lightingController.getSunPitch(); (window.__RTS as any).setSunYaw = (yaw: number) => { lightingController.setSunYaw(Number.isFinite(yaw) ? yaw : 120); return lightingController.getSunYaw(); }; (window.__RTS as any).getSunYaw = () => lightingController.getSunYaw(); (window.__RTS as any).setSunAngle = (angle: number) => { lightingController.setSunPitch(Number.isFinite(angle) ? angle : 55); return lightingController.getSunPitch(); }; (window.__RTS as any).enableDayCycle = (enabled: boolean) => lightingController.setAutomaticDayCycle(Boolean(enabled)); (window.__RTS as any).setSunSchedule = (startHour: number, endHour: number, durationMinutes = 10) => lightingController.configureSunSchedule( Number.isFinite(startHour) ? startHour : 10, Number.isFinite(endHour) ? endHour : 17, Number.isFinite(durationMinutes) ? durationMinutes * 60 : 10 * 60 ); (window.__RTS as any).getSunSchedule = () => lightingController.getSunSchedule(); (window.__RTS as any).setSunLightDistance = (distance: number) => { lightingController.setSunLightDistance(Number.isFinite(distance) ? distance : 420); return lightingController.getSunLightDistance(); }; (window.__RTS as any).getSunLightDistance = () => lightingController.getSunLightDistance(); (window.__RTS as any).setSunStrength = (intensity: number) => { lightingController.setSunLightIntensity(Number.isFinite(intensity) ? intensity : 3.0); return lightingController.getSunLightIntensity(); }; (window.__RTS as any).getSunStrength = () => lightingController.getSunLightIntensity(); (window.__RTS as any).setSunMarkerVisible = (enabled = true) => { setSunMarkerVisible(Boolean(enabled)); return sunMarkerEnabled; }; (window.__RTS as any).toggleSunMarker = () => { setSunMarkerVisible(!sunMarkerEnabled); return sunMarkerEnabled; }; (window.__RTS as any).getSunMarkerInfo = () => { lightingController.getSunLightPosition(sunMarkerPosition); lightingController.getSunTargetPosition(sunMarkerTarget); const info = { visible: sunMarkerEnabled, lightDistance: Number(lightingController.getSunLightDistance().toFixed(2)), sunStrength: Number(lightingController.getSunLightIntensity().toFixed(2)), position: { x: Number(sunMarkerPosition.x.toFixed(2)), y: Number(sunMarkerPosition.y.toFixed(2)), z: Number(sunMarkerPosition.z.toFixed(2)) }, target: { x: Number(sunMarkerTarget.x.toFixed(2)), y: Number(sunMarkerTarget.y.toFixed(2)), z: Number(sunMarkerTarget.z.toFixed(2)) } }; console.info('[Lighting] Sun marker info:', info); return info; }; const TREE_LIGHTING_TUNING_STORAGE_KEY = 'rts.framegraph.treeLightingTuning'; const parseTreeLightingTuning = (raw: string | null): Partial> => { if (!raw) return {}; try { const parsed = JSON.parse(raw) as Partial>; if (!parsed || typeof parsed !== 'object') return {}; const result: Partial> = {}; for (const spec of TREE_TUNING_SPECS) { const candidate = parsed[spec.key]; if (typeof candidate === 'number' && Number.isFinite(candidate)) { result[spec.key] = candidate; } } return result; } catch { return {}; } }; const applyTreeLightingTuning = ( tuning: Partial>, persist = true ) => { const next = rendererService.setTreeLightingTuning(tuning); if (persist) { window.localStorage.setItem(TREE_LIGHTING_TUNING_STORAGE_KEY, JSON.stringify(next)); } return next; }; const storedTreeLighting = parseTreeLightingTuning(window.localStorage.getItem(TREE_LIGHTING_TUNING_STORAGE_KEY)); if (Object.keys(storedTreeLighting).length > 0) { applyTreeLightingTuning(storedTreeLighting, false); } (window.__RTS as any).setTreeLightingTuning = (tuning: Partial>) => { const next = applyTreeLightingTuning(tuning ?? {}, true); console.info('[Trees] Lighting tuning updated:', next); return next; }; (window.__RTS as any).getTreeLightingTuning = () => rendererService.getTreeLightingTuning(); (window.__RTS as any).resetTreeLightingTuning = () => { window.localStorage.removeItem(TREE_LIGHTING_TUNING_STORAGE_KEY); const next = rendererService.setTreeLightingTuning({ ...DEFAULT_TREE_LIGHTING_TUNING }); console.info('[Trees] Lighting tuning reset:', next); return next; }; (window.__RTS as any).setTreeWrapDiffuse = (value: number) => (window.__RTS as any).setTreeLightingTuning({ wrapDiffuse: Number.isFinite(value) ? value : DEFAULT_TREE_LIGHTING_TUNING.wrapDiffuse }); (window.__RTS as any).setTreeSubsurface = (value: number) => (window.__RTS as any).setTreeLightingTuning({ subsurface: Number.isFinite(value) ? value : DEFAULT_TREE_LIGHTING_TUNING.subsurface }); (window.__RTS as any).setTreeSpecular = (value: number) => (window.__RTS as any).setTreeLightingTuning({ specular: Number.isFinite(value) ? value : DEFAULT_TREE_LIGHTING_TUNING.specular }); (window.__RTS as any).setTreeContactAO = (value: number) => (window.__RTS as any).setTreeLightingTuning({ contactAO: Number.isFinite(value) ? value : DEFAULT_TREE_LIGHTING_TUNING.contactAO }); (window.__RTS as any).setTreeShadowBias = (scale: number, offset = 0) => (window.__RTS as any).setTreeLightingTuning({ shadowBiasScale: Number.isFinite(scale) ? scale : DEFAULT_TREE_LIGHTING_TUNING.shadowBiasScale, shadowBiasOffset: Number.isFinite(offset) ? offset : DEFAULT_TREE_LIGHTING_TUNING.shadowBiasOffset }); console.info('[Trees] Debug controls: __RTS.setTreeLightingTuning({...}), __RTS.setTreeWrapDiffuse(v), __RTS.setTreeSubsurface(v), __RTS.setTreeSpecular(v), __RTS.setTreeContactAO(v), __RTS.setTreeShadowBias(scale, offset)'); (window.__RTS as any).setWaterNormalDebugMode = (enabled: boolean) => rendererService.setWaterNormalDebugMode(Boolean(enabled)); (window.__RTS as any).setWaterDebugViewMode = (mode: number) => { const clamped = Math.max(0, Math.min(7, Math.round(mode))); window.localStorage.setItem('rts.framegraph.waterDebugView', String(clamped)); rendererService.setWaterDebugViewMode(clamped); }; const rawWaterLevelOffset = window.localStorage.getItem('rts.framegraph.waterLevelOffset'); const storedWaterLevelOffset = rawWaterLevelOffset === null ? Number.NaN : Number(rawWaterLevelOffset); if (Number.isFinite(storedWaterLevelOffset)) { rendererService.setWaterLevelOffset(storedWaterLevelOffset); } (window.__RTS as any).setWaterLevelOffset = (offset: number) => { const clamped = Math.max(-100, Math.min(100, Number.isFinite(offset) ? offset : 0)); window.localStorage.setItem('rts.framegraph.waterLevelOffset', String(clamped)); rendererService.setWaterLevelOffset(clamped); return clamped; }; (window.__RTS as any).getWaterLevelOffset = () => rendererService.getWaterLevelOffset(); (window.__RTS as any).setWaterFeatureFlags = (flags: number) => rendererService.setWaterFeatureFlags(Number.isFinite(flags) ? flags : 0); (window.__RTS as any).setWaterLodDistances = ( lod0: number, lod1: number, lod2: number, lod3: number ) => rendererService.setWaterLodDistances( Number.isFinite(lod0) ? lod0 : 50, Number.isFinite(lod1) ? lod1 : 150, Number.isFinite(lod2) ? lod2 : 500, Number.isFinite(lod3) ? lod3 : 1200 ); (window.__RTS as any).setWaterWaveVisibility = (value: number) => { const clamped = Math.max(0.5, Math.min(2.5, Number.isFinite(value) ? value : 1.0)); window.localStorage.setItem('rts.framegraph.waterWaveVisibility', String(clamped)); rendererService.setWaterWaveVisibility(clamped); return clamped; }; (window.__RTS as any).setWaterScatterBoost = (value: number) => { const clamped = Math.max(0.4, Math.min(2.5, Number.isFinite(value) ? value : 1.0)); window.localStorage.setItem('rts.framegraph.waterScatterBoost', String(clamped)); rendererService.setWaterScatterBoost(clamped); return clamped; }; (window.__RTS as any).setWaterReflectionBreakup = (value: number) => { const clamped = Math.max(0.4, Math.min(2.5, Number.isFinite(value) ? value : 1.0)); window.localStorage.setItem('rts.framegraph.waterReflectionBreakup', String(clamped)); rendererService.setWaterReflectionBreakup(clamped); return clamped; }; (window.__RTS as any).setWaterTintBalance = (value: number) => { const clamped = Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0.62)); window.localStorage.setItem('rts.framegraph.waterTintBalance', String(clamped)); rendererService.setWaterTintBalance(clamped); return clamped; }; const rawWaterAbsorptionScale = window.localStorage.getItem('rts.framegraph.waterAbsorptionScale'); const rawWaterDepthBlendScale = window.localStorage.getItem('rts.framegraph.waterDepthBlendScale'); const storedWaterAbsorptionScale = rawWaterAbsorptionScale === null ? Number.NaN : Number(rawWaterAbsorptionScale); const storedWaterDepthBlendScale = rawWaterDepthBlendScale === null ? Number.NaN : Number(rawWaterDepthBlendScale); if (Number.isFinite(storedWaterAbsorptionScale) || Number.isFinite(storedWaterDepthBlendScale)) { rendererService.setWaterOpticalTuning({ absorptionScale: Number.isFinite(storedWaterAbsorptionScale) ? storedWaterAbsorptionScale : undefined, depthBlendScale: Number.isFinite(storedWaterDepthBlendScale) ? storedWaterDepthBlendScale : undefined }); } (window.__RTS as any).setWaterAbsorptionScale = (value: number) => { const clamped = Math.max(0.4, Math.min(3.0, Number.isFinite(value) ? value : 1.0)); window.localStorage.setItem('rts.framegraph.waterAbsorptionScale', String(clamped)); return rendererService.setWaterOpticalTuning({ absorptionScale: clamped }); }; (window.__RTS as any).setWaterDepthBlendScale = (value: number) => { const clamped = Math.max(0.4, Math.min(2.5, Number.isFinite(value) ? value : 1.0)); window.localStorage.setItem('rts.framegraph.waterDepthBlendScale', String(clamped)); return rendererService.setWaterOpticalTuning({ depthBlendScale: clamped }); }; (window.__RTS as any).setWaterOpticalTuning = (tuning: { absorptionScale?: number; depthBlendScale?: number }) => { const next = rendererService.setWaterOpticalTuning(tuning ?? {}); window.localStorage.setItem('rts.framegraph.waterAbsorptionScale', String(next.absorptionScale)); window.localStorage.setItem('rts.framegraph.waterDepthBlendScale', String(next.depthBlendScale)); return next; }; (window.__RTS as any).getWaterOpticalTuning = () => rendererService.getWaterOpticalTuning(); (window.__RTS as any).getWaterLookTuning = () => ({ levelOffset: window.localStorage.getItem('rts.framegraph.waterLevelOffset') ?? 'default(0)', waveVisibility: window.localStorage.getItem('rts.framegraph.waterWaveVisibility') ?? 'default(1.0)', scatterBoost: window.localStorage.getItem('rts.framegraph.waterScatterBoost') ?? 'default(1.0)', reflectionBreakup: window.localStorage.getItem('rts.framegraph.waterReflectionBreakup') ?? 'default(1.0)', tintBalance: window.localStorage.getItem('rts.framegraph.waterTintBalance') ?? 'default(0.62)', absorptionScale: window.localStorage.getItem('rts.framegraph.waterAbsorptionScale') ?? 'default(1.0)', depthBlendScale: window.localStorage.getItem('rts.framegraph.waterDepthBlendScale') ?? 'default(1.0)' }); (window.__RTS as any).setWaterClipmapMultilevel = (enabled: boolean) => { try { window.localStorage.setItem('rts.framegraph.waterClipmapMultilevel', enabled ? '1' : '0'); } finally { window.location.reload(); } }; (window.__RTS as any).setWaterClipmapLevels = (levels: number) => { const clamped = Math.max(1, Math.min(10, Math.round(levels))); try { window.localStorage.setItem('rts.framegraph.waterClipmapLevels', String(clamped)); if (clamped > 1) { window.localStorage.setItem('rts.framegraph.waterClipmapMultilevel', '1'); } } finally { window.location.reload(); } }; (window.__RTS as any).setWaterClipmapBaseScale = (scale: number) => { const clamped = Math.max(32, Number.isFinite(scale) ? scale : 96); try { window.localStorage.setItem('rts.framegraph.waterClipmapBaseScale', String(clamped)); } finally { window.location.reload(); } }; (window.__RTS as any).getWaterClipmapConfig = () => ({ multilevel: window.localStorage.getItem('rts.framegraph.waterClipmapMultilevel') ?? 'default(1)', levels: window.localStorage.getItem('rts.framegraph.waterClipmapLevels') ?? 'default(6)', baseScale: window.localStorage.getItem('rts.framegraph.waterClipmapBaseScale') ?? 'default(96)' }); (window.__RTS as any).waterDebugNormals = (enabled = true) => (window.__RTS as any).setWaterDebugViewMode(enabled ? 1 : 0); (window.__RTS as any).waterDebugFoam = (enabled = true) => (window.__RTS as any).setWaterDebugViewMode(enabled ? 2 : 0); (window.__RTS as any).waterDebugDepthThickness = (enabled = true) => (window.__RTS as any).setWaterDebugViewMode(enabled ? 3 : 0); (window.__RTS as any).waterDebugReflections = (enabled = true) => (window.__RTS as any).setWaterDebugViewMode(enabled ? 4 : 0); (window.__RTS as any).waterDebugLOD = (enabled = true) => (window.__RTS as any).setWaterDebugViewMode(enabled ? 5 : 0); (window.__RTS as any).waterDebugClipmap = (enabled = true) => (window.__RTS as any).setWaterDebugViewMode(enabled ? 6 : 0); (window.__RTS as any).waterDebugReflectionValidity = (enabled = true) => (window.__RTS as any).setWaterDebugViewMode(enabled ? 7 : 0); (window.__RTS as any).waterDebugOff = () => (window.__RTS as any).setWaterDebugViewMode(0); (window.__RTS as any).emitWaterRipple = ( x: number, z: number, radius: number = 8, strength: number = 0.8, velX: number = 0, velZ: number = 0 ) => { rendererService.enqueueWaterInteractionEvent({ positionWS: [Number.isFinite(x) ? x : 0, 0, Number.isFinite(z) ? z : 0], radius: Number.isFinite(radius) ? radius : 8, strength: Number.isFinite(strength) ? strength : 0.8, velocityDir: [Number.isFinite(velX) ? velX : 0, Number.isFinite(velZ) ? velZ : 0] }); }; (window.__RTS as any).getWaterTelemetry = () => rendererService.getWaterTelemetry(); (window.__RTS as any).setGameplayPanelsVisible = (visible: boolean) => uiManager.setGameplayPanelsVisible(Boolean(visible)); (window.__RTS as any).toggleRendererPerformanceOverlay = () => rendererService.togglePerformanceOverlay(); (window.__RTS as any).setRenderEnabled = (enabled: boolean = true) => { runtimeRenderEnabled = Boolean(enabled); console.info(`[RenderLoop] Rendering ${runtimeRenderEnabled ? 'ENABLED' : 'DISABLED'} (WebGL/WebGPU submits).`); return runtimeRenderEnabled; }; (window.__RTS as any).getRenderEnabled = () => { console.info(`[RenderLoop] Rendering is currently ${runtimeRenderEnabled ? 'ENABLED' : 'DISABLED'}.`); return runtimeRenderEnabled; }; (window.__RTS as any).setCameraClipPlanes = ( near: number, far: number, persist: boolean = false ) => { const nextNear = Number.isFinite(near) ? Math.min(Math.max(near, 0.05), 10) : camera.near; const nextFar = Number.isFinite(far) ? Math.min(Math.max(far, 4000), 100000) : camera.far; const safeFar = Math.max(nextFar, nextNear + 1000); camera.near = nextNear; camera.far = safeFar; camera.updateProjectionMatrix(); if (persist) { window.localStorage.setItem('rts.camera.near', String(nextNear)); window.localStorage.setItem('rts.camera.far', String(safeFar)); } console.info(`[Camera] Clip planes updated: near=${camera.near.toFixed(2)} far=${camera.far.toFixed(0)}${persist ? ' (persisted)' : ''}`); return { near: camera.near, far: camera.far }; }; (window.__RTS as any).getCameraClipPlanes = () => { const current = { near: camera.near, far: camera.far }; console.info('[Camera] Clip planes:', current); return current; }; (window.__RTS as any).setDisabledFramegraphPasses = (passNames: string[] = []) => { const names = Array.isArray(passNames) ? passNames : []; const next = rendererService.setDisabledFramegraphPasses(names); console.info('[Framegraph] Disabled passes:', next); return next; }; (window.__RTS as any).getDisabledFramegraphPasses = () => { const names = rendererService.getDisabledFramegraphPasses(); console.info('[Framegraph] Disabled passes:', names); return names; }; (window.__RTS as any).getFramegraphPassNames = () => { const names = rendererService.getFramegraphPassNames(); console.info('[Framegraph] Pass names:', names); return names; }; (window.__RTS as any).setFramegraphFeatureOverrides = (overrides: { shadows?: boolean | null; ssao?: boolean | null; bloom?: boolean | null; water?: boolean | null; lightCulling?: boolean | null; }) => { const next = rendererService.setFramegraphFeatureOverrides(overrides ?? {}); console.info('[Framegraph] Runtime feature overrides updated:', next); return next; }; (window.__RTS as any).getFramegraphFeatureOverrides = () => { const current = rendererService.getFramegraphFeatureOverrides(); console.info('[Framegraph] Runtime feature overrides:', current); return current; }; (window.__RTS as any).setRuntimeTerrainQualityTier = ( tier: number, persist: boolean = false ) => { const next = rendererService.setRuntimeTerrainQualityTier(tier); (window.__RTS as any).runtimeTerrainQualityTier = next; if (persist) { window.localStorage.setItem('rts.framegraph.terrainQuality', String(next)); } console.info( `[TerrainQuality] Runtime terrain quality tier set to ${next}${persist ? ' (persisted)' : ''}` ); return next; }; (window.__RTS as any).getRuntimeTerrainQualityTier = () => { const tier = rendererService.getRuntimeTerrainQualityTier(); (window.__RTS as any).runtimeTerrainQualityTier = tier; console.info(`[TerrainQuality] Runtime terrain quality tier: ${tier}`); return tier; }; // TAA (Temporal Anti-Aliasing) controls (window.__RTS as any).setTaaEnabled = (enabled: boolean) => { rendererService.setTaaEnabled(Boolean(enabled)); console.info(`[TAA] Temporal Anti-Aliasing ${enabled ? 'ENABLED' : 'DISABLED'}`); console.info('[TAA] TAA reduces aliasing and shimmering by accumulating multiple frames'); console.info('[TAA] Best visible when camera is moving slowly'); }; (window.__RTS as any).toggleTaa = () => { const currentState = rendererService.isTaaEnabled(); rendererService.setTaaEnabled(!currentState); console.info(`[TAA] Temporal Anti-Aliasing ${!currentState ? 'ENABLED' : 'DISABLED'}`); }; (window.__RTS as any).isTaaEnabled = () => { const enabled = rendererService.isTaaEnabled(); console.info(`[TAA] TAA is currently ${enabled ? 'ENABLED' : 'DISABLED'}`); return enabled; }; (window.__RTS as any).setSsrEnabled = (enabled: boolean) => { rendererService.setSsrEnabled(Boolean(enabled)); console.info(`[SSR] Full-screen SSR ${enabled ? 'ENABLED' : 'DISABLED'}`); console.info('[SSR] This toggles the framegraph SSR pass pair, not the water shader SSR.'); }; (window.__RTS as any).toggleSsr = () => { const currentState = rendererService.isSsrEnabled(); rendererService.setSsrEnabled(!currentState); console.info(`[SSR] Full-screen SSR ${!currentState ? 'ENABLED' : 'DISABLED'}`); }; (window.__RTS as any).isSsrEnabled = () => { const enabled = rendererService.isSsrEnabled(); console.info(`[SSR] Full-screen SSR is currently ${enabled ? 'ENABLED' : 'DISABLED'}`); return enabled; }; // 🔍 COMPREHENSIVE TERRAIN DEBUG FUNCTION (window.__RTS as any).debugTerrainDataFlow = () => { console.info('═══════════════════════════════════════════════════════════════'); console.info('🔍 TERRAIN DATA FLOW DEBUG - Tracing terrain from Terra to GPU'); console.info('═══════════════════════════════════════════════════════════════'); // 1. Check Terra (source of truth) console.info('\n📍 1. TERRA (Source of Truth):'); const terraData = { width: sim.terra.width, height: sim.terra.height, tileSize: sim.terra.tileSize, heightsLength: sim.terra.heights?.length, heightsType: sim.terra.heights?.constructor?.name, heightRange: sim.terra.getHeightRange(), terrainTypesLength: sim.terra.terrainTypes?.length, biomeType: sim.terra.getBiomeType?.() ?? 'unknown' }; console.table(terraData); // Sample some heights if (sim.terra.heights && sim.terra.heights.length > 0) { const w = sim.terra.width; const h = sim.terra.height; const samples = { 'topLeft [0,0]': sim.terra.heights[0], 'topRight [w-1,0]': sim.terra.heights[(w - 1) * h + 0], 'bottomLeft [0,h-1]': sim.terra.heights[0 * h + (h - 1)], 'bottomRight [w-1,h-1]': sim.terra.heights[(w - 1) * h + (h - 1)], 'center': sim.terra.heights[Math.floor(w / 2) * h + Math.floor(h / 2)] }; console.info(' Height samples:', samples); } // 2. Check Sim.terra reference console.info('\n📍 2. SIM.TERRA Reference:'); console.info(' Same as terra?', sim.terra === terra); console.info(' Width:', sim.terra.width); console.info(' Height:', sim.terra.height); // 3. Check RenderWorldExtractor console.info('\n📍 3. RENDER WORLD EXTRACTOR:'); console.info(' (Check console for [RenderWorldExtractor] logs on page load)'); // 4. Check RenderWorldManager snapshot console.info('\n📍 4. RENDER WORLD MANAGER (Current Snapshot):'); const snapshot0 = renderWorldManager.getSnapshot(0); const snapshot1 = renderWorldManager.getSnapshot(1); const logSnapshot = (name: string, snap: any) => { if (!snap) { console.info(` ${name}: NULL`); return; } console.info(` ${name}:`, { hasTerrain: !!snap.terrain, terrainWidth: snap.terrain?.width, terrainHeight: snap.terrain?.height, terrainMinHeight: snap.terrain?.minHeight?.toFixed(2), terrainMaxHeight: snap.terrain?.maxHeight?.toFixed(2), heightsLength: snap.terrain?.heights?.length, heightsType: snap.terrain?.heights?.constructor?.name }); // Sample heights from snapshot if (snap.terrain?.heights && snap.terrain.heights.length > 0) { const w = snap.terrain.width; const h = snap.terrain.height; const samples = { 'topLeft': snap.terrain.heights[0]?.toFixed(2), 'center': snap.terrain.heights[Math.floor(w / 2) * h + Math.floor(h / 2)]?.toFixed(2) }; console.info(` Height samples:`, samples); } }; logSnapshot('Slot 0', snapshot0); logSnapshot('Slot 1', snapshot1); // 5. Check RendererService/FramegraphExecutor console.info('\n📍 5. RENDERER SERVICE:'); const executor = (rendererService as any).executor; if (executor) { console.info(' Executor exists:', true); console.info(' terrainHeightSize:', executor.terrainHeightSize); const bindingData = executor.frameBindingData; if (bindingData) { console.info(' FrameBindingData exists:', true); const terrainHeightData = (bindingData as any).terrainHeightData; if (terrainHeightData) { console.info(' terrainHeightData:', { width: terrainHeightData.width, height: terrainHeightData.height, tileSize: terrainHeightData.tileSize, dataLength: terrainHeightData.data?.length, revision: terrainHeightData.revision }); } else { console.info(' terrainHeightData: NULL'); } console.info(' terrainHeightDirty:', (bindingData as any).terrainHeightDirty); } else { console.info(' FrameBindingData: NULL'); } // Check GPU textures const textures = executor.textures; if (textures) { const terrainHeight = textures.get('terrainHeight'); const terrainType = textures.get('terrainType'); console.info(' GPU Textures:'); console.info(' terrainHeight:', terrainHeight ? `${terrainHeight.width}x${terrainHeight.height}` : 'NULL'); console.info(' terrainType:', terrainType ? `${terrainType.width}x${terrainType.height}` : 'NULL'); } } else { console.info(' Executor: NULL'); } // 6. Summary console.info('\n═══════════════════════════════════════════════════════════════'); console.info('📊 SUMMARY:'); const expectedSize = sim.terra.width * sim.terra.height; const snapshotSize = snapshot0?.terrain?.width && snapshot0?.terrain?.height ? snapshot0.terrain.width * snapshot0.terrain.height : 0; const gpuSize = executor?.terrainHeightSize ? executor.terrainHeightSize.width * executor.terrainHeightSize.height : 0; console.info(` Terra size: ${sim.terra.width}x${sim.terra.height} = ${expectedSize} tiles`); console.info(` Snapshot size: ${snapshot0?.terrain?.width ?? '?'}x${snapshot0?.terrain?.height ?? '?'} = ${snapshotSize} tiles`); console.info(` GPU texture size: ${executor?.terrainHeightSize?.width ?? '?'}x${executor?.terrainHeightSize?.height ?? '?'} = ${gpuSize} tiles`); if (expectedSize !== snapshotSize) { console.error(' ❌ MISMATCH: Terra size !== Snapshot size!'); console.error(' This means RenderWorldExtractor is not reading the correct Terra data.'); } if (snapshotSize !== gpuSize && snapshotSize > 0) { console.error(' ❌ MISMATCH: Snapshot size !== GPU texture size!'); console.error(' This means FramegraphExecutor is not resizing textures correctly.'); } if (expectedSize === snapshotSize && snapshotSize === gpuSize && expectedSize > 4) { console.info(' ✅ All sizes match! Data flow appears correct.'); } console.info('═══════════════════════════════════════════════════════════════'); return { terra: terraData, snapshot0: snapshot0?.terrain ? { width: snapshot0.terrain.width, height: snapshot0.terrain.height, minHeight: snapshot0.terrain.minHeight, maxHeight: snapshot0.terrain.maxHeight } : null, gpuTextureSize: executor?.terrainHeightSize ?? null }; }; // Quick alias (window.__RTS as any).dtf = (window.__RTS as any).debugTerrainDataFlow; console.info('[Debug] Terrain debug function available: __RTS.debugTerrainDataFlow() or __RTS.dtf()'); // 🔍 DIAGNOSTIC: Lighting debug function (window.__RTS as any).debugLighting = () => { console.log('═══════════════════════════════════════════════════════════════'); console.log('🔆 LIGHTING DEBUG - Checking lighting uniforms'); console.log('═══════════════════════════════════════════════════════════════'); // 1. Check LightingController state console.log('\n📍 1. LIGHTING CONTROLLER STATE:'); const state = lightingController.getState(); const sunDir = state.sunDirection; const sunLen = Math.sqrt(sunDir.x*sunDir.x + sunDir.y*sunDir.y + sunDir.z*sunDir.z); console.table({ 'sunDirection': `(${sunDir.x.toFixed(3)}, ${sunDir.y.toFixed(3)}, ${sunDir.z.toFixed(3)}) len=${sunLen.toFixed(3)}`, 'sunColor': `(${state.sunColor.r.toFixed(3)}, ${state.sunColor.g.toFixed(3)}, ${state.sunColor.b.toFixed(3)})`, 'ambientColor': `(${state.ambientColor.r.toFixed(3)}, ${state.ambientColor.g.toFixed(3)}, ${state.ambientColor.b.toFixed(3)})`, 'skyColor': `(${state.skyColor.r.toFixed(3)}, ${state.skyColor.g.toFixed(3)}, ${state.skyColor.b.toFixed(3)})` }); // 2. Check if sun direction is valid console.log('\n📍 2. SUN DIRECTION VALIDATION:'); if (sunLen < 0.9 || sunLen > 1.1) { console.error(` ❌ Sun direction not normalized! Length: ${sunLen.toFixed(4)}`); } else { console.log(` ✅ Sun direction normalized. Length: ${sunLen.toFixed(4)}`); } if (sunDir.y < 0.28) { console.error(` ❌ Sun too low! Y: ${sunDir.y.toFixed(4)} (should be > 0.28)`); } else { console.log(` ✅ Sun above horizon. Y: ${sunDir.y.toFixed(4)}`); } // 3. Check lighting colors console.log('\n📍 3. COLOR VALIDATION:'); const sunBrightness = state.sunColor.r + state.sunColor.g + state.sunColor.b; const ambientBrightness = state.ambientColor.r + state.ambientColor.g + state.ambientColor.b; if (sunBrightness < 0.1) { console.error(` ❌ Sun color too dark! Brightness: ${sunBrightness.toFixed(3)}`); } else { console.log(` ✅ Sun color brightness: ${sunBrightness.toFixed(3)}`); } if (ambientBrightness < 0.1) { console.error(` ❌ Ambient color too dark! Brightness: ${ambientBrightness.toFixed(3)}`); } else { console.log(` ✅ Ambient color brightness: ${ambientBrightness.toFixed(3)}`); } // 4. GPU uniforms check skipped (no direct access to executor) console.log('\n📍 4. GPU UNIFORM CHECK:'); console.log(' ℹ️ GPU uniform verification requires shader debug output'); // 5. Check texture manager console.log('\n📍 5. TEXTURE MANAGER:'); const textureManager = (window.__RTS as any).textureManager; if (textureManager) { console.log(' ✅ Texture manager exists'); const config = textureManager.getTextureConfig?.(); if (config) { console.log(' Texture config:', { hasGrassAlbedo: !!config.grass?.albedo, hasRockAlbedo: !!config.rock?.albedo, hasSandAlbedo: !!config.sand?.albedo, hasDirtAlbedo: !!config.dirt?.albedo, useProcedural: config.useProcedural ?? 'unknown', useVirtualTextures: config.useVirtualTextures ?? 'unknown' }); } } else { console.warn(' ⚠️ Texture manager not found on window.__RTS'); } console.log('\n═══════════════════════════════════════════════════════════════'); console.log('💡 If lighting values look correct, the issue may be in shader'); console.log(' Try: __RTS.setTerrainDebugMode(7) to see baseColor without lighting'); console.log(' Try: __RTS.setTerrainDebugMode(8) to see lighting only'); console.log(' Try: __RTS.setShadowCascadeDebugMode("index") for cascade coverage'); console.log(' Try: __RTS.setShadowCascadeDebugMode("fit") to detect out-of-atlas projection'); console.log(' Try: __RTS.setShadowCascadeDebugMode("disagreement") for cascade seam mismatches'); console.log(' Try: __RTS.setShadowCascadeDebugMode("rawDepth") for raw atlas depth at receiver'); console.log(' Try: __RTS.setShadowCascadeDebugMode("compare") for compareDepth vs atlasDepth'); console.log('═══════════════════════════════════════════════════════════════'); }; (window.__RTS as any).dl = (window.__RTS as any).debugLighting; console.info('[Debug] Lighting debug function available: __RTS.debugLighting() or __RTS.dl()'); type ShadowCascadeSummaryRow = { cascade: number; splitDistance: number; viewportPx: string; matrix00: number; matrix11: number; matrix22: number; matrix30: number; matrix31: number; matrix32: number; }; type ShadowCascadeState = { terrainDebugMode: number; atlasSize: number; splits: number[]; sunDirection: [number, number, number]; cameraPosition: [number, number, number]; cascades: ShadowCascadeSummaryRow[]; }; type ShadowCasterMask = { objects: boolean; legacyTrees: boolean; scatterTrees: boolean }; type ShadowIncidentSnapshot = { index: number; label: string; timestampMs: number; timestampIso: string; camera: { position: [number, number, number]; forward: [number, number, number]; fov: number; near: number; far: number; }; terrainDebugMode: number; framegraphOverrides: { shadows: boolean | null; ssao: boolean | null; bloom: boolean | null; water: boolean | null; lightCulling: boolean | null; }; shadowCasterMask: ShadowCasterMask; sun: { direction: [number, number, number]; strength: number; distance: number; schedule: { startHour: number; endHour: number; durationSeconds: number }; }; cascadeState: ShadowCascadeState | null; }; type ShadowIncidentComparison = { from: { index: number; label: string; timestampIso: string }; to: { index: number; label: string; timestampIso: string }; cameraTravelMeters: number; sunDirectionDeltaDeg: number; changedOverrides: string[]; changedCasterMask: string[]; maxCascadeTranslationDelta: number; maxSplitDelta: number; likelyCauses: string[]; }; type ShadowDebugHost = Window & { __RTS_SHADOW_CASTER_DEBUG__?: { objects?: boolean; legacyTrees?: boolean; scatterTrees?: boolean }; __RTS_SHADOW_INCIDENT_HISTORY__?: ShadowIncidentSnapshot[]; __RTS_SHADOW_PASS_STATS__?: { timestampMs: number; cascadeCount: number; treeShadowCascadeCount?: number; objectCastersEnabled: boolean; legacyTreeCastersEnabled: boolean; scatterTreeCastersEnabled: boolean; useScatterTreeShadowPath: boolean; objectDrawCalls: number; objectInstancesDrawn: number; legacyTreeDrawCalls: number; legacyTreeInstancesDrawn: number; scatterDrawCalls: number; scatterInstancesDrawn: number; scatterShadowCount: number; treeCount: number; limitedTreeCount: number; perCascade?: Array<{ cascadeIndex: number; objectDrawCalls: number; objectInstancesDrawn: number; legacyTreeDrawCalls: number; legacyTreeInstancesDrawn: number; scatterDrawCalls: number; scatterInstancesDrawn: number; }>; }; }; const getShadowDebugHost = (): ShadowDebugHost => window as unknown as ShadowDebugHost; const readTerrainDebugModeLive = (): number => { try { const raw = window.localStorage.getItem('rts.framegraph.terrainDebug'); if (raw !== null) { const parsed = Number(raw); if (Number.isFinite(parsed)) { return parsed | 0; } } } catch { // no-op } return FRAMEGRAPH_TERRAIN_DEBUG_MODE; }; const readShadowCasterMask = (): ShadowCasterMask => { const host = getShadowDebugHost(); const current = host.__RTS_SHADOW_CASTER_DEBUG__ ?? {}; return { objects: current.objects ?? true, legacyTrees: current.legacyTrees ?? true, scatterTrees: current.scatterTrees ?? true }; }; const readShadowIncidentHistory = (): ShadowIncidentSnapshot[] => { const host = getShadowDebugHost(); if (!Array.isArray(host.__RTS_SHADOW_INCIDENT_HISTORY__)) { host.__RTS_SHADOW_INCIDENT_HISTORY__ = []; } return host.__RTS_SHADOW_INCIDENT_HISTORY__; }; const collectShadowCascadeState = (logOutput = true): ShadowCascadeState | null => { const bindingData = rendererService.frameBindingData; if (!bindingData) { if (logOutput) { console.warn('[ShadowDebug] Frame binding data not ready yet.'); } return null; } const globals = bindingData.getGlobalUniforms?.(); const cascadeCalculator = bindingData.getCascadeCalculator?.(); if (!globals || !cascadeCalculator) { if (logOutput) { console.warn('[ShadowDebug] Missing global uniforms or cascade calculator.'); } return null; } const cascades = cascadeCalculator.getCascades?.() ?? []; const splits = cascadeCalculator.getSplitDistances?.() ?? []; const atlasSize = cascadeCalculator.getAtlasSize?.() ?? 0; const summary = cascades.map((cascade: any, index: number): ShadowCascadeSummaryRow => { const vp = cascade.viewport; const m = cascade.viewProjMatrix?.elements ?? cascade.viewProjMatrix?.toArray?.() ?? []; return { cascade: index, splitDistance: Number(splits[index] ?? 0), viewportPx: `${Math.round(vp?.x ?? 0)},${Math.round(vp?.y ?? 0)} ${Math.round(vp?.width ?? 0)}x${Math.round(vp?.height ?? 0)}`, matrix00: Number(m[0] ?? 0), matrix11: Number(m[5] ?? 0), matrix22: Number(m[10] ?? 0), matrix30: Number(m[12] ?? 0), matrix31: Number(m[13] ?? 0), matrix32: Number(m[14] ?? 0) }; }); const result: ShadowCascadeState = { terrainDebugMode: readTerrainDebugModeLive(), atlasSize, splits: splits.map((v: number) => Number(v)), sunDirection: [ Number(globals[60] ?? 0), Number(globals[61] ?? 0), Number(globals[62] ?? 0) ], cameraPosition: [ Number(globals[48] ?? 0), Number(globals[49] ?? 0), Number(globals[50] ?? 0) ], cascades: summary }; if (logOutput) { const table = summary.map((row) => ({ cascade: row.cascade, splitDistance: row.splitDistance.toFixed(2), viewportPx: row.viewportPx, matrix00: row.matrix00.toFixed(5), matrix11: row.matrix11.toFixed(5), matrix22: row.matrix22.toFixed(5), matrix30: row.matrix30.toFixed(5), matrix31: row.matrix31.toFixed(5), matrix32: row.matrix32.toFixed(5) })); console.log('[ShadowDebug] Cascade state', result); console.table(table); } return result; }; const buildShadowIncidentSnapshot = (label = 'incident'): ShadowIncidentSnapshot => { const history = readShadowIncidentHistory(); const cameraForward = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion).normalize(); const perspective = camera as THREE.PerspectiveCamera; const lighting = lightingController.getState(); const snapshot: ShadowIncidentSnapshot = { index: history.length, label: String(label || 'incident'), timestampMs: performance.now(), timestampIso: new Date().toISOString(), camera: { position: [Number(camera.position.x), Number(camera.position.y), Number(camera.position.z)], forward: [Number(cameraForward.x), Number(cameraForward.y), Number(cameraForward.z)], fov: Number(Number.isFinite(perspective.fov) ? perspective.fov : 0), near: Number(Number.isFinite(perspective.near) ? perspective.near : 0), far: Number(Number.isFinite(perspective.far) ? perspective.far : 0) }, terrainDebugMode: readTerrainDebugModeLive(), framegraphOverrides: rendererService.getFramegraphFeatureOverrides(), shadowCasterMask: readShadowCasterMask(), sun: { direction: [Number(lighting.sunDirection.x), Number(lighting.sunDirection.y), Number(lighting.sunDirection.z)], strength: Number(lightingController.getSunLightIntensity()), distance: Number(lightingController.getSunLightDistance()), schedule: lightingController.getSunSchedule() }, cascadeState: collectShadowCascadeState(false) }; history.push(snapshot); if (history.length > 32) { history.shift(); for (let i = 0; i < history.length; i += 1) { history[i].index = i; } } return snapshot; }; const compareShadowIncidentSnapshots = ( from: ShadowIncidentSnapshot, to: ShadowIncidentSnapshot ): ShadowIncidentComparison => { const dx = to.camera.position[0] - from.camera.position[0]; const dy = to.camera.position[1] - from.camera.position[1]; const dz = to.camera.position[2] - from.camera.position[2]; const cameraTravelMeters = Math.hypot(dx, dy, dz); const dot = from.sun.direction[0] * to.sun.direction[0] + from.sun.direction[1] * to.sun.direction[1] + from.sun.direction[2] * to.sun.direction[2]; const clampedDot = Math.max(-1, Math.min(1, dot)); const sunDirectionDeltaDeg = (Math.acos(clampedDot) * 180) / Math.PI; const changedOverrides: string[] = []; for (const key of ['shadows', 'ssao', 'bloom', 'water', 'lightCulling'] as const) { if (from.framegraphOverrides[key] !== to.framegraphOverrides[key]) { changedOverrides.push(key); } } const changedCasterMask: string[] = []; for (const key of ['objects', 'legacyTrees', 'scatterTrees'] as const) { if (from.shadowCasterMask[key] !== to.shadowCasterMask[key]) { changedCasterMask.push(key); } } let maxCascadeTranslationDelta = 0; let maxSplitDelta = 0; if (from.cascadeState && to.cascadeState) { const count = Math.min(from.cascadeState.cascades.length, to.cascadeState.cascades.length); for (let i = 0; i < count; i += 1) { const a = from.cascadeState.cascades[i]; const b = to.cascadeState.cascades[i]; maxCascadeTranslationDelta = Math.max( maxCascadeTranslationDelta, Math.abs(a.matrix30 - b.matrix30), Math.abs(a.matrix31 - b.matrix31), Math.abs(a.matrix32 - b.matrix32) ); } const splitCount = Math.min(from.cascadeState.splits.length, to.cascadeState.splits.length); for (let i = 0; i < splitCount; i += 1) { maxSplitDelta = Math.max(maxSplitDelta, Math.abs(from.cascadeState.splits[i] - to.cascadeState.splits[i])); } } const likelyCauses: string[] = []; if (to.framegraphOverrides.shadows === false) { likelyCauses.push('Shadows are runtime-disabled via framegraph override.'); } if (to.shadowCasterMask.objects === false || to.shadowCasterMask.legacyTrees === false || to.shadowCasterMask.scatterTrees === false) { likelyCauses.push('Shadow caster mask excludes one or more caster groups.'); } if (to.sun.direction[1] < 0.28) { likelyCauses.push('Sun height is below the intended minimum; side-lighting can cause broad terrain bands.'); } if (cameraTravelMeters < 30 && maxCascadeTranslationDelta > 1.0) { likelyCauses.push('Cascade projection is moving too much relative to camera motion (fit/snap instability).'); } if (maxSplitDelta > 0.001) { likelyCauses.push('Cascade split distances changed between captures; verify split source and per-frame stability.'); } if (!to.cascadeState) { likelyCauses.push('Cascade state unavailable at capture time; shadow uniforms/bindings may be invalid for that frame.'); } if (changedOverrides.length > 0) { likelyCauses.push(`Runtime feature overrides changed: ${changedOverrides.join(', ')}.`); } if (likelyCauses.length === 0) { likelyCauses.push('No single dominant delta detected; likely receiver-side shader issue or bad caster in current frustum.'); } return { from: { index: from.index, label: from.label, timestampIso: from.timestampIso }, to: { index: to.index, label: to.label, timestampIso: to.timestampIso }, cameraTravelMeters, sunDirectionDeltaDeg, changedOverrides, changedCasterMask, maxCascadeTranslationDelta, maxSplitDelta, likelyCauses }; }; const resolveIncidentRef = (ref: number | string | undefined, fallbackIndex: number): ShadowIncidentSnapshot | null => { const history = readShadowIncidentHistory(); if (history.length === 0) return null; if (ref === undefined || ref === null) { const index = Math.max(0, Math.min(history.length - 1, fallbackIndex)); return history[index] ?? null; } if (typeof ref === 'number' && Number.isFinite(ref)) { const index = Math.max(0, Math.min(history.length - 1, ref | 0)); return history[index] ?? null; } const needle = String(ref).trim().toLowerCase(); const byLabel = [...history].reverse().find((entry) => entry.label.toLowerCase() === needle); if (byLabel) return byLabel; const parsed = Number(needle); if (Number.isFinite(parsed)) { const index = Math.max(0, Math.min(history.length - 1, parsed | 0)); return history[index] ?? null; } return null; }; (window.__RTS as any).dumpShadowCascadeState = () => collectShadowCascadeState(true); (window.__RTS as any).setShadowCasterDebug = ( flags: { objects?: boolean; legacyTrees?: boolean; scatterTrees?: boolean } = {} ) => { const host = getShadowDebugHost(); const prev = host.__RTS_SHADOW_CASTER_DEBUG__ ?? {}; host.__RTS_SHADOW_CASTER_DEBUG__ = { objects: flags.objects ?? prev.objects ?? true, legacyTrees: flags.legacyTrees ?? prev.legacyTrees ?? true, scatterTrees: flags.scatterTrees ?? prev.scatterTrees ?? true }; console.info('[ShadowDebug] Shadow caster mask updated:', host.__RTS_SHADOW_CASTER_DEBUG__); return host.__RTS_SHADOW_CASTER_DEBUG__; }; (window.__RTS as any).getShadowCasterDebug = () => { const current = readShadowCasterMask(); console.info('[ShadowDebug] Shadow caster mask:', current); return current; }; (window.__RTS as any).captureShadowIncident = (label = 'incident') => { const snapshot = buildShadowIncidentSnapshot(label); const history = readShadowIncidentHistory(); console.info('[ShadowIncident] Captured snapshot:', { index: snapshot.index, label: snapshot.label, timestampIso: snapshot.timestampIso, camera: snapshot.camera.position, sunY: Number(snapshot.sun.direction[1].toFixed(4)), terrainDebugMode: snapshot.terrainDebugMode }); let autoComparison: ShadowIncidentComparison | null = null; if (history.length >= 2) { autoComparison = compareShadowIncidentSnapshots(history[history.length - 2], history[history.length - 1]); console.info('[ShadowIncident] Auto-compare (previous -> latest):', autoComparison); } return { snapshot, autoComparison }; }; (window.__RTS as any).compareShadowIncidents = (a?: number | string, b?: number | string) => { const history = readShadowIncidentHistory(); if (history.length < 2) { console.warn('[ShadowIncident] Need at least two captures. Use __RTS.captureShadowIncident("good"/"bad").'); return null; } const from = resolveIncidentRef(a, history.length - 2); const to = resolveIncidentRef(b, history.length - 1); if (!from || !to) { console.warn('[ShadowIncident] Could not resolve one or both capture references.', { a, b }); return null; } if (from.index === to.index) { console.warn('[ShadowIncident] Comparison requires two distinct captures.', { capture: from.index }); return null; } const comparison = compareShadowIncidentSnapshots(from, to); console.log('[ShadowIncident] Comparison result', comparison); return comparison; }; (window.__RTS as any).listShadowIncidents = () => { const history = readShadowIncidentHistory(); const summary = history.map((entry) => ({ index: entry.index, label: entry.label, timestampIso: entry.timestampIso, cameraX: Number(entry.camera.position[0].toFixed(2)), cameraY: Number(entry.camera.position[1].toFixed(2)), cameraZ: Number(entry.camera.position[2].toFixed(2)), sunY: Number(entry.sun.direction[1].toFixed(4)), terrainDebugMode: entry.terrainDebugMode })); console.table(summary); return summary; }; (window.__RTS as any).clearShadowIncidents = () => { const history = readShadowIncidentHistory(); const cleared = history.length; history.length = 0; console.info(`[ShadowIncident] Cleared ${cleared} captures.`); return cleared; }; (window.__RTS as any).getShadowDebugStatus = () => { const overrides = rendererService.getFramegraphFeatureOverrides(); const disabledPasses = rendererService.getDisabledFramegraphPasses(); const shadowDisabledPasses = disabledPasses.filter((name) => /shadow/i.test(name)); const casterMask = readShadowCasterMask(); const terrainDebugMode = readTerrainDebugModeLive(); const cascadeState = collectShadowCascadeState(false); const lightingState = lightingController.getState(); const bindingData = rendererService.frameBindingData; const shadowPassStats = getShadowDebugHost().__RTS_SHADOW_PASS_STATS__ ?? null; const objectCount = bindingData?.getObjectCount?.() ?? null; const treeInstanceCount = bindingData?.getTreeInstanceCount?.() ?? null; const blockers: string[] = []; const notes: string[] = []; if (overrides.shadows === false) { blockers.push('Framegraph override disables shadows (overrides.shadows=false).'); } if (shadowDisabledPasses.length > 0) { blockers.push(`Shadow passes disabled: ${shadowDisabledPasses.join(', ')}`); } if (!casterMask.objects && !casterMask.legacyTrees && !casterMask.scatterTrees) { blockers.push('All shadow caster groups are disabled.'); } else if (!casterMask.objects) { notes.push('Object casters are disabled.'); } if (terrainDebugMode !== 0) { notes.push(`Terrain debug mode is ${terrainDebugMode} (may change visual interpretation).`); } if (!cascadeState) { blockers.push('Cascade state unavailable (frame binding/cascade calculator not ready).'); } else { const allSplitsZero = cascadeState.splits.every((value) => Math.abs(value) < 1e-5); if (allSplitsZero) { blockers.push('Cascade splits are zero (shadows likely disabled or not uploaded).'); } } if (typeof objectCount === 'number' && typeof treeInstanceCount === 'number') { if (objectCount <= 0 && treeInstanceCount <= 0) { notes.push('No shadow caster instances uploaded this frame (objects=0, trees=0).'); } } if (shadowPassStats) { const totalCasterDrawCalls = shadowPassStats.objectDrawCalls + shadowPassStats.legacyTreeDrawCalls + shadowPassStats.scatterDrawCalls; if (typeof shadowPassStats.treeShadowCascadeCount === 'number') { notes.push( `Tree shadow casters rendered in ${shadowPassStats.treeShadowCascadeCount}/${shadowPassStats.cascadeCount} cascades.` ); } if (totalCasterDrawCalls === 0) { blockers.push('ShadowPass issued zero caster draws in last frame.'); } else if (shadowPassStats.scatterDrawCalls === 0 && shadowPassStats.useScatterTreeShadowPath) { notes.push('Scatter shadow path active but produced zero scatter draws; fallback should handle legacy trees.'); } } else { notes.push('No ShadowPass stats captured yet (wait a frame and re-run).'); } if (lightingState.sunDirection.y < 0.2) { notes.push(`Sun Y is low (${lightingState.sunDirection.y.toFixed(3)}), shadows may look banded/side-lit.`); } if (terrainDebugMode === 3) { notes.push('Terrain debug mode 3 shows raw shadow visibility (without floor compensation). Use mode 0 for normal lit scene.'); } const status = { terrainDebugMode, overrides, disabledPasses, shadowDisabledPasses, casterMask, casterCounts: { objects: objectCount, treeInstances: treeInstanceCount }, shadowPassStats, sunDirection: [ Number(lightingState.sunDirection.x.toFixed(4)), Number(lightingState.sunDirection.y.toFixed(4)), Number(lightingState.sunDirection.z.toFixed(4)) ], cascadeReady: Boolean(cascadeState), blockers, notes }; console.info('[ShadowDebug] Status:', status); if (blockers.length === 0) { console.info('[ShadowDebug] No hard blockers detected. If shadows are still invisible, check receiver shader path.'); } return status; }; (window.__RTS as any).getShadowPassStats = () => { const stats = getShadowDebugHost().__RTS_SHADOW_PASS_STATS__ ?? null; console.info('[ShadowDebug] Shadow pass stats:', stats); return stats; }; (window.__RTS as any).restoreShadowVisibility = () => { const overridesBefore = rendererService.getFramegraphFeatureOverrides(); const disabledBefore = rendererService.getDisabledFramegraphPasses(); const shadowPassPattern = /shadow/i; const disabledAfter = disabledBefore.filter((name) => !shadowPassPattern.test(name)); if (disabledAfter.length !== disabledBefore.length) { rendererService.setDisabledFramegraphPasses(disabledAfter); } const overridesAfter = rendererService.setFramegraphFeatureOverrides({ shadows: true }); const casterMask = (window.__RTS as any).setShadowCasterDebug({ objects: true, legacyTrees: true, scatterTrees: true }); const status = (window.__RTS as any).getShadowDebugStatus(); const result = { overridesBefore, overridesAfter, disabledPassesBefore: disabledBefore, disabledPassesAfter: disabledAfter, casterMask, status }; console.info('[ShadowDebug] Shadow visibility restored (best effort):', result); return result; }; console.info('[ShadowDebug] Modes: __RTS.setShadowCascadeDebugMode("index"|"fit"|"disagreement"|"atlas"|"rawDepth"|"compare"), off with "off".'); console.info('[ShadowDebug] Dump state: __RTS.dumpShadowCascadeState()'); console.info('[ShadowDebug] Caster mask: __RTS.setShadowCasterDebug({objects:true|false, legacyTrees:true|false, scatterTrees:true|false})'); console.info('[ShadowDebug] Caster mask readback: __RTS.getShadowCasterDebug()'); console.info('[ShadowIncident] Capture: __RTS.captureShadowIncident("good"|"bad"), compare: __RTS.compareShadowIncidents(), list: __RTS.listShadowIncidents()'); console.info('[ShadowDebug] Recover visibility: __RTS.restoreShadowVisibility(), inspect state: __RTS.getShadowDebugStatus()'); const biomeType = sim.terra.getBiomeType?.() ?? BiomeType.TEMPERATE; const biomePalette = buildBiomePalette(biomeType); lightingController.setBiomePalette(biomePalette); const skyDome = enableWebglWorld && ENABLE_ATMOSPHERE ? new SkyDome() : null; if (skyDome) { skyDome.position.copy(camera.position); scene.add(skyDome); lightingController.registerSkyDome(skyDome); } if (enableWebglScene) { if (enableWebglWorld) { console.info('[Bootstrap] WebGL renderer active (fallback path)'); } else { console.info('[Bootstrap] WebGL entities rendered via DOM overlay'); } } else { console.info('[Bootstrap] WebGPU renderer active (no WebGL overlay)'); } const selectionManager = new SelectionManager(rtsCamera, gpuCanvas, sim.terra); const buildingPreview = new BuildingPreview(scene, sim.terra); const buildOverlayScene = enableWebglEntityScene ? scene : (enableWebglScene ? webglRenderScene : scene); const buildingGridOverlay = new BuildingGridOverlay(buildOverlayScene, sim.terra); // Create viewport indicator for integrated zoom system const viewportIndicator = new ViewportIndicator(scene, rtsCamera); console.log('[Bootstrap] Viewport indicator created (shows at 5000m+ zoom)'); const ENABLE_BUILD_GRID_OVERLAY = true; const screenEffects = new ScreenEffects(); screenEffects.setEnabled(!RENDER_TERRAIN_UNITS_BUILDINGS_ONLY); // Create combat feedback systems const damageNumbers = new DamageNumbersSystem(); damageNumbers.setCamera(camera); const combatFeedback = new CombatFeedbackSystem(); combatFeedback.setCamera(rtsCamera); combatFeedback.setScreenEffects(screenEffects); combatFeedback.setDamageNumbers(damageNumbers); // Wire up combat feedback callbacks to Units system if the helper exists if (typeof sim.units.setCombatFeedbackCallbacks === 'function') { sim.units.setCombatFeedbackCallbacks({ onDamage: (unitId, damage, isCritical, x, y, z) => { combatFeedback.onCombatEvent({ type: 'damage', position: { x, y, z }, damage, isCritical }); // Track damage for music intensity intensityCalculator.recordDamage(damage, performance.now() / 1000); }, onKill: (unitId, unitType, x, y, z, overkill, killDirX, killDirY, killDirZ) => { const hasKillDirection = typeof killDirX === 'number' && Number.isFinite(killDirX) && typeof killDirY === 'number' && Number.isFinite(killDirY) && typeof killDirZ === 'number' && Number.isFinite(killDirZ); combatFeedback.onCombatEvent({ type: 'kill', position: { x, y, z }, unitType, overkill, unitId, killDirection: hasKillDirection ? { x: killDirX, y: killDirY, z: killDirZ } : undefined }); // Track kills for music intensity intensityCalculator.recordKill(performance.now() / 1000); }, onExplosion: (x, y, z, intensity) => { combatFeedback.onCombatEvent({ type: 'explosion', position: { x, y, z }, intensity }); }, onImpact: (x, y, z, type) => { combatFeedback.onCombatEvent({ type: 'impact', position: { x, y, z }, impactType: type }); }, onVeterancyLevelUp: (unitId, newLevel) => { combatFeedback.onCombatEvent({ type: 'veterancy', unitId, newLevel }); } }); } else { console.warn('[Bootstrap] Units feedback callbacks not available yet; skipping combat feedback wiring'); } console.info('[Bootstrap] ✅ Combat feedback systems initialized'); const uiManager = new UIManager(sim, selectionManager); // Initialize game event notifications const gameEventNotifications = new GameEventNotifications(uiManager, sim); gameEventNotifications.initialize(); console.info('[Bootstrap] ✅ Game event notifications initialized'); // Create combat test panel const combatTestPanel = new CombatTestPanel(sim, rtsCamera, scene); (window.__RTS as any).spawnCombatScenario = ( id: | 'mirror-skirmish' | 'choke-break' | 'hill-contest' | 'kiting-drill' | 'full-spectrum-clash' | 'grand-mixed-arms' = 'mirror-skirmish', options?: { seed?: number; anchorX?: number; anchorZ?: number } ) => combatTestPanel.spawnScenario(id, { ...options, reason: 'console' }); (window.__RTS as any).resetCombatScenario = () => combatTestPanel.resetActiveScenario(); (window.__RTS as any).setCombatScenarioSeed = (seed: number) => combatTestPanel.setScenarioSeed(seed); (window.__RTS as any).getCombatScenarioState = () => combatTestPanel.getScenarioState(); (window.__RTS as any).getCombatScenarioMetrics = () => combatTestPanel.getScenarioMetrics(); (window.__RTS as any).downloadCombatScenarioMetrics = (filename?: string) => combatTestPanel.downloadScenarioMetrics(filename); (window.__RTS as any).setUnitCountScale = (scale: number, persist: boolean = true) => combatTestPanel.setUnitCountScale(scale, persist); (window.__RTS as any).getUnitCountScale = () => combatTestPanel.getUnitCountScale(); const scenarioDirectorPanel = new ScenarioDirectorPanel(sim, rtsCamera, gpuCanvas); (window.__RTS as any).toggleScenarioDirector = () => scenarioDirectorPanel.toggle(); (window.__RTS as any).showScenarioDirector = () => scenarioDirectorPanel.show(); (window.__RTS as any).hideScenarioDirector = () => scenarioDirectorPanel.hide(); (window.__RTS as any).setScenarioDirectorState = (enabled: boolean) => scenarioDirectorPanel.setScenarioStateActive(Boolean(enabled)); (window.__RTS as any).getScenarioDirectorState = () => scenarioDirectorPanel.getDirectorState(); (window.__RTS as any).captureScenarioCameraKeyframe = (durationSec?: number) => scenarioDirectorPanel.captureCameraKeyframe(durationSec); (window.__RTS as any).playScenarioCameraPath = () => scenarioDirectorPanel.playCameraPath(); (window.__RTS as any).stopScenarioCameraPath = () => scenarioDirectorPanel.stopCameraPath(); (window.__RTS as any).recordScenarioCameraVideo = () => scenarioDirectorPanel.recordCameraPathVideo(); (window.__RTS as any).stopScenarioCameraVideo = () => scenarioDirectorPanel.stopRecordingVideo(true); (window.__RTS as any).exportScenarioDirectorJson = () => scenarioDirectorPanel.exportScenarioJson(); (window.__RTS as any).loadScenarioDirectorJson = (payload: unknown) => scenarioDirectorPanel.loadFromObject(payload); const inputController = new InputController( sim, rtsCamera, gpuCanvas, // Use GPU canvas for input (WebGL canvas is offscreen) selectionManager, buildingPreview, screenEffects, () => uiManager.toggleTacticalMap(), (message, variant) => uiManager.handleUserMessage(message, variant), (preview) => uiManager.updatePlacementPreview(preview) ); const previewInfluenceOverlayOverride = readStoredBoolean('rts.render.previewInfluenceOverlay'); let previewInfluenceOverlayEnabled = previewInfluenceOverlayOverride ?? false; inputController.setPreviewInfluenceOverlayEnabled(previewInfluenceOverlayEnabled); console.info( `[Bootstrap] Build preview influence overlay ${previewInfluenceOverlayEnabled ? 'enabled' : 'disabled'}` ); // Connect renderer service to input controller for particle/decal effects inputController.setRendererService(rendererService); // Connect combat test panel to input controller inputController.setCombatTestPanel(combatTestPanel); inputController.setScenarioDirectorPanel(scenarioDirectorPanel); scenarioDirectorPanel.setStateChangeCallback((state) => { const active = state !== 'inactive'; if (!active) { return; } if (sim.buildMode) { inputController.setBuildMode(null); } if (inputController.isFormationCommandMode()) { inputController.setFormationCommandMode(false); } if (uiManager.isTacticalMapVisible()) { uiManager.setTacticalMapVisible(false); } }); uiManager.setBuildModeRequestHandler((type) => inputController.setBuildMode(type)); uiManager.setBuildingTargetRequestHandler((request) => inputController.setBuildingTargetRequest(request)); uiManager.setCamera(rtsCamera); // IMPROVEMENT 5: Connect tooltip position updates inputController.setTooltipPositionCallback((x, y) => uiManager.updateTooltipPosition(x, y)); inputController.setFormationCommandModeChangedCallback((enabled) => uiManager.setFormationCommandModeActive(enabled)); const constructionRenderer = enableWebglScene ? new ConstructionRenderer(webglRenderScene, sim.terra) : null; const entityRenderer = enableWebglEntityScene ? new EntityRenderer(scene, sim.terra, camera, rendererService) : null; const strategyIconOverlayRenderer = !entityRenderer && enableWebglScene ? new StrategyIconRenderer(webglRenderScene) : null; const projectileOverlayRenderer = !entityRenderer && enableWebglScene ? new AAAProjectileRenderer(webglRenderScene, camera, sim.terra, PERFORMANCE_PRESETS.ultra) : null; const overlayImpactContextBuilder = !entityRenderer && enableWebglScene ? new ImpactContextBuilder() : null; const strategyOverlayUnitIds = new Set(); const strategyOverlayBuildingIds = new Set(); // Connect entity renderer to input controller for selection visuals if (entityRenderer) { inputController.setEntityRenderer(entityRenderer); (window.__RTS as any).setLegacyImpactFxEnabled = (enabled: boolean = false) => { entityRenderer.setLegacyImpactFxEnabled(Boolean(enabled)); console.info(`[ImpactGraph] legacyImpactFx=${enabled ? 'on' : 'off'}`); return entityRenderer.isLegacyImpactFxEnabled(); }; // Configure player color lookup for team colors entityRenderer.setPlayerColorLookup((ownerId: number | undefined) => { if (ownerId === undefined) return undefined; const players = sim.getPlayers(); const player = players.find(p => p.id === ownerId); return player?.color; }); // Connect CombatFeedback for kill event data (explosion scaling) entityRenderer.setCombatFeedback(combatFeedback); // Pre-warm shaders to avoid first-use compilation stutter entityRenderer.preWarmShaders(renderer); } if (strategyIconOverlayRenderer) { strategyIconOverlayRenderer.setPlayerColorLookup((ownerId: number | undefined) => { if (ownerId === undefined) return undefined; const players = sim.getPlayers(); const player = players.find(p => p.id === ownerId); return player?.color; }); } type EngineSoundId = 'engine_light' | 'engine_medium' | 'engine_heavy'; type ProjectileAudioConfig = { soundId: string; baseVolume: number; basePitch: number; pitchVariation: number; volumeRamp: number; pitchRamp: number; referenceSpeed: number; extraLayers?: LayerPlayRequest[]; }; const heavyEngineUnitTypes: Set = new Set(['Tank', 'Siege', 'Commander', 'HeavyTank', 'ShieldGenerator']); const lightEngineUnitTypes: Set = new Set(['Gatherer', 'Drone', 'Medic', 'Engineer', 'Wasp']); const projectileAudioConfigByType: Record = { beam: { soundId: 'laser_fire', baseVolume: 0.58, basePitch: 1.06, pitchVariation: 0.08, volumeRamp: 0.15, pitchRamp: 0.05, referenceSpeed: 120, extraLayers: [ { soundId: 'laser_sizzle', volumeMultiplier: 0.72, pitchMultiplier: 1.1, delay: 0.02 }, { soundId: 'laser_tail', volumeMultiplier: 0.48, pitchMultiplier: 1.24, delay: 0.05 } ] }, laser: { soundId: 'laser_fire', baseVolume: 0.68, basePitch: 1.0, pitchVariation: 0.1, volumeRamp: 0.1, pitchRamp: 0.04, referenceSpeed: 140, extraLayers: [ { soundId: 'laser_sizzle', volumeMultiplier: 0.62, pitchMultiplier: 1.15, delay: 0.015 }, { soundId: 'laser_tail', volumeMultiplier: 0.4, pitchMultiplier: 1.3, delay: 0.04 } ] }, rocket: { soundId: 'missile_launch', baseVolume: 0.78, basePitch: 0.94, pitchVariation: 0.06, volumeRamp: 0.22, pitchRamp: 0.06, referenceSpeed: 90, extraLayers: [ { soundId: 'rocket_whoosh', volumeMultiplier: 0.7, pitchMultiplier: 0.92, delay: 0.0 } ] }, artillery: { soundId: 'artillery_fire', baseVolume: 0.92, basePitch: 0.84, pitchVariation: 0.04, volumeRamp: 0.12, pitchRamp: 0.08, referenceSpeed: 60, extraLayers: [ { soundId: 'artillery_shock', volumeMultiplier: 0.75, pitchMultiplier: 0.92, delay: 0.04 }, { soundId: 'impact_rumble', volumeMultiplier: 0.5, pitchMultiplier: 0.84, delay: 0.1 } ] }, kinetic: { soundId: 'tank_cannon', baseVolume: 0.86, basePitch: 0.9, pitchVariation: 0.05, volumeRamp: 0.16, pitchRamp: 0.07, referenceSpeed: 100, extraLayers: [ { soundId: 'artillery_shock', volumeMultiplier: 0.58, pitchMultiplier: 1.0, delay: 0.02 } ] } }; const unifiedAudioOptimizer = new AudioOptimizer({ maxEngineSounds: 52, maxEngineDistance: 950, soundCreationBatchSize: 12, enabled: true }); const unifiedEngineSounds = new Map(); const unifiedPreviousProjectileIds = new Set(); const unifiedUnitsById = new Map(); let unifiedEngineCandidateRescanCooldownMs = 0; const UNIFIED_ENGINE_RESCAN_INTERVAL_MS = 85; const clamp01 = (value: number): number => Math.max(0, Math.min(1, value)); const getUnifiedEngineSoundId = (unitType: UnitType): EngineSoundId => { if (heavyEngineUnitTypes.has(unitType)) { return 'engine_heavy'; } if (lightEngineUnitTypes.has(unitType)) { return 'engine_light'; } return 'engine_medium'; }; const getUnifiedEngineSpeedFactor = (unit: Unit, movementSpeed: number): number => { const maxSpeed = Math.max(0.15, (unit.speed ?? 1) * 2.0); return clamp01(movementSpeed / maxSpeed); }; const getUnifiedEngineVolume = (engineId: EngineSoundId, speedFactor: number, engaged: boolean): number => { const base = engineId === 'engine_heavy' ? 0.34 : engineId === 'engine_light' ? 0.24 : 0.29; const speedGain = 0.18 + speedFactor * 0.6; const combatBoost = engaged ? 0.08 : 0; return Math.min(1.0, base + speedGain + combatBoost); }; const getUnifiedEnginePitch = (engineId: EngineSoundId, speedFactor: number): number => { const basePitch = engineId === 'engine_heavy' ? 0.82 : engineId === 'engine_light' ? 1.06 : 0.94; return basePitch + Math.min(0.28, speedFactor * 0.3); }; const stopUnifiedEngineSound = (unitId: number): void => { const handle = unifiedEngineSounds.get(unitId); if (handle) { handle.stop(); unifiedEngineSounds.delete(unitId); } unifiedAudioOptimizer.unregisterEngineSound(unitId); }; const computeAudioLoudness = (x: number, z: number, maxDistance: number, minGain: number = 0.1): number => { const dx = x - camera.position.x; const dz = z - camera.position.z; const distance = Math.hypot(dx, dz); if (distance >= maxDistance * 1.35) { return 0; } const t = clamp01(distance / Math.max(1, maxDistance)); return Math.max(0, Math.max(minGain * (1 - t), 1 - t * t)); }; const syncUnifiedEngineAudio = (units: Unit[], deltaTimeMs: number): void => { if (entityRenderer) { if (unifiedEngineSounds.size > 0) { for (const unitId of Array.from(unifiedEngineSounds.keys())) { stopUnifiedEngineSound(unitId); } } unifiedAudioOptimizer.clearPending(); unifiedEngineCandidateRescanCooldownMs = 0; return; } const stepMs = Number.isFinite(deltaTimeMs) ? Math.max(0, deltaTimeMs) : 16.67; unifiedEngineCandidateRescanCooldownMs = Math.max( 0, unifiedEngineCandidateRescanCooldownMs - stepMs ); const shouldRescanCandidates = unifiedEngineCandidateRescanCooldownMs <= 0; if (shouldRescanCandidates) { unifiedEngineCandidateRescanCooldownMs = UNIFIED_ENGINE_RESCAN_INTERVAL_MS; } const cameraPosition = { x: camera.position.x, z: camera.position.z }; const selectedUnitIds = selectionManager.selectedUnitIds; unifiedUnitsById.clear(); for (const unit of units) { unifiedUnitsById.set(unit.id, unit); } for (const [unitId, handle] of Array.from(unifiedEngineSounds.entries())) { const unit = unifiedUnitsById.get(unitId); if (!unit) { handle.stop(); unifiedEngineSounds.delete(unitId); unifiedAudioOptimizer.unregisterEngineSound(unitId); continue; } const movementSpeed = Math.hypot(unit.vx ?? 0, unit.vz ?? 0); if (movementSpeed <= 0.1) { handle.stop(); unifiedEngineSounds.delete(unitId); unifiedAudioOptimizer.unregisterEngineSound(unitId); continue; } if ( shouldRescanCandidates && !unifiedAudioOptimizer.shouldPlayEngineSound(unit, cameraPosition, selectedUnitIds) ) { handle.stop(); unifiedEngineSounds.delete(unitId); unifiedAudioOptimizer.unregisterEngineSound(unitId); continue; } const speedFactor = getUnifiedEngineSpeedFactor(unit, movementSpeed); const engineId = getUnifiedEngineSoundId(unit.unitType); if (handle.soundId !== engineId) { handle.stop(); unifiedEngineSounds.delete(unitId); unifiedAudioOptimizer.unregisterEngineSound(unitId); continue; } const volume = getUnifiedEngineVolume(engineId, speedFactor, Boolean(unit.engagementTargetId || unit.engagementBuildingTargetId)); handle.updatePosition({ x: unit.x, y: Number.isFinite(unit.y) ? (unit.y as number) : 0, z: unit.z }); handle.updateVolume(volume); handle.updatePitch(getUnifiedEnginePitch(engineId, speedFactor)); } if (shouldRescanCandidates) { for (const unit of units) { if (unifiedEngineSounds.has(unit.id)) { continue; } const movementSpeed = Math.hypot(unit.vx ?? 0, unit.vz ?? 0); if (movementSpeed <= 0.1) { continue; } if (!unifiedAudioOptimizer.shouldPlayEngineSound(unit, cameraPosition, selectedUnitIds)) { continue; } const dx = unit.x - cameraPosition.x; const dz = unit.z - cameraPosition.z; const distance = Math.hypot(dx, dz); const priority = unifiedAudioOptimizer.getSoundPriority(unit, cameraPosition, selectedUnitIds); unifiedAudioOptimizer.queueSound(unit.id, priority, distance); } for (const unitId of unifiedAudioOptimizer.processPendingSounds()) { const unit = unifiedUnitsById.get(unitId); if (!unit) { continue; } const movementSpeed = Math.hypot(unit.vx ?? 0, unit.vz ?? 0); if (movementSpeed <= 0.1) { continue; } const speedFactor = getUnifiedEngineSpeedFactor(unit, movementSpeed); const engineId = getUnifiedEngineSoundId(unit.unitType); const handle = soundManager.playLoopingSound( `wgpu_unit_${unit.id}_engine`, engineId, { position: { x: unit.x, y: Number.isFinite(unit.y) ? (unit.y as number) : 0, z: unit.z }, volume: getUnifiedEngineVolume(engineId, speedFactor, Boolean(unit.engagementTargetId || unit.engagementBuildingTargetId)), pitch: getUnifiedEnginePitch(engineId, speedFactor) } ); if (handle) { unifiedEngineSounds.set(unit.id, handle); unifiedAudioOptimizer.registerEngineSound(unit.id); } } } }; const getUnifiedProjectileSoundMeta = (projectile: TowerProjectileSnapshot): { layers: LayerPlayRequest[]; options: PlaySoundOptions; } => { const config = projectileAudioConfigByType[projectile.type] ?? projectileAudioConfigByType.laser; const directionalSpeed = Math.sqrt(Math.pow(projectile.vx ?? 0, 2) + Math.pow(projectile.vz ?? 0, 2)); const rawSpeed = Number.isFinite(directionalSpeed) && directionalSpeed > 0 ? directionalSpeed : config.referenceSpeed; const speedFactor = clamp01(rawSpeed / Math.max(1, config.referenceSpeed)); const loudness = computeAudioLoudness(projectile.fromX, projectile.fromZ, 1400, 0.12); const volume = Math.min(1, (config.baseVolume + speedFactor * config.volumeRamp) * loudness); const pitch = config.basePitch + speedFactor * config.pitchRamp; const baseOptions: PlaySoundOptions = { position: { x: projectile.fromX, y: Number.isFinite(projectile.fromY) ? projectile.fromY : 0, z: projectile.fromZ }, volume, pitch, pitchVariation: config.pitchVariation }; const layers: LayerPlayRequest[] = [{ soundId: config.soundId }]; if (config.extraLayers) { layers.push(...config.extraLayers); } return { layers, options: baseOptions }; }; const playUnifiedImpactAudio = (impacts: TowerImpactSnapshot[]): void => { if (impacts.length === 0) { return; } const capped = impacts .map((impact) => ({ impact, loudness: computeAudioLoudness(impact.x, impact.z, 1800, 0.08) })) .filter(entry => entry.loudness > 0.03) .sort((a, b) => b.loudness - a.loudness) .slice(0, 14); for (const entry of capped) { const impact = entry.impact; const loudness = entry.loudness; const strength = Number.isFinite(impact.strength) ? impact.strength : 40; let layers: LayerPlayRequest[] = [{ soundId: 'impact_light' }]; let pitch = 1.0; if (impact.type === 'rocket') { layers = [ { soundId: 'impact_medium' }, { soundId: 'impact_rumble', volumeMultiplier: 0.45, pitchMultiplier: 0.92, delay: 0.04 } ]; pitch = 0.96; } else if (impact.type === 'artillery' || impact.type === 'kinetic') { layers = [ { soundId: 'impact_heavy' }, { soundId: 'impact_rumble', volumeMultiplier: 0.62, pitchMultiplier: 0.86, delay: 0.02 }, { soundId: 'artillery_shock', volumeMultiplier: 0.45, pitchMultiplier: 0.92, delay: 0.06 } ]; pitch = 0.88; } else if (impact.type === 'beam' || impact.type === 'laser') { layers = [ { soundId: 'impact_light' }, { soundId: 'laser_sizzle', volumeMultiplier: 0.38, pitchMultiplier: 1.08, delay: 0.01 } ]; pitch = 1.08; } const volume = Math.min(1, (0.35 + Math.min(0.45, strength / 180)) * loudness); const impactY = Number.isFinite(impact.y ?? Number.NaN) ? (impact.y as number) : 0; soundManager.playLayeredSound(layers, { position: { x: impact.x, y: impactY, z: impact.z }, volume, pitch, pitchVariation: 0.06 }); } }; const updateUnifiedProjectileAudio = ( projectiles: TowerProjectileSnapshot[], impacts: TowerImpactSnapshot[] ): void => { if (entityRenderer) { if (unifiedPreviousProjectileIds.size > 0) { unifiedPreviousProjectileIds.clear(); } return; } const currentProjectileIds = new Set(); for (const projectile of projectiles) { currentProjectileIds.add(projectile.id); if (unifiedPreviousProjectileIds.has(projectile.id)) { continue; } const { layers, options } = getUnifiedProjectileSoundMeta(projectile); if ((options.volume ?? 0) > 0.03) { soundManager.playLayeredSound(layers, options); } } unifiedPreviousProjectileIds.clear(); for (const projectileId of currentProjectileIds) { unifiedPreviousProjectileIds.add(projectileId); } playUnifiedImpactAudio(impacts); }; // Set unit position provider for camera follow rtsCamera.setUnitPositionProvider((unitId: number) => { const unit = sim.units.getUnits().find(u => u.id === unitId); return unit ? { x: unit.x, z: unit.z } : null; }); const rallyPointRenderer = enableWebglEntityScene && ENABLE_RALLY_RENDERER ? new RallyPointRenderer(scene, sim.terra) : null; const resourceRenderer = enableWebglEntityScene && ENABLE_RESOURCE_RENDERER ? new ResourceRenderer(scene, sim.terra) : null; const selectionBoxRenderer = enableWebglScene && ENABLE_SELECTION_BOX ? new SelectionBoxRenderer() : null; // Create formation debug renderer const formationDebugRenderer = enableWebglScene ? new FormationDebugRenderer(webglRenderScene, sim) : null; const cameraFocusIndicator = enableWebglScene ? new CameraFocusIndicator(webglRenderScene, rtsCamera, sim.terra) : null; const terrainMarkerLayer = ENABLE_TERRAIN_MARKERS ? new TerrainMarkerLayer( gpuCanvas, // Use GPU canvas for input (WebGL canvas is offscreen) rtsCamera, selectionManager, sim, sim.terra ) : null; if (terrainMarkerLayer) { uiManager.setTerrainLayer(terrainMarkerLayer); } inputController.setFormationCommandIssuedHandler((marker) => { terrainMarkerLayer?.setFormationTargetMarker(marker); }); if (!ENABLE_TERRAIN_MARKERS) { const markerCanvas = document.getElementById('terrain-marker-layer') as HTMLCanvasElement | null; if (markerCanvas) { markerCanvas.style.display = 'none'; } } inputController.setFormationCommandIssuedHandler((marker) => { terrainMarkerLayer?.setFormationTargetMarker(marker); }); (window.__RTS as any).toggleStrategicMap = () => { uiManager.toggleTacticalMap(); const visible = uiManager.isTacticalMapVisible(); console.info(`[Overlay] Strategic map ${visible ? 'enabled' : 'disabled'}`); return visible; }; (window.__RTS as any).setStrategicMapVisible = (enabled: boolean) => { uiManager.setTacticalMapVisible(Boolean(enabled)); const visible = uiManager.isTacticalMapVisible(); console.info(`[Overlay] Strategic map ${visible ? 'enabled' : 'disabled'}`); return visible; }; const formationOverlayUnavailable = (): false => { console.warn('[Overlay] Formation overlay is unavailable (WebGL overlay scene disabled).'); return false; }; (window.__RTS as any).toggleFormationOverlay = () => { if (!formationDebugRenderer) return formationOverlayUnavailable(); formationDebugRenderer.toggleAll(); const enabled = formationDebugRenderer.isEnabled(); console.info(`[Overlay] Formation overlay ${enabled ? 'enabled' : 'disabled'}`); return enabled; }; (window.__RTS as any).setFormationOverlayVisible = (enabled: boolean) => { if (!formationDebugRenderer) return formationOverlayUnavailable(); const next = Boolean(enabled); formationDebugRenderer.setOptions({ showFormationShapes: next, showSlotMarkers: next, showAssignmentLines: next, showCommandOverlays: next }); if (!next) { formationDebugRenderer.clear(); } console.info(`[Overlay] Formation overlay ${next ? 'enabled' : 'disabled'}`); return formationDebugRenderer.isEnabled(); }; (window.__RTS as any).setFormationMarkerPreviewVisible = (enabled: boolean) => { if (!terrainMarkerLayer) return false; const result = terrainMarkerLayer.setFormationMarkerPreviewEnabled(Boolean(enabled)); uiManager.refreshFormationMarkerPreviewButton(); return result; }; (window.__RTS as any).toggleFormationMarkerPreview = () => { if (!terrainMarkerLayer) return false; const next = !(terrainMarkerLayer?.isFormationMarkerPreviewEnabled() ?? false); const result = terrainMarkerLayer.setFormationMarkerPreviewEnabled(next); uiManager.refreshFormationMarkerPreviewButton(); return result; }; (window.__RTS as any).isFormationMarkerPreviewVisible = () => terrainMarkerLayer?.isFormationMarkerPreviewEnabled() ?? false; inputController.setFormationMarkerPreviewToggleHandler(() => (window.__RTS as any).toggleFormationMarkerPreview?.() ?? false); (window.__RTS as any).toggleFormationCommandMode = () => { const enabled = inputController.toggleFormationCommandMode(); if (enabled) { (window.__RTS as any).setFormationOverlayVisible?.(true); } console.info(`[Formation] Context command mode ${enabled ? 'enabled' : 'disabled'}`); return enabled; }; (window.__RTS as any).setFormationCommandMode = (enabled: boolean) => { const next = inputController.setFormationCommandMode(Boolean(enabled)); if (next) { (window.__RTS as any).setFormationOverlayVisible?.(true); } console.info(`[Formation] Context command mode ${next ? 'enabled' : 'disabled'}`); return next; }; (window.__RTS as any).getFormationCommandMode = () => inputController.isFormationCommandMode(); const moveOrderOrientationDragStorageKey = 'rts.units.moveOrderOrientationDrag'; const applyMoveOrderOrientationDragSetting = (enabled: boolean, persist = true): boolean => { const next = inputController.setMoveOrderOrientationDragEnabled(Boolean(enabled)); if (persist) { try { window.localStorage.setItem(moveOrderOrientationDragStorageKey, next ? '1' : '0'); } catch { // ignore persistence errors } } console.info(`[Formation] Right-drag orientation commands ${next ? 'enabled' : 'disabled'}`); return next; }; const storedMoveOrderOrientationDrag = readStoredBoolean(moveOrderOrientationDragStorageKey); applyMoveOrderOrientationDragSetting(storedMoveOrderOrientationDrag ?? true, false); (window.__RTS as any).setMoveOrderOrientationDrag = (enabled: boolean = true) => applyMoveOrderOrientationDragSetting(enabled, true); (window.__RTS as any).toggleMoveOrderOrientationDrag = () => applyMoveOrderOrientationDragSetting(!inputController.isMoveOrderOrientationDragEnabled(), true); (window.__RTS as any).getMoveOrderOrientationDrag = () => inputController.isMoveOrderOrientationDragEnabled(); const formationHoldOnArrivalStorageKey = 'rts.units.holdFormationOnArrival'; const applyFormationHoldOnArrivalSetting = (enabled: boolean, persist = true): boolean => { const next = sim.setHoldFormationOnArrivalEnabled(Boolean(enabled)); if (persist) { try { window.localStorage.setItem(formationHoldOnArrivalStorageKey, next ? '1' : '0'); } catch { // ignore persistence errors } } console.info(`[Formation] Hold slot positions on arrival ${next ? 'enabled' : 'disabled'}`); return next; }; const storedFormationHoldOnArrival = readStoredBoolean(formationHoldOnArrivalStorageKey); applyFormationHoldOnArrivalSetting(storedFormationHoldOnArrival ?? true, false); (window.__RTS as any).setFormationHoldOnArrival = (enabled: boolean = true, persist: boolean = true) => applyFormationHoldOnArrivalSetting(enabled, persist); (window.__RTS as any).toggleFormationHoldOnArrival = () => applyFormationHoldOnArrivalSetting(!sim.isHoldFormationOnArrivalEnabled(), true); (window.__RTS as any).getFormationHoldOnArrival = () => sim.isHoldFormationOnArrivalEnabled(); uiManager.setFormationInteractionHandlers({ toggleOverlay: () => (window.__RTS as any).toggleFormationOverlay?.() ?? false, setOverlayVisible: (enabled: boolean) => (window.__RTS as any).setFormationOverlayVisible?.(enabled) ?? false, toggleCommandMode: () => (window.__RTS as any).toggleFormationCommandMode?.() ?? false, setCommandMode: (enabled: boolean) => (window.__RTS as any).setFormationCommandMode?.(enabled) ?? false, getCommandMode: () => (window.__RTS as any).getFormationCommandMode?.() ?? false, setPreferredFormation: (formation: FormationType | null) => { inputController.setPreferredFormation(formation); }, toggleMarkerPreview: () => (window.__RTS as any).toggleFormationMarkerPreview?.() ?? false, setMarkerPreviewVisible: (enabled: boolean) => (window.__RTS as any).setFormationMarkerPreviewVisible?.(enabled) ?? false, getMarkerPreviewState: () => (window.__RTS as any).isFormationMarkerPreviewVisible?.() ?? false }); const hasGpuTerrainMarkerOverlays = webgpuReady && captureUiOverlays; const terrainMarkerToggleUnavailable = (name: string): false => { console.warn(`[Overlay] ${name} is unavailable (no GPU UI overlay path and no terrain marker layer).`); return false; }; const markerLayerLabelByAlias: Record<'strategic' | 'resources' | 'frontlines', string> = { strategic: 'Strategic terrain overlay', resources: 'Resource overlay', frontlines: 'Frontline overlay' }; const resolveTerrainOverlayLabel = (name: TerrainMarkerOverlayLayer): string => { const key = String(name ?? '').trim(); if (key === 'strategic' || key === 'resources' || key === 'frontlines') { return markerLayerLabelByAlias[key]; } return `Terrain overlay [${key}]`; }; const toggleTerrainMarkerOverlay = (name: TerrainMarkerOverlayLayer): boolean => { const label = resolveTerrainOverlayLabel(name); if (hasGpuTerrainMarkerOverlays) { const enabled = renderWorldExtractor.toggleTerrainOverlayVisibility(name); if (terrainMarkerLayer) { terrainMarkerLayer.setOverlayVisibility(name, enabled); } console.info(`[Overlay] ${label} ${enabled ? 'enabled' : 'disabled'} (WebGPU UiOverlay)`); return enabled; } if (!terrainMarkerLayer) return terrainMarkerToggleUnavailable(label); const enabled = terrainMarkerLayer.toggleOverlayVisibility(name); console.info(`[Overlay] ${label} ${enabled ? 'enabled' : 'disabled'}`); return enabled; }; const setTerrainMarkerOverlayVisible = (name: TerrainMarkerOverlayLayer, enabled: boolean): boolean => { const label = resolveTerrainOverlayLabel(name); if (hasGpuTerrainMarkerOverlays) { const next = renderWorldExtractor.setTerrainOverlayVisibility(name, Boolean(enabled)); if (terrainMarkerLayer) { terrainMarkerLayer.setOverlayVisibility(name, next); } console.info(`[Overlay] ${label} ${next ? 'enabled' : 'disabled'} (WebGPU UiOverlay)`); return next; } if (!terrainMarkerLayer) return terrainMarkerToggleUnavailable(label); const next = terrainMarkerLayer.setOverlayVisibility(name, Boolean(enabled)); console.info(`[Overlay] ${label} ${next ? 'enabled' : 'disabled'}`); return next; }; (window.__RTS as any).toggleStrategicTerrainOverlay = () => toggleTerrainMarkerOverlay('strategic'); (window.__RTS as any).setStrategicTerrainOverlayVisible = (enabled: boolean) => setTerrainMarkerOverlayVisible('strategic', enabled); (window.__RTS as any).toggleResourceOverlay = () => toggleTerrainMarkerOverlay('resources'); (window.__RTS as any).setResourceOverlayVisible = (enabled: boolean) => setTerrainMarkerOverlayVisible('resources', enabled); (window.__RTS as any).toggleFrontlineOverlay = () => toggleTerrainMarkerOverlay('frontlines'); (window.__RTS as any).setFrontlineOverlayVisible = (enabled: boolean) => setTerrainMarkerOverlayVisible('frontlines', enabled); (window.__RTS as any).toggleTerrainOverlayLayer = (layerId: string) => toggleTerrainMarkerOverlay(layerId); (window.__RTS as any).setTerrainOverlayLayerVisible = (layerId: string, enabled: boolean) => setTerrainMarkerOverlayVisible(layerId, enabled); (window.__RTS as any).setTerrainMarkerCloseSuppression = (enabled: boolean) => { if (!terrainMarkerLayer) return terrainMarkerToggleUnavailable('Terrain marker performance controls'); const next = terrainMarkerLayer.setCloseZoomSuppression(Boolean(enabled)); console.info(`[Overlay] Terrain marker close-range suppression ${next ? 'enabled' : 'disabled'}`); return next; }; (window.__RTS as any).setTerrainMarkerZoomThreshold = (distance: number) => { if (!terrainMarkerLayer) return terrainMarkerToggleUnavailable('Terrain marker performance controls'); const next = terrainMarkerLayer.setZoomThreshold(Number(distance)); console.info(`[Overlay] Terrain marker zoom threshold set to ${next}`); return next; }; (window.__RTS as any).setTerrainDefenseCoverageVisible = (enabled: boolean) => { if (!terrainMarkerLayer) return terrainMarkerToggleUnavailable('Terrain defense coverage overlay'); const next = terrainMarkerLayer.setDefenseCoverageVisible(Boolean(enabled)); console.info(`[Overlay] Terrain defense coverage ${next ? 'enabled' : 'disabled'}`); return next; }; (window.__RTS as any).setTerrainCommandZonesVisible = (enabled: boolean) => { if (!terrainMarkerLayer) return terrainMarkerToggleUnavailable('Terrain command-zone overlay'); const next = terrainMarkerLayer.setBuildingCommandZonesVisible(Boolean(enabled)); console.info(`[Overlay] Terrain command zones ${next ? 'enabled' : 'disabled'}`); return next; }; (window.__RTS as any).getTerrainMarkerOverlayConfig = () => { if (!terrainMarkerLayer && !hasGpuTerrainMarkerOverlays) { const unavailable = { strategic: false, resources: false, frontlines: false, visibility: {}, suppressAtCloseZoom: true, zoomThreshold: 360, defenseCoverage: false, buildingCommandZones: false, aliases: ['strategic', 'resources', 'frontlines'], runtimeLayerIds: [], source: 'none', available: false }; console.info('[Overlay] Terrain marker layer unavailable', unavailable); return unavailable; } const gpuConfig = hasGpuTerrainMarkerOverlays ? renderWorldExtractor.getTerrainOverlayVisibility() : null; const canvasOverlayVisibility = terrainMarkerLayer ? terrainMarkerLayer.getOverlayVisibility() : null; const canvasPerfConfig = terrainMarkerLayer ? terrainMarkerLayer.getPerformanceConfig() : null; const config = { strategic: gpuConfig?.strategic ?? canvasOverlayVisibility?.strategic ?? false, resources: gpuConfig?.resources ?? canvasOverlayVisibility?.resources ?? false, frontlines: gpuConfig?.frontlines ?? canvasOverlayVisibility?.frontlines ?? false, visibility: gpuConfig ?? canvasOverlayVisibility ?? {}, suppressAtCloseZoom: canvasPerfConfig?.suppressAtCloseZoom ?? true, zoomThreshold: canvasPerfConfig?.zoomThreshold ?? 360, aliases: ['strategic', 'resources', 'frontlines'], runtimeLayerIds: hasGpuTerrainMarkerOverlays ? (renderWorldExtractor.getTerrainOverlayRegistry().runtimeLayerIds ?? []) : ( typeof (sim.terra as any).getOverlayLayerIds === 'function' ? ((sim.terra as any).getOverlayLayerIds({ includeVisual: true, includeGameplay: true }) as string[]) : [] ), source: hasGpuTerrainMarkerOverlays ? 'webgpu-ui-overlay' : 'canvas-marker-layer', gpuPathEnabled: hasGpuTerrainMarkerOverlays, canvasPathEnabled: Boolean(terrainMarkerLayer), available: true }; console.info('[Overlay] Terrain marker overlay config', config); return config; }; (window.__RTS as any).getTerrainOverlayRegistry = () => { if (hasGpuTerrainMarkerOverlays) { const registry = renderWorldExtractor.getTerrainOverlayRegistry(); console.info('[Overlay] Terrain overlay registry', registry); return registry; } const visibility = terrainMarkerLayer ? terrainMarkerLayer.getOverlayVisibility() : {}; const runtimeLayerIds = typeof (sim.terra as any).getOverlayLayerIds === 'function' ? ((sim.terra as any).getOverlayLayerIds({ includeVisual: true, includeGameplay: true }) as string[]) : []; const registry = { aliases: ['strategic', 'resources', 'frontlines'], visibility, runtimeLayerIds, runtimeSummary: typeof (sim.terra as any).getStrategicOverlayConfigSummary === 'function' ? (sim.terra as any).getStrategicOverlayConfigSummary() : null }; console.info('[Overlay] Terrain overlay registry', registry); return registry; }; const HIGH_DETAIL_SEGMENT_LIMIT = 512; const terrainSegments = Math.min(MAP_SEGMENTS * 2, HIGH_DETAIL_SEGMENT_LIMIT); const terrainGeometry = new THREE.PlaneGeometry( MAP_SIZE_METERS, MAP_SIZE_METERS, terrainSegments, terrainSegments ); terrainGeometry.rotateX(-Math.PI / 2); const gpuAoOptions = { size: 512, radius: 6.0, strength: 0.55 }; let terrainMaterialRef: TerrainShaderMaterial | null = null; const gpuAoDisabled = (import.meta.env as Record).VITE_DISABLE_GPU_AO === '1'; let gpuAoEnabled = !gpuAoDisabled; let gpuAoGenerator: GpuAOGenerator | null = null; const ensureGpuAoGenerator = (): GpuAOGenerator => { if (!gpuAoGenerator) { gpuAoGenerator = new GpuAOGenerator(renderer, sim.terra, gpuAoOptions); if (terrainMaterialRef) { terrainMaterialRef.uniforms.gpuAoTexture.value = gpuAoGenerator.texture; } } return gpuAoGenerator; }; const refreshGpuAoTexture = (): void => { console.info('[refreshGpuAoTexture] Starting...'); if (!enableWebglWorld) { console.info('[refreshGpuAoTexture] WebGL terrain disabled, skipping'); return; } if (!gpuAoEnabled) { console.info('[refreshGpuAoTexture] GPU AO disabled, skipping'); return; } console.info('[refreshGpuAoTexture] Ensuring GPU AO generator...'); const generator = ensureGpuAoGenerator(); console.info('[refreshGpuAoTexture] Calling renderFromTerrain...'); generator.renderFromTerrain(sim.terra); console.info('[refreshGpuAoTexture] renderFromTerrain completed'); if (terrainMaterialRef) { terrainMaterialRef.uniforms.gpuAoTexture.value = generator.texture; } console.info('[refreshGpuAoTexture] Completed'); }; let terrainAttributesReady = false; let cachedAoData: Float32Array | null = null; let cachedTerrains: Terra | null = null; const ensureTerrainAttributes = async (): Promise => { if (terrainAttributesReady && cachedTerrains === sim.terra) { return; } cachedTerrains = sim.terra; terrainAttributesReady = false; await applyHeightmap(terrainGeometry, sim.terra, { enableAO: true }); const aoAttr = terrainGeometry.attributes.ao as THREE.BufferAttribute; cachedAoData = new Float32Array(aoAttr.count); cachedAoData.set(aoAttr.array as Float32Array); terrainAttributesReady = true; ensureGpuAoGenerator().markDirty(); }; const getGpuAoTexture = (): THREE.Texture | undefined => { return gpuAoGenerator ? gpuAoGenerator.texture : undefined; }; const refreshAo = async (enabled: boolean): Promise => { const refreshStart = performance.now(); console.info(`[refreshAo] Starting with enabled=${enabled}`); if (enabled) { console.info('[refreshAo] Calling refreshGpuAoTexture (first call)...'); refreshGpuAoTexture(); console.info('[refreshAo] refreshGpuAoTexture (first call) completed'); } console.info('[refreshAo] Calling ensureTerrainAttributes...'); await ensureTerrainAttributes(); console.info('[refreshAo] ensureTerrainAttributes completed'); const aoAttr = terrainGeometry.attributes.ao as THREE.BufferAttribute; const aoArray = aoAttr.array as Float32Array; if (enabled) { console.info('[refreshAo] Filling AO array with 1...'); aoArray.fill(1); } else if (cachedAoData) { aoArray.set(cachedAoData); } aoAttr.needsUpdate = true; if (enabled) { console.info('[refreshAo] Calling refreshGpuAoTexture (second call)...'); refreshGpuAoTexture(); console.info('[refreshAo] refreshGpuAoTexture (second call) completed'); } if (terrainMaterialRef) { terrainMaterialRef.uniforms.gpuAoEnabled.value = enabled ? 1.0 : 0.0; } console.info('[refreshAo] Completed'); logDebugProfile('refreshAo', performance.now() - refreshStart, `GPU_AO=${enabled}`); }; const setGpuAoEnabled = async (enabled: boolean): Promise => { if (!enableWebglWorld) { console.info('[GpuAO] WebGL terrain disabled; ignoring toggle.'); return; } if (gpuAoEnabled === enabled) { if (terrainMaterialRef) { terrainMaterialRef.uniforms.gpuAoEnabled.value = enabled ? 1.0 : 0.0; } return; } gpuAoEnabled = enabled; await refreshAo(enabled); }; let terrainMaterial: THREE.Material | null = null; if (enableWebglWorld) { const aoStart = performance.now(); await refreshAo(gpuAoEnabled); stageLog(`GPU AO refresh (${gpuAoEnabled ? 'enabled' : 'disabled'}) took ${(performance.now() - aoStart).toFixed(1)}ms`, 27); console.info('[Bootstrap] refreshAo completed, generating textures...'); const textures = TerrainTextureGenerator.generateTextures(); console.info('[Bootstrap] Base textures generated'); const normalMaps = TerrainTextureGenerator.generateNormalMaps(); loadingProgress.setProgress(30, 'Renderer: baking terrain textures & AO...'); stageLog('Terrain textures baked', 30); const detailResources = TerrainTextureGenerator.generateDetailResources(); const detailTexture = detailResources.texture; const detailNormalTexture = detailResources.normalTexture; const macroTexture = detailResources.macroTexture; const detailHeightTexture = TerrainTextureGenerator.generateDetailHeightMap(); const terrainHeightTextureSize = parseInt(localStorage.getItem('rts.terrain.heightmapSize') || '1024'); const terrainHeightTexture = createTerrainHeightTexture(sim.terra, terrainHeightTextureSize); const overlayTexelSize = new THREE.Vector2(1 / terrainHeightTextureSize, 1 / terrainHeightTextureSize); const terrainColorTextures = [textures.grass, textures.rock, textures.sand, textures.dirt]; terrainColorTextures.forEach((tex) => configureTerrainTexture(tex, true, 1)); configureTerrainTexture(detailTexture, true, 180); configureTerrainTexture(detailNormalTexture, false, 180); configureTerrainTexture(macroTexture, true, 100); configureTerrainTexture(detailHeightTexture, false, 140); const heightRange = sim.terra.getHeightRange(); const splitHeight = (heightRange.maxHeight + heightRange.minHeight) / 2; const detailScale = 44.0; const createFallbackTerrainMaterial = (): THREE.Material => { console.warn('[Bootstrap] Falling back to MeshStandardMaterial for terrain'); return new THREE.MeshStandardMaterial({ color: 0x567c4a, roughness: 0.85, metalness: 0.05, vertexColors: true, side: THREE.FrontSide }); }; try { terrainMaterial = new TerrainShaderMaterial( textures.grass, textures.rock, textures.sand, textures.dirt, normalMaps.grass, normalMaps.rock, normalMaps.sand, normalMaps.dirt, detailTexture, detailScale, splitHeight, terrainHeightTexture, 0.35, undefined, // FIX: Overlay texture disabled (was incorrectly set to terrainHeightTexture) 0.0, 0.55, undefined, overlayTexelSize, detailNormalTexture, 0.65, detailHeightTexture, 0.42, macroTexture, 0.006, 0.42, getGpuAoTexture(), gpuAoEnabled ); terrainMaterialRef = terrainMaterial as TerrainShaderMaterial; terrainMaterialRef.uniforms.gpuAoTexture.value = getGpuAoTexture(); terrainMaterialRef.uniforms.gpuAoEnabled.value = gpuAoEnabled ? 1.0 : 0.0; // PHASE 1 UPGRADE: Enhanced normal map strength with dynamic configuration const normalStrength = parseFloat(localStorage.getItem('rts.terrain.normalStrength') || '0.75'); terrainMaterialRef.uniforms.normalStrength.value = normalStrength; console.info(`[Bootstrap] Terrain material created with normalStrength=${normalStrength}`); } catch (err) { console.error('[Bootstrap] TerrainShaderMaterial creation failed, using fallback', err); terrainMaterial = createFallbackTerrainMaterial(); } } else { console.info('[Bootstrap] WebGL terrain pipeline disabled; skipping terrain material setup.'); } console.info('[Bootstrap] Registering terrain material with lighting controller...'); if (terrainMaterialRef) { lightingController.registerTerrainMaterial(terrainMaterialRef); } console.info('[Bootstrap] Terrain material registered'); window.__RTS = window.__RTS ?? {}; window.__RTS.setGpuAoEnabled = setGpuAoEnabled; window.__RTS.refreshGpuAo = (): void => { refreshGpuAoTexture(); }; window.__RTS.isGpuAoEnabled = (): boolean => gpuAoEnabled; if (terrainMaterial) { console.info('[Bootstrap] Creating terrain mesh...'); const terrain = new THREE.Mesh(terrainGeometry, terrainMaterial); terrain.receiveShadow = true; // NOTE: Terrain is rendered by WebGPU framegraph, not THREE.js // Do NOT add terrain to THREE.js scene - it will block buildings/units from rendering // scene.add(terrain); console.info('[Bootstrap] Terrain mesh created (rendered by WebGPU, not added to THREE.js scene)'); } else { console.info('[Bootstrap] WebGL terrain mesh skipped (WebGPU active).'); } console.info('[Bootstrap] Creating boundary helper...'); const boundaryHelper = new THREE.LineSegments( new THREE.EdgesGeometry(new THREE.PlaneGeometry(MAP_SIZE_METERS, MAP_SIZE_METERS, 2, 2)), new THREE.LineBasicMaterial({ color: 0x88eaff, transparent: true, opacity: 0.75 }) ); boundaryHelper.rotation.x = -Math.PI / 2; boundaryHelper.position.y = 0.15; boundaryHelper.visible = ENABLE_GRID_HELPERS && !enableWebglWorld; scene.add(boundaryHelper); console.info('[Bootstrap] Creating grid helper...'); const gridHelper = new THREE.GridHelper(MAP_SIZE_METERS, 32, 0x33d6ff, 0x0f3c54); gridHelper.position.y = 0.08; gridHelper.material.opacity = 0.35; gridHelper.material.transparent = true; gridHelper.visible = ENABLE_GRID_HELPERS && !enableWebglWorld; scene.add(gridHelper); console.info('[Bootstrap] Creating feature overlays...'); let highGroundOverlay: THREE.LineSegments | null = null; let chokeOverlay: THREE.LineSegments | null = null; let rampOverlay: THREE.LineSegments | null = null; let featureOverlayGroup: THREE.Group | null = null; let refreshFeatureOverlays: (() => void) | null = null; if (ENABLE_FEATURE_OVERLAY) { highGroundOverlay = createFeatureOverlay(0xffc750); chokeOverlay = createFeatureOverlay(0xff5e8b); rampOverlay = createFeatureOverlay(0x5de0ff); const overlayGroup = new THREE.Group(); overlayGroup.name = 'terrain-feature-overlay'; overlayGroup.add(highGroundOverlay, chokeOverlay, rampOverlay); overlayGroup.visible = false; scene.add(overlayGroup); featureOverlayGroup = overlayGroup; console.info('[Bootstrap] Feature overlays enabled'); refreshFeatureOverlays = (): void => { updateFeatureOverlay( highGroundOverlay!, sim.terra.getStrategicHighlights([StrategicFeature.HIGH_GROUND]), (x, z) => sim.terra.getHeightWorld(x, z), 14 ); updateFeatureOverlay( chokeOverlay!, sim.terra.getStrategicHighlights([StrategicFeature.CHOKE_POINT]), (x, z) => sim.terra.getHeightWorld(x, z), 10 ); const rampHighlights = sim.terra.getAffordanceHighlights([AffordanceType.RAMP]); updateFeatureOverlay( rampOverlay!, rampHighlights, (x, z) => sim.terra.getHeightWorld(x, z), 8 ); }; refreshFeatureOverlays(); console.info('[Bootstrap] Feature overlays refreshed'); } else { console.info('[Bootstrap] Feature overlays disabled'); } window.__RTS.toggleFeatureOverlay = () => { if (!featureOverlayGroup) { console.warn('[Overlay] Feature overlay is unavailable (ENABLE_FEATURE_OVERLAY is false).'); return false; } featureOverlayGroup.visible = !featureOverlayGroup.visible; console.info(`[Overlay] Feature overlay ${featureOverlayGroup.visible ? 'enabled' : 'disabled'}`); return featureOverlayGroup.visible; }; window.__RTS.setFeatureOverlayVisible = (enabled: boolean) => { if (!featureOverlayGroup) { console.warn('[Overlay] Feature overlay is unavailable (ENABLE_FEATURE_OVERLAY is false).'); return false; } featureOverlayGroup.visible = Boolean(enabled); console.info(`[Overlay] Feature overlay ${featureOverlayGroup.visible ? 'enabled' : 'disabled'}`); return featureOverlayGroup.visible; }; window.__RTS.refreshFeatureOverlay = () => { if (!refreshFeatureOverlays) { console.warn('[Overlay] Feature overlay refresh is unavailable.'); return; } refreshFeatureOverlays(); console.info('[Overlay] Feature overlay refreshed'); }; const aiPlacementOverlay = createAiPlacementOverlay(scene, sim.terra); window.__RTS.toggleAiPlacementOverlay = () => { aiPlacementOverlay.toggle(); aiPlacementOverlay.update(sim.getAiPlacementDebug()); }; console.info('[Bootstrap] AI placement overlay ready: __RTS.toggleAiPlacementOverlay()'); const terrainDebugOverlay = createTerrainDebugOverlay(sim.terra); window.__RTS.toggleTerrainDebugOverlay = () => { terrainDebugOverlay.toggle(); }; window.__RTS.refreshTerrainDebugOverlay = () => { terrainDebugOverlay.render(); }; window.__RTS.exportTerrainHeightmap = () => { exportTerrainHeightmap(sim.terra); }; window.addEventListener('keydown', (event) => { if (event.code === 'F9') { event.preventDefault(); terrainDebugOverlay.toggle(); } }); let scatterGroupRef: THREE.Group | null = null; const refreshScatterAlignment = (): void => { if (!scatterGroupRef) return; scatterGroupRef.userData.terra = sim.terra; rendererService.setScatterGroup(scatterGroupRef); console.info('[Debug] Scatter instances refreshed for terrain heights'); }; (window.__RTS as any).refreshScatter = refreshScatterAlignment; // Initialize tree system OUTSIDE the ENABLE_SCATTER block so it's accessible in animation loop loadingProgress.setProgress(40, 'Vegetation: initializing tree assets...'); stageLog('Trees: asset init', 40); const treeSeason = getSeasonForBiome(biomeType); const treeBiomePreset = getTreeBiomePresetForBiome(biomeType); const treeSystem = new ImprovedTreeSystem(); const { geometries: treeGeometries, midGeometries: treeMidGeometries, materials: treeMaterials, variantManifest: treeVariantManifest, materialManifest: treeMaterialManifest, firMidVariantIndex, firMaterialIndex } = await treeSystem.initialize({ biomePreset: treeBiomePreset, season: treeSeason }); (window.__RTS as any).getTreeVariantManifest = () => ({ variants: treeVariantManifest, materials: treeMaterialManifest }); loadingProgress.setProgress(45, 'Vegetation: preparing placement data...'); stageLog('Trees: placement prep', 45); if (ENABLE_SCATTER) { const scatterGroup = new THREE.Group(); scatterGroup.name = 'terrain-scatter'; scatterGroup.visible = enableWebglWorld; scatterGroup.userData.terra = sim.terra; scene.add(scatterGroup); scatterGroupRef = scatterGroup; const scatterMeshCache = new WeakMap>(); const isScatterGeometryValid = (mesh: THREE.InstancedMesh | undefined | null): boolean => { const geometry = mesh?.geometry; if (!geometry) { return false; } const position = geometry.getAttribute('position'); return Boolean(position && position.count > 0); }; const getCachedScatterMesh = ( key: string, builder: () => THREE.InstancedMesh ): THREE.InstancedMesh => { let cache = scatterMeshCache.get(sim.terra); if (!cache) { cache = new Map(); scatterMeshCache.set(sim.terra, cache); } let mesh = cache.get(key); if (!mesh || !isScatterGeometryValid(mesh)) { if (mesh && !isScatterGeometryValid(mesh)) { console.warn(`[Scatter] Rebuilding ${key} due to missing geometry attributes.`); } mesh = builder(); mesh.name = key; // Set mesh name for scatter integration cache.set(key, mesh); } return mesh; }; const season = treeSeason; const seasonPalette = SEASON_PALETTES[season]; const forestProfile = getForestProfileForBiome(biomeType); const forestSeed = (Math.floor(sim.terra.getHeightWorld(0, 0) * 1024) ^ MAP_SIZE_METERS) >>> 0; const forestSampler = createForestFactorSampler(sim.terra, forestSeed, forestProfile); const separationCoreScale = Math.max(0.35, Math.min(TREE_SEPARATION_CORE_SCALE, 1.0)); const separationEdgeScale = Math.max(1.0, TREE_SEPARATION_EDGE_SCALE); const baseSeparation = forestProfile.minSeparation * Math.max(0.5, TREE_SEPARATION_SCALE); const minSeparationCore = baseSeparation * separationCoreScale; const minSeparationEdge = baseSeparation * separationEdgeScale; const coniferWeight = getConiferWeightForBiome(biomeType, season); const forestFactorOverlay = createForestFactorOverlay(sim.terra, forestSampler); window.__RTS.toggleForestFactorOverlay = () => { forestFactorOverlay.toggle(); }; window.__RTS.refreshForestFactorOverlay = () => { forestFactorOverlay.render(); }; window.__RTS.exportForestFactorOverlay = () => { forestFactorOverlay.export(); }; console.info('[Bootstrap] Forest factor overlay ready: __RTS.toggleForestFactorOverlay()'); if (TREE_DIAGNOSTICS_ENABLED) { console.info('[TreeDiagnostics] LocalStorage keys:', { treeDensity: 'rts.tree.density', treeDiagnostics: 'rts.tree.diagnostics', separationCore: 'rts.tree.separation.core', separationEdge: 'rts.tree.separation.edge', separationScale: 'rts.tree.separation.scale', overlapPrune: 'rts.tree.overlap.prune', overlapFactor: 'rts.tree.overlap.factor', forestRelax: 'rts.tree.forest.relax', instanceCapPerKm2: 'rts.tree.instanceCapPerKm2', treeLod: 'rts.tree.lod', treeWindStrength: TREE_WIND_STRENGTH_STORAGE_KEY, treeWindSpeed: TREE_WIND_SPEED_STORAGE_KEY, grassDensity: 'rts.grass.density', grassLod: 'rts.grass.lod', bushDensity: 'rts.bush.density', treeScatterMaxDistance: 'rts.framegraph.treeScatterMaxDistance', treeScatterFadeDistance: 'rts.framegraph.treeScatterFadeDistance', treeBillboardMinDistance: 'rts.framegraph.treeBillboardMinDistance', treeBillboardFadeDistance: 'rts.framegraph.treeBillboardFadeDistance' }); console.info('[TreeDiagnostics] Forest profile:', forestProfile); const patchSeeds = forestSampler.getPatchSeeds?.() ?? []; if (patchSeeds.length > 0) { const avgRadius = patchSeeds.reduce((sum, entry) => sum + entry.radius, 0) / patchSeeds.length; console.info('[TreeDiagnostics] Forest patch seeds:', { count: patchSeeds.length, avgRadius: avgRadius.toFixed(1), sample: patchSeeds.slice(0, 3).map((entry) => ({ id: entry.id, x: Math.round(entry.x), z: Math.round(entry.z), radius: Math.round(entry.radius) })) }); } console.info('[TreeDiagnostics] Separation tuning:', { baseSeparation, minSeparationCore, minSeparationEdge }); const patchStats = computeForestPatchStats( sim.terra, forestSampler, forestProfile.minForestFactor ); console.info('[TreeDiagnostics] Forest patch stats:', patchStats); } // BIOME-DRIVEN: Generate candidates based on forest density map // High forest areas get MORE candidates, low forest areas get FEWER const treeWaterLevel = sim.terra.getWaterLevel(); const treePositions: TreeCandidate[] = []; // REAL FORESTS: Finer grid sampling to better follow forest map details const sampleGridSpacing = 35; // 35m grid for sampling (~571x571 = 326k samples) const sampleSteps = Math.ceil(MAP_SIZE_METERS / sampleGridSpacing); let slopeRejects = 0; let waterRejects = 0; let forestFactorRejects = 0; let totalSampled = 0; for (let ix = 0; ix < sampleSteps; ix++) { for (let iz = 0; iz < sampleSteps; iz++) { totalSampled++; const baseX = -MAP_HALF_SIZE + (ix + 0.5) * sampleGridSpacing; const baseZ = -MAP_HALF_SIZE + (iz + 0.5) * sampleGridSpacing; // Sample forest factor at cell center const forestFactor = forestSampler(baseX, baseZ); // Skip if forest factor too low if (forestFactor < forestProfile.minForestFactor) { forestFactorRejects++; continue; } // Check terrain suitability at cell center const height = sim.terra.getHeightWorld(baseX, baseZ); // BIOME-AWARE WATER TOLERANCE: Different biomes have different water tolerances let waterMargin = 0.5; // Default margin above water switch (biomeType) { case BiomeType.DESERT: // Desert trees (palms, acacias) can grow closer to water (oases) waterMargin = 0.2; break; case BiomeType.ALIEN: // Alien vegetation might tolerate shallow water waterMargin = 0.1; break; case BiomeType.TEMPERATE: // Temperate trees need more clearance from water waterMargin = 0.8; break; default: waterMargin = 0.5; } if (height <= treeWaterLevel + waterMargin) { waterRejects++; continue; } const slopeInfo = sim.terra.getSlopeWorld(baseX, baseZ); // STRICT SLOPE REJECTION: Trees should NOT grow on slopes // Use very conservative slope limits for realistic forests const MAX_TREE_SLOPE = 12; // Only allow trees on nearly flat terrain (12 degrees) if (slopeInfo.slopeDegrees > MAX_TREE_SLOPE) { slopeRejects++; continue; } // Generate multiple candidates based on forest density // REAL FORESTS: High forest areas get MANY more candidates // forestFactor 0.15-1.0 -> 1-10 candidates per cell const normalizedFactor = (forestFactor - forestProfile.minForestFactor) / (1.0 - forestProfile.minForestFactor); const densityCurve = Math.pow(normalizedFactor, 0.5); // Aggressive curve for dramatic clustering const candidatesPerCell = Math.max(1, Math.round(densityCurve * 10 * forestProfile.densityScale)); // NATURAL PLACEMENT: Use blue noise and golden ratio spiral to break grid pattern const jitterRange = sampleGridSpacing * 0.95; // INCREASED from 0.45 to 0.95 - almost full cell const goldenAngle = Math.PI * (3 - Math.sqrt(5)); // Golden angle for spiral distribution for (let c = 0; c < candidatesPerCell; c++) { // Blue noise: combine random with deterministic offset based on cell position const blueNoiseX = (ix * 73 + iz * 37 + c * 17) % 100 / 100.0; const blueNoiseZ = (ix * 41 + iz * 83 + c * 29) % 100 / 100.0; // Golden ratio spiral for natural clustering const angle = c * goldenAngle; const radius = Math.sqrt(c / candidatesPerCell) * jitterRange; const spiralX = Math.cos(angle) * radius; const spiralZ = Math.sin(angle) * radius; // Combine blue noise with spiral for organic distribution const jitterX = spiralX + (blueNoiseX - 0.5) * jitterRange * 0.3; const jitterZ = spiralZ + (blueNoiseZ - 0.5) * jitterRange * 0.3; // Calculate final position const finalX = clampToMap(baseX + jitterX); const finalZ = clampToMap(baseZ + jitterZ); // ADDITIONAL VALIDATION: Check jittered position for water/slope/elevation // This prevents trees from being placed in water or on steep slopes // even if the cell center was valid const jitteredHeight = sim.terra.getHeightWorld(finalX, finalZ); if (jitteredHeight <= treeWaterLevel + waterMargin) { continue; // Skip this candidate } const jitteredSlope = sim.terra.getSlopeWorld(finalX, finalZ); if (jitteredSlope.slopeDegrees > MAX_TREE_SLOPE) { continue; // Skip this candidate } // ELEVATION-BASED FILTERING: Different biomes prefer different elevation ranges // This creates natural tree lines and elevation-based distribution const { minHeight, maxHeight } = sim.terra.getHeightRange(); const heightRange = Math.max(1e-5, maxHeight - minHeight); const normalizedHeight = (jitteredHeight - minHeight) / heightRange; // 0 = lowest, 1 = highest let elevationAcceptance = 1.0; // Default: accept all elevations switch (biomeType) { case BiomeType.ARCTIC: // Arctic trees prefer lower elevations (valleys), avoid peaks // Acceptance drops off above 60% elevation if (normalizedHeight > 0.6) { elevationAcceptance = Math.max(0, 1.0 - (normalizedHeight - 0.6) / 0.4); } break; case BiomeType.DESERT: // Desert oases prefer lower elevations near water sources // Strong preference for low elevations (0-40%) if (normalizedHeight > 0.4) { elevationAcceptance = Math.max(0, 1.0 - (normalizedHeight - 0.4) / 0.3); } break; case BiomeType.VOLCANIC: // Volcanic vegetation avoids extreme elevations // Prefers mid-range (30-70%) if (normalizedHeight < 0.3 || normalizedHeight > 0.7) { const distFromMid = Math.abs(normalizedHeight - 0.5); elevationAcceptance = Math.max(0, 1.0 - (distFromMid - 0.2) / 0.3); } break; case BiomeType.TEMPERATE: // Temperate forests prefer mid-to-low elevations // Gradual falloff above 70% if (normalizedHeight > 0.7) { elevationAcceptance = Math.max(0, 1.0 - (normalizedHeight - 0.7) / 0.3); } break; case BiomeType.ALIEN: // Alien vegetation grows everywhere - no elevation preference elevationAcceptance = 1.0; break; case BiomeType.URBAN_RUINS: // Urban overgrowth prefers lower elevations (ruins in valleys) if (normalizedHeight > 0.5) { elevationAcceptance = Math.max(0, 1.0 - (normalizedHeight - 0.5) / 0.5); } break; } // Probabilistically accept based on elevation if (Math.random() > elevationAcceptance) { continue; // Skip this candidate } const finalForestFactor = forestSampler(finalX, finalZ); const patchId = forestSampler.getPatchId?.(finalX, finalZ) ?? -1; if (patchId < 0) { continue; } treePositions.push({ x: finalX, z: finalZ, forestFactor: finalForestFactor, patchId }); } // Progress logging if (totalSampled % 50000 === 0 && totalSampled > 0) { console.info(`[Bootstrap] Sampled ${totalSampled}/${sampleSteps * sampleSteps} cells, generated ${treePositions.length} candidates...`); } } } console.info(`[Bootstrap] Biome-driven generation complete`); if (TREE_DIAGNOSTICS_ENABLED) { console.info('[TreeDiagnostics] Candidate sampling:', { sampled: totalSampled, candidates: treePositions.length, forestFactorRejects, waterRejects, slopeRejects }); } // CRITICAL: Check spatial distribution across the map if (treePositions.length > 0) { const mapQuadrants = { 'NW (-X,-Z)': 0, 'NE (+X,-Z)': 0, 'SW (-X,+Z)': 0, 'SE (+X,+Z)': 0, 'Center': 0 }; const xBounds = { min: Infinity, max: -Infinity }; const zBounds = { min: Infinity, max: -Infinity }; for (const pos of treePositions) { xBounds.min = Math.min(xBounds.min, pos.x); xBounds.max = Math.max(xBounds.max, pos.x); zBounds.min = Math.min(zBounds.min, pos.z); zBounds.max = Math.max(zBounds.max, pos.z); if (Math.abs(pos.x) < 2000 && Math.abs(pos.z) < 2000) { mapQuadrants['Center']++; } else if (pos.x < 0 && pos.z < 0) { mapQuadrants['NW (-X,-Z)']++; } else if (pos.x >= 0 && pos.z < 0) { mapQuadrants['NE (+X,-Z)']++; } else if (pos.x < 0 && pos.z >= 0) { mapQuadrants['SW (-X,+Z)']++; } else { mapQuadrants['SE (+X,+Z)']++; } } } const placementStart = performance.now(); // Use GPU compute shader for tree placement const gpuDeviceManager = await getGpuDeviceManager(); let rawTreePlacements: RawTreePlacement[]; startupTreePlacementMode = 'cpu'; // Cap candidates to avoid overwhelming GPU (max 2M candidates) const MAX_TREE_CANDIDATES = 2_000_000; let finalTreePositions = treePositions; if (treePositions.length > MAX_TREE_CANDIDATES) { // Patch-aware sampling to avoid starving small forest islands finalTreePositions = capTreeCandidatesByPatch( treePositions, MAX_TREE_CANDIDATES, forestSampler, forestSeed + 271 ); } const treeDiagnostics: Record | null = TREE_DIAGNOSTICS_ENABLED ? { candidates: treePositions.length, candidatesCapped: finalTreePositions.length } : null; loadingProgress.setProgress(50, 'Vegetation: computing placement maps (GPU)...'); stageLog('Trees: GPU placement start', 50); if (gpuDeviceManager) { startupTreePlacementMode = 'gpu'; const gpuGenerator = new TreePlacementGenerator(gpuDeviceManager); rawTreePlacements = await gpuGenerator.generatePlacements(finalTreePositions, { minSeparation: baseSeparation, minSeparationCore, minSeparationEdge, mapSize: MAP_SIZE_METERS }); } else { startupTreePlacementMode = 'cpu'; console.warn('[Bootstrap] GPU not available, using CPU fallback'); rawTreePlacements = await generateTreePlacements(finalTreePositions, sim.terra, { minSeparation: baseSeparation, cohesionChance: 0.12, jitterMultiplier: 0.65, minSeparationCore, minSeparationEdge }); } if (treeDiagnostics) { treeDiagnostics.placements = rawTreePlacements.length; treeDiagnostics.minSeparation = baseSeparation; treeDiagnostics.minSeparationCore = minSeparationCore; treeDiagnostics.minSeparationEdge = minSeparationEdge; } // SDF-based density falloff: denser at forest centers, sparser at edges // This creates natural-looking forest boundaries with gradual thinning console.info(`[Bootstrap] Applying SDF-based density falloff to ${rawTreePlacements.length} trees...`); const sdfFilteredPlacements: RawTreePlacement[] = []; for (const placement of rawTreePlacements) { const forestFactor = forestSampler(placement.x, placement.z); // Calculate distance from forest patch center using forest factor as proxy // forestFactor ranges from 0 (edge) to 1 (center) // We want: center (1.0) = 100% density, edge (minForestFactor) = ~10% density const normalizedDistance = 1.0 - forestFactor; // 0 at center, 1 at edge // SDF-based density curve: quadratic falloff for smooth gradient // At center (distance=0): density = 1.0 (100%) // At edge (distance=1): density = 0.1 (10%) const densityAtPoint = 1.0 - (normalizedDistance * normalizedDistance * 0.9); // Probabilistically keep trees based on density if (Math.random() < densityAtPoint) { sdfFilteredPlacements.push(placement); } } console.info(`[Bootstrap] SDF filter: ${rawTreePlacements.length} → ${sdfFilteredPlacements.length} trees (${(sdfFilteredPlacements.length / rawTreePlacements.length * 100).toFixed(1)}% retained)`); rawTreePlacements = sdfFilteredPlacements; if (treeDiagnostics) { treeDiagnostics.sdfFiltered = rawTreePlacements.length; } // Cap final placements for performance // Using heavily decimated GLB model - prioritize quantity over detail // CRITICAL: Randomly sample to maintain even distribution across map const MAX_TREE_PLACEMENTS = 300_000; if (rawTreePlacements.length > MAX_TREE_PLACEMENTS) { console.warn(`[Bootstrap] Too many placements (${rawTreePlacements.length}), patch-aware sampling ${MAX_TREE_PLACEMENTS}`); rawTreePlacements = capTreePlacementsByDensity( rawTreePlacements, MAX_TREE_PLACEMENTS, forestSampler, forestSeed + 503 ); } if (treeDiagnostics) { treeDiagnostics.maxPlacementCap = rawTreePlacements.length; } const treeInstanceCap = getTreeInstanceCap(MAP_SIZE_METERS); if (treeDiagnostics) { const mapAreaKm2 = (MAP_SIZE_METERS * MAP_SIZE_METERS) / 1_000_000; treeDiagnostics.mapAreaKm2 = mapAreaKm2; treeDiagnostics.instanceCapTarget = treeInstanceCap; treeDiagnostics.instanceCapPerKm2 = parseDensityMultiplier( window.localStorage.getItem('rts.tree.instanceCapPerKm2'), 45 ); treeDiagnostics.treeDensityMultiplier = TREE_DENSITY_MULTIPLIER_EFFECTIVE; } if (rawTreePlacements.length > treeInstanceCap) { rawTreePlacements = capTreePlacementsByDensity( rawTreePlacements, treeInstanceCap, forestSampler, forestSeed + 409 ); console.info(`[Bootstrap] Pre-capped tree placements: ${rawTreePlacements.length} (cap=${treeInstanceCap})`); } if (treeDiagnostics) { treeDiagnostics.instanceCap = rawTreePlacements.length; } const treePlacements = enrichTreePlacements( rawTreePlacements, sim.terra, forestSampler, season, seasonPalette, forestSeed + 97 ); // STRICT SLOPE FILTERING: Remove all trees on slopes (already filtered during generation) // This is a final safety check to ensure no trees ended up on slopes const FINAL_SLOPE_CHECK = 12; // Same as MAX_TREE_SLOPE used during generation const filteredTreePlacements = treePlacements.filter((placement) => { const slopeDegrees = sim.terra.getSlopeWorld(placement.x, placement.z).slopeDegrees; return slopeDegrees <= FINAL_SLOPE_CHECK; }); if (filteredTreePlacements.length !== treePlacements.length) { console.info(`[Bootstrap] Final slope filter removed ${treePlacements.length - filteredTreePlacements.length} trees on slopes > ${FINAL_SLOPE_CHECK}°`); } if (treeDiagnostics) { treeDiagnostics.slopeFiltered = filteredTreePlacements.length; } let densifiedTreePlacements = filteredTreePlacements; if (filteredTreePlacements.length > treeInstanceCap) { densifiedTreePlacements = capTreePlacementsByDensity( filteredTreePlacements, treeInstanceCap, forestSampler, forestSeed + 409 ); console.info(`[Bootstrap] Capped tree placements: ${filteredTreePlacements.length} -> ${densifiedTreePlacements.length} (cap=${treeInstanceCap})`); } if (TREE_DENSITY_MULTIPLIER_EFFECTIVE < 1.0) { const beforeDensity = densifiedTreePlacements.length; densifiedTreePlacements = applyTreeDensity(densifiedTreePlacements); console.info( `[Bootstrap] Tree density multiplier ${TREE_DENSITY_MULTIPLIER_EFFECTIVE.toFixed(2)} ` + `(configured=${TREE_DENSITY_MULTIPLIER.toFixed(2)}, perfGuard=${TREE_PERF_GUARD ? 'on' : 'off'}): ` + `${beforeDensity} -> ${densifiedTreePlacements.length}` ); } if (treeDiagnostics) { treeDiagnostics.densityApplied = densifiedTreePlacements.length; } // User-requested global tree up-scale for in-game readability. // Double the previous global boost (3.54 -> 7.08). const TREE_SCALE_BOOST = 7.08; /** * Calculate environmental scale factor for realistic tree sizing * Trees grow larger in fertile areas (good soil, mid-elevation, near water) * and smaller in harsh conditions (rocky terrain, extreme elevations) */ const getEnvironmentalScaleFactor = (x: number, z: number, terra: Terra): number => { // 1. Terrain type factor (soil quality) const tile = terra.worldToTile(x, z); const idx = tile.i * terra.height + tile.k; const terrainType = (idx >= 0 && idx < terra.terrainTypes.length) ? terra.terrainTypes[idx] : 0; const terrainFactors: Record = { 0: 1.0, // GRASS - best growth 1: 0.85, // DIRT - good growth 2: 0.65, // SAND - poor growth 3: 0.55 // ROCK - very poor growth }; const terrainFactor = terrainFactors[terrainType] ?? 0.8; // 2. Elevation factor (trees prefer mid-elevations) const heightRange = terra.getHeightRange(); const heightSpan = Math.max(1, heightRange.maxHeight - heightRange.minHeight); const currentHeight = terra.getHeightWorld(x, z); const normalizedHeight = (currentHeight - heightRange.minHeight) / heightSpan; // Optimal growth at 0.3-0.7 elevation, reduced at extremes const elevationFactor = 0.7 + 0.3 * (1 - Math.abs(normalizedHeight - 0.5) * 2); // 3. Vegetation density factor (existing forest density map) const densityFactor = typeof terra.getVegetationDensityAt === 'function' ? 0.8 + terra.getVegetationDensityAt(x, z) * 0.4 : 1.0; // 4. Natural variation using position-based noise const noiseX = Math.sin(x * 0.01 + z * 0.007) * 0.5 + 0.5; const noiseZ = Math.cos(x * 0.007 - z * 0.01) * 0.5 + 0.5; const noiseFactor = 0.85 + (noiseX * noiseZ) * 0.3; // Combine all factors return terrainFactor * elevationFactor * densityFactor * noiseFactor; }; const estimateTreeRadius = ( placement: TreePlacement, terra: Terra, seed: number ): number => { const baseRand = hash2(placement.x, placement.z, seed); const baseScale = THREE.MathUtils.lerp(2.0, 3.9, baseRand); const ageScale = 0.5 + placement.age * 0.5; const healthScale = 0.7 + placement.health * 0.3; const environmentalScale = getEnvironmentalScaleFactor(placement.x, placement.z, terra); const finalScale = baseScale * ageScale * healthScale * environmentalScale; const boostedScale = finalScale * TREE_SCALE_BOOST; const horizontalScale = boostedScale * 1.38; return horizontalScale * 0.5; }; const pruneTreeOverlaps = ( placements: TreePlacement[], terra: Terra, seed: number ): TreePlacement[] => { if (!TREE_OVERLAP_PRUNE_ENABLED || placements.length === 0) { return placements; } const scored = placements.map((placement) => { const forestFactor = forestSampler(placement.x, placement.z); const jitter = hash2(placement.x, placement.z, seed) * 0.15; const score = forestFactor + placement.health * 0.25 + jitter; return { placement, score }; }); scored.sort((a, b) => b.score - a.score); const radii = new Map(); let maxRadius = 0; for (const entry of scored) { const radius = estimateTreeRadius(entry.placement, terra, seed + 911); radii.set(entry.placement, radius); if (radius > maxRadius) { maxRadius = radius; } } const overlapFactor = Math.max(0.75, TREE_OVERLAP_FACTOR); const cellSize = Math.max(1, maxRadius * 2 * overlapFactor); const neighborRange = overlapFactor < 1 ? 2 : 1; const grid = new Map(); const accepted: TreePlacement[] = []; const getCellKey = (x: number, z: number): string => { const cx = Math.floor(x / cellSize); const cz = Math.floor(z / cellSize); return `${cx},${cz}`; }; for (const entry of scored) { const placement = entry.placement; const radius = radii.get(placement) ?? maxRadius * 0.5; const cx = Math.floor(placement.x / cellSize); const cz = Math.floor(placement.z / cellSize); let overlaps = false; for (let dx = -neighborRange; dx <= neighborRange && !overlaps; dx++) { for (let dz = -neighborRange; dz <= neighborRange && !overlaps; dz++) { const key = `${cx + dx},${cz + dz}`; const bucket = grid.get(key); if (!bucket) continue; for (const other of bucket) { const otherRadius = radii.get(other) ?? maxRadius * 0.5; const required = (radius + otherRadius) * overlapFactor; const deltaX = placement.x - other.x; const deltaZ = placement.z - other.z; if (deltaX * deltaX + deltaZ * deltaZ < required * required) { overlaps = true; break; } } } } if (overlaps) { continue; } const key = getCellKey(placement.x, placement.z); if (!grid.has(key)) { grid.set(key, []); } grid.get(key)!.push(placement); accepted.push(placement); } if (TREE_DIAGNOSTICS_ENABLED) { console.info('[TreeDiagnostics] Overlap prune:', { before: placements.length, after: accepted.length, overlapFactor, cellSize: cellSize.toFixed(2) }); } return accepted; }; const overlapPrunedPlacements = pruneTreeOverlaps( densifiedTreePlacements, sim.terra, forestSeed + 1337 ); if (overlapPrunedPlacements.length !== densifiedTreePlacements.length) { console.info(`[Bootstrap] Overlap prune: ${densifiedTreePlacements.length} -> ${overlapPrunedPlacements.length}`); } // FIX: Remove exact duplicate positions to prevent Z-fighting const DUPLICATE_EPSILON = 0.01; // 1cm tolerance const positionSet = new Map(); const dedupedPlacements: TreePlacement[] = []; for (const placement of overlapPrunedPlacements) { // Create position key with epsilon rounding const keyX = Math.round(placement.x / DUPLICATE_EPSILON); const keyZ = Math.round(placement.z / DUPLICATE_EPSILON); const key = `${keyX},${keyZ}`; if (!positionSet.has(key)) { positionSet.set(key, placement); dedupedPlacements.push(placement); } } if (dedupedPlacements.length !== overlapPrunedPlacements.length) { console.info(`[Bootstrap] Duplicate position filter: ${overlapPrunedPlacements.length} -> ${dedupedPlacements.length} (removed ${overlapPrunedPlacements.length - dedupedPlacements.length} duplicates)`); } densifiedTreePlacements = dedupedPlacements; // Skip detailed distribution logging const placementQuadrants = { 'NW (-X,-Z)': 0, 'NE (+X,-Z)': 0, 'SW (-X,+Z)': 0, 'SE (+X,+Z)': 0, 'Center': 0 }; const placementBounds = { minX: Infinity, maxX: -Infinity, minZ: Infinity, maxZ: -Infinity }; for (const p of densifiedTreePlacements) { placementBounds.minX = Math.min(placementBounds.minX, p.x); placementBounds.maxX = Math.max(placementBounds.maxX, p.x); placementBounds.minZ = Math.min(placementBounds.minZ, p.z); placementBounds.maxZ = Math.max(placementBounds.maxZ, p.z); if (Math.abs(p.x) < 2000 && Math.abs(p.z) < 2000) { placementQuadrants['Center']++; } else if (p.x < 0 && p.z < 0) { placementQuadrants['NW (-X,-Z)']++; } else if (p.x >= 0 && p.z < 0) { placementQuadrants['NE (+X,-Z)']++; } else if (p.x < 0 && p.z >= 0) { placementQuadrants['SW (-X,+Z)']++; } else { placementQuadrants['SE (+X,+Z)']++; } } if (treeDiagnostics) { const mapAreaKm2 = (MAP_SIZE_METERS * MAP_SIZE_METERS) / 1_000_000; const density = mapAreaKm2 > 0 ? densifiedTreePlacements.length / mapAreaKm2 : 0; console.info('[TreeDiagnostics] Placement coverage:', { bounds: placementBounds, quadrants: placementQuadrants, densityPerKm2: Number.isFinite(density) ? density.toFixed(1) : 'n/a' }); } const treeElevationBounds = { minHeight: Infinity, maxHeight: -Infinity }; for (const placement of densifiedTreePlacements) { const height = sim.terra.getHeightWorld(placement.x, placement.z); treeElevationBounds.minHeight = Math.min(treeElevationBounds.minHeight, height); treeElevationBounds.maxHeight = Math.max(treeElevationBounds.maxHeight, height); } if (!Number.isFinite(treeElevationBounds.minHeight) || !Number.isFinite(treeElevationBounds.maxHeight)) { treeElevationBounds.minHeight = 0; treeElevationBounds.maxHeight = 1; } const treeElevationRange = Math.max(1e-3, treeElevationBounds.maxHeight - treeElevationBounds.minHeight); const speciesSplit = splitTreePlacementsBySpecies( densifiedTreePlacements, forestSampler, forestSeed + 211, coniferWeight ); if (treeDiagnostics) { treeDiagnostics.conifer = speciesSplit.conifer.length; treeDiagnostics.broadleaf = speciesSplit.broadleaf.length; } // Tree density reduction already applied pre-split to keep billboards and meshes in sync const coniferReduced = speciesSplit.conifer; const broadleafReduced = speciesSplit.broadleaf; const coniferPlacementPool = [...coniferReduced]; const broadleafPlacementPool = TREE_CONIFER_AAA_SKELETON ? [] : broadleafReduced; if (TREE_CONIFER_AAA_SKELETON && broadleafReduced.length > 0) { coniferPlacementPool.push(...broadleafReduced); console.info( `[TreeStabilization] Routed ${broadleafReduced.length} broadleaf placements to conifer solver output` ); } const highVariantEntries = treeVariantManifest.filter((entry) => entry.lodTier === 'high'); const midVariantByKey = new Map( treeVariantManifest .filter((entry) => entry.lodTier === 'mid') .map((entry) => [entry.variantKey, entry] as const) ); const coniferHighEntries = highVariantEntries.filter((entry) => entry.semantic === 'conifer'); const broadleafHighEntries = highVariantEntries.filter((entry) => entry.semantic === 'broadleaf'); const firHighEntry = highVariantEntries.find((entry) => entry.semantic === 'fir') ?? null; const fallbackConiferEntry = coniferHighEntries.length > 0 ? coniferHighEntries : [{ variantId: 'tree-conifer-skeleton-fallback-v2', variantKey: 'conifer:0', family: 'conifer', semantic: 'conifer', subtype: 'fir', lodTier: 'high', sourceVariantIndex: 0, geometryIndex: 0 }]; const fallbackBroadleafEntry: typeof broadleafHighEntries = TREE_CONIFER_AAA_SKELETON ? [] : (broadleafHighEntries.length > 0 ? broadleafHighEntries : [{ variantId: 'tree-broadleaf-compat-fallback-v2', variantKey: 'broadleaf:1', family: 'broadleaf', semantic: 'broadleaf', subtype: 'oak', lodTier: 'high', sourceVariantIndex: 1, geometryIndex: Math.min(1, Math.max(0, treeGeometries.length - 1)) }]); const effectiveConiferEntries = (() => { if (!TREE_MESH_LAB_PARITY) { return fallbackConiferEntry; } const canonical = fallbackConiferEntry.find( (entry) => entry.sourceVariantIndex === TREE_MESH_LAB_CONIFER_VARIANT ); return canonical ? [canonical] : [fallbackConiferEntry[0]]; })(); if (TREE_MESH_LAB_PARITY) { const selected = effectiveConiferEntries[0]; if (selected) { const isCanonical = selected.sourceVariantIndex === TREE_MESH_LAB_CONIFER_VARIANT; if (!isCanonical) { console.warn( `[Trees] MeshLab parity fallback in use: requested conifer variant ` + `${TREE_MESH_LAB_CONIFER_VARIANT}, selected ${selected.sourceVariantIndex}` ); } else { console.info( `[Trees] MeshLab parity active: using canonical conifer variant ${selected.sourceVariantIndex}` ); } } } const hasFirVariant = firHighEntry !== null && firMaterialIndex >= 0; const useDedicatedFirVariant = hasFirVariant && !TREE_MESH_LAB_PARITY; const seasonalMaterialCount = Math.max(1, useDedicatedFirVariant ? firMaterialIndex : treeMaterials.length); type ConiferSubtype = 'fir' | 'pine' | 'spruce'; type BroadleafSubtype = 'oak' | 'poplar' | 'birch'; const getConiferSubtypeWeightsByBiome = (biome: BiomeType): Record => { switch (biome) { case BiomeType.ARCTIC: return { fir: 0.35, pine: 0.15, spruce: 0.5 }; case BiomeType.DESERT: return { fir: 0.2, pine: 0.65, spruce: 0.15 }; case BiomeType.VOLCANIC: return { fir: 0.28, pine: 0.5, spruce: 0.22 }; case BiomeType.ALIEN: return { fir: 0.33, pine: 0.34, spruce: 0.33 }; case BiomeType.URBAN_RUINS: return { fir: 0.22, pine: 0.56, spruce: 0.22 }; case BiomeType.TEMPERATE: default: return { fir: 0.42, pine: 0.35, spruce: 0.23 }; } }; const getBroadleafSubtypeWeightsByBiome = (biome: BiomeType): Record => { switch (biome) { case BiomeType.ARCTIC: return { oak: 0.22, poplar: 0.28, birch: 0.5 }; case BiomeType.DESERT: return { oak: 0.18, poplar: 0.64, birch: 0.18 }; case BiomeType.VOLCANIC: return { oak: 0.48, poplar: 0.24, birch: 0.28 }; case BiomeType.ALIEN: return { oak: 0.34, poplar: 0.33, birch: 0.33 }; case BiomeType.URBAN_RUINS: return { oak: 0.44, poplar: 0.31, birch: 0.25 }; case BiomeType.TEMPERATE: default: return { oak: 0.38, poplar: 0.34, birch: 0.28 }; } }; const coniferSubtypeWeights = getConiferSubtypeWeightsByBiome(biomeType); const broadleafSubtypeWeights = getBroadleafSubtypeWeightsByBiome(biomeType); if (treeDiagnostics) { treeDiagnostics.coniferFirWeight = coniferSubtypeWeights.fir; treeDiagnostics.coniferPineWeight = coniferSubtypeWeights.pine; treeDiagnostics.coniferSpruceWeight = coniferSubtypeWeights.spruce; treeDiagnostics.broadleafOakWeight = broadleafSubtypeWeights.oak; treeDiagnostics.broadleafPoplarWeight = broadleafSubtypeWeights.poplar; treeDiagnostics.broadleafBirchWeight = broadleafSubtypeWeights.birch; } if (treeDiagnostics) { treeDiagnostics.generatedConiferVariants = effectiveConiferEntries.length; treeDiagnostics.generatedBroadleafVariants = fallbackBroadleafEntry.length; treeDiagnostics.generatedFirVariants = useDedicatedFirVariant ? 1 : 0; treeDiagnostics.generatedTreeMaterials = treeMaterialManifest.length; } type SpeciesMetric = { generatedVariants: number; placedInstances: number; highLodInstances: number; midLodInstances: number; }; const speciesMetrics: Record<'conifer' | 'broadleaf' | 'fir', SpeciesMetric> = { conifer: { generatedVariants: effectiveConiferEntries.length, placedInstances: 0, highLodInstances: 0, midLodInstances: 0 }, broadleaf: { generatedVariants: fallbackBroadleafEntry.length, placedInstances: 0, highLodInstances: 0, midLodInstances: 0 }, fir: { generatedVariants: useDedicatedFirVariant ? 1 : 0, placedInstances: 0, highLodInstances: 0, midLodInstances: 0 } }; const subtypePlacementMetrics: Record = {}; const applyTreeTransforms = ( meshes: THREE.InstancedMesh[], variantTrees: TreePlacement[], species: TreeSpecies, colors?: THREE.Color[] ): void => { if (meshes.length === 0 || variantTrees.length === 0) { return; } const matrix = new THREE.Matrix4(); const quaternion = new THREE.Quaternion(); const scale = new THREE.Vector3(); const yAxis = new THREE.Vector3(0, 1, 0); const tiltAxis = new THREE.Vector3(); // Optional: Prevailing wind direction for subtle directional bias const PREVAILING_WIND_ANGLE = Math.PI * 0.25; // NE direction const WIND_INFLUENCE = species === 'conifer' ? 0.14 : 0.1; const baseScaleMin = species === 'conifer' ? 1.0 : 0.95; const baseScaleMax = species === 'conifer' ? 1.85 : 1.7; const horizontalBase = species === 'conifer' ? 1.08 : 1.26; const verticalBase = species === 'conifer' ? 1.46 : 1.3; for (let j = 0; j < variantTrees.length; j++) { const tree = variantTrees[j]; const height = sim.terra.getHeightWorld(tree.x, tree.z) + Math.max(sim.terra.tileSize * 0.02, 0.2); const scaleNoise = hash2(tree.x * 0.17, tree.z * 0.23, forestSeed + 911); const yawNoise = hash2(tree.x * 0.41, tree.z * 0.37, forestSeed + 1733); const tiltNoise = hash2(tree.x * 0.59, tree.z * 0.47, forestSeed + 2897); const shapeNoise = hash2(tree.x * 0.31, tree.z * 0.53, forestSeed + 3559); // === REALISTIC SCALING === // Base scale with deterministic per-position variation. const baseScale = THREE.MathUtils.lerp(baseScaleMin, baseScaleMax, scaleNoise); // Age and health factors (keep existing good logic) const ageScale = 0.5 + tree.age * 0.5; const healthScale = 0.7 + tree.health * 0.3; // Environmental factors (NEW: realistic growth conditions) const environmentalScale = getEnvironmentalScaleFactor(tree.x, tree.z, sim.terra); const finalScale = baseScale * ageScale * healthScale * environmentalScale; const boostedScale = finalScale * TREE_SCALE_BOOST; const horizontalSkewX = species === 'conifer' ? THREE.MathUtils.lerp(0.94, 1.07, shapeNoise) : THREE.MathUtils.lerp(0.9, 1.13, shapeNoise); const horizontalSkewZ = species === 'conifer' ? THREE.MathUtils.lerp(0.95, 1.08, 1 - shapeNoise) : THREE.MathUtils.lerp(0.9, 1.14, 1 - shapeNoise); const horizontalScaleX = boostedScale * horizontalBase * horizontalSkewX; const horizontalScaleZ = boostedScale * horizontalBase * horizontalSkewZ; const verticalScale = boostedScale * verticalBase; scale.set(horizontalScaleX, verticalScale, horizontalScaleZ); // === REALISTIC ROTATION === // Random Y-axis rotation with optional wind bias const randomAngle = yawNoise * Math.PI * 2; const windBiasedAngle = randomAngle + (Math.sin(randomAngle - PREVAILING_WIND_ANGLE) * WIND_INFLUENCE); // Small random tilt for organic variation (trees aren't perfectly vertical) const maxTilt = species === 'conifer' ? 0.03 : 0.09; const tiltAngle = (tiltNoise - 0.5) * maxTilt; const tiltAngle2 = hash2(tree.x * 0.73, tree.z * 0.67, forestSeed + 4801) * Math.PI * 2; tiltAxis.set(Math.cos(tiltAngle2), 0, Math.sin(tiltAngle2)).normalize(); // Compose rotation: base upright + random Y rotation + small tilt const yRotation = new THREE.Quaternion().setFromAxisAngle(yAxis, windBiasedAngle); const tiltRotation = new THREE.Quaternion().setFromAxisAngle(tiltAxis, tiltAngle); quaternion.copy(yRotation).multiply(tiltRotation); matrix.compose(new THREE.Vector3(tree.x, height, tree.z), quaternion, scale); for (const mesh of meshes) { mesh.setMatrixAt(j, matrix); // FIX: Set instance colors for scatter mesh rendering if (colors && colors[j]) { mesh.setColorAt(j, colors[j]); } } } for (const mesh of meshes) { mesh.instanceMatrix.needsUpdate = true; if (mesh.instanceColor) { mesh.instanceColor.needsUpdate = true; } mesh.computeBoundingSphere(); } }; const shouldUseMidTreeLod = (useWebglWorld: boolean, hasMid: boolean): boolean => { if (!hasMid) return false; if (TREE_LOD_PREFERENCE === 'mid') return true; if (TREE_LOD_PREFERENCE === 'high') return false; // Auto mode is RTS-first: prefer mid mesh trees for scatter-scale instance counts. // WebGL fallback worlds still bias to high as their tree counts are typically lower. return !useWebglWorld; }; let treeLodLogged = false; const treeInstanceMeshes: THREE.InstancedMesh[] = []; type TreeVariantPlacementEntry = { variantId: string; variantKey: string; sourceVariantIndex: number; geometryIndex: number; semantic: 'conifer' | 'broadleaf' | 'fir'; subtype: string; }; type HabitatMetrics = { forestFactor: number; slopeNorm: number; heightNorm: number; dryness: number; }; const habitatMetricsCache = new Map(); const getPlacementHabitatMetrics = (tree: TreePlacement): HabitatMetrics => { const key = `${Math.round(tree.x * 10)}:${Math.round(tree.z * 10)}`; const cached = habitatMetricsCache.get(key); if (cached) { return cached; } const height = sim.terra.getHeightWorld(tree.x, tree.z); const slope = sim.terra.getSlopeWorld(tree.x, tree.z).slopeDegrees; const forestFactor = clamp01(forestSampler(tree.x, tree.z)); const heightNorm = clamp01((height - treeElevationBounds.minHeight) / treeElevationRange); const slopeNorm = clamp01(slope / 42); const dryness = clamp01((1 - forestFactor) * 0.62 + heightNorm * 0.24 + slopeNorm * 0.14); const metrics: HabitatMetrics = { forestFactor, slopeNorm, heightNorm, dryness }; habitatMetricsCache.set(key, metrics); return metrics; }; const getConiferSubtypeHabitatScore = (subtype: string, tree: TreePlacement): number => { const metrics = getPlacementHabitatMetrics(tree); const stochastic = 0.9 + hash2(tree.x * 0.37, tree.z * 0.29, forestSeed + 2197) * 0.2; if (subtype === 'pine') { const suitability = 0.45 + metrics.dryness * 0.95 + (1 - metrics.forestFactor) * 0.3 + metrics.slopeNorm * 0.18; return suitability * stochastic; } if (subtype === 'spruce') { const suitability = 0.42 + metrics.heightNorm * 1.05 + metrics.forestFactor * 0.36 + metrics.slopeNorm * 0.24; return suitability * stochastic; } // fallback/fir: denser, moister forest interiors. const suitability = 0.52 + metrics.forestFactor * 1.04 + (1 - metrics.dryness) * 0.42 + (1 - metrics.slopeNorm) * 0.18; return suitability * stochastic; }; const getBroadleafSubtypeHabitatScore = (subtype: string, tree: TreePlacement): number => { const metrics = getPlacementHabitatMetrics(tree); const stochastic = 0.9 + hash2(tree.x * 0.23, tree.z * 0.41, forestSeed + 2371) * 0.2; if (subtype === 'poplar') { // Poplar favors lowlands and wetter corridors. const suitability = 0.46 + metrics.forestFactor * 0.92 + (1 - metrics.heightNorm) * 0.38 + (1 - metrics.slopeNorm) * 0.22; return suitability * stochastic; } if (subtype === 'birch') { // Birch handles colder, slightly elevated / edge conditions. const suitability = 0.4 + metrics.heightNorm * 0.84 + (1 - metrics.forestFactor) * 0.28 + metrics.slopeNorm * 0.2; return suitability * stochastic; } // Oak favors stable mid-elevation interiors. const suitability = 0.5 + metrics.forestFactor * 0.62 + (1 - metrics.dryness) * 0.34 + (1 - metrics.slopeNorm) * 0.2; return suitability * stochastic; }; const distributeTreesByVariantWeights = ( trees: TreePlacement[], variants: TreeVariantPlacementEntry[], seedOffset: number, subtypeWeights?: Partial>, subtypeScoreResolver?: (subtype: string, tree: TreePlacement) => number ): TreePlacement[][] => { const groups = variants.map(() => [] as TreePlacement[]); if (variants.length === 0 || trees.length === 0) { return groups; } if (variants.length === 1) { groups[0] = [...trees]; return groups; } const rawWeights = variants.map((variant, index) => { const weighted = subtypeWeights?.[variant.subtype]; const base = Number.isFinite(weighted) ? Math.max(0, weighted as number) : 1; // tiny deterministic bias to avoid exact tie buckets return base + index * 1e-6; }); const totalWeight = rawWeights.reduce((sum, value) => sum + value, 0); const normalized = totalWeight > 0 ? rawWeights.map((value) => value / totalWeight) : rawWeights.map(() => 1 / rawWeights.length); const cumulative: number[] = []; let running = 0; for (const weight of normalized) { running += weight; cumulative.push(running); } cumulative[cumulative.length - 1] = 1; for (let i = 0; i < trees.length; i++) { const tree = trees[i]; const sample = hash2( tree.x * 0.43 + i * 0.007, tree.z * 0.59 + i * 0.011, forestSeed + seedOffset ); let dynamicCumulative = cumulative; if (subtypeScoreResolver) { const dynamicWeights = variants.map((variant, index) => { const baseWeight = normalized[index]; const score = Math.max(0, subtypeScoreResolver(variant.subtype, tree)); return baseWeight * Math.max(0.01, score); }); const dynamicTotal = dynamicWeights.reduce((sum, value) => sum + value, 0); if (dynamicTotal > 0) { dynamicCumulative = []; let dynamicRunning = 0; for (const weight of dynamicWeights) { dynamicRunning += weight / dynamicTotal; dynamicCumulative.push(dynamicRunning); } dynamicCumulative[dynamicCumulative.length - 1] = 1; } } let variantIndex = 0; for (let j = 0; j < dynamicCumulative.length; j++) { if (sample <= dynamicCumulative[j]) { variantIndex = j; break; } } groups[variantIndex].push(tree); } return groups; }; // Helper function to create tree instances for a species const createTreeInstances = ( trees: TreePlacement[], variantEntries: TreeVariantPlacementEntry[], semanticSpecies: 'conifer' | 'broadleaf' | 'fir', transformSpecies: TreeSpecies, seedOffset: number, subtypeWeights?: Partial>, subtypeScoreResolver?: (subtype: string, tree: TreePlacement) => number ) => { if (variantEntries.length === 0) { return; } const treesByVariant = distributeTreesByVariantWeights( trees, variantEntries, seedOffset, subtypeWeights, subtypeScoreResolver ); for (let i = 0; i < variantEntries.length; i++) { const variantEntry = variantEntries[i]; const variantIdx = variantEntry.sourceVariantIndex; const highGeometryIndex = variantEntry.geometryIndex; const variantTrees = treesByVariant[i]; if (variantTrees.length === 0) continue; // FIX: Generate instance colors for scatter mesh rendering const variantColors = buildTreeInstanceColors( variantTrees, seasonPalette, season, forestSeed + variantIdx * 17, transformSpecies, variantEntry.subtype as TreeSubtype ); // Create instanced mesh using improved tree system const positions = variantTrees.map(t => ({ x: t.x, z: t.z })); const isFirVariant = variantEntry.semantic === 'fir'; const subtypeMaterialSeed = getTreeSubtypeMaterialSeed(variantEntry.subtype); const subtypeMaterialJitter = Math.floor( hash2(variantIdx * 0.173 + i * 0.031, seedOffset * 0.067, forestSeed + 7907) * 2.0 ); const subtypeMaterialIndex = (subtypeMaterialSeed + subtypeMaterialJitter) % seasonalMaterialCount; const materialIdx = isFirVariant && firMaterialIndex >= 0 ? firMaterialIndex : subtypeMaterialIndex; const hasMidLodGeometry = treeMidGeometries.length > 0; const canonicalParitySpecies = TREE_MESH_LAB_PARITY && (semanticSpecies === 'conifer' || semanticSpecies === 'fir'); const forceMidForBudget = hasMidLodGeometry && !canonicalParitySpecies && TREE_LOD_PREFERENCE !== 'high' && TREE_LOD_PREFERENCE !== 'mid' && !TREE_HYBRID_LOD && trees.length >= 1800 && semanticSpecies !== 'fir'; const autoHybridFallback = hasMidLodGeometry && TREE_LOD_PREFERENCE === 'auto' && !enableWebglWorld; // In MeshLab parity mode, keep canonical conifers in hybrid/high path so runtime // actually shows the same authored crown shape as the inspection OBJ. const preferParityHybrid = hasMidLodGeometry && canonicalParitySpecies && TREE_LOD_PREFERENCE === 'auto'; const useHybridLod = hasMidLodGeometry && ( preferParityHybrid || ( TREE_LOD_PREFERENCE === 'auto' && !forceMidForBudget && (TREE_HYBRID_LOD || autoHybridFallback) ) ); const useMidLod = !useHybridLod && (forceMidForBudget || shouldUseMidTreeLod(enableWebglWorld, hasMidLodGeometry)); if (TREE_DIAGNOSTICS_ENABLED && !treeLodLogged) { console.info('[TreeDiagnostics] LOD selection:', { preference: TREE_LOD_PREFERENCE, useMidLod, useHybridLod, autoHybridFallback, preferParityHybrid, canonicalParitySpecies, forceMidForBudget, treeCountForVariant: variantTrees.length, enableWebglWorld, hasMid: hasMidLodGeometry }); treeLodLogged = true; } const annotateTreeMesh = ( treeMesh: THREE.InstancedMesh, meshName: string, lodTag: 'high' | 'mid', geometryIndexForMetadata: number ): void => { treeMesh.name = meshName; (treeMesh.userData as { scatterSemantic?: string; scatterLod?: string; treeSubtype?: string; treeVariantId?: string; treeVariantKey?: string; treeGeometryIndex?: number; treeMaterialIndex?: number; }).scatterSemantic = semanticSpecies; (treeMesh.userData as { scatterSemantic?: string; scatterLod?: string; treeSubtype?: string; treeVariantId?: string; treeVariantKey?: string; treeGeometryIndex?: number; treeMaterialIndex?: number; }).scatterLod = lodTag; (treeMesh.userData as { scatterSemantic?: string; scatterLod?: string; treeSubtype?: string; treeVariantId?: string; treeVariantKey?: string; treeGeometryIndex?: number; treeMaterialIndex?: number; }).treeSubtype = variantEntry.subtype; (treeMesh.userData as { scatterSemantic?: string; scatterLod?: string; treeSubtype?: string; treeVariantId?: string; treeVariantKey?: string; treeGeometryIndex?: number; treeMaterialIndex?: number; }).treeVariantId = variantEntry.variantId; (treeMesh.userData as { scatterSemantic?: string; scatterLod?: string; treeSubtype?: string; treeVariantId?: string; treeVariantKey?: string; treeGeometryIndex?: number; treeMaterialIndex?: number; }).treeVariantKey = variantEntry.variantKey; (treeMesh.userData as { scatterSemantic?: string; scatterLod?: string; treeSubtype?: string; treeVariantId?: string; treeVariantKey?: string; treeGeometryIndex?: number; treeMaterialIndex?: number; }).treeGeometryIndex = geometryIndexForMetadata; (treeMesh.userData as { scatterSemantic?: string; scatterLod?: string; treeSubtype?: string; treeVariantId?: string; treeVariantKey?: string; treeGeometryIndex?: number; treeMaterialIndex?: number; }).treeMaterialIndex = materialIdx; }; const createdMeshes: Array<{ mesh: THREE.InstancedMesh; lod: 'high' | 'mid' }> = []; if (useHybridLod || !useMidLod) { const meshName = `tree-${semanticSpecies}-v${variantIdx}`; const treeMesh = getCachedScatterMesh(meshName, () => treeSystem.createInstancedMesh( positions, highGeometryIndex, materialIdx, variantTrees.length ) ); annotateTreeMesh(treeMesh, meshName, 'high', highGeometryIndex); createdMeshes.push({ mesh: treeMesh, lod: 'high' }); } if (useHybridLod || useMidLod) { const midVariant = midVariantByKey.get(variantEntry.variantKey); const midVariantIdx = isFirVariant && firMidVariantIndex >= 0 ? firMidVariantIndex : (midVariant?.geometryIndex ?? highGeometryIndex); const midGeometry = treeMidGeometries[midVariantIdx % treeMidGeometries.length]; const meshName = `tree-${semanticSpecies}-mid-v${variantIdx}`; const treeMesh = getCachedScatterMesh(meshName, () => treeSystem.createInstancedMeshFromGeometry( positions, midGeometry, materialIdx, variantTrees.length ) ); annotateTreeMesh(treeMesh, meshName, 'mid', midVariantIdx); createdMeshes.push({ mesh: treeMesh, lod: 'mid' }); } if (createdMeshes.length === 0) continue; const meshesForTransform = createdMeshes.map((entry) => entry.mesh); applyTreeTransforms(meshesForTransform, variantTrees, transformSpecies, variantColors); speciesMetrics[semanticSpecies].placedInstances += variantTrees.length; subtypePlacementMetrics[variantEntry.subtype] = (subtypePlacementMetrics[variantEntry.subtype] ?? 0) + variantTrees.length; for (const entry of createdMeshes) { if (entry.lod === 'mid') { speciesMetrics[semanticSpecies].midLodInstances += variantTrees.length; } else { speciesMetrics[semanticSpecies].highLodInstances += variantTrees.length; } treeInstanceMeshes.push(entry.mesh); entry.mesh.castShadow = false; entry.mesh.receiveShadow = true; scatterGroup.add(entry.mesh); } } }; const takeRandomPositions = (source: TreePlacement[], count: number): TreePlacement[] => { const taken: TreePlacement[] = []; const len = Math.min(count, source.length); for (let i = 0; i < len; i++) { const idx = Math.floor(Math.random() * source.length); taken.push(source.splice(idx, 1)[0]); } return taken; }; let firPlacedCount = 0; const firVariantPlacementEntries: TreeVariantPlacementEntry[] = useDedicatedFirVariant && firHighEntry ? [{ variantId: firHighEntry.variantId, variantKey: firHighEntry.variantKey, sourceVariantIndex: firHighEntry.sourceVariantIndex, geometryIndex: firHighEntry.geometryIndex, semantic: 'fir', subtype: firHighEntry.subtype }] : []; if (useDedicatedFirVariant && firVariantPlacementEntries.length > 0 && firMaterialIndex >= 0) { const firPlacementCount = Math.max(24, Math.floor(coniferPlacementPool.length * coniferSubtypeWeights.fir)); const firPositions = takeRandomPositions(coniferPlacementPool, firPlacementCount); if (firPositions.length > 0) { firPlacedCount = firPositions.length; createTreeInstances(firPositions, firVariantPlacementEntries, 'fir', 'conifer', 4101); } } const coniferVariantsPrimary: TreeVariantPlacementEntry[] = effectiveConiferEntries.map((entry): TreeVariantPlacementEntry => ({ variantId: entry.variantId, variantKey: entry.variantKey, sourceVariantIndex: entry.sourceVariantIndex, geometryIndex: entry.geometryIndex, semantic: 'conifer', subtype: entry.subtype })).filter((entry) => entry.subtype !== 'fir'); if (coniferVariantsPrimary.length === 0 && effectiveConiferEntries.length > 0) { coniferVariantsPrimary.push({ variantId: effectiveConiferEntries[0].variantId, variantKey: effectiveConiferEntries[0].variantKey, sourceVariantIndex: effectiveConiferEntries[0].sourceVariantIndex, geometryIndex: effectiveConiferEntries[0].geometryIndex, semantic: 'conifer', subtype: effectiveConiferEntries[0].subtype }); } const broadleafVariantsPrimary: TreeVariantPlacementEntry[] = fallbackBroadleafEntry.map((entry): TreeVariantPlacementEntry => ({ variantId: entry.variantId, variantKey: entry.variantKey, sourceVariantIndex: entry.sourceVariantIndex, geometryIndex: entry.geometryIndex, semantic: 'broadleaf', subtype: entry.subtype })); speciesMetrics.conifer.generatedVariants = coniferVariantsPrimary.length; speciesMetrics.broadleaf.generatedVariants = broadleafVariantsPrimary.length; speciesMetrics.fir.generatedVariants = firVariantPlacementEntries.length; if (treeDiagnostics) { treeDiagnostics.generatedConiferVariants = speciesMetrics.conifer.generatedVariants; treeDiagnostics.generatedBroadleafVariants = speciesMetrics.broadleaf.generatedVariants; treeDiagnostics.generatedFirVariants = speciesMetrics.fir.generatedVariants; } const finalPlacementCount = coniferPlacementPool.length + broadleafPlacementPool.length + firPlacedCount; if (treeDiagnostics) { treeDiagnostics.finalPlacements = finalPlacementCount; } // Create tree instances loadingProgress.setProgress(70, `Vegetation: instancing ${finalPlacementCount} trees...`); stageLog(`Trees: instancing ${finalPlacementCount}`, 70); if (coniferPlacementPool.length > 0) { createTreeInstances( coniferPlacementPool, coniferVariantsPrimary, 'conifer', 'conifer', 5119, { pine: coniferSubtypeWeights.pine, spruce: coniferSubtypeWeights.spruce }, getConiferSubtypeHabitatScore ); } if (broadleafPlacementPool.length > 0) { createTreeInstances( broadleafPlacementPool, broadleafVariantsPrimary, 'broadleaf', 'broadleaf', 6143, { oak: broadleafSubtypeWeights.oak, poplar: broadleafSubtypeWeights.poplar, birch: broadleafSubtypeWeights.birch }, getBroadleafSubtypeHabitatScore ); } type TreeQaMeshData = { scatterSemantic?: string; scatterLod?: string; treeSubtype?: string; qaBaseInstanceColors?: Float32Array; }; const getVegetationQaColor = (semantic: string, lod: string, subtype: string): THREE.Color => { const isMid = lod === 'mid'; if (semantic === 'fir') { return new THREE.Color(isMid ? 0xffd08a : 0xff9f1a); } if (semantic === 'conifer') { if (subtype === 'pine') return new THREE.Color(isMid ? 0x72e0bf : 0x00b894); if (subtype === 'spruce') return new THREE.Color(isMid ? 0x74b9ff : 0x0984e3); return new THREE.Color(isMid ? 0xffd08a : 0xff9f1a); } if (semantic === 'broadleaf') { if (subtype === 'poplar') return new THREE.Color(isMid ? 0xb8e994 : 0x6ab04c); if (subtype === 'birch') return new THREE.Color(isMid ? 0xfff3cf : 0xf5e6a3); return new THREE.Color(isMid ? 0xd8a971 : 0xa66b2f); } return new THREE.Color(isMid ? 0xb2bec3 : 0x636e72); }; let vegetationQaOverlayEnabled = false; const setVegetationQaOverlay = (enabled: boolean): boolean => { vegetationQaOverlayEnabled = Boolean(enabled); for (const mesh of treeInstanceMeshes) { const meshData = mesh.userData as TreeQaMeshData; const instanceColor = mesh.instanceColor; if (!instanceColor || !instanceColor.array) { continue; } if (!meshData.qaBaseInstanceColors) { meshData.qaBaseInstanceColors = new Float32Array(instanceColor.array as ArrayLike); } if (!vegetationQaOverlayEnabled) { if (meshData.qaBaseInstanceColors.length === instanceColor.array.length) { (instanceColor.array as Float32Array).set(meshData.qaBaseInstanceColors); instanceColor.needsUpdate = true; } continue; } const qaColor = getVegetationQaColor( meshData.scatterSemantic ?? 'unknown', meshData.scatterLod ?? 'high', meshData.treeSubtype ?? 'unknown' ); for (let i = 0; i < mesh.count; i++) { mesh.setColorAt(i, qaColor); } if (mesh.instanceColor) { mesh.instanceColor.needsUpdate = true; } } console.info(`[TreeQA] Vegetation QA overlay ${vegetationQaOverlayEnabled ? 'enabled' : 'disabled'}`); if (vegetationQaOverlayEnabled) { console.info('[TreeQA] Legend: fir=orange, pine=teal, spruce=blue, oak=brown, poplar=green, birch=cream (lighter shade = mid LOD)'); } return vegetationQaOverlayEnabled; }; (window.__RTS as any).setVegetationQaOverlay = (enabled: boolean = true) => setVegetationQaOverlay(Boolean(enabled)); (window.__RTS as any).toggleVegetationQaOverlay = () => setVegetationQaOverlay(!vegetationQaOverlayEnabled); (window.__RTS as any).getVegetationQaOverlay = () => vegetationQaOverlayEnabled; const computeLodRatio = (metric: SpeciesMetric): number => { if (metric.highLodInstances <= 0) { return metric.midLodInstances > 0 ? 999 : 0; } return Number((metric.midLodInstances / metric.highLodInstances).toFixed(3)); }; const speciesLodMetrics = { conifer: { ...speciesMetrics.conifer, midHighRatio: computeLodRatio(speciesMetrics.conifer) }, broadleaf: { ...speciesMetrics.broadleaf, midHighRatio: computeLodRatio(speciesMetrics.broadleaf) }, fir: { ...speciesMetrics.fir, midHighRatio: computeLodRatio(speciesMetrics.fir) } }; if (treeDiagnostics) { treeDiagnostics.coniferPlaced = speciesMetrics.conifer.placedInstances; treeDiagnostics.broadleafPlaced = speciesMetrics.broadleaf.placedInstances; treeDiagnostics.firPlaced = speciesMetrics.fir.placedInstances; treeDiagnostics.coniferMidHighRatio = speciesLodMetrics.conifer.midHighRatio; treeDiagnostics.broadleafMidHighRatio = speciesLodMetrics.broadleaf.midHighRatio; treeDiagnostics.firMidHighRatio = speciesLodMetrics.fir.midHighRatio; console.info('[TreeDiagnostics] Species metrics:', speciesLodMetrics); console.info('[TreeDiagnostics] Subtype placements:', subtypePlacementMetrics); console.info('[TreeDiagnostics] Stage counts:', treeDiagnostics); } if (webgpuReady && FRAMEGRAPH_TREE_BILLBOARDS) { const billboardColors = buildTreeInstanceColors(densifiedTreePlacements, seasonPalette, season, forestSeed + 97, 'conifer'); const billboardInstances = densifiedTreePlacements.map((p, idx) => ({ x: p.x, z: p.z, age: p.age, color: [billboardColors[idx].r, billboardColors[idx].g, billboardColors[idx].b] as [number, number, number] })); rendererService.setTreeInstances(billboardInstances); } else if (webgpuReady) { console.info('[Trees] Billboard tree path disabled; using mesh scatter trees only.'); } // Add tree keyboard controls let currentTreeSeason: 'spring' | 'summer' | 'autumn' | 'winter' = (season === 'dry' ? 'summer' : season) as 'spring' | 'summer' | 'autumn' | 'winter'; const applyTreeWindControls = ( strengthInput: number, speedInput: number, persist: boolean ): { strength: number; speed: number; webglStrength: number; webglSpeed: number; } => { const strength = Math.max(0.0, Math.min(4.0, Number.isFinite(strengthInput) ? strengthInput : 1.0)); const speed = Math.max(0.0, Math.min(4.0, Number.isFinite(speedInput) ? speedInput : 1.0)); const webglWind = toWebglTreeWind(strength, speed); treeSystem.setWindParameters(webglWind.strength, webglWind.speed); window.__RTS = window.__RTS ?? {}; window.__RTS.treeWindStrength = strength; window.__RTS.treeWindSpeed = speed; if (persist) { try { window.localStorage.setItem(TREE_WIND_STRENGTH_STORAGE_KEY, String(strength)); window.localStorage.setItem(TREE_WIND_SPEED_STORAGE_KEY, String(speed)); } catch (e) { console.error('[Trees] Failed to persist tree wind tuning:', e); } } return { strength, speed, webglStrength: webglWind.strength, webglSpeed: webglWind.speed }; }; (window.__RTS as any).setTreeWind = (strength: number, speed: number = TREE_WIND_SPEED_MULTIPLIER) => { const next = applyTreeWindControls(strength, speed, true); console.info( `[Trees] Wind multipliers set: strength=${next.strength.toFixed(2)} speed=${next.speed.toFixed(2)} ` + `(WebGL uniforms: ${next.webglStrength.toFixed(3)}, ${next.webglSpeed.toFixed(3)})` ); return next; }; (window.__RTS as any).getTreeWind = () => { const current = applyTreeWindControls( window.__RTS?.treeWindStrength ?? TREE_WIND_STRENGTH_MULTIPLIER, window.__RTS?.treeWindSpeed ?? TREE_WIND_SPEED_MULTIPLIER, false ); const state = { ...current, storage: { strength: window.localStorage.getItem(TREE_WIND_STRENGTH_STORAGE_KEY) ?? `default(${TREE_WIND_STRENGTH_MULTIPLIER})`, speed: window.localStorage.getItem(TREE_WIND_SPEED_STORAGE_KEY) ?? `default(${TREE_WIND_SPEED_MULTIPLIER})` } }; console.info('[Trees] Wind tuning:', state); console.info(' __RTS.setTreeWind(strength, speed) - Runtime + persisted tree wind multipliers (0.0-4.0)'); return state; }; applyTreeWindControls(TREE_WIND_STRENGTH_MULTIPLIER, TREE_WIND_SPEED_MULTIPLIER, false); window.addEventListener('keydown', (event) => { if (!event.shiftKey) return; if (event.key.toLowerCase() === 't') { // Cycle seasons const seasons: Array<'spring' | 'summer' | 'autumn' | 'winter'> = ['spring', 'summer', 'autumn', 'winter']; const currentIdx = seasons.indexOf(currentTreeSeason); currentTreeSeason = seasons[(currentIdx + 1) % seasons.length]; treeSystem.setSeason(currentTreeSeason); console.info(`[TreeControls] 🌲 Season changed to ${currentTreeSeason}`); } else if (event.key.toLowerCase() === 'w') { // Toggle wind multipliers (applies immediately in WebGL and WebGPU scatter trees). const currentStrength = window.__RTS?.treeWindStrength ?? TREE_WIND_STRENGTH_MULTIPLIER; const currentSpeed = window.__RTS?.treeWindSpeed ?? TREE_WIND_SPEED_MULTIPLIER; const nextStrength = currentStrength > 1.7 ? 0.9 : 2.0; const nextSpeed = currentSpeed > 1.4 ? 1.0 : 1.5; (window.__RTS as any).setTreeWind(nextStrength, nextSpeed); } else if (event.key.toLowerCase() === 's') { // Toggle subsurface scattering const currentSSS = treeMaterials[0].uniforms.subsurfaceStrength.value; const enabled = currentSSS < 0.1; treeSystem.setSubsurfaceScattering(enabled, 0.3); console.info(`[TreeControls] ✨ Subsurface scattering: ${enabled ? 'ON' : 'OFF'}`); } else if (event.key.toLowerCase() === 'v') { (window.__RTS as any).toggleVegetationQaOverlay?.(); } }); console.info('[Bootstrap] 🎮 Tree keyboard controls enabled:'); console.info(' Shift + T: Cycle seasons'); console.info(' Shift + W: Toggle wind strength/speed'); console.info(' Shift + S: Toggle subsurface scattering'); console.info(' Shift + V: Toggle vegetation QA overlay (species + LOD)'); // PHASE 4: Tree LOD/Performance console commands (window.__RTS as any).setTreeDensity = (multiplier: number) => { const clamped = Math.max(0.1, Math.min(3.0, multiplier)); try { window.localStorage.setItem('rts.tree.density', String(clamped)); console.info(`[Trees] Tree density set to ${(clamped * 100).toFixed(0)}%`); console.info('[Trees] Reload the page to apply changes'); } catch (e) { console.error('[Trees] Failed to set tree density:', e); } }; (window.__RTS as any).setGrassDensity = (multiplier: number) => { const clamped = Math.max(0.1, Math.min(2.0, multiplier)); try { window.localStorage.setItem('rts.grass.density', String(clamped)); console.info(`[Foliage] Grass density set to ${(clamped * 100).toFixed(0)}%`); console.info('[Foliage] Reload the page to apply changes'); } catch (e) { console.error('[Foliage] Failed to set grass density:', e); } }; (window.__RTS as any).setBushDensity = (multiplier: number) => { const clamped = Math.max(0.1, Math.min(2.0, multiplier)); try { window.localStorage.setItem('rts.bush.density', String(clamped)); console.info(`[Foliage] Bush density set to ${(clamped * 100).toFixed(0)}%`); console.info('[Foliage] Reload the page to apply changes'); } catch (e) { console.error('[Foliage] Failed to set bush density:', e); } }; (window.__RTS as any).setGrassLodRelax = (multiplier: number) => { const clamped = Math.max(0.35, Math.min(3.0, multiplier)); try { window.localStorage.setItem('rts.grass.lod', String(clamped)); console.info(`[Foliage] Grass LOD relax set to ${clamped.toFixed(2)}`); console.info('[Foliage] Higher values keep more distant grass'); console.info('[Foliage] Reload the page to apply changes'); } catch (e) { console.error('[Foliage] Failed to set grass LOD relax:', e); } }; (window.__RTS as any).setGrassChunkSize = (chunkSize: number) => { const clamped = Math.max(128, Math.min(4096, Math.round(chunkSize))); try { window.localStorage.setItem('rts.grass.chunkSize', String(clamped)); console.info(`[Foliage] Grass chunk size set to ${clamped}`); console.info('[Foliage] Larger chunks reduce draw calls but cull less aggressively'); console.info('[Foliage] Reload the page to apply changes'); } catch (e) { console.error('[Foliage] Failed to set grass chunk size:', e); } }; (window.__RTS as any).setGrassMaxChunks = (maxChunks: number) => { const clamped = Math.max(16, Math.min(1024, Math.round(maxChunks))); try { window.localStorage.setItem('rts.grass.maxChunks', String(clamped)); console.info(`[Foliage] Grass max chunk count set to ${clamped}`); console.info('[Foliage] Lower values reduce draw calls by merging chunk cells'); console.info('[Foliage] Reload the page to apply changes'); } catch (e) { console.error('[Foliage] Failed to set grass max chunk count:', e); } }; (window.__RTS as any).setTreeLOD = (preference: 'auto' | 'high' | 'mid') => { const validPrefs = ['auto', 'high', 'mid']; if (!validPrefs.includes(preference)) { console.error(`[Trees] Invalid LOD preference: "${preference}". Valid options: ${validPrefs.join(', ')}`); return; } try { window.localStorage.setItem('rts.tree.lod', preference); console.info(`[Trees] Tree LOD preference set to "${preference}"`); console.info('[Trees] Reload the page to apply changes'); console.info('[Trees] - auto: RTS-safe default (prefers mid on WebGPU scatter, high on WebGL world)'); console.info('[Trees] - high: Always use high detail LOD'); console.info('[Trees] - mid: Always use mid detail LOD (better performance)'); } catch (e) { console.error('[Trees] Failed to set LOD preference:', e); } }; (window.__RTS as any).setTreeHybridLod = (enabled: boolean = true) => { const next = Boolean(enabled); try { window.localStorage.setItem('rts.tree.hybridLod', next ? '1' : '0'); console.info(`[Trees] Hybrid high+mid LOD ${next ? 'enabled' : 'disabled'}`); console.info('[Trees] Enabled = both tree mesh tiers are generated and shader min/max distance splits decide visibility.'); console.info('[Trees] Reload the page to apply changes'); } catch (e) { console.error('[Trees] Failed to set hybrid LOD:', e); } }; (window.__RTS as any).setTreeGpuCompactionCulling = (enabled: boolean = true) => { const next = Boolean(enabled); try { window.localStorage.setItem('rts.scatter.treeGpuCompactionCull', next ? '1' : '0'); console.info(`[Trees] GPU instance compaction culling ${next ? 'enabled' : 'disabled'}`); console.info('[Trees] Enabled = compute pass compacts visible tree instances and render uses indirect draw counts.'); console.info('[Trees] Reload the page to apply changes'); } catch (e) { console.error('[Trees] Failed to set tree GPU compaction culling:', e); } }; (window.__RTS as any).setTreeScatterDistance = (maxDistance: number, fadeDistance?: number) => { const clampedMax = Math.max(100, Math.min(20000, maxDistance)); const clampedFade = fadeDistance !== undefined ? Math.max(0, Math.min(2000, fadeDistance)) : 700; const scatterHighMax = Math.max(200, Math.min(3800, clampedMax)); const scatterHighFade = Math.max(50, Math.min(scatterHighMax, clampedFade)); const scatterMidMax = Math.max( scatterHighMax + 120, Math.min(4600, Math.round(scatterHighMax * 1.55)) ); const scatterMidFade = Math.max( 80, Math.min(scatterMidMax, Math.round(Math.max(scatterHighFade * 1.4, 700))) ); const scatterMidMin = Math.max( 0, Math.min(scatterMidMax - 50, Math.round(scatterHighMax - scatterHighFade * 0.85)) ); try { // Legacy framegraph keys (kept for backward compatibility with non-scatter tree paths). window.localStorage.setItem('rts.framegraph.treeScatterMaxDistance', String(clampedMax)); window.localStorage.setItem('rts.framegraph.treeScatterFadeDistance', String(clampedFade)); // Active WebGPU scatter keys used by ScatterIntegration.ts. window.localStorage.setItem('rts.scatter.tree.maxDistance', String(scatterHighMax)); window.localStorage.setItem('rts.scatter.tree.fadeDistance', String(scatterHighFade)); window.localStorage.setItem('rts.scatter.treeMid.maxDistance', String(scatterMidMax)); window.localStorage.setItem('rts.scatter.treeMid.fadeDistance', String(scatterMidFade)); window.localStorage.setItem('rts.scatter.treeMid.minDistance', String(scatterMidMin)); console.info( `[Trees] Scatter LOD distances set. high=${scatterHighMax}m fade=${scatterHighFade}m, ` + `mid=${scatterMidMax}m fade=${scatterMidFade}m min=${scatterMidMin}m` ); console.info('[Trees] (Framegraph compatibility keys were updated too)'); console.info('[Trees] Reload the page to apply changes'); } catch (e) { console.error('[Trees] Failed to set scatter distance:', e); } }; (window.__RTS as any).getTreeLodCullingStats = () => { const cullingDiagnosticsHost = window as unknown as { __RTS_TREE_GPU_CULLING_DIAGNOSTICS__?: { timestampMs: number; compactionEnabled: boolean; byScatterType: Record; tiers: { high: { sourceInstances: number; visibleInstances: number | null }; mid: { sourceInstances: number; visibleInstances: number | null }; }; }; }; const readDistance = (key: string, fallback: number): number => { const raw = window.localStorage.getItem(key); const parsed = raw != null ? Number.parseFloat(raw) : Number.NaN; return Number.isFinite(parsed) ? parsed : fallback; }; const gpuCompactionCull = (() => { const raw = window.localStorage.getItem('rts.scatter.treeGpuCompactionCull'); if (raw == null) return true; const normalized = raw.trim().toLowerCase(); return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; })(); const highMax = readDistance('rts.scatter.tree.maxDistance', 2200); const highFade = readDistance('rts.scatter.tree.fadeDistance', 500); const midMax = readDistance('rts.scatter.treeMid.maxDistance', 3600); const midFade = readDistance('rts.scatter.treeMid.fadeDistance', 900); const midMin = readDistance( 'rts.scatter.treeMid.minDistance', Math.max(0, highMax - highFade * 0.85) ); const treeMeshes = Array.isArray((window.__RTS as any).scatterMeshes?.trees) ? (window.__RTS as any).scatterMeshes.trees as THREE.InstancedMesh[] : []; const bySemantic: Record = {}; const triangleBreakdown = { high: { trianglesPerInstance: 0, visibleTrianglesEstimate: 0, sourceTriangles: 0 }, mid: { trianglesPerInstance: 0, visibleTrianglesEstimate: 0, sourceTriangles: 0 } }; const meshTriangles: Array<{ name: string; semantic: string; lod: 'high' | 'mid'; trianglesPerInstance: number; instanceCount: number; visibleInstancesEstimate: number; visibleTrianglesEstimate: number; }> = []; let highMeshCount = 0; let midMeshCount = 0; let highInstances = 0; let midInstances = 0; type TreeMeshDebugEntry = { mesh: THREE.InstancedMesh; name: string; semantic: string; lodKey: 'high' | 'mid'; cullingTypeKey: string; trianglesPerInstance: number; instanceCount: number; }; const meshDebugEntries: TreeMeshDebugEntry[] = []; const entriesByCullingType = new Map(); for (const mesh of treeMeshes) { const meshData = (mesh?.userData ?? {}) as { scatterSemantic?: string; scatterLod?: string }; const semantic = typeof meshData.scatterSemantic === 'string' ? meshData.scatterSemantic.toLowerCase() : 'unknown'; const lodTag = typeof meshData.scatterLod === 'string' ? meshData.scatterLod.toLowerCase() : ''; const name = typeof mesh?.name === 'string' ? mesh.name.toLowerCase() : ''; const isMid = lodTag === 'mid' || lodTag === 'lod_mid' || name.includes('-mid-'); const lodKey: 'high' | 'mid' = isMid ? 'mid' : 'high'; const geometry = mesh.geometry; const trianglesPerInstance = geometry?.index ? Math.max(0, Math.floor(geometry.index.count / 3)) : Math.max( 0, Math.floor(((geometry?.getAttribute('position') as THREE.BufferAttribute | undefined)?.count ?? 0) / 3) ); const cullingTypeKey = (() => { if (semantic === 'conifer' || semantic === 'fir') { return isMid ? 'TreeConiferMid' : 'TreeConifer'; } if (semantic === 'broadleaf') { return isMid ? 'TreeBroadleafMid' : 'TreeBroadleaf'; } return isMid ? 'TreeMid' : 'Tree'; })(); const debugEntry: TreeMeshDebugEntry = { mesh, name: mesh.name, semantic, lodKey, cullingTypeKey, trianglesPerInstance, instanceCount: mesh.count }; meshDebugEntries.push(debugEntry); if (!entriesByCullingType.has(cullingTypeKey)) { entriesByCullingType.set(cullingTypeKey, []); } entriesByCullingType.get(cullingTypeKey)!.push(debugEntry); if (!bySemantic[semantic]) { bySemantic[semantic] = { meshes: 0, highInstances: 0, midInstances: 0 }; } bySemantic[semantic].meshes += 1; if (isMid) { midMeshCount += 1; midInstances += mesh.count; bySemantic[semantic].midInstances += mesh.count; } else { highMeshCount += 1; highInstances += mesh.count; bySemantic[semantic].highInstances += mesh.count; } } const visibleEstimateByMesh = new WeakMap(); for (const [cullingTypeKey, entries] of entriesByCullingType.entries()) { const sourceTotal = entries.reduce((sum, entry) => sum + Math.max(0, entry.instanceCount), 0); const cullingEntry = cullingDiagnosticsHost.__RTS_TREE_GPU_CULLING_DIAGNOSTICS__?.byScatterType?.[cullingTypeKey]; const hasStableReadback = !!cullingEntry && cullingEntry.lastReadbackFrame >= 0; const pooledVisibleRaw = hasStableReadback && Number.isFinite(cullingEntry.visibleInstances as number) ? Math.max(0, Math.floor(cullingEntry.visibleInstances as number)) : null; if (pooledVisibleRaw === null || sourceTotal <= 0) { for (const entry of entries) { visibleEstimateByMesh.set(entry.mesh, entry.instanceCount); } continue; } const pooledVisible = Math.min(sourceTotal, pooledVisibleRaw); const provisional = entries.map((entry) => { const ideal = pooledVisible * (entry.instanceCount / sourceTotal); const floorValue = Math.min(entry.instanceCount, Math.max(0, Math.floor(ideal))); return { entry, assigned: floorValue, remainder: ideal - floorValue }; }); let assignedTotal = provisional.reduce((sum, item) => sum + item.assigned, 0); let remainderBudget = Math.max(0, pooledVisible - assignedTotal); provisional.sort((a, b) => { if (Math.abs(a.remainder - b.remainder) > 1e-6) { return b.remainder - a.remainder; } return b.entry.instanceCount - a.entry.instanceCount; }); for (const item of provisional) { if (remainderBudget <= 0) { break; } if (item.assigned >= item.entry.instanceCount) { continue; } item.assigned += 1; remainderBudget -= 1; } for (const item of provisional) { visibleEstimateByMesh.set(item.entry.mesh, item.assigned); } } for (const entry of meshDebugEntries) { const visibleInstancesEstimate = visibleEstimateByMesh.get(entry.mesh) ?? entry.instanceCount; const visibleTrianglesEstimate = visibleInstancesEstimate * entry.trianglesPerInstance; triangleBreakdown[entry.lodKey].trianglesPerInstance = Math.max( triangleBreakdown[entry.lodKey].trianglesPerInstance, entry.trianglesPerInstance ); triangleBreakdown[entry.lodKey].visibleTrianglesEstimate += visibleTrianglesEstimate; triangleBreakdown[entry.lodKey].sourceTriangles += entry.instanceCount * entry.trianglesPerInstance; meshTriangles.push({ name: entry.name, semantic: entry.semantic, lod: entry.lodKey, trianglesPerInstance: entry.trianglesPerInstance, instanceCount: entry.instanceCount, visibleInstancesEstimate, visibleTrianglesEstimate }); } const hasHighAndMid = highMeshCount > 0 && midMeshCount > 0; const activeMode = hasHighAndMid ? 'hybrid' : (midMeshCount > 0 ? 'mid-only' : 'high-only'); const hybridLodEffective = hasHighAndMid; const gpuVisible = cullingDiagnosticsHost.__RTS_TREE_GPU_CULLING_DIAGNOSTICS__; const resolveTierEffectiveDistances = ( keys: string[] ): { maxDistance: number; fadeDistance: number; minDistance: number } | null => { const byType = gpuVisible?.byScatterType; if (!byType) return null; for (const key of keys) { const entry = byType[key]; if (!entry) continue; return { maxDistance: entry.maxDistance, fadeDistance: entry.fadeDistance, minDistance: entry.minDistance }; } return null; }; const effectiveHighDistances = resolveTierEffectiveDistances(['Tree', 'TreeConifer', 'TreeBroadleaf']); const effectiveMidDistances = resolveTierEffectiveDistances(['TreeMid', 'TreeConiferMid', 'TreeBroadleafMid']); const visibleTierBreakdown = gpuVisible?.tiers ? { high: { sourceInstances: gpuVisible.tiers.high.sourceInstances, visibleInstances: gpuVisible.tiers.high.visibleInstances }, mid: { sourceInstances: gpuVisible.tiers.mid.sourceInstances, visibleInstances: gpuVisible.tiers.mid.visibleInstances } } : null; const stats = { activeMode, hybridLodFlag: TREE_HYBRID_LOD, hybridLodEffective, gpuCompactionCull, gpuCompactionVisible: visibleTierBreakdown, gpuCompactionByScatterType: gpuVisible?.byScatterType ?? {}, gpuCompactionReadbackTimestampMs: gpuVisible?.timestampMs ?? null, lodPreference: TREE_LOD_PREFERENCE, treeMeshCount: treeMeshes.length, meshBreakdown: { high: { meshes: highMeshCount, instances: highInstances }, mid: { meshes: midMeshCount, instances: midInstances } }, speciesLodMetrics, cullingDistances: { high: { maxDistance: highMax, fadeDistance: highFade, minDistance: 0 }, mid: { maxDistance: midMax, fadeDistance: midFade, minDistance: midMin } }, effectiveCullingDistances: { high: effectiveHighDistances ?? { maxDistance: highMax, fadeDistance: highFade, minDistance: 0 }, mid: effectiveMidDistances ?? { maxDistance: midMax, fadeDistance: midFade, minDistance: midMin } }, semanticBreakdown: bySemantic, triangleBreakdown, meshTriangles }; console.info('[Trees] LOD/Culling stats:', stats); if (hasHighAndMid && midMin <= 0) { console.warn('[Trees] Hybrid mode is active but mid minDistance is 0; high and mid meshes will overlap too much.'); } if (!hasHighAndMid) { console.info('[Trees] Single-tier mode detected. This is expected when force-mid budget mode or explicit LOD mode is selected.'); } return stats; }; const TREE_SKELETON_PARAM_KEYS = [ 'rts.tree.skeleton.crownBase', 'rts.tree.skeleton.crownBaseRatioMin', 'rts.tree.skeleton.crownBaseRatioMax', 'rts.tree.skeleton.Rmax', 'rts.tree.skeleton.crownRadiusRatio', 'rts.tree.skeleton.crownRadiusRatioMin', 'rts.tree.skeleton.crownRadiusRatioMax', 'rts.tree.skeleton.mid', 'rts.tree.skeleton.crownTop', 'rts.tree.skeleton.crownEnvelopeMidRatio', 'rts.tree.skeleton.crownEnvelopeTopClamp', 'rts.tree.skeleton.whorlSpacingMul', 'rts.tree.skeleton.branchCountMul', 'rts.tree.skeleton.branchLengthMul', 'rts.tree.skeleton.gravitropism', 'rts.tree.skeleton.phototropism', 'rts.tree.skeleton.apicalDominance', 'rts.tree.skeleton.artisticPredictability', 'rts.tree.skeleton.maxAge.order1', 'rts.tree.skeleton.maxAge.order2', 'rts.tree.skeleton.maxAge.order3', 'rts.tree.skeleton.envelopeStall.order1', 'rts.tree.skeleton.envelopeStall.order2', 'rts.tree.skeleton.envelopeStall.order3', 'rts.tree.skeleton.lowerCrownSheddingBand', 'rts.tree.skeleton.apicalSuppressionDistanceRatio', 'rts.tree.skeleton.apicalSuppressionStrength', 'rts.tree.skeleton.sagBase', 'rts.tree.skeleton.sagExponent', 'rts.tree.skeleton.sagAgeFactor' ] as const; const readTreeSkeletonParamOverrides = (): Record => { const out: Record = {}; for (const key of TREE_SKELETON_PARAM_KEYS) { const raw = window.localStorage.getItem(key); if (raw == null || raw.trim() === '') continue; out[key] = raw; } return out; }; (window.__RTS as any).setTreeSkeletonParam = (key: string, value: number | string) => { if (!TREE_SKELETON_PARAM_KEYS.includes(key as typeof TREE_SKELETON_PARAM_KEYS[number])) { console.error(`[Trees] Unknown skeleton key "${key}".`); console.info('[Trees] Use __RTS.getTreeSkeletonProfile() to list active keys.'); return false; } const normalized = typeof value === 'number' ? String(value) : String(value ?? '').trim(); if (normalized.length === 0) { window.localStorage.removeItem(key); console.info(`[Trees] Cleared skeleton override ${key}`); return true; } window.localStorage.setItem(key, normalized); console.info(`[Trees] Set skeleton override ${key}=${normalized}`); console.info('[Trees] Rebuild tree meshes or reload to guarantee full propagation across cached variants.'); return true; }; (window.__RTS as any).clearTreeSkeletonParams = () => { for (const key of TREE_SKELETON_PARAM_KEYS) { window.localStorage.removeItem(key); } console.info('[Trees] Cleared all tree skeleton runtime overrides.'); return true; }; (window.__RTS as any).getTreeSkeletonProfile = () => { const profile = ImprovedTreeGeometry.getActiveConiferProfile(); const overrides = readTreeSkeletonParamOverrides(); const payload = { profile, overrides, keys: [...TREE_SKELETON_PARAM_KEYS] }; console.info('[Trees] Active conifer skeleton profile:', payload); return payload; }; (window.__RTS as any).getTreeConfig = () => { const readDistance = (key: string, fallback: string): string => window.localStorage.getItem(key) || fallback; const skeletonProfile = ImprovedTreeGeometry.getActiveConiferProfile(); const skeletonOverrides = readTreeSkeletonParamOverrides(); const config = { density: window.localStorage.getItem('rts.tree.density') || 'default(0.45)', lodPreference: window.localStorage.getItem('rts.tree.lod') || 'default(auto)', hybridLod: window.localStorage.getItem('rts.tree.hybridLod') || `default(${TREE_HYBRID_LOD ? 'true' : 'false'})`, treeGpuCompactionCull: window.localStorage.getItem('rts.scatter.treeGpuCompactionCull') || 'default(true)', scatterHighMaxDistance: readDistance('rts.scatter.tree.maxDistance', 'default(2200)'), scatterHighFadeDistance: readDistance('rts.scatter.tree.fadeDistance', 'default(500)'), scatterMidMaxDistance: readDistance('rts.scatter.treeMid.maxDistance', 'default(3600)'), scatterMidFadeDistance: readDistance('rts.scatter.treeMid.fadeDistance', 'default(900)'), scatterMidMinDistance: readDistance('rts.scatter.treeMid.minDistance', 'default(auto-from-high)'), framegraphTreeScatterMaxDistance: window.localStorage.getItem('rts.framegraph.treeScatterMaxDistance') || 'default(3600)', framegraphTreeScatterFadeDistance: window.localStorage.getItem('rts.framegraph.treeScatterFadeDistance') || 'default(700)', separationScale: window.localStorage.getItem('rts.tree.separation.scale') || 'default(1.0)', instanceCapPerKm2: window.localStorage.getItem('rts.tree.instanceCapPerKm2') || 'default(45)', perfGuard: window.localStorage.getItem('rts.tree.perfGuard') || 'default(true)', windStrength: window.localStorage.getItem(TREE_WIND_STRENGTH_STORAGE_KEY) || `default(${TREE_WIND_STRENGTH_MULTIPLIER})`, windSpeed: window.localStorage.getItem(TREE_WIND_SPEED_STORAGE_KEY) || `default(${TREE_WIND_SPEED_MULTIPLIER})`, currentLOD: TREE_LOD_PREFERENCE, currentDensity: TREE_DENSITY_MULTIPLIER_EFFECTIVE, configuredDensity: TREE_DENSITY_MULTIPLIER, currentWindStrength: window.__RTS?.treeWindStrength ?? TREE_WIND_STRENGTH_MULTIPLIER, currentWindSpeed: window.__RTS?.treeWindSpeed ?? TREE_WIND_SPEED_MULTIPLIER, meshLabParity: TREE_MESH_LAB_PARITY, meshLabConiferVariant: TREE_MESH_LAB_CONIFER_VARIANT, vegetationQaOverlay: vegetationQaOverlayEnabled, forcedSkeletonOverrides: TREE_MESH_LAB_PARITY ? { ...TREE_MESH_LAB_ART_STRONG_OVERRIDES } : null, skeletonProfile, skeletonOverrides }; console.info('[Trees] Current Tree Configuration:'); console.info(' Density:', config.density); console.info(' LOD Preference:', config.lodPreference); console.info(' Hybrid LOD:', config.hybridLod); console.info(' Tree GPU Compaction Culling:', config.treeGpuCompactionCull); console.info(' Scatter High Max Distance:', config.scatterHighMaxDistance); console.info(' Scatter High Fade Distance:', config.scatterHighFadeDistance); console.info(' Scatter Mid Max Distance:', config.scatterMidMaxDistance); console.info(' Scatter Mid Fade Distance:', config.scatterMidFadeDistance); console.info(' Scatter Mid Min Distance:', config.scatterMidMinDistance); console.info(' Framegraph Max Distance (compat):', config.framegraphTreeScatterMaxDistance); console.info(' Framegraph Fade Distance (compat):', config.framegraphTreeScatterFadeDistance); console.info(' Separation Scale:', config.separationScale); console.info(' Instance Cap (per km^2):', config.instanceCapPerKm2); console.info(' Perf Guard:', config.perfGuard); console.info(' Wind Strength Multiplier:', config.windStrength); console.info(' Wind Speed Multiplier:', config.windSpeed); console.info(' MeshLab Parity:', config.meshLabParity); console.info(' MeshLab Conifer Variant:', config.meshLabConiferVariant); console.info(' Forced Skeleton Overrides:', config.forcedSkeletonOverrides); console.info(''); console.info('[Trees] Console Commands:'); console.info(' __RTS.setTreeDensity(multiplier) - Set tree density (0.1-3.0)'); console.info(' __RTS.setTreeWind(strength, speed) - Set runtime/persisted tree wind (0.0-4.0)'); console.info(' __RTS.setTreeLOD(preference) - Set LOD preference (auto/high/mid)'); console.info(' __RTS.setTreeHybridLod(true|false) - Enable/disable high+mid hybrid tree LOD'); console.info(' __RTS.setTreeGpuCompactionCulling(true|false) - Enable/disable compute compaction culling'); console.info(' __RTS.setTreeScatterDistance(max, fade?) - Set render distance'); console.info(' __RTS.getTreeLodCullingStats() - Print active tree LOD + culling diagnostics'); console.info(' __RTS.getTreeWind() - Show active tree wind settings'); console.info(' __RTS.toggleVegetationQaOverlay() - Toggle species/LOD debug colors'); console.info(' __RTS.setVegetationQaOverlay(true|false) - Set species/LOD debug colors'); console.info(' __RTS.getTreeConfig() - Show current configuration'); console.info(' __RTS.getTreeSkeletonProfile() - Show active conifer profile + override keys'); console.info(' __RTS.setTreeSkeletonParam(key, value) - Set one runtime skeleton override key'); console.info(' __RTS.clearTreeSkeletonParams() - Clear all runtime skeleton override keys'); return config; }; (window.__RTS as any).getFoliageConfig = () => { const config = { grassDensity: window.localStorage.getItem('rts.grass.density') || 'default(1.8)', bushDensity: window.localStorage.getItem('rts.bush.density') || 'default(1.25)', grassLodRelax: window.localStorage.getItem('rts.grass.lod') || 'default(2.2)', grassChunkSize: window.localStorage.getItem('rts.grass.chunkSize') || 'default(720)', grassMaxChunks: window.localStorage.getItem('rts.grass.maxChunks') || 'default(96)', currentGrassDensity: GRASS_DENSITY_MULTIPLIER, currentBushDensity: BUSH_DENSITY_MULTIPLIER, currentGrassLodRelax: GRASS_LOD_RELAX, currentGrassChunkSize: GRASS_CHUNK_SIZE, currentGrassMaxChunks: GRASS_MAX_CHUNKS }; console.info('[Foliage] Current foliage configuration:'); console.info(' Grass density:', config.grassDensity); console.info(' Bush density:', config.bushDensity); console.info(' Grass LOD relax:', config.grassLodRelax); console.info(' Grass chunk size:', config.grassChunkSize); console.info(' Grass max chunks:', config.grassMaxChunks); console.info(''); console.info('[Foliage] Console commands:'); console.info(' __RTS.setGrassDensity(multiplier) - Set grass density (0.1-2.0)'); console.info(' __RTS.setBushDensity(multiplier) - Set bush density (0.1-2.0)'); console.info(' __RTS.setGrassLodRelax(multiplier) - Keep more/less distant grass (0.35-3.0)'); console.info(' __RTS.setGrassChunkSize(size) - Set grass chunk size (128-4096)'); console.info(' __RTS.setGrassMaxChunks(count) - Set max grass chunk count (16-1024)'); console.info(' __RTS.getFoliageConfig() - Show current foliage settings'); return config; }; (window.__RTS as any).setGroupAccentRings = (enabled: boolean) => { try { window.localStorage.setItem('rts.render.groupAccentRings', enabled ? '1' : '0'); console.info(`[Render] Group accent rings ${enabled ? 'enabled' : 'disabled'}`); console.info('[Render] Reload the page to apply changes'); } catch (e) { console.error('[Render] Failed to set group accent ring flag:', e); } }; (window.__RTS as any).setBuildingStyleRings = (enabled: boolean) => { try { window.localStorage.setItem('rts.render.buildingStyleRings', enabled ? '1' : '0'); console.info(`[Render] Building style rim rings ${enabled ? 'enabled' : 'disabled'}`); console.info('[Render] Reload the page to apply changes'); } catch (e) { console.error('[Render] Failed to set building style ring flag:', e); } }; (window.__RTS as any).setShieldBubbleVisuals = (enabled: boolean) => { try { window.localStorage.setItem('rts.render.shieldBubbleVisuals', enabled ? '1' : '0'); console.info(`[Render] Shield bubble visuals ${enabled ? 'enabled' : 'disabled'}`); console.info('[Render] Reload the page to apply changes'); } catch (e) { console.error('[Render] Failed to set shield bubble visuals flag:', e); } }; (window.__RTS as any).setSdfShieldVisuals = (enabled: boolean) => { try { window.localStorage.setItem('rts.render.sdfShieldVisuals', enabled ? '1' : '0'); console.info(`[Render] SDF shield visuals ${enabled ? 'enabled' : 'disabled'}`); console.info('[Render] Reload the page to apply changes'); } catch (e) { console.error('[Render] Failed to set SDF shield visuals flag:', e); } }; (window.__RTS as any).setWebglEntities = (enabled: boolean) => { try { if (enabled) { console.warn('[Render] WebGL entity mode is disabled; forcing WebGPU unified entities.'); } window.localStorage.setItem('rts.framegraph.webglEntities', '0'); window.localStorage.setItem('rts.framegraph.entityMode', 'unified_gpu'); console.info('[Render] WebGPU unified entity rendering forced (WebGL entities disabled).'); console.info('[Render] Reloading to apply entity rendering mode...'); window.location.reload(); } catch (e) { console.error('[Render] Failed to set WebGL entities flag:', e); } }; (window.__RTS as any).setEntityScreenspacePass = (enabled: boolean) => { try { window.localStorage.setItem('rts.framegraph.entityScreenspacePass', enabled ? '1' : '0'); const nextUrl = new URL(window.location.href); if (enabled) { nextUrl.searchParams.set('entityScreenspacePass', '1'); } else { nextUrl.searchParams.delete('entityScreenspacePass'); } console.info(`[Render] Entity screen-space proxy pass ${enabled ? 'enabled' : 'disabled'}`); console.info('[Render] Reloading to apply pass configuration...'); window.location.assign(nextUrl.toString()); } catch (e) { console.error('[Render] Failed to set entity screen-space pass flag:', e); } }; (window.__RTS as any).setEntityProxyCapture = (enabled: boolean) => { if (!FRAMEGRAPH_USE_WEBGL_ENTITIES && !enabled) { console.warn( '[Render] Cannot disable proxy capture while WebGL entities are disabled. ' + 'Keeping proxy capture enabled to avoid invisible units/buildings.' ); enabled = true; } try { window.localStorage.setItem('rts.render.captureEntityProxyObjects', enabled ? '1' : '0'); console.info(`[Render] Entity proxy capture ${enabled ? 'enabled' : 'disabled'}`); console.info('[Render] Reloading to apply capture mode...'); window.location.reload(); } catch (e) { console.error('[Render] Failed to set entity proxy capture flag:', e); } }; (window.__RTS as any).setClusteredLighting = (enabled: boolean) => { try { window.localStorage.setItem('rts.render.captureLights', enabled ? '1' : '0'); console.info(`[Render] Clustered lighting capture ${enabled ? 'enabled' : 'disabled'}`); console.info('[Render] Reloading to apply lighting capture mode...'); window.location.reload(); } catch (e) { console.error('[Render] Failed to set clustered lighting flag:', e); } }; (window.__RTS as any).setRenderDecals = (enabled: boolean) => { try { window.localStorage.setItem('rts.render.captureDecals', enabled ? '1' : '0'); console.info(`[Render] Decal capture ${enabled ? 'enabled' : 'disabled'}`); console.info('[Render] Reloading to apply decal capture mode...'); window.location.reload(); } catch (e) { console.error('[Render] Failed to set decal capture flag:', e); } }; (window.__RTS as any).setRenderUiOverlays = (enabled: boolean) => { try { window.localStorage.setItem('rts.render.captureUiOverlays', enabled ? '1' : '0'); console.info(`[Render] UI overlay capture ${enabled ? 'enabled' : 'disabled'}`); console.info('[Render] Reloading to apply UI overlay capture mode...'); window.location.reload(); } catch (e) { console.error('[Render] Failed to set UI overlay capture flag:', e); } }; (window.__RTS as any).setRenderTacticalOverlays = (enabled: boolean, persist: boolean = true) => { try { const next = Boolean(enabled); renderWorldExtractor.setCaptureTacticalCommandOverlays(next); runtimeTacticalOverlayCaptureEnabled = next; if (persist) { window.localStorage.setItem('rts.render.captureTacticalOverlays', next ? '1' : '0'); } console.info(`[Render] Tactical command overlay capture ${next ? 'enabled' : 'disabled'}`); return next; } catch (e) { console.error('[Render] Failed to set tactical command overlay capture flag:', e); return runtimeTacticalOverlayCaptureEnabled; } }; (window.__RTS as any).setPreviewInfluenceOverlay = (enabled: boolean) => { try { previewInfluenceOverlayEnabled = Boolean(enabled); inputController.setPreviewInfluenceOverlayEnabled(previewInfluenceOverlayEnabled); window.localStorage.setItem('rts.render.previewInfluenceOverlay', previewInfluenceOverlayEnabled ? '1' : '0'); console.info( `[Render] Build preview influence overlay ${previewInfluenceOverlayEnabled ? 'enabled' : 'disabled'}` ); } catch (e) { console.error('[Render] Failed to set preview influence overlay flag:', e); } }; (window.__RTS as any).getRenderCaptureConfig = () => { const config = { configured: { webglEntities: window.localStorage.getItem('rts.framegraph.webglEntities') ?? 'default(off, locked)', entityScreenspacePass: window.localStorage.getItem('rts.framegraph.entityScreenspacePass') ?? 'default(off)', captureEntityProxyObjects: window.localStorage.getItem('rts.render.captureEntityProxyObjects') ?? 'default(auto)', captureLights: window.localStorage.getItem('rts.render.captureLights') ?? 'default(on)', captureDecals: window.localStorage.getItem('rts.render.captureDecals') ?? 'default(off)', captureUiOverlays: window.localStorage.getItem('rts.render.captureUiOverlays') ?? 'default(off)', captureTacticalOverlays: window.localStorage.getItem('rts.render.captureTacticalOverlays') ?? 'default(on)', previewInfluenceOverlay: window.localStorage.getItem('rts.render.previewInfluenceOverlay') ?? 'default(off)', entityFrustumCulling: window.localStorage.getItem('rts.render.entityFrustumCulling') ?? 'default(auto)', entityDistanceCulling: window.localStorage.getItem('rts.render.entityDistanceCulling') ?? 'default(auto)', entityCullDistance: window.localStorage.getItem('rts.render.entityCullDistance') ?? 'default(2600)' }, effective: { webglEntities: FRAMEGRAPH_USE_WEBGL_ENTITIES, entityScreenspacePass: FRAMEGRAPH_ENTITY_SCREENSPACE_PASS, captureEntityProxyObjects, captureLights, captureDecals, captureUiOverlays, captureTacticalOverlays: runtimeTacticalOverlayCaptureEnabled, previewInfluenceOverlay: previewInfluenceOverlayEnabled, entityFrustumCulling, entityCullingConfig: renderWorldExtractor.getCullingConfig() } }; console.info('[Render] Capture pipeline config:', config); return config; }; const meshProxyTextureDebugSlots = ['base', 'primary', 'secondary', 'accent'] as const; const logMeshProxyTextureDebugHelp = () => { console.info('[Render] Mesh proxy texture slot debug: no reload required; shader rebuilds automatically.'); console.info(' __RTS.getMeshProxyTextureLayerKeys() - List generated material texture layers'); console.info(' __RTS.getMeshProxyTextureDebugConfig() - Show live mesh proxy texture debug state'); console.info(' __RTS.setMeshProxySlotVisualization(true|false) - Flat-color unit slots (base/primary/secondary/accent)'); console.info(' __RTS.setMeshProxySlotOverrides(true|false) - Force explicit texture layer per unit slot'); console.info(' __RTS.setMeshProxyTextureSlot("primary", "painted_unit_markings") - Reassign a slot layer live'); console.info(' __RTS.setMeshProxyMaterialIntensity(0.25..4.0) - Scale unit material breakup strength live'); console.info(' __RTS.setMeshProxyMaterialVerification(true|false) - Force unmistakable slot-tinted verification shading'); }; (window.__RTS as any).getMeshProxyTextureLayerKeys = () => { const keys = [...getMeshProxyTextureLayerKeys()]; console.info('[Render] Mesh proxy generated texture layers:', keys); return keys; }; (window.__RTS as any).getMeshProxyTextureDebugConfig = () => { const config = getMeshProxyTextureDebugConfig(); console.info('[Render] Mesh proxy texture debug config:', config); return config; }; (window.__RTS as any).setMeshProxyTextureDebugConfig = (patch: { slotLayerKeys?: Partial>; useSlotOverrides?: boolean; visualizeSlots?: boolean; materialIntensity?: number; verifyMaterialPath?: boolean; } = {}) => { const next = setMeshProxyTextureDebugConfig(patch ?? {}); console.info('[Render] Mesh proxy texture debug config updated:', next); logMeshProxyTextureDebugHelp(); return next; }; (window.__RTS as any).setMeshProxyTextureSlot = ( slot: 'base' | 'primary' | 'secondary' | 'accent', key: string ) => { if (!(meshProxyTextureDebugSlots as readonly string[]).includes(slot)) { console.warn('[Render] Invalid mesh proxy slot. Expected one of:', meshProxyTextureDebugSlots); return getMeshProxyTextureDebugConfig(); } const next = setMeshProxyTextureDebugConfig({ slotLayerKeys: { [slot]: key } }); console.info(`[Render] Mesh proxy slot '${slot}' now uses '${key}'.`, next); logMeshProxyTextureDebugHelp(); return next; }; (window.__RTS as any).setMeshProxySlotOverrides = (enabled?: boolean) => { const current = getMeshProxyTextureDebugConfig(); const nextEnabled = typeof enabled === 'boolean' ? enabled : !current.useSlotOverrides; const next = setMeshProxyTextureDebugConfig({ useSlotOverrides: nextEnabled }); console.info(`[Render] Mesh proxy slot overrides ${next.useSlotOverrides ? 'ENABLED' : 'DISABLED'}.`); logMeshProxyTextureDebugHelp(); return next; }; (window.__RTS as any).setMeshProxySlotVisualization = (enabled?: boolean) => { const current = getMeshProxyTextureDebugConfig(); const nextEnabled = typeof enabled === 'boolean' ? enabled : !current.visualizeSlots; const next = setMeshProxyTextureDebugConfig({ visualizeSlots: nextEnabled }); console.info(`[Render] Mesh proxy slot visualization ${next.visualizeSlots ? 'ENABLED' : 'DISABLED'}.`); logMeshProxyTextureDebugHelp(); return next; }; (window.__RTS as any).setMeshProxyMaterialIntensity = (value: number) => { const next = setMeshProxyTextureDebugConfig({ materialIntensity: value }); console.info(`[Render] Mesh proxy material intensity set to ${next.materialIntensity.toFixed(2)}.`); logMeshProxyTextureDebugHelp(); return next; }; (window.__RTS as any).setMeshProxyMaterialVerification = (enabled?: boolean) => { const current = getMeshProxyTextureDebugConfig() as { verifyMaterialPath?: boolean }; const nextEnabled = typeof enabled === 'boolean' ? enabled : !current.verifyMaterialPath; const next = setMeshProxyTextureDebugConfig({ verifyMaterialPath: nextEnabled }); console.info(`[Render] Mesh proxy material verification ${next.verifyMaterialPath ? 'ENABLED' : 'DISABLED'}.`); logMeshProxyTextureDebugHelp(); return next; }; (window.__RTS as any).getRenderPerfConfig = () => { const config = { webglEntities: window.localStorage.getItem('rts.framegraph.webglEntities') || 'default(off, locked)', entityScreenspacePass: window.localStorage.getItem('rts.framegraph.entityScreenspacePass') || 'default(off)', groupAccentRings: window.localStorage.getItem('rts.render.groupAccentRings') || 'default(0/off)', buildingStyleRings: window.localStorage.getItem('rts.render.buildingStyleRings') || 'default(0/off)', shieldBubbleVisuals: window.localStorage.getItem('rts.render.shieldBubbleVisuals') || 'default(0/off)', sdfShieldVisuals: window.localStorage.getItem('rts.render.sdfShieldVisuals') || 'default(0/off)', captureEntityProxyObjects: window.localStorage.getItem('rts.render.captureEntityProxyObjects') || 'default(auto)', captureLights: window.localStorage.getItem('rts.render.captureLights') || 'default(on)', captureDecals: window.localStorage.getItem('rts.render.captureDecals') || 'default(off)', captureUiOverlays: window.localStorage.getItem('rts.render.captureUiOverlays') || 'default(off)', captureTacticalOverlays: window.localStorage.getItem('rts.render.captureTacticalOverlays') || 'default(on)', previewInfluenceOverlay: window.localStorage.getItem('rts.render.previewInfluenceOverlay') || 'default(off)', entityFrustumCulling: window.localStorage.getItem('rts.render.entityFrustumCulling') || 'default(auto)', entityDistanceCulling: window.localStorage.getItem('rts.render.entityDistanceCulling') || 'default(auto)', entityCullDistance: window.localStorage.getItem('rts.render.entityCullDistance') || 'default(2600)' }; console.info('[Render] Performance-sensitive visual flags:'); console.info(' WebGL entity flag (forced off):', config.webglEntities); console.info(' Entity screenspace proxy pass:', config.entityScreenspacePass); console.info(' Group accent rings:', config.groupAccentRings); console.info(' Building style rim rings:', config.buildingStyleRings); console.info(' Shield bubble visuals:', config.shieldBubbleVisuals); console.info(' SDF shield visuals:', config.sdfShieldVisuals); console.info(' Capture entity proxy objects:', config.captureEntityProxyObjects); console.info(' Capture lights (clustered lighting input):', config.captureLights); console.info(' Capture decals (colored circles):', config.captureDecals); console.info(' Capture UI overlays (placement/construction):', config.captureUiOverlays); console.info(' Capture tactical command overlays:', config.captureTacticalOverlays); console.info(' Build preview influence overlay:', config.previewInfluenceOverlay); console.info(' Entity frustum culling:', config.entityFrustumCulling); console.info(' Entity distance culling:', config.entityDistanceCulling); console.info(' Entity cull distance:', config.entityCullDistance); console.info(''); console.info('[Render] Console commands:'); console.info(' __RTS.setGroupAccentRings(true|false) - Toggle per-unit accent rings'); console.info(' __RTS.setBuildingStyleRings(true|false) - Toggle decorative building rim rings'); console.info(' __RTS.setShieldBubbleVisuals(true|false) - Toggle shield bubble domes'); console.info(' __RTS.setSdfShieldVisuals(true|false) - Toggle SDF shield volumes'); console.info(' __RTS.setWebglEntities(true|false) - Compatibility alias (always forces WebGPU entities)'); console.info(' __RTS.setEntityScreenspacePass(true|false) - Toggle proxy circle overlay pass (reload)'); console.info(' __RTS.clearShields() - Clear active shield visuals now'); console.info(' __RTS.setEntityProxyCapture(true|false) - Toggle framegraph entity proxy capture (reload)'); console.info(' __RTS.setClusteredLighting(true|false) - Toggle clustered lighting capture (reload)'); console.info(' __RTS.setRenderDecals(true|false) - Toggle colored decal circles (reload)'); console.info(' __RTS.setRenderUiOverlays(true|false) - Toggle placement/construction overlays (reload)'); console.info(' __RTS.setRenderTacticalOverlays(true|false)- Toggle tactical move/formation WebGPU terrain markers'); console.info(' __RTS.setPreviewInfluenceOverlay(true|false)- Toggle building-preview influence rings'); console.info(' __RTS.setEntityDistanceCulling(true|false) - Toggle entity distance culling'); console.info(' __RTS.setEntityCullDistance(2300) - Set entity cull distance in meters'); console.info(' __RTS.toggleStrategicMap() - Toggle tactical/strategic map overlay'); console.info(' __RTS.toggleFormationOverlay() - Toggle formation debug overlay'); console.info(' __RTS.toggleFormationMarkerPreview() - Toggle the formation marker preview overlay'); console.info(' __RTS.setFormationMarkerPreviewVisible(true|false) - Force the formation marker preview state'); console.info(' __RTS.toggleFormationCommandMode() - Toggle contextual formation right-drag command mode'); console.info(' __RTS.setMoveOrderOrientationDrag(true|false) - Enable right-drag move orientation/facing commands'); console.info(' __RTS.setFormationHoldOnArrival(true|false)- Keep units in their assigned formation slots at destination'); console.info(' __RTS.toggleStrategicTerrainOverlay() - Toggle strategic terrain markers'); console.info(' __RTS.toggleResourceOverlay() - Toggle terrain resource cluster markers'); console.info(' __RTS.toggleFrontlineOverlay() - Toggle terrain frontline markers'); console.info(' __RTS.setTerrainDefenseCoverageVisible(true|false) - Toggle terrain defense coverage rings'); console.info(' __RTS.setTerrainCommandZonesVisible(true|false) - Toggle terrain building command-zone rings'); console.info(' __RTS.getTerrainMarkerOverlayConfig() - Show terrain marker overlay states'); console.info(' __RTS.getRenderCaptureConfig() - Show configured/effective capture modes'); console.info(' __RTS.getRenderPerfConfig() - Show render perf flags'); console.info(' __RTS.setMeshProxySlotVisualization(true) - Show flat slot colors on unit materials'); console.info(' __RTS.setMeshProxySlotOverrides(true) - Force per-slot texture layers for unit proxies'); console.info(' __RTS.setMeshProxyTextureSlot("primary", "painted_unit_markings") - Reassign a slot texture live'); return config; }; console.info('[Bootstrap] 🌲 Tree LOD/Performance commands available:'); console.info(' __RTS.setTreeDensity(1.5) - Increase tree density by 50%'); console.info(' __RTS.setTreeWind(2.0, 1.5) - Increase runtime tree wind strength/speed'); console.info(' __RTS.setTreeLOD("mid") - Use mid LOD for better performance'); console.info(' __RTS.setTreeHybridLod(true) - Enable high+mid hybrid tree LOD split'); console.info(' __RTS.setTreeGpuCompactionCulling(true) - Enable compute compaction + indirect tree draws'); console.info(' __RTS.setTreeScatterDistance(3200, 700) - RTS-safe tree range/fade tuning'); console.info(' __RTS.getTreeLodCullingStats() - Print tree LOD/culling runtime diagnostics'); console.info(' __RTS.getTreeWind() - Show current tree wind settings'); console.info(' __RTS.getTreeConfig() - Show current tree configuration'); console.info(' __RTS.setGrassDensity(1.4) - Increase grass coverage'); console.info(' __RTS.setBushDensity(1.3) - Increase bush coverage'); console.info(' __RTS.setGrassLodRelax(1.6) - Keep dense grass farther from camera'); console.info(' __RTS.setGrassChunkSize(720) - Increase chunk size to reduce draw calls'); console.info(' __RTS.setGrassMaxChunks(96) - Cap chunk count for dense maps'); console.info(' __RTS.getFoliageConfig() - Show grass/bush configuration'); console.info(' __RTS.setGroupAccentRings(false) - Disable per-unit accent rings (perf)'); console.info(' __RTS.setBuildingStyleRings(false) - Disable decorative building rim rings'); console.info(' __RTS.setShieldBubbleVisuals(false) - Disable shield bubble domes'); console.info(' __RTS.setSdfShieldVisuals(false) - Disable SDF shield volumes'); console.info(' __RTS.setWebglEntities(true|false) - Compatibility alias (always forces WebGPU entities)'); console.info(' __RTS.setEntityScreenspacePass(false) - Disable proxy circle overlay pass (reload)'); console.info(' __RTS.clearShields() - Clear active shield visuals now'); console.info(' __RTS.setEntityProxyCapture(true|false) - Toggle framegraph entity proxy capture (reload)'); console.info(' __RTS.setClusteredLighting(true|false) - Toggle clustered lighting capture (reload)'); console.info(' __RTS.setRenderDecals(true|false) - Toggle colored decal circles (reload)'); console.info(' __RTS.setRenderUiOverlays(true|false) - Toggle placement/construction overlays (reload)'); console.info(' __RTS.setRenderTacticalOverlays(true|false)- Toggle tactical move/formation WebGPU terrain markers'); console.info(' __RTS.setPreviewInfluenceOverlay(true|false)- Toggle building-preview influence rings'); console.info(' __RTS.toggleStrategicMap() - Toggle tactical/strategic map overlay'); console.info(' __RTS.toggleFormationOverlay() - Toggle formation debug overlay'); console.info(' __RTS.toggleFormationMarkerPreview() - Toggle the formation marker preview overlay'); console.info(' __RTS.setFormationMarkerPreviewVisible(true|false) - Force the formation marker preview state'); console.info(' __RTS.toggleFormationCommandMode() - Toggle contextual formation right-drag command mode'); console.info(' __RTS.setMoveOrderOrientationDrag(true|false) - Enable right-drag move orientation/facing commands'); console.info(' __RTS.setFormationHoldOnArrival(true|false)- Keep units in their assigned formation slots at destination'); console.info(' __RTS.toggleStrategicTerrainOverlay() - Toggle strategic terrain markers'); console.info(' __RTS.toggleResourceOverlay() - Toggle terrain resource cluster markers'); console.info(' __RTS.toggleFrontlineOverlay() - Toggle terrain frontline markers'); console.info(' __RTS.setTerrainDefenseCoverageVisible(true|false) - Toggle terrain defense coverage rings'); console.info(' __RTS.setTerrainCommandZonesVisible(true|false) - Toggle terrain building command-zone rings'); console.info(' __RTS.getTerrainMarkerOverlayConfig() - Show terrain marker overlay states'); console.info(' __RTS.getRenderCaptureConfig() - Show configured/effective capture modes'); console.info(' __RTS.getRenderPerfConfig() - Show render performance flags'); console.info(' __RTS.setMeshProxySlotVisualization(true) - Show flat slot colors on unit materials'); console.info(' __RTS.setMeshProxySlotOverrides(true) - Force per-slot texture layers for unit proxies'); console.info(' __RTS.setMeshProxyTextureSlot("primary", "painted_unit_markings") - Reassign a slot texture live'); console.info(' __RTS.setRenderEnabled(true|false) - Toggle render submits (WebGL/WebGPU) at runtime'); console.info(' __RTS.setDisabledFramegraphPasses([...]) - Disable specific framegraph passes by name'); console.info(' __RTS.getDisabledFramegraphPasses() - Show currently disabled framegraph passes'); console.info(' __RTS.getFramegraphPassNames() - List framegraph pass names'); console.info(' __RTS.setFramegraphFeatureOverrides({...}) - Runtime toggle shadows/ssao/bloom/water/light culling'); console.info(' __RTS.runRenderFeatureSweep({...}) - Auto A/B sweep to rank render bottlenecks'); console.info(' __RTS.applyTenPointOptimizationSuite() - Apply all 10 runtime perf upgrades'); console.info(' __RTS.run10msPerfGate({...}) - Evaluate pass/fail against 10ms target'); // Add draw call profiler keyboard shortcut window.addEventListener('keydown', (event) => { if (event.shiftKey && event.key.toLowerCase() === 'p') { (window.__RTS as any).profileDrawCalls(); } }); console.info('[Bootstrap] 🎮 Performance profiler enabled:'); console.info(' Shift + P: Profile draw calls'); console.info(' __RTS.enableFrameProfile(true) - Enable CPU stage profiler + spike logs'); console.info(' __RTS.setFrameProfileSpikeThreshold(24) - Lower spike threshold (ms)'); console.info(' __RTS.setUnitSimProfiling(true) - Enable unit-sim subsystem timings'); console.info(' __RTS.getUnitSimPerformance() - Print latest unit-sim timings'); console.info(' __RTS.getEntityCullingStats() - Print current entity culling stats'); console.info(' __RTS.setEntityFrustumCulling(true|false) - Toggle entity frustum culling'); console.info(' __RTS.setEntityDistanceCulling(true|false) - Toggle entity distance culling'); console.info(' __RTS.setEntityCullDistance(2300) - Set entity distance-culling range (meters)'); console.info(' __RTS.setWebglEntities(true|false) - Compatibility alias (always forces WebGPU entities)'); console.info(' __RTS.setEntityScreenspacePass(true|false) - Toggle proxy circle overlay pass (reload)'); console.info(' __RTS.setRenderDecals(true|false) - Toggle colored decal circles (reload)'); console.info(' __RTS.setRenderUiOverlays(true|false) - Toggle placement/construction overlays (reload)'); console.info(' __RTS.setRenderTacticalOverlays(true|false) - Toggle tactical move/formation WebGPU terrain markers'); console.info(' __RTS.setPreviewInfluenceOverlay(true|false)- Toggle building-preview influence rings'); console.info(' __RTS.getClusteredLightingStats() - Print clustered lighting stats'); console.info(' __RTS.getRendererFrameStats() - Print latest framegraph overlay stats'); console.info(' __RTS.getBindingUploadStats() - Print frame binding upload/skip counters'); // Add SDF shield toggle keyboard shortcut // Store shieldedUnits in __RTS for access by debug commands (window.__RTS as any).shieldedUnits = new Set(); window.addEventListener('keydown', (event) => { if (event.shiftKey && event.key.toLowerCase() === 'h') { // Toggle shields on all selected units const selectedUnitIds = Array.from(selectionManager.selectedUnitIds); const shieldedUnits = (window.__RTS as any).shieldedUnits as Set; if (selectedUnitIds.length > 0 && entityRenderer) { selectedUnitIds.forEach((unitId: number) => { if (shieldedUnits.has(unitId)) { entityRenderer!.disableShield(unitId); shieldedUnits.delete(unitId); console.info(`[Shield] 🛡️ Disabled shield on unit ${unitId}`); } else { entityRenderer!.enableUnitShield(unitId, 2.0); shieldedUnits.add(unitId); console.info(`[Shield] 🛡️ Enabled shield on unit ${unitId}`); } }); } else { console.info('[Shield] ⚠️ No units selected. Select units first, then press Shift+H to toggle shields.'); } } }); console.info('[Bootstrap] 🎮 SDF Shield effects enabled:'); console.info(' Shift + H: Toggle shields on selected units'); /** * Create detailed rock geometry with natural irregularity * Uses multiple geometry types and vertex displacement for realism */ const createDetailedRockGeometry = (seed: number): THREE.BufferGeometry => { const rng = (n: number) => { const x = Math.sin(seed * 12.9898 + n * 78.233) * 43758.5453; return x - Math.floor(x); }; // Choose base shape randomly const shapeType = Math.floor(rng(1) * 3); let geometry: THREE.BufferGeometry; if (shapeType === 0) { // Dodecahedron - angular rocks geometry = new THREE.DodecahedronGeometry(1, 0); } else if (shapeType === 1) { // Icosahedron - rounded rocks geometry = new THREE.IcosahedronGeometry(1, 0); } else { // Octahedron - sharp rocks geometry = new THREE.OctahedronGeometry(1, 0); } // Apply vertex displacement for irregularity const positions = geometry.attributes.position; for (let i = 0; i < positions.count; i++) { const x = positions.getX(i); const y = positions.getY(i); const z = positions.getZ(i); // Calculate displacement based on vertex position const noise = Math.sin(x * 3.7 + seed) * Math.cos(y * 4.3 + seed) * Math.sin(z * 5.1 + seed); const displacement = 0.15 + noise * 0.25; // 15-40% variation // Apply displacement along vertex normal direction const length = Math.sqrt(x * x + y * y + z * z); const scale = (1 + displacement); positions.setXYZ(i, x * scale / length, y * scale / length, z * scale / length); } positions.needsUpdate = true; geometry.computeVertexNormals(); return geometry; }; /** * Create realistic grass blade geometry * Multiple curved blades in a clump for natural appearance */ const createGrassBladeGeometry = (): THREE.BufferGeometry => { // 10ms profile: ultra-light grass clump geometry. const bladeCount = 3; const bladeSegments = 1; const geometries: THREE.BufferGeometry[] = []; for (let i = 0; i < bladeCount; i++) { const t = i / bladeCount; const angle = t * Math.PI * 2 + Math.sin(t * 19.17) * 0.22; const radius = 0.06 + 0.04 * (0.5 + 0.5 * Math.sin(t * 13.73 + 0.7)); const height = 0.46 + 0.22 * (0.5 + 0.5 * Math.sin(t * 11.31 + 1.1)); const width = 0.038 + 0.014 * (0.5 + 0.5 * Math.cos(t * 9.41 + 0.35)); const curve = 0.08 + 0.09 * (0.5 + 0.5 * Math.cos(t * 15.03)); const blade = new THREE.PlaneGeometry(width, height, 1, bladeSegments); const positions = blade.attributes.position as THREE.BufferAttribute; // Bend each blade forward slightly to avoid flat-card look. for (let j = 0; j < positions.count; j++) { const y = positions.getY(j); const y01 = (y + height * 0.5) / Math.max(height, 0.0001); const bend = y01 * y01 * curve; positions.setX(j, positions.getX(j) + bend); positions.setZ(j, positions.getZ(j) + bend * 0.35); } positions.needsUpdate = true; blade.computeVertexNormals(); blade.translate( Math.cos(angle) * radius, height * 0.5, Math.sin(angle) * radius ); blade.rotateY(angle); geometries.push(blade); } const merged = mergeGeometries(geometries, true); return merged ?? new THREE.BufferGeometry(); }; // ========== CREATE TERRAIN QUERY CACHE FOR FAST SCATTER GENERATION ========== console.info('[Bootstrap] Creating terrain query cache for 10-20x faster scatter generation...'); const terrainCache = new TerrainQueryCache(sim.terra, 128); console.info('[Bootstrap] Terrain cache ready'); const scatterColorSeedBase = Math.floor(sim.terra.getHeightWorld(0, 0) * 1000); const SCATTER_STAGE_BUDGET_MS = { pebbleDensify: 1400, bushPoisson: 1300, bushRelaxedPoisson: 1000, grassPoisson: 1700, fernDensify: 1000, bushDensify: 1200 } as const; const scatterWorker = getScatterWorkerManager(); let scatterWorkerReady = false; try { await scatterWorker.initialize(); await scatterWorker.setSnapshot(terrainCache.createSnapshot()); scatterWorkerReady = true; console.info('[ScatterWorker] Ready (densify + weighted Poisson offloaded)'); } catch (error) { scatterWorkerReady = false; console.warn('[ScatterWorker] Initialization failed; falling back to main-thread scatter generation:', error); } console.info('[Bootstrap] Creating enhanced stone/rock scatter...'); // Create multiple rock geometry variants for variety const rockGeometries: THREE.BufferGeometry[] = []; for (let i = 0; i < 5; i++) { rockGeometries.push(createDetailedRockGeometry(i * 123.456)); } // Use first rock geometry as default (will be varied per instance) const stoneGeometry = rockGeometries[0]; // AAA: Robust rock material with proper PBR values const stoneMaterial = new THREE.MeshStandardMaterial({ color: new THREE.Color(0.45, 0.40, 0.35), // Neutral gray-brown stone (linear color space) flatShading: true, roughness: 0.85, // AAA: Rough but not completely matte (allows some specular highlights) metalness: 0.02 // AAA: Minimal metalness (stone has trace minerals) }); console.info('[Bootstrap] Generating balanced rock positions...'); // REFACTOR: Remove clustering - use individual rocks instead of groups // Changed from extendScatterPositions(3 copies) to just using base positions const rockBasePositions = sim.terra.getScatterPositions('rock'); const cliffSeeds = sim.terra .getSlopeHighlights(20, 2, 6000) .map((point) => ({ x: point.x, z: point.z })); // REFACTOR: Use grid-based placement instead of clustering for natural distribution const cliffPositions: { x: number; z: number }[] = []; const cliffGridSize = 28; // denser cliff rock spacing const cliffJitter = cliffGridSize * 0.7; // 70% jitter for natural variation const sampleRockDetailDensity = (x: number, z: number): number => { if (typeof sim.terra.getDetailRockDensityAt === 'function') { return clamp01(sim.terra.getDetailRockDensityAt(x, z)); } const overlayValue = sim.terra.getOverlayLayerValue('detail_rock_density', x, z); return overlayValue === null ? 0.5 : clamp01(overlayValue); }; for (const seed of cliffSeeds) { // Place rocks on a grid around each cliff seed for (let dx = -cliffGridSize; dx <= cliffGridSize; dx += cliffGridSize) { for (let dz = -cliffGridSize; dz <= cliffGridSize; dz += cliffGridSize) { const x = clampToMap(seed.x + dx + (Math.random() - 0.5) * cliffJitter); const z = clampToMap(seed.z + dz + (Math.random() - 0.5) * cliffJitter); const rockDetail = sampleRockDetailDensity(x, z); const cliffSpawnChance = THREE.MathUtils.lerp(0.2, 0.9, rockDetail); if (Math.random() > cliffSpawnChance) continue; const slope = terrainCache.getSlopeFast(x, z); if (slope < 18 || slope > 60) continue; // Only on cliffs cliffPositions.push({ x, z }); } } } // REFACTOR: Grid-based ambient rocks for even distribution const ambientRockPositions: { x: number; z: number }[] = []; const ambientGridSize = 32; // denser ambient rock spacing const ambientJitter = ambientGridSize * 0.8; // 80% jitter for natural variation const waterLevel = sim.terra.getWaterLevel(); for (let x = -MAP_HALF_SIZE; x <= MAP_HALF_SIZE; x += ambientGridSize) { for (let z = -MAP_HALF_SIZE; z <= MAP_HALF_SIZE; z += ambientGridSize) { const seedX = clampToMap(x + (Math.random() - 0.5) * ambientJitter); const seedZ = clampToMap(z + (Math.random() - 0.5) * ambientJitter); const rockDetail = sampleRockDetailDensity(seedX, seedZ); const ambientSpawnChance = THREE.MathUtils.lerp(0.12, 0.68, rockDetail); if (Math.random() > ambientSpawnChance) continue; const height = terrainCache.getHeightFast(seedX, seedZ); if (height <= waterLevel + 1.0) continue; const slope = terrainCache.getSlopeFast(seedX, seedZ); if (slope < 8 || slope > 50) continue; // Only on moderate slopes ambientRockPositions.push({ x: seedX, z: seedZ }); } } const densityFilteredRockPositions = applyScatterDensity( [...rockBasePositions, ...cliffPositions, ...ambientRockPositions] ); const rockPositionsSlopeFiltered = filterPositionsBySlope(densityFilteredRockPositions, sim.terra, 12, terrainCache); const rockSlopeWeight = (pos: { x: number; z: number }): number => { const slope = terrainCache.getSlopeFast(pos.x, pos.z); const slopeWeight = Math.max(0, Math.min(1, (slope - 8) / 30)); const detailWeight = sampleRockDetailDensity(pos.x, pos.z); return clamp01(slopeWeight * 0.6 + detailWeight * 0.4); }; const rockPositions = samplePositions( rockPositionsSlopeFiltered.length > 0 ? rockPositionsSlopeFiltered : densityFilteredRockPositions, 0.45, rockSlopeWeight ); console.info(`[Bootstrap] Rock positions: ${rockPositions.length}`); console.info('[Bootstrap] Creating rock instances...'); // FIX: Generate instance colors for rocks like trees const rockColors = buildRockInstanceColors( rockPositions, stoneMaterial.color, scatterColorSeedBase, 0.3 ); const rockInstanced = getCachedScatterMesh('rocks', () => createInstancedScatter(rockPositions, stoneGeometry, stoneMaterial, sim.terra, { scaleRange: [3.5, 9.0], heightOffset: 0.02, slopeScale: 0.2, allowTilt: true, instanceColors: rockColors, cache: terrainCache }) ); scatterGroup.add(rockInstanced); console.info('[Bootstrap] Rock instances added'); // ========== LARGE BOULDERS FOR RIVERBEDS AND SLOPES ========== console.info('[Bootstrap] Creating LARGE BOULDERS for riverbeds and slopes...'); // Create larger boulder geometry variants const boulderGeometries: THREE.BufferGeometry[] = []; for (let i = 0; i < 3; i++) { boulderGeometries.push(createDetailedRockGeometry(i * 456.789 + 1000)); } const boulderGeometry = boulderGeometries[0]; // AAA: Robust boulder material with proper PBR values const boulderMaterial = new THREE.MeshStandardMaterial({ color: new THREE.Color(0.38, 0.34, 0.28), // AAA: Darker gray-brown for large boulders (linear color space) flatShading: true, roughness: 0.88, // AAA: Slightly rougher than small rocks (weathered surface) metalness: 0.01 // AAA: Minimal metalness (natural stone) }); // RIVERBED BOULDERS: Large rocks in and near water - use grid with spacing console.info('[Bootstrap] Generating riverbed boulder positions...'); const riverbedBoulderPositions: { x: number; z: number }[] = []; const riverbedSpacing = 30; // denser riverbed boulder spacing const riverbedJitter = 8; // Small jitter for natural variation const waterMargin = 2.5; for (let x = -MAP_HALF_SIZE; x <= MAP_HALF_SIZE; x += riverbedSpacing) { for (let z = -MAP_HALF_SIZE; z <= MAP_HALF_SIZE; z += riverbedSpacing) { const jx = (Math.random() - 0.5) * riverbedJitter; const jz = (Math.random() - 0.5) * riverbedJitter; const bx = clampToMap(x + jx); const bz = clampToMap(z + jz); const height = terrainCache.getHeightFast(bx, bz); const rockDetail = sampleRockDetailDensity(bx, bz); const riverbedSpawnChance = THREE.MathUtils.lerp(0.22, 0.78, rockDetail); if (Math.random() > riverbedSpawnChance) continue; // Only place in water or just above water line if (height > waterLevel - 0.5 && height <= waterLevel + waterMargin) { riverbedBoulderPositions.push({ x: bx, z: bz }); } } } // SLOPE BOULDERS: Large rocks on steep terrain - use grid with slope filtering console.info('[Bootstrap] Generating slope boulder positions...'); const slopeBoulderPositions: { x: number; z: number }[] = []; const slopeSpacing = 26; // denser slope boulder spacing const slopeJitter = 8; // Small jitter for natural variation for (let x = -MAP_HALF_SIZE; x <= MAP_HALF_SIZE; x += slopeSpacing) { for (let z = -MAP_HALF_SIZE; z <= MAP_HALF_SIZE; z += slopeSpacing) { const jx = (Math.random() - 0.5) * slopeJitter; const jz = (Math.random() - 0.5) * slopeJitter; const bx = clampToMap(x + jx); const bz = clampToMap(z + jz); const height = terrainCache.getHeightFast(bx, bz); const slope = terrainCache.getSlopeFast(bx, bz); // Only place on steep slopes (>15°) above water if (slope > 15 && height > waterLevel + 1.0) { // Higher spawn rate on steeper slopes const slopeSpawnBase = Math.min(0.82, 0.32 + (slope - 15) / 34); const rockDetail = sampleRockDetailDensity(bx, bz); const spawnChance = Math.min(0.9, slopeSpawnBase * (0.6 + rockDetail * 0.8)); if (Math.random() < spawnChance) { slopeBoulderPositions.push({ x: bx, z: bz }); } } } } console.info(`[Bootstrap] Generated ${slopeBoulderPositions.length} slope boulder positions`); // Combine and create boulder instances (NO density reduction - spacing already controlled) const allBoulderPositions = [...riverbedBoulderPositions, ...slopeBoulderPositions]; console.info(`[Bootstrap] Boulder positions: Riverbed ${riverbedBoulderPositions.length}, Slope ${slopeBoulderPositions.length}, Total ${allBoulderPositions.length}`); if (allBoulderPositions.length > 0) { // FIX: Generate instance colors for boulders like trees const boulderColors = buildRockInstanceColors( allBoulderPositions, boulderMaterial.color, scatterColorSeedBase + 500, 0.25 ); const boulderInstanced = getCachedScatterMesh('boulders', () => createInstancedScatter(allBoulderPositions, boulderGeometry, boulderMaterial, sim.terra, { scaleRange: [8, 18], // PERFORMANCE: Reduced from [15, 35] to [8, 18] - still large but not massive heightOffset: 0.05, slopeScale: 0.15, allowTilt: true, instanceColors: boulderColors, cache: terrainCache }) ); scatterGroup.add(boulderInstanced); console.info('[Bootstrap] Large boulder instances added'); } // ========== END LARGE BOULDERS ========== console.info('[Bootstrap] Creating pebble scatter...'); const pebbleGeometry = createPebbleGeometry(); const pebbleMaterial = new THREE.MeshStandardMaterial({ color: new THREE.Color(0x7b6e59), flatShading: true, roughness: 1.0, metalness: 0.05 }); console.info('[Bootstrap] Generating balanced pebble positions...'); console.time('[PERF] Pebble generation'); // AAA OPTIMIZATION: Reduce pebble density for fast loading const pebbleRockBase = sim.terra.getScatterPositions('rock'); console.info(`[Bootstrap] Rock base positions: ${pebbleRockBase.length}`); const pebbleRockBasePacked = packPositions(pebbleRockBase); const pebbleBasePacked = extendScatterPositionsPacked(pebbleRockBasePacked, 6, 1.0); // denser debris base copies console.info(`[Bootstrap] Pebble base after extend: ${packedPositionCount(pebbleBasePacked)}`); let pebblePositionsRawPacked: Float32Array; if (scatterWorkerReady) { try { const densifyResult = await scatterWorker.densifyPacked( new Float32Array(pebbleBasePacked), { jitter: 0.2, slopeMode: 'high-slope-boost', slopePivot: 8, slopeDivisor: 12, densityScale: 3.8, densityMapInfluence: 1.0, maxPositions: 1_400_000, maxRuntimeMs: SCATTER_STAGE_BUDGET_MS.pebbleDensify, adaptiveScaleOnBudget: 0.55, hardStopOnBudget: true, seed: (forestSeed ^ 0x4d1f2a93) >>> 0 } ); pebblePositionsRawPacked = densifyResult.positions; if (densifyResult.meta.budgetExceeded) { console.warn( `[Performance] Pebble densify hit budget ${SCATTER_STAGE_BUDGET_MS.pebbleDensify}ms; ` + `adaptive fallback applied (count=${densifyResult.meta.outputCount})` ); } } catch (error) { console.warn('[ScatterWorker] Pebble densify failed; using main-thread fallback:', error); pebblePositionsRawPacked = packPositions(densifyScatterPositions(unpackPositions(pebbleBasePacked), sim.terra, { jitter: 0.2, slopeModifier: (slope) => Math.max(0.0, (slope - 8) / 12), densityScale: 3.8, densityMapInfluence: 1.0, maxPositions: 1_400_000, cache: terrainCache })); } } else { pebblePositionsRawPacked = packPositions(densifyScatterPositions(unpackPositions(pebbleBasePacked), sim.terra, { jitter: 0.2, slopeModifier: (slope) => Math.max(0.0, (slope - 8) / 12), densityScale: 3.8, densityMapInfluence: 1.0, maxPositions: 1_400_000, cache: terrainCache })); } console.info(`[Bootstrap] Pebble positions after densify: ${packedPositionCount(pebblePositionsRawPacked)}`); const pebbleDensityFilteredPacked = applyScatterDensityPacked(pebblePositionsRawPacked); const pebbleSampledPacked = applyDensityMultiplierPacked(pebbleDensityFilteredPacked, 0.78); const pebbleSlopeFilteredPacked = filterPackedBySlope(pebbleSampledPacked, 10, terrainCache); const pebblePositionsPacked = packedPositionCount(pebbleSlopeFilteredPacked) > 0 ? pebbleSlopeFilteredPacked : pebbleSampledPacked; const pebblePositions = unpackPositions(pebblePositionsPacked); console.info( `[Bootstrap] ENHANCED Pebble positions: ${packedPositionCount(pebblePositionsRawPacked)} -> ${pebblePositions.length}` ); console.timeEnd('[PERF] Pebble generation'); console.info('[Bootstrap] Creating pebble instances...'); // FIX: Generate instance colors for pebbles like trees const pebbleColors = buildRockInstanceColors( pebblePositions, pebbleMaterial.color, scatterColorSeedBase + 1000, 0.35 ); const pebbleInstanced = getCachedScatterMesh('pebbles', () => createInstancedScatter(pebblePositions, pebbleGeometry, pebbleMaterial, sim.terra, { scaleRange: [0.8, 5.0], // Wider range for variety (10x scale) heightOffset: 0.01, slopeScale: 0.15, scaleVariance: 0.6, // More variation instanceColors: pebbleColors, cache: terrainCache }) ); pebbleInstanced.castShadow = false; pebbleInstanced.receiveShadow = false; scatterGroup.add(pebbleInstanced); console.info('[Bootstrap] Pebble instances added'); // PERFORMANCE: reduce bush geometry complexity (detail=1 => ~4x fewer tris vs detail=2). const bushGeometry = new THREE.IcosahedronGeometry(0.68, 1); const bushTexture = createPlantTexture(0x4f9c54, 45, 6); const bushNormal = createPlantNormalTexture(bushTexture); const bushMaterial = new THREE.MeshStandardMaterial({ map: bushTexture, normalMap: bushNormal, roughnessMap: bushTexture, color: new THREE.Color(0x4f9c54), flatShading: false, roughness: 0.86, metalness: 0.0 }); const layoutSeedRaw = (sim.terra as Terra & { getLayoutSeed?: () => number }).getLayoutSeed?.(); const scatterSeedBase = Number.isFinite(layoutSeedRaw) ? (layoutSeedRaw as number) : forestSeed; const grassBiomeProfile = getGrassDensityProfileForBiome(biomeType); const grassSeaLevel = sim.terra.getWaterLevel(); const grassMapSizeMeters = sim.terra.width * sim.terra.tileSize; const grassMapAreaKm2 = (grassMapSizeMeters * grassMapSizeMeters) / 1_000_000; const grassInstanceCap = getGrassInstanceCap(grassMapSizeMeters); const bushInstanceCap = getBushInstanceCap(grassMapSizeMeters); const grassLandDensitySampler = (x: number, z: number): number => { const height = terrainCache.getHeightFast(x, z); if (height <= grassSeaLevel + 0.12) { return 0; } const slope = terrainCache.getSlopeFast(x, z); // Keep broad land coverage and avoid hard cutoff on mountain/plateau slopes. const slopeFactor = 0.22 + 0.78 * clamp01((74 - slope) / 38); // Slight altitude boost so higher land remains grassy unless truly cliff-like. const altitudeFactor = 0.92 + clamp01((height - grassSeaLevel - 4) / 260) * 0.14; const macro = 0.78 + hash2(x * 0.0029, z * 0.0031, scatterSeedBase + 901) * 0.22; const micro = 0.9 + hash2(x * 0.021, z * 0.019, scatterSeedBase + 1171) * 0.2; // Still use terrain density as a hint, but enforce a high baseline for broad coverage. const terrainDensity = clamp01(terrainCache.getDensityFast(x, z)); const baseLand = Math.max(0.72, terrainDensity * 0.52 + 0.48); return clamp01(baseLand * slopeFactor * altitudeFactor * macro * micro); }; const bushScatterFromTerrain = sim.terra.getBushScatterPositions(); // Terrain-provided bush seeds can be grid-biased; add deterministic world-space jitter to break patterns. const bushTerrainJitteredSeed = bushScatterFromTerrain .map((pos) => { const jitterX = (hash2(pos.x * 0.071, pos.z * 0.103, scatterSeedBase ^ 0x17a9c3d1) - 0.5) * 18; const jitterZ = (hash2(pos.x * 0.113, pos.z * 0.067, scatterSeedBase ^ 0x6c8e9f25) - 0.5) * 18; return { x: clampToMap(pos.x + jitterX), z: clampToMap(pos.z + jitterZ) }; }) .filter((pos) => grassLandDensitySampler(pos.x, pos.z) > 0.07); const bushTerrainJitteredSeedPacked = packPositions(bushTerrainJitteredSeed); let bushPoissonSeedPacked: Float32Array = new Float32Array(0); if (scatterWorkerReady) { try { const poissonResult = await scatterWorker.weightedPoissonPacked({ minDistance: 16, maxAttempts: 16, maxPositions: 240_000, densityThreshold: Math.max(0.06, grassBiomeProfile.grassThreshold + 0.04), densityPower: grassBiomeProfile.grassPower + 0.08, denseSpacingScale: 0.74, sparseSpacingScale: 1.24, baseAcceptance: 0.16, densityMode: 'grass-land', densitySeaLevel: grassSeaLevel, densitySeed: scatterSeedBase, slopeMode: 'low-slope-boost', slopePivot: 14, slopeDivisor: 10, slopeBase: 1, maxRuntimeMs: SCATTER_STAGE_BUDGET_MS.bushPoisson, adaptiveAttemptsOnBudget: 0.55, adaptivePositionCapOnBudget: 0.72, hardStopOnBudget: true, seed: (scatterSeedBase ^ 0x2f6e2b1d) >>> 0 }); bushPoissonSeedPacked = poissonResult.positions; if (poissonResult.meta.budgetExceeded) { console.warn( `[Performance] Bush weighted Poisson exceeded ${SCATTER_STAGE_BUDGET_MS.bushPoisson}ms; ` + `adaptive fallback applied (count=${poissonResult.meta.outputCount})` ); } } catch (error) { console.warn('[ScatterWorker] Bush weighted Poisson failed; using main-thread fallback:', error); bushPoissonSeedPacked = packPositions(generateWeightedPoissonScatter(sim.terra, { minDistance: 16, maxAttempts: 16, maxPositions: 240_000, densityThreshold: Math.max(0.06, grassBiomeProfile.grassThreshold + 0.04), densityPower: grassBiomeProfile.grassPower + 0.08, denseSpacingScale: 0.74, sparseSpacingScale: 1.24, baseAcceptance: 0.16, slopeDensityModifier: (slope) => 1 + Math.max(0, (14 - slope) / 10), densitySampler: grassLandDensitySampler, cache: terrainCache, random: createSeededRng((scatterSeedBase ^ 0x2f6e2b1d) >>> 0) })); } } else { bushPoissonSeedPacked = packPositions(generateWeightedPoissonScatter(sim.terra, { minDistance: 16, maxAttempts: 16, maxPositions: 240_000, densityThreshold: Math.max(0.06, grassBiomeProfile.grassThreshold + 0.04), densityPower: grassBiomeProfile.grassPower + 0.08, denseSpacingScale: 0.74, sparseSpacingScale: 1.24, baseAcceptance: 0.16, slopeDensityModifier: (slope) => 1 + Math.max(0, (14 - slope) / 10), densitySampler: grassLandDensitySampler, cache: terrainCache, random: createSeededRng((scatterSeedBase ^ 0x2f6e2b1d) >>> 0) })); } const bushRelaxedPoissonSeedPacked = packedPositionCount(bushPoissonSeedPacked) === 0 ? (scatterWorkerReady ? await (async () => { try { const relaxedResult = await scatterWorker.weightedPoissonPacked({ minDistance: 14, maxAttempts: 12, maxPositions: 180_000, densityThreshold: 0.01, densityPower: 0.95, denseSpacingScale: 0.78, sparseSpacingScale: 1.18, baseAcceptance: 0.28, densityMode: 'grass-land', densitySeaLevel: grassSeaLevel, densitySeed: scatterSeedBase, slopeMode: 'low-slope-boost', slopePivot: 14, slopeDivisor: 12, slopeBase: 1, maxRuntimeMs: SCATTER_STAGE_BUDGET_MS.bushRelaxedPoisson, adaptiveAttemptsOnBudget: 0.5, adaptivePositionCapOnBudget: 0.68, hardStopOnBudget: true, seed: (scatterSeedBase ^ 0x4b7f2e19) >>> 0 }); if (relaxedResult.meta.budgetExceeded) { console.warn( `[Performance] Bush relaxed Poisson exceeded ${SCATTER_STAGE_BUDGET_MS.bushRelaxedPoisson}ms; ` + `adaptive fallback applied (count=${relaxedResult.meta.outputCount})` ); } return relaxedResult.positions; } catch (error) { console.warn('[ScatterWorker] Bush relaxed Poisson failed; using main-thread fallback:', error); return packPositions(generateWeightedPoissonScatter(sim.terra, { minDistance: 14, maxAttempts: 12, maxPositions: 180_000, densityThreshold: 0.01, densityPower: 0.95, denseSpacingScale: 0.78, sparseSpacingScale: 1.18, baseAcceptance: 0.28, slopeDensityModifier: (slope) => 1 + Math.max(0, (14 - slope) / 12), densitySampler: grassLandDensitySampler, cache: terrainCache, random: createSeededRng((scatterSeedBase ^ 0x4b7f2e19) >>> 0) })); } })() : packPositions(generateWeightedPoissonScatter(sim.terra, { minDistance: 14, maxAttempts: 12, maxPositions: 180_000, densityThreshold: 0.01, densityPower: 0.95, denseSpacingScale: 0.78, sparseSpacingScale: 1.18, baseAcceptance: 0.28, slopeDensityModifier: (slope) => 1 + Math.max(0, (14 - slope) / 12), densitySampler: grassLandDensitySampler, cache: terrainCache, random: createSeededRng((scatterSeedBase ^ 0x4b7f2e19) >>> 0) }))) : new Float32Array(0); if (packedPositionCount(bushPoissonSeedPacked) === 0 && packedPositionCount(bushRelaxedPoissonSeedPacked) === 0) { if (packedPositionCount(bushTerrainJitteredSeedPacked) === 0) { console.warn('[Bootstrap] Bush seeding sparse after Poisson attempts and terrain jitter; continuing with empty bush seeds.'); } else { console.warn(`[Bootstrap] Bush weighted Poisson returned 0 seeds; using jittered terrain seeds (${packedPositionCount(bushTerrainJitteredSeedPacked)})`); } } const bushScatterSeedPacked = packedPositionCount(bushPoissonSeedPacked) > 0 ? bushPoissonSeedPacked : (packedPositionCount(bushRelaxedPoissonSeedPacked) > 0 ? bushRelaxedPoissonSeedPacked : bushTerrainJitteredSeedPacked); console.info( `[Bootstrap] Bush seeds: poisson=${packedPositionCount(bushPoissonSeedPacked)}, ` + `poissonRelaxed=${packedPositionCount(bushRelaxedPoissonSeedPacked)}, terrainJittered=${packedPositionCount(bushTerrainJitteredSeedPacked)}, selected=${packedPositionCount(bushScatterSeedPacked)}` ); const bushBasePacked = extendScatterPositionsPacked(bushScatterSeedPacked, 0, 2.2); let bushPositionsRawPacked: Float32Array; if (scatterWorkerReady) { try { const bushDensifyResult = await scatterWorker.densifyPacked( new Float32Array(bushBasePacked), { jitter: 1.25, slopeMode: 'low-slope-boost', slopePivot: 16, slopeDivisor: 8, slopeBase: 1, densityScale: 0.58, densityMapInfluence: 0.9, maxPositions: 260_000, maxRuntimeMs: SCATTER_STAGE_BUDGET_MS.bushDensify, adaptiveScaleOnBudget: 0.62, hardStopOnBudget: true, seed: (scatterSeedBase ^ 0x2ac7d14f) >>> 0 } ); bushPositionsRawPacked = bushDensifyResult.positions; if (bushDensifyResult.meta.budgetExceeded) { console.warn( `[Performance] Bush densify exceeded ${SCATTER_STAGE_BUDGET_MS.bushDensify}ms; ` + `adaptive fallback applied (count=${bushDensifyResult.meta.outputCount})` ); } } catch (error) { console.warn('[ScatterWorker] Bush densify failed; using main-thread fallback:', error); bushPositionsRawPacked = packPositions(densifyScatterPositions(unpackPositions(bushBasePacked), sim.terra, { jitter: 1.25, slopeModifier: (slope) => 1 + Math.max(0, (16 - slope) / 8), densityScale: 0.58, densityMapInfluence: 0.9, maxPositions: 260_000, cache: terrainCache })); } } else { bushPositionsRawPacked = packPositions(densifyScatterPositions(unpackPositions(bushBasePacked), sim.terra, { jitter: 1.25, slopeModifier: (slope) => 1 + Math.max(0, (16 - slope) / 8), densityScale: 0.58, densityMapInfluence: 0.9, maxPositions: 260_000, cache: terrainCache })); } const bushPositionsPacked = applyBushDensityPacked(bushPositionsRawPacked); const aboveSeaBushPositionsPacked = filterPackedByHeight(bushPositionsPacked, grassSeaLevel + 0.12, terrainCache); const cappedBushPositionsPacked = packedPositionCount(aboveSeaBushPositionsPacked) > bushInstanceCap ? capPackedPositionsUniformlyByHash(aboveSeaBushPositionsPacked, bushInstanceCap, scatterSeedBase ^ 0x2ac7d14f) : aboveSeaBushPositionsPacked; const cappedBushPositions = unpackPositions(cappedBushPositionsPacked); console.info( `[Performance] Bushes: raw=${packedPositionCount(bushPositionsRawPacked)}, density=${packedPositionCount(bushPositionsPacked)}, ` + `aboveSea=${packedPositionCount(aboveSeaBushPositionsPacked)}, capped=${cappedBushPositions.length} (cap=${bushInstanceCap})` ); // FIX: Generate instance colors for bushes like trees const bushColors = buildVegetationInstanceColors( cappedBushPositions, seasonPalette, scatterColorSeedBase + 2000, 0.05 ); const bushInstanced = getCachedScatterMesh('bushes', () => createInstancedScatter(cappedBushPositions, bushGeometry, bushMaterial, sim.terra, { scaleRange: [0.95, 2.4], heightOffset: 0.22, slopeScale: 0.28, scaleVariance: 0.45, instanceColors: bushColors, cache: terrainCache }) ); scatterGroup.add(bushInstanced); const ENABLE_SCATTER_GRASS = true; const ENABLE_SCATTER_TALL_GRASS = true; const ENABLE_SCATTER_FERNS = true; console.info('[Bootstrap] Creating ENHANCED grass with realistic blade geometry...'); // ENHANCED: Use detailed grass blade geometry instead of simple cone const grassGeometry = createGrassBladeGeometry(); const grassUv = grassGeometry.getAttribute('uv'); if (grassUv && !grassGeometry.getAttribute('uv2')) { grassGeometry.setAttribute('uv2', grassUv.clone()); } const grassTexture = createPlantTexture(0x5f8f37, 38, 8); const grassNormal = createPlantNormalTexture(grassTexture); const grassRoughness = createRoughnessMap(0.92, 0.08); const grassAo = createAOMap(0.72); const grassMaterial = new THREE.MeshStandardMaterial({ map: grassTexture, normalMap: grassNormal, roughnessMap: grassRoughness, aoMap: grassAo, aoMapIntensity: 0.45, color: new THREE.Color(0x5f8f37), flatShading: false, // Smooth shading for grass blades roughness: 1.0, metalness: 0.0, side: THREE.DoubleSide // Grass blades visible from both sides }); console.time('[PERF] Grass generation'); // High-fidelity grass: weighted Poisson base to avoid grid-like spacing while respecting vegetation density. const grassScatterFromTerrainPacked = packPositions(sim.terra.getScatterPositions('grass')); let grassPoissonSeedPacked: Float32Array = new Float32Array(0); if (scatterWorkerReady) { try { const grassPoissonResult = await scatterWorker.weightedPoissonPacked({ minDistance: 12.5, maxAttempts: 16, maxPositions: Math.max(380_000, Math.round(grassMapAreaKm2 * 1600)), initialSeedCount: Math.max(8, Math.min(96, Math.round(grassMapAreaKm2 * 0.2))), initialSeedAttempts: 4096, densityThreshold: 0.01, densityPower: 0.82, denseSpacingScale: 0.62, sparseSpacingScale: 1.08, baseAcceptance: 0.20, densityMode: 'grass-land', densitySeaLevel: grassSeaLevel, densitySeed: scatterSeedBase, slopeMode: 'low-slope-boost', slopePivot: 22, slopeDivisor: 10, slopeBase: 1, maxRuntimeMs: SCATTER_STAGE_BUDGET_MS.grassPoisson, adaptiveAttemptsOnBudget: 0.52, adaptivePositionCapOnBudget: 0.68, hardStopOnBudget: true, seed: (scatterSeedBase ^ 0x6e624eb7) >>> 0 }); grassPoissonSeedPacked = grassPoissonResult.positions; if (grassPoissonResult.meta.budgetExceeded) { console.warn( `[Performance] Grass weighted Poisson exceeded ${SCATTER_STAGE_BUDGET_MS.grassPoisson}ms; ` + `adaptive fallback applied (count=${grassPoissonResult.meta.outputCount})` ); } } catch (error) { console.warn('[ScatterWorker] Grass weighted Poisson failed; using main-thread fallback:', error); grassPoissonSeedPacked = packPositions(generateWeightedPoissonScatter(sim.terra, { minDistance: 12.5, maxAttempts: 16, maxPositions: Math.max(380_000, Math.round(grassMapAreaKm2 * 1600)), initialSeedCount: Math.max(8, Math.min(96, Math.round(grassMapAreaKm2 * 0.2))), initialSeedAttempts: 4096, densityThreshold: 0.01, densityPower: 0.82, denseSpacingScale: 0.62, sparseSpacingScale: 1.08, baseAcceptance: 0.20, slopeDensityModifier: (slope) => 1 + Math.max(0, (22 - slope) / 10), densitySampler: grassLandDensitySampler, cache: terrainCache, random: createSeededRng((scatterSeedBase ^ 0x6e624eb7) >>> 0) })); } } else { grassPoissonSeedPacked = packPositions(generateWeightedPoissonScatter(sim.terra, { minDistance: 12.5, maxAttempts: 16, maxPositions: Math.max(380_000, Math.round(grassMapAreaKm2 * 1600)), initialSeedCount: Math.max(8, Math.min(96, Math.round(grassMapAreaKm2 * 0.2))), initialSeedAttempts: 4096, densityThreshold: 0.01, densityPower: 0.82, denseSpacingScale: 0.62, sparseSpacingScale: 1.08, baseAcceptance: 0.20, slopeDensityModifier: (slope) => 1 + Math.max(0, (22 - slope) / 10), densitySampler: grassLandDensitySampler, cache: terrainCache, random: createSeededRng((scatterSeedBase ^ 0x6e624eb7) >>> 0) })); } const fallbackGrassSeedPacked = packedPositionCount(grassScatterFromTerrainPacked) === 0 ? packPositions(generateUniformGridPositions(170, 48)) : new Float32Array(0); if (packedPositionCount(grassPoissonSeedPacked) === 0) { if (packedPositionCount(grassScatterFromTerrainPacked) === 0) { console.warn(`[Bootstrap] Grass scatter seeds missing from terrain; using fallback grid (${packedPositionCount(fallbackGrassSeedPacked)})`); } else { console.warn(`[Bootstrap] Grass weighted Poisson returned 0 seeds; falling back to terrain seeds (${packedPositionCount(grassScatterFromTerrainPacked)})`); } } const grassScatterBasePacked = packedPositionCount(grassPoissonSeedPacked) > 0 ? grassPoissonSeedPacked : (packedPositionCount(grassScatterFromTerrainPacked) > 0 ? grassScatterFromTerrainPacked : fallbackGrassSeedPacked); const grassScatterBaseCount = packedPositionCount(grassScatterBasePacked); const bushToGrassTarget = Math.min( cappedBushPositions.length, Math.max(0, Math.round(grassScatterBaseCount * 0.22)) ); const bushToGrassBasePacked = bushToGrassTarget > 0 ? capPackedPositionsUniformlyByHash( cappedBushPositionsPacked, bushToGrassTarget, scatterSeedBase ^ 0x31bf5ac7 ) : new Float32Array(0); const bushToGrassAnchorsPacked = new Float32Array(bushToGrassBasePacked.length); for (let i = 0; i < bushToGrassBasePacked.length; i += 2) { const posIndex = i >> 1; const x = bushToGrassBasePacked[i]; const z = bushToGrassBasePacked[i + 1]; const jitterX = (hash2(x * 0.043 + posIndex * 0.013, z * 0.057, scatterSeedBase ^ 0x74d8a3e1) - 0.5) * 14; const jitterZ = (hash2(x * 0.061 + posIndex * 0.017, z * 0.049, scatterSeedBase ^ 0x5c2b3d97) - 0.5) * 14; bushToGrassAnchorsPacked[i] = clampToMap(x + jitterX); bushToGrassAnchorsPacked[i + 1] = clampToMap(z + jitterZ); } const mixedGrassScatterBasePacked = packedPositionCount(bushToGrassAnchorsPacked) > 0 ? capPackedPositionsUniformlyByHash( mergePackedPositions([grassScatterBasePacked, bushToGrassAnchorsPacked]), Math.max(grassScatterBaseCount, Math.round(grassScatterBaseCount * 1.15)), scatterSeedBase ^ 0x4f1e27bd ) : grassScatterBasePacked; console.info(`[Bootstrap] Grass base positions: ${grassScatterBaseCount}`); console.info( `[Bootstrap] Grass seeds: poisson=${packedPositionCount(grassPoissonSeedPacked)}, terrain=${packedPositionCount(grassScatterFromTerrainPacked)}, ` + `selected=${grassScatterBaseCount}, bushMixed=${packedPositionCount(bushToGrassAnchorsPacked)}, mixedSelected=${packedPositionCount(mixedGrassScatterBasePacked)}` ); console.info('[Bootstrap] Grass biome density profile:', { biomeType, grass: { densityScale: grassBiomeProfile.grassDensityScale, mapInfluence: grassBiomeProfile.grassMapInfluence, threshold: grassBiomeProfile.grassThreshold, power: grassBiomeProfile.grassPower, lodThreshold: grassBiomeProfile.grassLodThreshold, lodPower: grassBiomeProfile.grassLodPower }, tallGrass: { densityScale: grassBiomeProfile.tallDensityScale, mapInfluence: grassBiomeProfile.tallMapInfluence, threshold: grassBiomeProfile.tallThreshold, power: grassBiomeProfile.tallPower, lodThreshold: grassBiomeProfile.tallLodThreshold, lodPower: grassBiomeProfile.tallLodPower } }); const grassStrandAnchorsPacked = extendScatterPositionsPacked(mixedGrassScatterBasePacked, 0, 6.4); const grassPositionsRawPacked = expandAnchorsToDependentScatterPacked(grassStrandAnchorsPacked, { seed: scatterSeedBase ^ 0x53ad2f1c, dependentsMin: Math.round(4 * grassBiomeProfile.grassDensityScale), dependentsMax: Math.round(12 * grassBiomeProfile.grassDensityScale), radiusMin: 0.75, radiusMax: 4.6, densitySampler: grassLandDensitySampler, densityPower: 0.66, includeAnchor: true, maxPositions: Math.max(1_000_000, Math.round(grassMapAreaKm2 * 3600)) }); console.info( `[Bootstrap] Grass strands: anchors=${packedPositionCount(grassStrandAnchorsPacked)}, expanded=${packedPositionCount(grassPositionsRawPacked)}` ); const grassSeedBase = scatterSeedBase; const grassLodScale = Math.max(0.35, Math.min(2.5, GRASS_LOD_RELAX)); const grassKeep = (value: number) => Math.min(1, value * grassLodScale); const grassBudgetKeep = (x: number, z: number, chance: number, seedOffset: number): boolean => hash2(x * 0.173, z * 0.197, grassSeedBase + seedOffset) < chance; const densityMaskAt = (x: number, z: number, threshold: number, power: number): number => { const hintedDensity = clamp01(terrainCache.getDensityFast(x, z)); const landDensity = grassLandDensitySampler(x, z); const density = Math.max(landDensity * 0.92, hintedDensity * 0.38 + 0.44); const relaxedThreshold = clamp01(threshold * 0.58); const relaxedPower = Math.max(0.1, power * 0.72); if (density <= relaxedThreshold) { if (landDensity <= 0.14) { return 0; } return Math.pow(clamp01(landDensity), 0.7) * 0.42; } const normalized = (density - relaxedThreshold) / Math.max(0.0001, 1 - relaxedThreshold); return Math.pow(clamp01(normalized), relaxedPower); }; const grassPositionsLODPacked = filterPackedPositions(grassPositionsRawPacked, (x, z) => { const macroNoise = hash2(x * 0.008, z * 0.008, grassSeedBase + 11); const densityMask = densityMaskAt( x, z, grassBiomeProfile.grassLodThreshold, grassBiomeProfile.grassLodPower ); const densityKeep = 0.68 + densityMask * 0.32; const targetChance = macroNoise > 0.82 ? grassKeep(0.82) : macroNoise > 0.58 ? grassKeep(0.9) : macroNoise > 0.35 ? grassKeep(0.96) : grassKeep(1.0); return grassBudgetKeep(x, z, targetChance * densityKeep, 23); }); const grassPositionsPacked = applyGrassDensityPacked(grassPositionsLODPacked); console.info(`[Performance] Grass LOD: ${packedPositionCount(grassPositionsRawPacked)} -> ${packedPositionCount(grassPositionsLODPacked)} -> ${packedPositionCount(grassPositionsPacked)}`); console.timeEnd('[PERF] Grass generation'); const tallGrassStrandAnchorsPacked = extendScatterPositionsPacked(mixedGrassScatterBasePacked, 0, 7.8); const tallGrassPositionsRawPacked = expandAnchorsToDependentScatterPacked(tallGrassStrandAnchorsPacked, { seed: scatterSeedBase ^ 0x7bdac431, dependentsMin: Math.round(2 * grassBiomeProfile.tallDensityScale), dependentsMax: Math.round(8 * grassBiomeProfile.tallDensityScale), radiusMin: 1.2, radiusMax: 5.8, densitySampler: grassLandDensitySampler, densityPower: 0.78, includeAnchor: false, maxPositions: Math.max(700_000, Math.round(grassMapAreaKm2 * 2400)) }); console.info( `[Bootstrap] Tall grass strands: anchors=${packedPositionCount(tallGrassStrandAnchorsPacked)}, expanded=${packedPositionCount(tallGrassPositionsRawPacked)}` ); const tallGrassPositionsLODPacked = filterPackedPositions(tallGrassPositionsRawPacked, (x, z) => { const macroNoise = hash2(x * 0.008, z * 0.008, grassSeedBase + 47); const densityMask = densityMaskAt( x, z, grassBiomeProfile.tallLodThreshold, grassBiomeProfile.tallLodPower ); const densityKeep = 0.44 + densityMask * 0.56; const targetChance = macroNoise > 0.84 ? grassKeep(0.66) : macroNoise > 0.60 ? grassKeep(0.78) : macroNoise > 0.38 ? grassKeep(0.88) : grassKeep(0.96); return grassBudgetKeep(x, z, targetChance * densityKeep, 59); }); const tallGrassPositionsPacked = applyGrassDensityPacked(tallGrassPositionsLODPacked); console.info(`[Performance] Tall grass LOD: ${packedPositionCount(tallGrassPositionsRawPacked)} -> ${packedPositionCount(tallGrassPositionsLODPacked)} -> ${packedPositionCount(tallGrassPositionsPacked)}`); const buildCoarseGrassCoverageCandidatesPacked = ( spacing: number, jitter: number, minDensity: number, baseChance: number, seed: number ): Float32Array => { const result: number[] = []; const halfSize = MAP_HALF_SIZE; for (let x = -halfSize + spacing * 0.5; x < halfSize; x += spacing) { for (let z = -halfSize + spacing * 0.5; z < halfSize; z += spacing) { const jitterX = (hash2(x * 0.021, z * 0.017, seed ^ 0x9e3779b9) - 0.5) * jitter; const jitterZ = (hash2(x * 0.019, z * 0.023, seed ^ 0x85ebca6b) - 0.5) * jitter; const px = clampToMap(x + jitterX); const pz = clampToMap(z + jitterZ); const density = grassLandDensitySampler(px, pz); if (density < minDensity) { continue; } const keepChance = clamp01(baseChance + density * 0.5); if (hash2(px * 0.071, pz * 0.089, seed ^ 0xc2b2ae35) >= keepChance) { continue; } result.push(px, pz); } } return new Float32Array(result); }; const grassCoverageCandidatesPacked = buildCoarseGrassCoverageCandidatesPacked( 136, 44, 0.18, 0.10, grassSeedBase ^ 0x17d63ab1 ); const tallGrassCoverageCandidatesPacked = buildCoarseGrassCoverageCandidatesPacked( 184, 58, 0.22, 0.08, grassSeedBase ^ 0x29a4eec7 ); const grassPositionsBalancedPacked = packedPositionCount(grassCoverageCandidatesPacked) > 0 ? capPackedPositionsUniformlyByHash( mergePackedPositions([grassPositionsPacked, grassCoverageCandidatesPacked]), packedPositionCount(grassPositionsPacked), grassSeedBase ^ 0x4d73bf1a ) : grassPositionsPacked; const tallGrassPositionsBalancedPacked = packedPositionCount(tallGrassCoverageCandidatesPacked) > 0 ? capPackedPositionsUniformlyByHash( mergePackedPositions([tallGrassPositionsPacked, tallGrassCoverageCandidatesPacked]), packedPositionCount(tallGrassPositionsPacked), grassSeedBase ^ 0x66f2cb3d ) : tallGrassPositionsPacked; console.info( `[Performance] Grass coverage rebalance: grass base=${packedPositionCount(grassPositionsPacked)}, candidates=${packedPositionCount(grassCoverageCandidatesPacked)}, balanced=${packedPositionCount(grassPositionsBalancedPacked)}; ` + `tall base=${packedPositionCount(tallGrassPositionsPacked)}, candidates=${packedPositionCount(tallGrassCoverageCandidatesPacked)}, balanced=${packedPositionCount(tallGrassPositionsBalancedPacked)}` ); const aboveSeaGrassPositionsPacked = filterPackedByHeight( grassPositionsBalancedPacked, grassSeaLevel + 0.08, terrainCache ); const aboveSeaTallGrassPositionsPacked = filterPackedByHeight( tallGrassPositionsBalancedPacked, grassSeaLevel + 0.08, terrainCache ); const totalAboveSeaGrass = packedPositionCount(aboveSeaGrassPositionsPacked) + packedPositionCount(aboveSeaTallGrassPositionsPacked); let cappedGrassPositionsPacked = aboveSeaGrassPositionsPacked; let cappedTallGrassPositionsPacked = aboveSeaTallGrassPositionsPacked; if (totalAboveSeaGrass > grassInstanceCap) { const grassShare = packedPositionCount(aboveSeaGrassPositionsPacked) / Math.max(1, totalAboveSeaGrass); let grassCap = Math.round(grassInstanceCap * grassShare); let tallCap = grassInstanceCap - grassCap; if (packedPositionCount(aboveSeaGrassPositionsPacked) > 0 && grassCap <= 0) { grassCap = 1; tallCap = Math.max(0, grassInstanceCap - grassCap); } if (packedPositionCount(aboveSeaTallGrassPositionsPacked) > 0 && tallCap <= 0) { tallCap = 1; grassCap = Math.max(0, grassInstanceCap - tallCap); } cappedGrassPositionsPacked = capPackedPositionsUniformlyByHash( aboveSeaGrassPositionsPacked, Math.min(grassCap, packedPositionCount(aboveSeaGrassPositionsPacked)), scatterSeedBase ^ 0x41f23a9d ); cappedTallGrassPositionsPacked = capPackedPositionsUniformlyByHash( aboveSeaTallGrassPositionsPacked, Math.min(tallCap, packedPositionCount(aboveSeaTallGrassPositionsPacked)), scatterSeedBase ^ 0x6f4a19c7 ); } const cappedGrassPositions = unpackPositions(cappedGrassPositionsPacked); const cappedTallGrassPositions = unpackPositions(cappedTallGrassPositionsPacked); const cappedCombinedCount = cappedGrassPositions.length + cappedTallGrassPositions.length; const grassCapRatio = totalAboveSeaGrass > 0 ? cappedCombinedCount / totalAboveSeaGrass : 1; console.info( `[Performance] Grass coverage: base=${packedPositionCount(grassPositionsBalancedPacked) + packedPositionCount(tallGrassPositionsBalancedPacked)}, ` + `aboveSea=${totalAboveSeaGrass}, capped=${cappedCombinedCount} ` + `(cap=${grassInstanceCap}, keep=${(grassCapRatio * 100).toFixed(1)}%, ` + `grass=${cappedGrassPositions.length}, tall=${cappedTallGrassPositions.length})` ); if (ENABLE_SCATTER_GRASS) { const grassColors = buildVegetationInstanceColors( cappedGrassPositions, seasonPalette, scatterColorSeedBase + 3000, 0.08 ); const grassChunked = createChunkedInstancedScatter( cappedGrassPositions, grassGeometry, grassMaterial, sim.terra, { keyPrefix: 'grass', chunkSize: GRASS_CHUNK_SIZE, maxChunkCount: GRASS_MAX_CHUNKS, scaleRange: [1.25, 3.4], heightOffset: 0.24, scaleVariance: 1.05, instanceColors: grassColors, cache: terrainCache } ); scatterGroup.add(grassChunked); } else { console.info('[Bootstrap] Short grass scatter disabled for debugging'); } if (ENABLE_SCATTER_TALL_GRASS) { const tallGrassColors = buildVegetationInstanceColors( cappedTallGrassPositions, seasonPalette, scatterColorSeedBase + 3400, 0.12 ); const tallGrassChunked = createChunkedInstancedScatter( cappedTallGrassPositions, grassGeometry, grassMaterial, sim.terra, { keyPrefix: 'tall-grass', chunkSize: GRASS_CHUNK_SIZE, maxChunkCount: GRASS_MAX_CHUNKS, scaleRange: [2.0, 4.8], heightOffset: 0.3, scaleVariance: 1.15, instanceColors: tallGrassColors, cache: terrainCache } ); scatterGroup.add(tallGrassChunked); } else { console.info('[Bootstrap] Tall grass scatter disabled for debugging'); } const fernGeometry = createFernGeometry(); const fernTexture = createPlantTexture(0x3fb34a, 45, 6); const fernNormal = createPlantNormalTexture(fernTexture); const fernMaterial = new THREE.MeshStandardMaterial({ map: fernTexture, normalMap: fernNormal, roughnessMap: fernTexture, color: new THREE.Color(0x3fb34a), side: THREE.DoubleSide, flatShading: true, roughness: 1.0, metalness: 0.0 }); const fernBasePacked = extendScatterPositionsPacked(grassScatterBasePacked, 5, 4.0); // denser fern coverage let fernPositionsRawPacked: Float32Array; if (scatterWorkerReady) { try { const fernDensifyResult = await scatterWorker.densifyPacked( new Float32Array(fernBasePacked), { jitter: 0.5, slopeMode: 'low-slope-boost', slopePivot: 16, slopeDivisor: 5, slopeBase: 1, densityScale: 0.42, densityMapInfluence: 0.35, maxPositions: 180_000, maxRuntimeMs: SCATTER_STAGE_BUDGET_MS.fernDensify, adaptiveScaleOnBudget: 0.6, hardStopOnBudget: true, seed: (scatterSeedBase ^ 0x6d32f5b1) >>> 0 } ); fernPositionsRawPacked = fernDensifyResult.positions; if (fernDensifyResult.meta.budgetExceeded) { console.warn( `[Performance] Fern densify exceeded ${SCATTER_STAGE_BUDGET_MS.fernDensify}ms; ` + `adaptive fallback applied (count=${fernDensifyResult.meta.outputCount})` ); } } catch (error) { console.warn('[ScatterWorker] Fern densify failed; using main-thread fallback:', error); fernPositionsRawPacked = packPositions(densifyScatterPositions(unpackPositions(fernBasePacked), sim.terra, { jitter: 0.5, slopeModifier: (slope) => 1 + Math.max(0, (16 - slope) / 5), densityScale: 0.42, densityMapInfluence: 0.35, maxPositions: 180_000, cache: terrainCache })); } } else { fernPositionsRawPacked = packPositions(densifyScatterPositions(unpackPositions(fernBasePacked), sim.terra, { jitter: 0.5, slopeModifier: (slope) => 1 + Math.max(0, (16 - slope) / 5), densityScale: 0.42, densityMapInfluence: 0.35, maxPositions: 180_000, cache: terrainCache })); } const fernPositionsPacked = applyScatterDensityPacked(fernPositionsRawPacked); const fernPositions = unpackPositions(fernPositionsPacked); console.info(`[Performance] Ferns: ${packedPositionCount(fernPositionsRawPacked)} -> ${fernPositions.length}`); if (ENABLE_SCATTER_FERNS) { // FIX: Generate instance colors for ferns like trees const fernColors = buildVegetationInstanceColors( fernPositions, seasonPalette, scatterColorSeedBase + 4000, 0.1 ); const fernInstanced = getCachedScatterMesh('ferns', () => createInstancedScatter(fernPositions, fernGeometry, fernMaterial, sim.terra, { scaleRange: [0.2, 0.6], heightOffset: 0.15, // INCREASED: Compensate for heightmap/grid mismatch (was 0.02) slopeScale: 0.08, scaleVariance: 0.35, instanceColors: fernColors, cache: terrainCache }) ); fernInstanced.castShadow = false; fernInstanced.receiveShadow = false; scatterGroup.add(fernInstanced); } else { console.info('[Bootstrap] Fern scatter disabled for debugging'); } // Pass scatter group to WebGPU renderer loadingProgress.setProgress(82, 'Scatter: setting up render buffers...'); rendererService.setScatterGroup(scatterGroup); stageLog('Scatter: render buffers set', 82); // Store scatter mesh references for debug panel (window.__RTS as any).scatterMeshes = { scatterGroup: scatterGroup, // Find meshes by name in the scatter group trees: scatterGroup.children.filter(child => child.name.includes('tree')), bushes: scatterGroup.children.find(child => child.name === 'bushes'), grass: scatterGroup.children.find(child => child.name === 'grass' || child.name === 'combined-grass'), tallGrass: scatterGroup.children.find(child => child.name === 'tall-grass'), ferns: scatterGroup.children.find(child => child.name === 'ferns'), rocks: scatterGroup.children.filter(child => child.name === 'rocks' || child.name === 'boulders' || child.name === 'pebbles' ) }; console.info('[Bootstrap] Scatter mesh references stored for debug panel'); loadingProgress.setProgress(90, 'Finalizing scene & UI...'); } else { loadingProgress.setProgress(90, 'Finalizing scene & UI...'); } const cliffRenderer = enableWebglWorld && ENABLE_CLIFFS ? new CliffRenderer(scene, sim.terra) : null; const waterRenderer = enableWebglWorld && ENABLE_WATER ? new WaterRenderer(scene, renderer, sim.terra) : null; if (waterRenderer) { waterRenderer.setWindDirection(new THREE.Vector3(0.62, 0, 0.48)); waterRenderer.applyBiomePalette(biomePalette); lightingController.registerWaterRenderer(waterRenderer); } // Store water renderer reference for terrain debug panel (window.__RTS as any).waterRenderer = waterRenderer; // Add modern mesh-based terrain renderer // NOTE: Always create the terrain renderer so organic enhancements are available // When WebGPU is active, this serves as a fallback/debug option let modernTerrainRenderer: ModernTerrainRenderer | null = null; const forceModernTerrain = localStorage.getItem('rts.forceModernTerrain') === 'true'; (window.__RTS as any).forceModernTerrain = forceModernTerrain; const shouldCreateModernTerrain = enableWebglWorld && forceModernTerrain; if (shouldCreateModernTerrain) { console.info('[Bootstrap] Creating modern terrain renderer (forced WebGL fallback)...'); modernTerrainRenderer = createModernTerrainRenderer(sim.terra, scene, renderer); console.info('[Bootstrap] Modern terrain renderer created and added to scene'); } else { console.info('[Bootstrap] Modern terrain renderer SKIPPED (WebGPU terrain active or force flag not set)'); console.info('[Bootstrap] To enable organic enhancements, set localStorage.rts.forceModernTerrain = "true" while running the WebGL fallback (webgpuReady=false) and reload.'); } if (enableWebglWorld) { loadingProgress.setProgress(58, 'Terrain mesh: building geometry & LODs...'); stageLog('Terrain mesh build start', 58); } else { loadingProgress.setProgress(30, 'Renderer: WebGPU-only terrain (skipping baked textures)...'); } const axesHelper = new THREE.AxesHelper(50); axesHelper.visible = ENABLE_GRID_HELPERS && !enableWebglWorld; scene.add(axesHelper); const postProcessing = enableWebglEntityScene ? new PostProcessing(renderer, scene, camera, { enableFXAA: true, resolutionScale: 1.0 }) : null; const demoSpawns = spawnPoints.length > 0 ? spawnPoints : [ { x: 0, z: 0 }, { x: 20, z: 10 }, { x: -15, z: 5 } ]; // Fallback: if the match configuration produced no units at all, spawn a // small player-owned starter group so entities are always visible. if (sim.units.getUnits().length === 0) { const localPlayer = sim.getLocalPlayer(); const unitCountScale = Math.max(1, combatTestPanel.getUnitCountScale()); demoSpawns.forEach((spawn, index) => { sim.units.spawn( index === 0 ? 'Commander' : 'Combat', spawn.x, spawn.z, undefined, 'player', localPlayer?.id, localPlayer?.teamId ); }); if (unitCountScale > 1.01 && demoSpawns.length > 0) { const anchor = demoSpawns[0]; const extraUnits = Math.max(0, Math.round((unitCountScale - 1) * 10)); const ringRadius = 14; for (let i = 0; i < extraUnits; i++) { const angle = (i / Math.max(1, extraUnits)) * Math.PI * 2; const x = anchor.x + Math.sin(angle) * ringRadius; const z = anchor.z + Math.cos(angle) * ringRadius; sim.units.spawn('Combat', x, z, undefined, 'player', localPlayer?.id, localPlayer?.teamId); } } console.warn('[Bootstrap] No units were present after setup; spawned fallback starter units.'); } window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); terrainMarkerLayer?.resize(); applyRuntimeResolutionScale(currentDynamicResolutionScale, true); }); let lastTime = performance.now(); let previousBuildMode: string | null = null; const reflectionUpdateInterval = 0.26; let reflectionAccumulator = reflectionUpdateInterval; const ENTITY_HEAVY_INTERVAL = 0.15; let entityHeavyAccumulator = ENTITY_HEAVY_INTERVAL; type FrameProjectile = | ReturnType[number] | ReturnType[number]; const visibleUnitsScratch: Unit[] = []; const visibleBuildingsScratch: Building[] = []; const selectedUnitsScratch: Unit[] = []; const playerUnitsScratch: Unit[] = []; const enemyUnitsScratch: Unit[] = []; const selectedGroupIdsScratch = new Set(); const listenerForwardScratch = new THREE.Vector3(); const combinedProjectilesScratch: FrameProjectile[] = []; const moraleDataScratch = new Map>>(); const threatDataScratch = new Map>>(); let frameIndex = 0; let fpsLogStart = performance.now(); let fpsLogFrames = 0; let performanceDiagnosisActive = false; let runtimeRenderEnabled = true; let gameplayPanelsAutoShown = false; let lastWebglDrawCalls = 0; let lastWebglTriangles = 0; // Performance benchmarking (assigned after lazy import) let performanceBenchmark: PerformanceBenchmark | undefined; // Enhanced FPS tracking let lastFrameTime = performance.now(); let frameTimeSamples: number[] = []; let frameTimeHistory: number[] = []; const FRAME_TIME_HISTORY_LIMIT = 1200; // ~20s at 60 FPS for stable 1%/0.1% lows let minFPS = Infinity; let maxFPS = 0; type QualityTier = 0 | 1 | 2; type AdaptiveQualityState = { enabled: boolean; targetFrameMs: number; overBudgetFrameThreshold: number; cooldownMs: number; scatterTier: QualityTier; terrainTier: QualityTier; maxScatterTier: QualityTier; maxTerrainTier: QualityTier; overBudgetFrames: number; lastStepTimestampMs: number; stepCount: number; }; const readAdaptiveNumber = ( key: string, fallback: number, min: number, max: number ): number => { const raw = window.localStorage.getItem(key); const parsed = raw != null ? Number.parseFloat(raw) : Number.NaN; if (!Number.isFinite(parsed)) return fallback; return Math.max(min, Math.min(max, parsed)); }; const readAdaptiveBoolean = (key: string, fallback: boolean): boolean => { const raw = window.localStorage.getItem(key); if (raw == null) return fallback; const normalized = raw.trim().toLowerCase(); return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; }; const clampQualityTier = (tier: number): QualityTier => Math.max(0, Math.min(2, Math.round(tier))) as QualityTier; const scatterDistanceScaleByTier: Record = { 0: Math.max(0.2, SCATTER_DISTANCE_SCALE * 0.5), 1: Math.max(0.22, SCATTER_DISTANCE_SCALE * 0.68), 2: SCATTER_DISTANCE_SCALE }; const adaptiveQualityState: AdaptiveQualityState = { enabled: readAdaptiveBoolean('rts.adaptiveBudget.enabled', true), targetFrameMs: readAdaptiveNumber('rts.adaptiveBudget.targetMs', 10.0, 8, 50), overBudgetFrameThreshold: Math.round( readAdaptiveNumber('rts.adaptiveBudget.overFrames', 18, 5, 600) ), cooldownMs: readAdaptiveNumber('rts.adaptiveBudget.cooldownMs', 550, 100, 10_000), scatterTier: 1, terrainTier: 0, maxScatterTier: 2, maxTerrainTier: clampQualityTier(initialRuntimeTerrainQualityTier), overBudgetFrames: 0, lastStepTimestampMs: 0, stepCount: 0 }; const applyScatterBudgetTier = (tier: QualityTier, persist: boolean = false): QualityTier => { const next = clampQualityTier(tier); adaptiveQualityState.scatterTier = next; const scale = scatterDistanceScaleByTier[next]; (window.__RTS as any).setScatterDistanceScale?.(scale, persist); return adaptiveQualityState.scatterTier; }; const applyTerrainBudgetTier = (tier: QualityTier, persist: boolean = false): QualityTier => { const next = clampQualityTier(tier); adaptiveQualityState.terrainTier = next; rendererService.setRuntimeTerrainQualityTier(next); (window.__RTS as any).runtimeTerrainQualityTier = next; if (persist) { window.localStorage.setItem('rts.framegraph.terrainQuality', String(next)); } return adaptiveQualityState.terrainTier; }; applyScatterBudgetTier(adaptiveQualityState.scatterTier, false); applyTerrainBudgetTier(adaptiveQualityState.terrainTier, false); const getAdaptiveQualitySnapshot = () => ({ enabled: adaptiveQualityState.enabled, targetFrameMs: adaptiveQualityState.targetFrameMs, overBudgetFrameThreshold: adaptiveQualityState.overBudgetFrameThreshold, cooldownMs: adaptiveQualityState.cooldownMs, scatterTier: adaptiveQualityState.scatterTier, terrainTier: adaptiveQualityState.terrainTier, maxScatterTier: adaptiveQualityState.maxScatterTier, maxTerrainTier: adaptiveQualityState.maxTerrainTier, overBudgetFrames: adaptiveQualityState.overBudgetFrames, stepCount: adaptiveQualityState.stepCount, scatterDistanceScaleByTier: { ...scatterDistanceScaleByTier }, currentScatterDistanceScale: (window.__RTS as any).scatterDistanceScale }); (window.__RTS as any).getAdaptiveQualityState = () => { const snapshot = getAdaptiveQualitySnapshot(); console.info('[AdaptiveQuality] State:', snapshot); return snapshot; }; (window.__RTS as any).setAdaptiveQualityEnabled = (enabled: boolean = true) => { adaptiveQualityState.enabled = Boolean(enabled); window.localStorage.setItem('rts.adaptiveBudget.enabled', adaptiveQualityState.enabled ? '1' : '0'); console.info(`[AdaptiveQuality] ${adaptiveQualityState.enabled ? 'ENABLED' : 'DISABLED'}`); return adaptiveQualityState.enabled; }; (window.__RTS as any).setAdaptiveQualityTargets = (options: { targetMs?: number; overFrames?: number; cooldownMs?: number; } = {}) => { if (Number.isFinite(options.targetMs)) { adaptiveQualityState.targetFrameMs = Math.max(8, Math.min(50, Number(options.targetMs))); window.localStorage.setItem('rts.adaptiveBudget.targetMs', adaptiveQualityState.targetFrameMs.toFixed(2)); } if (Number.isFinite(options.overFrames)) { adaptiveQualityState.overBudgetFrameThreshold = Math.max(5, Math.min(600, Math.round(Number(options.overFrames)))); window.localStorage.setItem('rts.adaptiveBudget.overFrames', String(adaptiveQualityState.overBudgetFrameThreshold)); } if (Number.isFinite(options.cooldownMs)) { adaptiveQualityState.cooldownMs = Math.max(100, Math.min(10_000, Number(options.cooldownMs))); window.localStorage.setItem('rts.adaptiveBudget.cooldownMs', String(Math.round(adaptiveQualityState.cooldownMs))); } const snapshot = getAdaptiveQualitySnapshot(); console.info('[AdaptiveQuality] Targets updated:', snapshot); return snapshot; }; (window.__RTS as any).setScatterBudgetTier = (tier: number, persist: boolean = false) => { const next = applyScatterBudgetTier(clampQualityTier(tier), persist); console.info(`[AdaptiveQuality] Scatter budget tier set to ${next}${persist ? ' (persisted)' : ''}`); return next; }; (window.__RTS as any).setAdaptiveTerrainBudgetTier = (tier: number, persist: boolean = false) => { const next = applyTerrainBudgetTier(clampQualityTier(tier), persist); console.info(`[AdaptiveQuality] Terrain budget tier set to ${next}${persist ? ' (persisted)' : ''}`); return next; }; (window.__RTS as any).resetAdaptiveQuality = () => { adaptiveQualityState.overBudgetFrames = 0; adaptiveQualityState.stepCount = 0; applyScatterBudgetTier(adaptiveQualityState.maxScatterTier, false); applyTerrainBudgetTier(adaptiveQualityState.maxTerrainTier, false); const snapshot = getAdaptiveQualitySnapshot(); console.info('[AdaptiveQuality] Reset to max tiers:', snapshot); return snapshot; }; const tickAdaptiveQuality = (frameDurationMs: number, nowMs: number): void => { if (!adaptiveQualityState.enabled) { adaptiveQualityState.overBudgetFrames = 0; return; } if (performanceDiagnosisActive || document.hidden) { adaptiveQualityState.overBudgetFrames = 0; return; } if (!Number.isFinite(frameDurationMs) || frameDurationMs <= 0) { return; } if (frameDurationMs <= adaptiveQualityState.targetFrameMs) { adaptiveQualityState.overBudgetFrames = 0; return; } adaptiveQualityState.overBudgetFrames += 1; const onCooldown = nowMs - adaptiveQualityState.lastStepTimestampMs < adaptiveQualityState.cooldownMs; if ( adaptiveQualityState.overBudgetFrames < adaptiveQualityState.overBudgetFrameThreshold || onCooldown ) { return; } let stepped: 'scatter' | 'terrain' | null = null; if (adaptiveQualityState.scatterTier > 0) { const next = clampQualityTier(adaptiveQualityState.scatterTier - 1); applyScatterBudgetTier(next, false); stepped = 'scatter'; } else if (adaptiveQualityState.terrainTier > 0) { const next = clampQualityTier(adaptiveQualityState.terrainTier - 1); applyTerrainBudgetTier(next, false); stepped = 'terrain'; } adaptiveQualityState.lastStepTimestampMs = nowMs; adaptiveQualityState.overBudgetFrames = 0; if (stepped) { adaptiveQualityState.stepCount += 1; console.info( `[AdaptiveQuality] Budget step ${adaptiveQualityState.stepCount}: ` + `${stepped} tier -> ${stepped === 'scatter' ? adaptiveQualityState.scatterTier : adaptiveQualityState.terrainTier} ` + `(frame=${frameDurationMs.toFixed(2)}ms, target=${adaptiveQualityState.targetFrameMs.toFixed(2)}ms)` ); } }; type DynamicResolutionState = { enabled: boolean; targetFrameMs: number; minScale: number; maxScale: number; stepDown: number; stepUp: number; overBudgetFrames: number; recoverFrameThreshold: number; recoverFrames: number; overBudgetFrameThreshold: number; cooldownMs: number; lastAdjustTimestampMs: number; }; const dynamicResolutionState: DynamicResolutionState = { enabled: readAdaptiveBoolean('rts.dynamicResolution.enabled', PERF_TARGET_10MS), targetFrameMs: readAdaptiveNumber('rts.dynamicResolution.targetMs', 10.0, 8, 50), minScale: readAdaptiveNumber('rts.dynamicResolution.minScale', 0.6, 0.35, 1.0), maxScale: readAdaptiveNumber('rts.dynamicResolution.maxScale', 1.0, 0.5, 1.25), stepDown: readAdaptiveNumber('rts.dynamicResolution.stepDown', 0.06, 0.01, 0.25), stepUp: readAdaptiveNumber('rts.dynamicResolution.stepUp', 0.025, 0.005, 0.2), overBudgetFrames: 0, recoverFrameThreshold: Math.round(readAdaptiveNumber('rts.dynamicResolution.recoverFrames', 56, 12, 800)), recoverFrames: 0, overBudgetFrameThreshold: Math.round(readAdaptiveNumber('rts.dynamicResolution.overFrames', 14, 3, 300)), cooldownMs: readAdaptiveNumber('rts.dynamicResolution.cooldownMs', 300, 50, 5_000), lastAdjustTimestampMs: 0 }; if (dynamicResolutionState.maxScale < dynamicResolutionState.minScale) { dynamicResolutionState.maxScale = dynamicResolutionState.minScale; } const baseWebglPixelRatio = Math.min(window.devicePixelRatio, 1.5) * WEBGL_OVERLAY_SCALE; let currentDynamicResolutionScale = Math.max( dynamicResolutionState.minScale, Math.min(dynamicResolutionState.maxScale, readAdaptiveNumber('rts.dynamicResolution.scale', 1.0, 0.35, 1.25)) ); const applyRuntimeResolutionScale = (scale: number, force: boolean = false): number => { const clamped = Math.max(dynamicResolutionState.minScale, Math.min(dynamicResolutionState.maxScale, scale)); if (!force && Math.abs(clamped - currentDynamicResolutionScale) < 0.002) { return currentDynamicResolutionScale; } currentDynamicResolutionScale = clamped; const renderWidth = Math.max(1, Math.round(window.innerWidth * clamped)); const renderHeight = Math.max(1, Math.round(window.innerHeight * clamped)); gpuCanvas.width = renderWidth; gpuCanvas.height = renderHeight; if (enableWebglScene) { renderer.setPixelRatio(baseWebglPixelRatio * clamped); renderer.setSize(window.innerWidth, window.innerHeight); } if (postProcessing) { postProcessing.resize(window.innerWidth, window.innerHeight); } if (webgpuReady) { rendererService.resize(renderWidth, renderHeight); rendererService.setDynamicResolutionScale(clamped); } return currentDynamicResolutionScale; }; applyRuntimeResolutionScale(currentDynamicResolutionScale, true); const getDynamicResolutionSnapshot = () => ({ enabled: dynamicResolutionState.enabled, targetFrameMs: dynamicResolutionState.targetFrameMs, minScale: dynamicResolutionState.minScale, maxScale: dynamicResolutionState.maxScale, stepDown: dynamicResolutionState.stepDown, stepUp: dynamicResolutionState.stepUp, overBudgetFrameThreshold: dynamicResolutionState.overBudgetFrameThreshold, recoverFrameThreshold: dynamicResolutionState.recoverFrameThreshold, cooldownMs: dynamicResolutionState.cooldownMs, currentScale: currentDynamicResolutionScale, renderWidth: gpuCanvas.width, renderHeight: gpuCanvas.height }); (window.__RTS as any).getDynamicResolutionState = () => { const snapshot = getDynamicResolutionSnapshot(); console.info('[DynamicResolution] State:', snapshot); return snapshot; }; (window.__RTS as any).setDynamicResolutionEnabled = (enabled: boolean = true, persist: boolean = true) => { dynamicResolutionState.enabled = Boolean(enabled); if (persist) { window.localStorage.setItem('rts.dynamicResolution.enabled', dynamicResolutionState.enabled ? '1' : '0'); } if (!dynamicResolutionState.enabled) { applyRuntimeResolutionScale(dynamicResolutionState.maxScale, true); } console.info(`[DynamicResolution] ${dynamicResolutionState.enabled ? 'ENABLED' : 'DISABLED'}`); return dynamicResolutionState.enabled; }; (window.__RTS as any).setDynamicResolutionScale = (scale: number = 1.0, persist: boolean = false) => { const next = applyRuntimeResolutionScale(Number(scale), true); if (persist) { window.localStorage.setItem('rts.dynamicResolution.scale', next.toFixed(4)); } console.info(`[DynamicResolution] Scale set to ${next.toFixed(3)} (${gpuCanvas.width}x${gpuCanvas.height})`); return next; }; (window.__RTS as any).setDynamicResolutionTargets = (options: { targetMs?: number; minScale?: number; maxScale?: number; stepDown?: number; stepUp?: number; overFrames?: number; recoverFrames?: number; cooldownMs?: number; } = {}) => { if (Number.isFinite(options.targetMs)) { dynamicResolutionState.targetFrameMs = Math.max(8, Math.min(50, Number(options.targetMs))); window.localStorage.setItem('rts.dynamicResolution.targetMs', dynamicResolutionState.targetFrameMs.toFixed(2)); } if (Number.isFinite(options.minScale)) { dynamicResolutionState.minScale = Math.max(0.35, Math.min(1.0, Number(options.minScale))); window.localStorage.setItem('rts.dynamicResolution.minScale', dynamicResolutionState.minScale.toFixed(3)); } if (Number.isFinite(options.maxScale)) { dynamicResolutionState.maxScale = Math.max(dynamicResolutionState.minScale, Math.min(1.25, Number(options.maxScale))); window.localStorage.setItem('rts.dynamicResolution.maxScale', dynamicResolutionState.maxScale.toFixed(3)); } if (dynamicResolutionState.maxScale < dynamicResolutionState.minScale) { dynamicResolutionState.maxScale = dynamicResolutionState.minScale; } if (Number.isFinite(options.stepDown)) { dynamicResolutionState.stepDown = Math.max(0.01, Math.min(0.25, Number(options.stepDown))); window.localStorage.setItem('rts.dynamicResolution.stepDown', dynamicResolutionState.stepDown.toFixed(3)); } if (Number.isFinite(options.stepUp)) { dynamicResolutionState.stepUp = Math.max(0.005, Math.min(0.2, Number(options.stepUp))); window.localStorage.setItem('rts.dynamicResolution.stepUp', dynamicResolutionState.stepUp.toFixed(3)); } if (Number.isFinite(options.overFrames)) { dynamicResolutionState.overBudgetFrameThreshold = Math.max(3, Math.min(300, Math.round(Number(options.overFrames)))); window.localStorage.setItem('rts.dynamicResolution.overFrames', String(dynamicResolutionState.overBudgetFrameThreshold)); } if (Number.isFinite(options.recoverFrames)) { dynamicResolutionState.recoverFrameThreshold = Math.max(8, Math.min(800, Math.round(Number(options.recoverFrames)))); window.localStorage.setItem('rts.dynamicResolution.recoverFrames', String(dynamicResolutionState.recoverFrameThreshold)); } if (Number.isFinite(options.cooldownMs)) { dynamicResolutionState.cooldownMs = Math.max(50, Math.min(5_000, Number(options.cooldownMs))); window.localStorage.setItem('rts.dynamicResolution.cooldownMs', String(Math.round(dynamicResolutionState.cooldownMs))); } applyRuntimeResolutionScale(currentDynamicResolutionScale, true); const snapshot = getDynamicResolutionSnapshot(); console.info('[DynamicResolution] Targets updated:', snapshot); return snapshot; }; const tickDynamicResolution = (frameDurationMs: number, nowMs: number): void => { if (!dynamicResolutionState.enabled || !runtimeRenderEnabled || !webgpuReady) { dynamicResolutionState.overBudgetFrames = 0; dynamicResolutionState.recoverFrames = 0; return; } if (performanceDiagnosisActive || document.hidden) { return; } const overBudget = frameDurationMs > dynamicResolutionState.targetFrameMs + 0.35; const comfortablyUnder = frameDurationMs < dynamicResolutionState.targetFrameMs - 0.75; if (overBudget) { dynamicResolutionState.overBudgetFrames += 1; dynamicResolutionState.recoverFrames = 0; } else if (comfortablyUnder) { dynamicResolutionState.recoverFrames += 1; dynamicResolutionState.overBudgetFrames = 0; } else { dynamicResolutionState.overBudgetFrames = 0; dynamicResolutionState.recoverFrames = 0; } const onCooldown = nowMs - dynamicResolutionState.lastAdjustTimestampMs < dynamicResolutionState.cooldownMs; if (onCooldown) { return; } if ( dynamicResolutionState.overBudgetFrames >= dynamicResolutionState.overBudgetFrameThreshold && currentDynamicResolutionScale > dynamicResolutionState.minScale + 1e-4 ) { const next = applyRuntimeResolutionScale(currentDynamicResolutionScale - dynamicResolutionState.stepDown); dynamicResolutionState.lastAdjustTimestampMs = nowMs; dynamicResolutionState.overBudgetFrames = 0; console.info(`[DynamicResolution] Downscale -> ${next.toFixed(3)} (frame=${frameDurationMs.toFixed(2)}ms)`); return; } if ( dynamicResolutionState.recoverFrames >= dynamicResolutionState.recoverFrameThreshold && currentDynamicResolutionScale < dynamicResolutionState.maxScale - 1e-4 ) { const next = applyRuntimeResolutionScale(currentDynamicResolutionScale + dynamicResolutionState.stepUp); dynamicResolutionState.lastAdjustTimestampMs = nowMs; dynamicResolutionState.recoverFrames = 0; console.info(`[DynamicResolution] Upscale -> ${next.toFixed(3)} (frame=${frameDurationMs.toFixed(2)}ms)`); } }; type AdaptiveFeatureLevel = 0 | 1 | 2 | 3; type AdaptiveFeatureThrottleState = { enabled: boolean; targetFrameMs: number; overBudgetFrameThreshold: number; recoverFrameThreshold: number; cooldownMs: number; level: AdaptiveFeatureLevel; overBudgetFrames: number; recoverFrames: number; lastAdjustTimestampMs: number; }; const featureThrottleState: AdaptiveFeatureThrottleState = { enabled: readAdaptiveBoolean('rts.featureThrottle.enabled', PERF_TARGET_10MS), targetFrameMs: readAdaptiveNumber('rts.featureThrottle.targetMs', 10.8, 8, 50), overBudgetFrameThreshold: Math.round(readAdaptiveNumber('rts.featureThrottle.overFrames', 20, 5, 400)), recoverFrameThreshold: Math.round(readAdaptiveNumber('rts.featureThrottle.recoverFrames', 90, 20, 900)), cooldownMs: readAdaptiveNumber('rts.featureThrottle.cooldownMs', 850, 100, 10_000), level: 0, overBudgetFrames: 0, recoverFrames: 0, lastAdjustTimestampMs: 0 }; const getFeatureOverridesForLevel = (level: AdaptiveFeatureLevel) => { if (level === 0) { return { shadows: null, ssao: null, bloom: null, water: null, lightCulling: null }; } if (level === 1) { return { shadows: null, ssao: false, bloom: null, water: null, lightCulling: null }; } if (level === 2) { return { shadows: null, ssao: false, bloom: false, water: null, lightCulling: null }; } return { shadows: null, ssao: false, bloom: false, water: false, lightCulling: null }; }; const applyFeatureThrottleLevel = (level: AdaptiveFeatureLevel): AdaptiveFeatureLevel => { const next = Math.max(0, Math.min(3, Math.round(level))) as AdaptiveFeatureLevel; featureThrottleState.level = next; rendererService.setFramegraphFeatureOverrides(getFeatureOverridesForLevel(next)); return featureThrottleState.level; }; const getAdaptiveFeatureThrottleSnapshot = () => ({ enabled: featureThrottleState.enabled, level: featureThrottleState.level, targetFrameMs: featureThrottleState.targetFrameMs, overBudgetFrameThreshold: featureThrottleState.overBudgetFrameThreshold, recoverFrameThreshold: featureThrottleState.recoverFrameThreshold, cooldownMs: featureThrottleState.cooldownMs, activeOverrides: rendererService.getFramegraphFeatureOverrides() }); (window.__RTS as any).getAdaptiveFeatureThrottleState = () => { const snapshot = getAdaptiveFeatureThrottleSnapshot(); console.info('[FeatureThrottle] State:', snapshot); return snapshot; }; (window.__RTS as any).setAdaptiveFeatureThrottleEnabled = (enabled: boolean = true, persist: boolean = true) => { featureThrottleState.enabled = Boolean(enabled); if (persist) { window.localStorage.setItem('rts.featureThrottle.enabled', featureThrottleState.enabled ? '1' : '0'); } if (!featureThrottleState.enabled) { featureThrottleState.level = 0; rendererService.setFramegraphFeatureOverrides({ shadows: null, ssao: null, bloom: null, water: null, lightCulling: null }); } console.info(`[FeatureThrottle] ${featureThrottleState.enabled ? 'ENABLED' : 'DISABLED'}`); return featureThrottleState.enabled; }; (window.__RTS as any).setAdaptiveFeatureThrottleTargets = (options: { targetMs?: number; overFrames?: number; recoverFrames?: number; cooldownMs?: number; } = {}) => { if (Number.isFinite(options.targetMs)) { featureThrottleState.targetFrameMs = Math.max(8, Math.min(50, Number(options.targetMs))); window.localStorage.setItem('rts.featureThrottle.targetMs', featureThrottleState.targetFrameMs.toFixed(2)); } if (Number.isFinite(options.overFrames)) { featureThrottleState.overBudgetFrameThreshold = Math.max(5, Math.min(400, Math.round(Number(options.overFrames)))); window.localStorage.setItem('rts.featureThrottle.overFrames', String(featureThrottleState.overBudgetFrameThreshold)); } if (Number.isFinite(options.recoverFrames)) { featureThrottleState.recoverFrameThreshold = Math.max(12, Math.min(900, Math.round(Number(options.recoverFrames)))); window.localStorage.setItem('rts.featureThrottle.recoverFrames', String(featureThrottleState.recoverFrameThreshold)); } if (Number.isFinite(options.cooldownMs)) { featureThrottleState.cooldownMs = Math.max(100, Math.min(10_000, Number(options.cooldownMs))); window.localStorage.setItem('rts.featureThrottle.cooldownMs', String(Math.round(featureThrottleState.cooldownMs))); } const snapshot = getAdaptiveFeatureThrottleSnapshot(); console.info('[FeatureThrottle] Targets updated:', snapshot); return snapshot; }; const tickAdaptiveFeatureThrottle = (frameDurationMs: number, nowMs: number): void => { if (!featureThrottleState.enabled || !runtimeRenderEnabled) { featureThrottleState.overBudgetFrames = 0; featureThrottleState.recoverFrames = 0; return; } if (performanceDiagnosisActive || document.hidden) { return; } const overBudget = frameDurationMs > featureThrottleState.targetFrameMs + 0.4; const comfortablyUnder = frameDurationMs < featureThrottleState.targetFrameMs - 0.9; if (overBudget) { featureThrottleState.overBudgetFrames += 1; featureThrottleState.recoverFrames = 0; } else if (comfortablyUnder) { featureThrottleState.recoverFrames += 1; featureThrottleState.overBudgetFrames = 0; } else { featureThrottleState.overBudgetFrames = 0; featureThrottleState.recoverFrames = 0; } const onCooldown = nowMs - featureThrottleState.lastAdjustTimestampMs < featureThrottleState.cooldownMs; if (onCooldown) { return; } if ( featureThrottleState.overBudgetFrames >= featureThrottleState.overBudgetFrameThreshold && featureThrottleState.level < 3 ) { const next = applyFeatureThrottleLevel((featureThrottleState.level + 1) as AdaptiveFeatureLevel); featureThrottleState.lastAdjustTimestampMs = nowMs; featureThrottleState.overBudgetFrames = 0; console.info(`[FeatureThrottle] Step-down -> level ${next} (frame=${frameDurationMs.toFixed(2)}ms)`); return; } if ( featureThrottleState.recoverFrames >= featureThrottleState.recoverFrameThreshold && featureThrottleState.level > 0 ) { const next = applyFeatureThrottleLevel((featureThrottleState.level - 1) as AdaptiveFeatureLevel); featureThrottleState.lastAdjustTimestampMs = nowMs; featureThrottleState.recoverFrames = 0; console.info(`[FeatureThrottle] Recovery -> level ${next} (frame=${frameDurationMs.toFixed(2)}ms)`); } }; if (featureThrottleState.enabled) { applyFeatureThrottleLevel(featureThrottleState.level); } type ShadowPolicyLevel = 'full' | 'reduced' | 'objectsOnly'; type ShadowAutoPolicyState = { enabled: boolean; softFrameMs: number; severeFrameMs: number; highCameraMeters: number; recoverFrames: number; underBudgetFrames: number; current: ShadowPolicyLevel; }; const baselineShadowMask = readShadowCasterMask(); const shadowAutoPolicyState: ShadowAutoPolicyState = { enabled: readAdaptiveBoolean('rts.shadowAutoPolicy.enabled', PERF_TARGET_10MS), softFrameMs: readAdaptiveNumber('rts.shadowAutoPolicy.softFrameMs', 12.0, 8, 60), severeFrameMs: readAdaptiveNumber('rts.shadowAutoPolicy.severeFrameMs', 16.0, 10, 80), highCameraMeters: readAdaptiveNumber('rts.shadowAutoPolicy.highCameraMeters', 800, 120, 12_000), recoverFrames: Math.round(readAdaptiveNumber('rts.shadowAutoPolicy.recoverFrames', 160, 20, 1200)), underBudgetFrames: 0, current: 'full' }; const applyShadowPolicyLevel = (level: ShadowPolicyLevel): ShadowPolicyLevel => { shadowAutoPolicyState.current = level; const setMask = (window.__RTS as any).setShadowCasterDebug as ((flags: { objects?: boolean; legacyTrees?: boolean; scatterTrees?: boolean; }) => unknown) | undefined; if (typeof setMask !== 'function') { return shadowAutoPolicyState.current; } if (level === 'objectsOnly') { setMask({ objects: true, legacyTrees: false, scatterTrees: false }); return shadowAutoPolicyState.current; } if (level === 'reduced') { setMask({ objects: true, legacyTrees: true, scatterTrees: false }); return shadowAutoPolicyState.current; } setMask({ objects: baselineShadowMask.objects, legacyTrees: baselineShadowMask.legacyTrees, scatterTrees: baselineShadowMask.scatterTrees }); return shadowAutoPolicyState.current; }; const getShadowAutoPolicySnapshot = () => ({ enabled: shadowAutoPolicyState.enabled, softFrameMs: shadowAutoPolicyState.softFrameMs, severeFrameMs: shadowAutoPolicyState.severeFrameMs, highCameraMeters: shadowAutoPolicyState.highCameraMeters, recoverFrames: shadowAutoPolicyState.recoverFrames, current: shadowAutoPolicyState.current, mask: readShadowCasterMask() }); (window.__RTS as any).getShadowCasterAutoPolicyState = () => { const snapshot = getShadowAutoPolicySnapshot(); console.info('[ShadowAutoPolicy] State:', snapshot); return snapshot; }; (window.__RTS as any).setShadowCasterAutoPolicyEnabled = (enabled: boolean = true, persist: boolean = true) => { shadowAutoPolicyState.enabled = Boolean(enabled); if (persist) { window.localStorage.setItem('rts.shadowAutoPolicy.enabled', shadowAutoPolicyState.enabled ? '1' : '0'); } if (!shadowAutoPolicyState.enabled) { applyShadowPolicyLevel('full'); } console.info(`[ShadowAutoPolicy] ${shadowAutoPolicyState.enabled ? 'ENABLED' : 'DISABLED'}`); return shadowAutoPolicyState.enabled; }; (window.__RTS as any).setShadowCasterAutoPolicyTargets = (options: { softFrameMs?: number; severeFrameMs?: number; highCameraMeters?: number; recoverFrames?: number; } = {}) => { if (Number.isFinite(options.softFrameMs)) { shadowAutoPolicyState.softFrameMs = Math.max(8, Math.min(60, Number(options.softFrameMs))); window.localStorage.setItem('rts.shadowAutoPolicy.softFrameMs', shadowAutoPolicyState.softFrameMs.toFixed(2)); } if (Number.isFinite(options.severeFrameMs)) { shadowAutoPolicyState.severeFrameMs = Math.max(10, Math.min(80, Number(options.severeFrameMs))); window.localStorage.setItem('rts.shadowAutoPolicy.severeFrameMs', shadowAutoPolicyState.severeFrameMs.toFixed(2)); } if (Number.isFinite(options.highCameraMeters)) { shadowAutoPolicyState.highCameraMeters = Math.max(120, Math.min(12_000, Number(options.highCameraMeters))); window.localStorage.setItem('rts.shadowAutoPolicy.highCameraMeters', shadowAutoPolicyState.highCameraMeters.toFixed(1)); } if (Number.isFinite(options.recoverFrames)) { shadowAutoPolicyState.recoverFrames = Math.max(20, Math.min(1200, Math.round(Number(options.recoverFrames)))); window.localStorage.setItem('rts.shadowAutoPolicy.recoverFrames', String(shadowAutoPolicyState.recoverFrames)); } const snapshot = getShadowAutoPolicySnapshot(); console.info('[ShadowAutoPolicy] Targets updated:', snapshot); return snapshot; }; const tickShadowAutoPolicy = (frameDurationMs: number): void => { if (!shadowAutoPolicyState.enabled || performanceDiagnosisActive || document.hidden) { shadowAutoPolicyState.underBudgetFrames = 0; return; } const cameraHeight = camera.position.y; let desired: ShadowPolicyLevel = 'full'; if (frameDurationMs >= shadowAutoPolicyState.severeFrameMs) { desired = 'objectsOnly'; } else if ( frameDurationMs >= shadowAutoPolicyState.softFrameMs && (cameraHeight >= shadowAutoPolicyState.highCameraMeters || rtsCamera.getDistance() >= 1200) ) { desired = 'reduced'; } if (desired === 'full') { shadowAutoPolicyState.underBudgetFrames += 1; if ( shadowAutoPolicyState.current !== 'full' && shadowAutoPolicyState.underBudgetFrames >= shadowAutoPolicyState.recoverFrames ) { applyShadowPolicyLevel('full'); shadowAutoPolicyState.underBudgetFrames = 0; console.info('[ShadowAutoPolicy] Restored full caster mask'); } return; } shadowAutoPolicyState.underBudgetFrames = 0; if (shadowAutoPolicyState.current !== desired) { applyShadowPolicyLevel(desired); console.info(`[ShadowAutoPolicy] Applied ${desired} caster mask`); } }; if (shadowAutoPolicyState.enabled) { applyShadowPolicyLevel('full'); } let runtimeProxyCaptureEnabled = captureEntityProxyObjects; let runtimeDecalCaptureEnabled = captureDecals; let runtimeUiOverlayCaptureEnabled = captureUiOverlays; let runtimeTacticalOverlayCaptureEnabled = captureTacticalOverlays; const getTenPointOptimizationSuite = () => { const cullingConfig = renderWorldExtractor.getCullingConfig(); return { points: [ { id: 1, label: 'Entity proxy capture ON', active: runtimeProxyCaptureEnabled }, { id: 2, label: 'Entity frustum culling ON', active: cullingConfig.frustumCulling }, { id: 3, label: 'Entity distance culling ON', active: cullingConfig.distanceCulling }, { id: 4, label: 'Entity cull distance tuned', active: cullingConfig.distanceCullMaxRange <= 2600, value: cullingConfig.distanceCullMaxRange }, { id: 5, label: 'Decal capture OFF', active: !runtimeDecalCaptureEnabled }, { id: 6, label: 'UI overlay capture OFF', active: !runtimeUiOverlayCaptureEnabled }, { id: 7, label: 'Dynamic resolution enabled', active: dynamicResolutionState.enabled }, { id: 8, label: 'Adaptive quality enabled', active: adaptiveQualityState.enabled }, { id: 9, label: 'Adaptive feature throttle enabled', active: featureThrottleState.enabled }, { id: 10, label: 'Shadow caster auto policy enabled', active: shadowAutoPolicyState.enabled } ], dynamicResolution: getDynamicResolutionSnapshot(), adaptiveQuality: getAdaptiveQualitySnapshot(), featureThrottle: getAdaptiveFeatureThrottleSnapshot(), shadowAutoPolicy: getShadowAutoPolicySnapshot() }; }; (window.__RTS as any).getTenPointOptimizationSuite = () => { const snapshot = getTenPointOptimizationSuite(); console.info('[Perf10] Optimization suite state:', snapshot); return snapshot; }; (window.__RTS as any).applyTenPointOptimizationSuite = (persist: boolean = true) => { renderWorldExtractor.setCaptureEntityProxyObjects(true); renderWorldExtractor.setFrustumCulling(true); renderWorldExtractor.setDistanceCulling(true); renderWorldExtractor.setDistanceCullMaxRange(2300); renderWorldExtractor.setCaptureDecals(false); renderWorldExtractor.setCaptureUiOverlays(false); renderWorldExtractor.setCaptureTacticalCommandOverlays(false); runtimeProxyCaptureEnabled = true; runtimeDecalCaptureEnabled = false; runtimeUiOverlayCaptureEnabled = false; runtimeTacticalOverlayCaptureEnabled = false; (window.__RTS as any).setAdaptiveQualityEnabled?.(true); (window.__RTS as any).setAdaptiveFeatureThrottleEnabled?.(true, persist); (window.__RTS as any).setDynamicResolutionEnabled?.(true, persist); (window.__RTS as any).setShadowCasterAutoPolicyEnabled?.(true, persist); (window.__RTS as any).setScatterBudgetTier?.(0, persist); (window.__RTS as any).setAdaptiveTerrainBudgetTier?.(0, persist); if (persist) { window.localStorage.setItem('rts.render.captureEntityProxyObjects', '1'); window.localStorage.setItem('rts.render.entityFrustumCulling', '1'); window.localStorage.setItem('rts.render.entityDistanceCulling', '1'); window.localStorage.setItem('rts.render.entityCullDistance', '2300'); window.localStorage.setItem('rts.render.captureDecals', '0'); window.localStorage.setItem('rts.render.captureUiOverlays', '0'); window.localStorage.setItem('rts.render.captureTacticalOverlays', '0'); } const snapshot = getTenPointOptimizationSuite(); console.info('[Perf10] Applied 10-point optimization suite.', snapshot); return snapshot; }; if (PERF_TARGET_10MS) { (window.__RTS as any).applyTenPointOptimizationSuite?.(false); } // Create FPS display overlay const fpsDisplay = new FPSDisplay(); fpsDisplay.show(); const animationDebugOverlay = (() => { let enabled = readStoredBoolean('rts.debug.animationOverlay') ?? true; let panel: HTMLDivElement | null = null; let lastText = 'Animation Debug\nSelected: 0'; let selectedCount = 0; let lastUpdateAt = 0; const updateIntervalMs = 80; const maxRows = 8; const ensurePanel = (): HTMLDivElement | null => { if (panel) return panel; if (typeof document === 'undefined') return null; const next = document.createElement('div'); next.id = 'animation-debug-overlay'; next.style.position = 'fixed'; next.style.right = '14px'; next.style.top = '88px'; next.style.background = 'rgba(8, 12, 18, 0.86)'; next.style.color = '#ccefff'; next.style.border = '1px solid rgba(95, 175, 220, 0.72)'; next.style.borderRadius = '8px'; next.style.padding = '9px 10px'; next.style.fontFamily = 'Consolas, "Courier New", monospace'; next.style.fontSize = '11px'; next.style.whiteSpace = 'pre'; next.style.lineHeight = '1.26'; next.style.maxWidth = '420px'; next.style.zIndex = '10005'; next.style.pointerEvents = 'none'; next.style.display = enabled ? 'block' : 'none'; next.textContent = lastText; const attach = () => document.body?.appendChild(next); if (document.body) { attach(); } else { window.addEventListener('DOMContentLoaded', attach, { once: true }); } panel = next; return panel; }; const setEnabled = (value: boolean, persist = true): boolean => { enabled = Boolean(value); if (persist) { try { window.localStorage.setItem('rts.debug.animationOverlay', enabled ? '1' : '0'); } catch { // ignore persistence failures } } const node = ensurePanel(); if (node) { node.style.display = enabled ? 'block' : 'none'; } return enabled; }; const fmt = (value: number | undefined, digits = 2): string => Number.isFinite(value) ? Number(value).toFixed(digits) : '-'; const fmtDeg = (radians: number | undefined): string => Number.isFinite(radians) ? (Number(radians) * (180 / Math.PI)).toFixed(1) : '-'; const buildRow = (unit: Unit): string => { const intent = unit.animationIntent; const mode = intent?.mode ?? 'none'; return [ `#${unit.id.toString().padStart(4, '0')}`, `${unit.unitType.padEnd(14, ' ')}`, `m:${mode.padEnd(7, ' ')}`, `spd:${fmt(intent?.moveSpeedNorm, 2).padStart(4, ' ')}`, `tur:${fmtDeg(intent?.turretYawDelta).padStart(6, ' ')}°`, `pit:${fmtDeg(intent?.aimPitch).padStart(6, ' ')}°`, `tor:${fmtDeg(intent?.torsoYawDelta).padStart(6, ' ')}°`, `fire:${fmt(intent?.firePulse, 2).padStart(4, ' ')}` ].join(' '); }; const update = (units: Unit[], selectedUnitIds: Set): void => { if (!enabled) { return; } const now = performance.now(); if (now - lastUpdateAt < updateIntervalMs) { return; } lastUpdateAt = now; const selected = units .filter((unit) => selectedUnitIds.has(unit.id)) .sort((a, b) => a.id - b.id); selectedCount = selected.length; const rows: string[] = [ 'Animation Debug (selected)', `Selected: ${selectedCount}` ]; if (selected.length <= 0) { rows.push('No unit selected.'); } else { rows.push('ID Type mode/spd/tur/pit/tor/fire'); for (const unit of selected.slice(0, maxRows)) { rows.push(buildRow(unit)); } if (selected.length > maxRows) { rows.push(`... +${selected.length - maxRows} more`); } } lastText = rows.join('\n'); const node = ensurePanel(); if (node) { node.textContent = lastText; } }; const getState = () => ({ enabled, selectedCount, text: lastText }); ensurePanel(); return { update, setEnabled, getState }; })(); (window.__RTS as any).setAnimationDebugOverlay = (enabled: boolean = true, persist: boolean = true) => animationDebugOverlay.setEnabled(Boolean(enabled), persist); (window.__RTS as any).getAnimationDebugOverlay = () => animationDebugOverlay.getState(); console.info('[Debug] Animation overlay command: __RTS.setAnimationDebugOverlay(true|false)'); // Add keyboard shortcut to toggle FPS display (F3 key) window.addEventListener('keydown', (e) => { if (e.key === 'F3') { fpsDisplay.toggle(); } }); // 🗺️ TERRAIN DEBUG PANEL: Add F4 keyboard shortcut to toggle terrain debug panel const terrainDebugPanel = new TerrainDebugPanel(); const debugToolsPanel = new DebugToolsPanel(); const overlayHubPanel = new OverlayHubPanel({ toggleFpsOverlay: () => fpsDisplay.toggle(), toggleTerrainDebugPanel: () => terrainDebugPanel.toggle(), toggleDebugToolsPanel: () => debugToolsPanel.toggle() }); (window.__RTS as any).toggleOverlayHubPanel = () => overlayHubPanel.toggle(); (window.__RTS as any).showOverlayHubPanel = () => overlayHubPanel.show(); (window.__RTS as any).hideOverlayHubPanel = () => overlayHubPanel.hide(); (window.__RTS as any).toggleDebugToolsPanel = () => debugToolsPanel.toggle(); (window.__RTS as any).showDebugToolsPanel = () => debugToolsPanel.show(); (window.__RTS as any).hideDebugToolsPanel = () => debugToolsPanel.hide(); (window.__RTS as any).getDebugToolsRegistry = () => getDebugToolsManifest(); (window.__RTS as any).listDebugTools = () => { console.info('[DebugTools] Registered controls and commands:'); let currentSection = ''; for (const entry of DEBUG_RUNTIME_COMMANDS) { if (entry.section !== currentSection) { currentSection = entry.section; console.info(` [${currentSection}]`); } console.info(` ${entry.usage} - ${entry.description}`); } return DEBUG_RUNTIME_COMMANDS.map((entry) => ({ ...entry })); }; window.addEventListener('keydown', (e) => { if (e.key === 'F7') { e.preventDefault(); overlayHubPanel.toggle(); } }); window.addEventListener('keydown', (e) => { if (e.key === 'F8') { e.preventDefault(); debugToolsPanel.toggle(); } }); console.info('[OverlayHub] Ready: F7 or __RTS.toggleOverlayHubPanel()'); console.info('[DebugTools] Ready: F8 or __RTS.toggleDebugToolsPanel()'); console.info('[DebugTools] Command list: __RTS.listDebugTools()'); // Set up terrain regeneration callback terrainDebugPanel.setOnRegenerateCallback(async (flags: TerrainFeatureFlags, tuning: HeightmapTuningOverrides) => { console.log('[TerrainDebug] Regenerating terrain with feature flags:', flags); try { // Get references from window.__RTS const rts = window.__RTS as any; console.log('[TerrainDebug] Checking references:', { hasRts: !!rts, hasSim: !!rts?.sim, hasScene: !!rts?.scene, hasTerrainRenderer: !!rts?.terrainRenderer, hasTerrainTextures: !!rts?.terrainTextures, hasWaterRenderer: !!rts?.waterRenderer }); if (!rts || !rts.sim || !rts.scene) { console.error('[TerrainDebug] Missing required references (sim or scene)'); return; } const { sim, scene } = rts; // Show loading notification const loadingNotification = document.createElement('div'); loadingNotification.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 10001; background: rgba(0, 0, 0, 0.9); color: #00ff00; padding: 24px 48px; border: 2px solid #00ff00; border-radius: 8px; font-family: 'Courier New', monospace; font-size: 16px; font-weight: bold; `; loadingNotification.textContent = '🗺️ Regenerating Terrain...'; document.body.appendChild(loadingNotification); // Get current terrain settings const currentTerra = sim.terra; // Debug: Check if currentTerra has the expected methods console.log('[TerrainDebug] Current terra type:', currentTerra?.constructor?.name); console.log('[TerrainDebug] Has getBiome?', typeof currentTerra?.getBiome); // Debug: Log the feature flags being passed console.log('[TerrainDebug] ========================================'); console.log('[TerrainDebug] FEATURE FLAGS BEING PASSED TO TERRA:'); console.log('[TerrainDebug] ========================================'); console.log('[TerrainDebug] flags object:', JSON.stringify(flags, null, 2)); console.log('[TerrainDebug] ========================================'); // Create new Terra instance with feature flags // Use safe access with fallbacks in case methods don't exist const newTileSize = (typeof currentTerra?.getMapSizeMeters === 'function' ? currentTerra.getMapSizeMeters() / MAP_TILES : TILE_SIZE); const newTerra = new Terra(MAP_TILES, MAP_TILES, newTileSize, { detailSettings: TERRAIN_DETAIL_SETTINGS, biome: (typeof currentTerra?.getBiome === 'function' ? currentTerra.getBiome() : null) ?? undefined, heightmapResolution: (typeof currentTerra?.getHeightmapResolution === 'function' ? currentTerra.getHeightmapResolution() : 1024) ?? 1024, fastLoad: false, layoutSeed: (typeof currentTerra?.getLayoutSeed === 'function' ? currentTerra.getLayoutSeed() : undefined) ?? undefined, layoutPlayerCount: (typeof currentTerra?.getLayoutPlayerCount === 'function' ? currentTerra.getLayoutPlayerCount() : undefined) ?? undefined, layoutSymmetry: (typeof currentTerra?.getLayoutSymmetry === 'function' ? currentTerra.getLayoutSymmetry() : undefined) ?? undefined, enforceSymmetry: (typeof currentTerra?.getEnforceSymmetry === 'function' ? currentTerra.getEnforceSymmetry() : false) ?? false, resourceDensity: (typeof currentTerra?.getResourceDensity === 'function' ? currentTerra.getResourceDensity() : 'medium') ?? 'medium', terrainRoughness: (typeof currentTerra?.getTerrainRoughness === 'function' ? currentTerra.getTerrainRoughness() : 0.5) ?? 0.5, waterCoverage: (typeof currentTerra?.getWaterCoverage === 'function' ? currentTerra.getWaterCoverage() : 0.5) ?? 0.5, plateauDensity: (typeof currentTerra?.getPlateauDensity === 'function' ? currentTerra.getPlateauDensity() : 0.5) ?? 0.5, featureFlags: flags, // Pass feature flags! heightmapTuning: tuning }); // Wait for terrain generation to complete await new Promise(resolve => setTimeout(resolve, 100)); // Update sim.terra reference sim.terra = newTerra; console.log('[TerrainDebug] Updated sim.terra reference'); // CRITICAL: Regenerate scatter meshes for the new terrain // Remove old scatter meshes from scene const oldScatterMeshes = rts.scatterMeshes; if (oldScatterMeshes?.scatterGroup) { scene.remove(oldScatterMeshes.scatterGroup); console.log('[TerrainDebug] Removed old scatter meshes'); } // Generate new scatter for the new terrain console.log('[TerrainDebug] Generating new scatter meshes...'); await newTerra.generateScatterAsync(); // Recreate scatter meshes in the scene // This is a simplified version - in production you'd want to call the full scatter setup console.log('[TerrainDebug] Scatter generation complete'); // CRITICAL: Update WebGPU terrain renderer with new heightmap if (rts.rendererService) { console.log('[TerrainDebug] Updating WebGPU terrain renderer...'); // The renderer service should automatically pick up changes from sim.terra // Force a rebuild by marking terrain as dirty (newTerra as any).markHeightDirty?.(); } console.log('[TerrainDebug] Terrain regenerated successfully'); // Display performance metrics (with safe access) const pipelineResult = typeof newTerra.getPipelineResult === 'function' ? newTerra.getPipelineResult() : undefined; terrainDebugPanel.showPerformanceMetrics(pipelineResult); if (pipelineResult) { console.log('[TerrainDebug] Performance metrics:', { totalDuration: pipelineResult.totalDuration, stages: pipelineResult.stages.map(s => ({ name: s.name, duration: s.duration })) }); } else { console.log('[TerrainDebug] No performance metrics available (getPipelineResult not available or returned undefined)'); } // Remove loading notification loadingNotification.remove(); // Show success notification const successNotification = document.createElement('div'); successNotification.style.cssText = ` position: fixed; top: 20px; right: 20px; z-index: 10000; background: rgba(0, 128, 0, 0.9); color: white; padding: 12px 24px; border-radius: 8px; font-family: 'Segoe UI', Arial, sans-serif; font-size: 14px; animation: fadeInOut 2s ease-out forwards; `; successNotification.textContent = '✅ Terrain Regenerated'; document.body.appendChild(successNotification); setTimeout(() => successNotification.remove(), 2000); console.log('[TerrainDebug] Terrain regeneration complete'); } catch (error) { console.error('[TerrainDebug] Terrain regeneration failed:', error); // Show error notification const errorNotification = document.createElement('div'); errorNotification.style.cssText = ` position: fixed; top: 20px; right: 20px; z-index: 10000; background: rgba(128, 0, 0, 0.9); color: white; padding: 12px 24px; border-radius: 8px; font-family: 'Segoe UI', Arial, sans-serif; font-size: 14px; `; errorNotification.textContent = '❌ Terrain Regeneration Failed'; document.body.appendChild(errorNotification); setTimeout(() => errorNotification.remove(), 3000); } }); // Set up scatter visibility toggle callback terrainDebugPanel.setOnScatterToggleCallback((flags) => { console.log('[TerrainDebug] Toggling scatter visibility:', flags); const rts = window.__RTS as any; if (!rts || !rts.scatterMeshes) { console.error('[TerrainDebug] Scatter meshes not available'); return; } const meshes = rts.scatterMeshes; // Toggle trees visibility if (meshes.trees && Array.isArray(meshes.trees)) { meshes.trees.forEach((treeMesh: any) => { if (treeMesh) treeMesh.visible = flags.trees ?? true; }); } // Toggle bushes visibility if (meshes.bushes) { meshes.bushes.visible = flags.bushes ?? true; } // Toggle short and tall grass visibility independently. if (meshes.grass) { meshes.grass.visible = flags.grass ?? true; } if (meshes.tallGrass) { meshes.tallGrass.visible = flags.tallGrass ?? true; } // Toggle ferns visibility if (meshes.ferns) { meshes.ferns.visible = flags.ferns ?? true; } // Toggle rocks visibility (includes rocks, boulders, pebbles) if (meshes.rocks && Array.isArray(meshes.rocks)) { meshes.rocks.forEach((rockMesh: any) => { if (rockMesh) rockMesh.visible = flags.rocks ?? true; }); } console.log('[TerrainDebug] Scatter visibility updated'); }); // Add F4 keyboard shortcut to toggle terrain debug panel window.addEventListener('keydown', (e) => { if (e.key === 'F4') { e.preventDefault(); terrainDebugPanel.toggle(); console.log('[TerrainDebug] Panel toggled'); } }); // 🔥 PHASE 2: Add F5/F9 for quicksave/quickload window.addEventListener('keydown', async (e) => { if (e.key === 'F5') { e.preventDefault(); const gameTime = victoryManager.getGameTime(); const settingsToSave = normalizedSettings ?? settings ?? DEFAULT_MATCH_SETTINGS; const result = await saveManager.quickSave(sim, settingsToSave, getCameraStateForSave(), gameTime); // Show save notification const notification = document.createElement('div'); notification.style.cssText = ` position: fixed; top: 20px; right: 20px; z-index: 10000; background: ${result.success ? 'rgba(0, 128, 0, 0.9)' : 'rgba(128, 0, 0, 0.9)'}; color: white; padding: 12px 24px; border-radius: 8px; font-family: 'Segoe UI', Arial, sans-serif; font-size: 14px; animation: fadeInOut 2s ease-out forwards; `; notification.textContent = result.success ? '💾 Game Saved' : '❌ Save Failed'; document.body.appendChild(notification); setTimeout(() => notification.remove(), 2000); } if (e.key === 'F9') { e.preventDefault(); const result = await saveManager.quickLoad(); if (result.success && result.data) { // Show load notification and reload prompt const notification = document.createElement('div'); notification.style.cssText = ` position: fixed; top: 20px; right: 20px; z-index: 10000; background: rgba(0, 100, 200, 0.95); color: white; padding: 16px 24px; border-radius: 8px; font-family: 'Segoe UI', Arial, sans-serif; font-size: 14px; `; notification.innerHTML = `
📂 Load Quicksave?
${result.data.metadata.mapName} - ${SaveManager.formatGameTime(result.data.metadata.gameTime)}
`; document.body.appendChild(notification); document.getElementById('load-confirm')?.addEventListener('click', () => { // Store pending load data and reload sessionStorage.setItem('pendingLoad', JSON.stringify(result.data)); window.location.reload(); }); document.getElementById('load-cancel')?.addEventListener('click', () => { notification.remove(); }); } else { const notification = document.createElement('div'); notification.style.cssText = ` position: fixed; top: 20px; right: 20px; z-index: 10000; background: rgba(128, 0, 0, 0.9); color: white; padding: 12px 24px; border-radius: 8px; font-family: 'Segoe UI', Arial, sans-serif; font-size: 14px; animation: fadeInOut 2s ease-out forwards; `; notification.textContent = '❌ No Quicksave Found'; document.body.appendChild(notification); setTimeout(() => notification.remove(), 2000); } } }); // Add CSS for save/load animations const saveLoadStyles = document.createElement('style'); saveLoadStyles.textContent = ` @keyframes fadeInOut { 0% { opacity: 0; transform: translateY(-10px); } 20% { opacity: 1; transform: translateY(0); } 80% { opacity: 1; transform: translateY(0); } 100% { opacity: 0; transform: translateY(-10px); } } `; document.head.appendChild(saveLoadStyles); // 🔥 PHASE 1: Entity culling statistics logging let cullingLogAccumulator = 0; const CULLING_LOG_INTERVAL = 5.0; // Log every 5 seconds // 🔥 PHASE 2: Victory system state let gameOverHandled = false; /** * Handle game over - display victory/defeat UI */ const handleGameOver = (victoryState: VictoryState): void => { console.info('[Victory] 🏆 GAME OVER!'); console.info('[Victory] Winner: Team', victoryState.winningTeamId); console.info('[Victory] Reason:', victoryState.reason); console.info('[Victory] Game Time:', Math.floor(victoryState.gameTime / 60) + 'm ' + Math.floor(victoryState.gameTime % 60) + 's'); // Get match statistics const stats = victoryManager.getMatchStatistics(sim); console.info('[Victory] Final Scores:', stats.teamScores); // Determine if local player won or lost const localPlayer = sim.getLocalPlayer(); const localPlayerId = localPlayer?.id ?? -1; const isWinner = victoryState.winningPlayerIds.includes(localPlayerId); // Create game over overlay const overlay = document.createElement('div'); overlay.id = 'game-over-overlay'; overlay.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.85); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 10000; font-family: 'Segoe UI', Arial, sans-serif; color: white; animation: fadeIn 0.5s ease-out; `; // Add victory/defeat banner const banner = document.createElement('div'); banner.style.cssText = ` font-size: 72px; font-weight: bold; text-shadow: 0 0 20px ${isWinner ? '#00ff00' : '#ff0000'}; margin-bottom: 40px; color: ${isWinner ? '#00ff00' : '#ff3333'}; `; banner.textContent = isWinner ? '🏆 VICTORY!' : '💀 DEFEAT'; overlay.appendChild(banner); // Add reason const reason = document.createElement('div'); reason.style.cssText = ` font-size: 24px; margin-bottom: 30px; opacity: 0.8; `; const reasonText = { 'annihilation': 'All enemies have been eliminated', 'timeout': 'Time limit reached', 'economic': 'Economic victory achieved', 'domination': 'Map control achieved', 'surrender': 'Enemy surrendered', 'disconnect': 'Enemy disconnected' }[victoryState.reason ?? 'annihilation']; reason.textContent = reasonText; overlay.appendChild(reason); // Add game time const gameTimeDiv = document.createElement('div'); gameTimeDiv.style.cssText = ` font-size: 20px; margin-bottom: 30px; opacity: 0.6; `; const mins = Math.floor(victoryState.gameTime / 60); const secs = Math.floor(victoryState.gameTime % 60); gameTimeDiv.textContent = `Game Duration: ${mins}m ${secs}s`; overlay.appendChild(gameTimeDiv); // Add team scores const scoresDiv = document.createElement('div'); scoresDiv.style.cssText = ` background: rgba(255, 255, 255, 0.1); border-radius: 10px; padding: 20px 40px; margin-bottom: 40px; `; scoresDiv.innerHTML = '
Final Scores
'; for (const team of stats.teamScores) { const isWinningTeam = team.teamId === victoryState.winningTeamId; const teamRow = document.createElement('div'); teamRow.style.cssText = ` display: flex; justify-content: space-between; gap: 40px; padding: 8px 0; font-size: 16px; ${isWinningTeam ? 'color: #00ff00; font-weight: bold;' : 'opacity: 0.8;'} `; teamRow.innerHTML = ` Team ${team.teamId} ${isWinningTeam ? '👑' : ''} ${Math.floor(team.score).toLocaleString()} pts ${team.unitCount} units ${team.buildingCount} buildings `; scoresDiv.appendChild(teamRow); } overlay.appendChild(scoresDiv); // Add return to menu button const button = document.createElement('button'); button.style.cssText = ` background: linear-gradient(180deg, #4a90d9, #357abd); border: none; border-radius: 8px; color: white; padding: 15px 40px; font-size: 18px; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; `; button.textContent = 'Return to Menu'; button.onmouseover = () => { button.style.transform = 'scale(1.05)'; button.style.boxShadow = '0 0 20px rgba(74, 144, 217, 0.5)'; }; button.onmouseout = () => { button.style.transform = 'scale(1)'; button.style.boxShadow = 'none'; }; button.onclick = () => { overlay.remove(); // Reload the page to return to menu window.location.reload(); }; overlay.appendChild(button); // Add CSS animation const style = document.createElement('style'); style.textContent = ` @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } `; document.head.appendChild(style); document.body.appendChild(overlay); }; const animate = (): void => { requestAnimationFrame(animate); renderer.info.reset(); // Update FPS display fpsDisplay.update(); const currentTime = performance.now(); const deltaTime = (currentTime - lastTime) / 1000; lastTime = currentTime; const currentFrame = frameIndex; const stageTimings: string[] = []; const stageProfilingEnabled = debugProfilingEnabled || frameProfiler.isEnabled(); const measureStage = (label: string, action: () => void): void => { if (!stageProfilingEnabled) { action(); return; } const start = performance.now(); action(); const duration = performance.now() - start; if (debugProfilingEnabled) { stageTimings.push(`${label}=${duration.toFixed(1)}ms`); console.debug(`[PROFILE] milestone:${label} took ${duration.toFixed(1)}ms`); } frameProfiler.recordStage(label, duration); }; const frameStart = performance.now(); frameProfiler.beginFrame(); const callsStart = debugProfilingEnabled ? renderer.info.render.calls : 0; if (!gameplayPanelsAutoShown) { uiManager.setGameplayPanelsVisible(true); gameplayPanelsAutoShown = true; } let frameLightingState: LightingState | null = null; measureStage('scene', () => { lightingController.update(deltaTime); frameLightingState = lightingController.getState(); updateSunMarker(); setGlobalParticleLighting( frameLightingState.sunDirection, frameLightingState.sunColor, frameLightingState.ambientColor, 1.0 ); rtsCamera.update(deltaTime); // Update viewport indicator (shows camera bounds at strategic zoom) viewportIndicator.update(rtsCamera.getDistance()); // Update strategic icons based on camera distance if (entityRenderer) { entityRenderer.updateStrategyIcons(rtsCamera.getDistance()); } else if (strategyIconOverlayRenderer) { const tacticalMapVisible = uiManager.isTacticalMapVisible(); strategyIconOverlayRenderer.update(tacticalMapVisible ? rtsCamera.getDistance() : 0); } // Update audio listener position to follow camera camera.getWorldDirection(listenerForwardScratch); soundManager.updateListener({ position: { x: camera.position.x, y: camera.position.y, z: camera.position.z }, forward: { x: listenerForwardScratch.x, y: listenerForwardScratch.y, z: listenerForwardScratch.z }, up: { x: camera.up.x, y: camera.up.y, z: camera.up.z } }); if (skyDome) { skyDome.position.copy(camera.position); skyDome.update(currentTime / 1000); } if (terrainMaterialRef) { terrainMaterialRef.uniforms.terrainCameraPosition.value.copy(camera.position); } treeSystem.applyLightingState(frameLightingState); // Update tree wind animation treeSystem.update(deltaTime); // Update combat feedback systems damageNumbers.update(deltaTime); }); const formationCommandPreview = inputController.getFormationCommandPreview(); if (webgpuReady) { const overlayFocus = rtsCamera.getPivotPosition(); renderWorldExtractor.setUiOverlayFocus(overlayFocus.x, overlayFocus.z); renderWorldExtractor.setSelectedUnitIds(selectionManager.selectedUnitIds); renderWorldExtractor.setTacticalCommandPreview(formationCommandPreview); const cameraSnapshot = captureCameraSnapshot(camera); const lightingSnapshot = createLightingSnapshot(frameLightingState ?? lightingController.getState()); renderWorldExtractor.tick(currentFrame, cameraSnapshot, lightingSnapshot); } if (runtimeRenderEnabled && waterRenderer) { measureStage('waterUpdate', () => waterRenderer.update(deltaTime, camera)); } measureStage('input', () => inputController.update()); // Update combat test panel (delta in ms for adaptive heavy-scenario throttles) combatTestPanel.update(deltaTime * 1000); scenarioDirectorPanel.update(); if (cameraFocusIndicator) { const hasSelection = selectionManager.selectedUnitIds.size > 0 || selectionManager.selectedBuildingIds.size > 0; const suppressForCommanding = hasSelection || inputController.isFormationCommandMode() || Boolean(sim.buildMode); cameraFocusIndicator.setEnabled(!suppressForCommanding); cameraFocusIndicator.update(); } measureStage('buildToggle', () => { if (!ENABLE_BUILD_GRID_OVERLAY) { buildingGridOverlay.hide(); previousBuildMode = sim.buildMode; return; } if (sim.buildMode !== previousBuildMode) { if (sim.buildMode) { buildingGridOverlay.show(); } else { buildingGridOverlay.hide(); } previousBuildMode = sim.buildMode; } if (sim.buildMode) { buildingGridOverlay.update(rtsCamera.getPivotPosition(), sim.buildMode as BuildingType); } }); measureStage('simTick', () => { sim.tick((1 / 60) * gameSpeedMultiplier); }); aiPlacementOverlay.update(sim.getAiPlacementDebug()); // 🔥 PHASE 2: Check victory conditions (cached, runs ~1/sec) measureStage('victoryCheck', () => { const victoryState = victoryManager.checkVictory(sim); if (victoryState.isGameOver && !gameOverHandled) { gameOverHandled = true; handleGameOver(victoryState); } }); const fogRendererEnabled = Boolean(fogOfWarRenderer?.isEnabled()); const fogFilterEnabled = fogRendererEnabled && sim.isFogOfWarEnabled(); // 🔥 PHASE 4: Update fog of war visibility texture measureStage('fogOfWar', () => { if (fogRendererEnabled && fogOfWarRenderer) { fogOfWarRenderer.updateFromSim(sim, currentFrame); } }); // Cache simulation snapshots once per frame to avoid repeated queries and allocations. const localPlayer = sim.getLocalPlayer(); const localPlayerId = localPlayer?.id; const localPlayerTeamId = localPlayer?.teamId; const hasLocalPlayer = localPlayerId !== undefined && localPlayerTeamId !== undefined; const selectedUnitIds = selectionManager.selectedUnitIds; const frameUnits = sim.units.getUnits(); const frameBuildings = sim.buildings.getBuildings(); animationDebugOverlay.update(frameUnits, selectedUnitIds); syncUnifiedEngineAudio(frameUnits, deltaTime * 1000); if (formationDebugRenderer) { formationDebugRenderer.setCommandPreview(formationCommandPreview); } if (terrainMarkerLayer) { terrainMarkerLayer.setFormationCommandPreview(formationCommandPreview); } if (formationDebugRenderer?.isEnabled()) { selectedGroupIdsScratch.clear(); for (const unit of frameUnits) { if (!selectedUnitIds.has(unit.id)) { continue; } if (typeof unit.combatGroupId === 'number') { selectedGroupIdsScratch.add(unit.combatGroupId); } } if (selectedGroupIdsScratch.size > 0) { formationDebugRenderer.update(selectedGroupIdsScratch); } else { formationDebugRenderer.clear(); } } else if (formationDebugRenderer) { formationDebugRenderer.clear(); } if (enableWebglScene && entityRenderer) { const frameCommandNodes = sim.getCommandNodes(); const frameTowerTargets = sim.getTowerTargets(); measureStage('gameVisuals', () => { // Get units and filter by visibility (fog of war) let units = frameUnits; if (fogFilterEnabled) { visibleUnitsScratch.length = 0; for (const unit of frameUnits) { // Always show own units if (hasLocalPlayer && unit.ownerId === localPlayerId) { visibleUnitsScratch.push(unit); continue; } // Show allied units (same team) if (hasLocalPlayer && unit.teamId === localPlayerTeamId) { visibleUnitsScratch.push(unit); continue; } // Only show enemy units if they're in visible area if (sim.isPositionVisible(unit.x, unit.z)) { visibleUnitsScratch.push(unit); } } units = visibleUnitsScratch; } entityRenderer.updateUnits(units); entityRenderer.updateCommandNodes(frameCommandNodes, units); // Update combat feedback (morale, threats, suppression) - ONLY for selected units selectedUnitsScratch.length = 0; moraleDataScratch.clear(); threatDataScratch.clear(); for (const unit of units) { if (!selectedUnitIds.has(unit.id)) { continue; } selectedUnitsScratch.push(unit); const morale = sim.units.getUnitMorale(unit.id); const threat = sim.units.getThreatAssessment(unit.id); if (morale) moraleDataScratch.set(unit.id, morale); if (threat) threatDataScratch.set(unit.id, threat); } entityRenderer.updateCombatFeedback(selectedUnitsScratch, moraleDataScratch, threatDataScratch); // Get buildings and filter by visibility (fog of war) let buildings = frameBuildings; if (fogFilterEnabled) { visibleBuildingsScratch.length = 0; for (const building of frameBuildings) { // Always show own buildings if (hasLocalPlayer && building.ownerId === localPlayerId) { visibleBuildingsScratch.push(building); continue; } // Show allied buildings (same team) if (hasLocalPlayer && building.teamId === localPlayerTeamId) { visibleBuildingsScratch.push(building); continue; } // Show enemy buildings if position is visible OR explored (shroud - last known position) if (sim.isPositionExplored(building.x, building.z)) { visibleBuildingsScratch.push(building); } } buildings = visibleBuildingsScratch; } entityRenderer.updateBuildings(buildings, frameTowerTargets); const towerProjectiles = sim.getTowerProjectiles(); const unitProjectiles = sim.getUnitProjectiles(); const muzzleFlashes = sim.consumeMuzzleFlashes(); const impacts = sim.consumeTowerHits(); const shieldImpacts = sim.consumeShieldImpacts(); combinedProjectilesScratch.length = 0; for (const projectile of towerProjectiles) { combinedProjectilesScratch.push(projectile); } for (const projectile of unitProjectiles) { combinedProjectilesScratch.push(projectile); } entityRenderer.updateProjectiles(combinedProjectilesScratch, muzzleFlashes, impacts); entityRenderer.updateTowerImpacts(impacts, deltaTime); entityRenderer.updateShieldImpacts(shieldImpacts); // Update procedural animations entityRenderer.updateAnimations(deltaTime); entityHeavyAccumulator += deltaTime; if (entityHeavyAccumulator >= ENTITY_HEAVY_INTERVAL) { entityHeavyAccumulator = 0; if (constructionRenderer) { constructionRenderer.update(buildings, deltaTime); } if (rallyPointRenderer) { rallyPointRenderer.update(buildings); } if (resourceRenderer) { resourceRenderer.update(sim.resourceDeposits.getDeposits()); } } }); // Camera shake disabled for better visual clarity // if (entityRenderer && (entityRenderer as any).aaaProjectileRenderer) { // const cameraShake = (entityRenderer as any).aaaProjectileRenderer.getCameraShake(); // if (cameraShake && cameraShake.isShaking()) { // const shakeOffset = cameraShake.getOffset(); // camera.position.add(shakeOffset); // } // } } // In WebGPU proxy-entity mode, keep overlay visuals (construction + projectiles) alive in WebGL. if (enableWebglScene && !entityRenderer) { measureStage('gameVisuals', () => { let units = frameUnits; if (fogFilterEnabled) { visibleUnitsScratch.length = 0; for (const unit of frameUnits) { if (hasLocalPlayer && unit.ownerId === localPlayerId) { visibleUnitsScratch.push(unit); continue; } if (hasLocalPlayer && unit.teamId === localPlayerTeamId) { visibleUnitsScratch.push(unit); continue; } if (sim.isPositionVisible(unit.x, unit.z)) { visibleUnitsScratch.push(unit); } } units = visibleUnitsScratch; } let buildings = frameBuildings; if (fogFilterEnabled) { visibleBuildingsScratch.length = 0; for (const building of frameBuildings) { if (hasLocalPlayer && building.ownerId === localPlayerId) { visibleBuildingsScratch.push(building); continue; } if (hasLocalPlayer && building.teamId === localPlayerTeamId) { visibleBuildingsScratch.push(building); continue; } if (sim.isPositionExplored(building.x, building.z)) { visibleBuildingsScratch.push(building); } } buildings = visibleBuildingsScratch; } if (strategyIconOverlayRenderer) { const tacticalMapVisible = uiManager.isTacticalMapVisible(); if (tacticalMapVisible) { const nextUnitIds = new Set(); for (const unit of units) { strategyIconOverlayRenderer.addUnitIcon(unit); nextUnitIds.add(unit.id); } for (const previousId of strategyOverlayUnitIds) { if (!nextUnitIds.has(previousId)) { strategyIconOverlayRenderer.removeIcon(previousId); } } strategyOverlayUnitIds.clear(); for (const id of nextUnitIds) { strategyOverlayUnitIds.add(id); } const nextBuildingIds = new Set(); for (const building of buildings) { const iconId = building.id + 1000000; strategyIconOverlayRenderer.addBuildingIcon(building); nextBuildingIds.add(iconId); } for (const previousId of strategyOverlayBuildingIds) { if (!nextBuildingIds.has(previousId)) { strategyIconOverlayRenderer.removeIcon(previousId); } } strategyOverlayBuildingIds.clear(); for (const id of nextBuildingIds) { strategyOverlayBuildingIds.add(id); } } else if (strategyOverlayUnitIds.size > 0 || strategyOverlayBuildingIds.size > 0) { for (const id of strategyOverlayUnitIds) { strategyIconOverlayRenderer.removeIcon(id); } for (const id of strategyOverlayBuildingIds) { strategyIconOverlayRenderer.removeIcon(id); } strategyOverlayUnitIds.clear(); strategyOverlayBuildingIds.clear(); } } if (constructionRenderer) { constructionRenderer.update(buildings, deltaTime); } const towerProjectiles = sim.getTowerProjectiles(); const unitProjectiles = sim.getUnitProjectiles(); const muzzleFlashes = sim.consumeMuzzleFlashes(); const impacts = sim.consumeTowerHits(); const shieldImpacts = sim.consumeShieldImpacts(); combinedProjectilesScratch.length = 0; for (const projectile of towerProjectiles) { combinedProjectilesScratch.push(projectile); } for (const projectile of unitProjectiles) { combinedProjectilesScratch.push(projectile); } updateUnifiedProjectileAudio(combinedProjectilesScratch as TowerProjectileSnapshot[], impacts); let overlayImpacts = impacts; const effectsManager = rendererService.getEffectsManager(); const overlayImpactGraphEnabled = Boolean(effectsManager?.isImpactGraphEnabled()) && overlayImpactContextBuilder !== null; if (overlayImpactGraphEnabled && effectsManager) { let spawnedCount = 0; try { for (const impact of impacts) { const context = overlayImpactContextBuilder.buildFromSnapshot(impact, sim.terra); if (effectsManager.spawnImpactGraph(context)) { spawnedCount += 1; } } // If graph authored all impacts, suppress legacy overlay impact bursts for clarity. if (spawnedCount === impacts.length && impacts.length > 0) { overlayImpacts = []; } } catch (error) { console.warn('[ImpactGraph] Overlay bridge failed; keeping legacy overlay impacts active', error); } } projectileOverlayRenderer?.update( combinedProjectilesScratch, muzzleFlashes, camera.position, deltaTime, overlayImpacts ); void shieldImpacts; }); } measureStage('ui', () => { if (selectionBoxRenderer) { // IMPROVEMENT 8: Update preview count when box selecting if (selectionManager.selectionBox.active) { const previewUnits = selectionManager.getUnitsInBoxPreview(frameUnits); selectionBoxRenderer.setPreviewUnits(previewUnits); } selectionBoxRenderer.render(selectionManager.selectionBox); } if (terrainMarkerLayer) { terrainMarkerLayer.update(); } if (!RENDER_TERRAIN_UNITS_BUILDINGS_ONLY) { screenEffects.update(deltaTime); } uiManager.update(); // Update game event notifications gameEventNotifications.update(); // Update music intensity based on combat playerUnitsScratch.length = 0; enemyUnitsScratch.length = 0; for (const unit of frameUnits) { if (unit.faction === 'player') { playerUnitsScratch.push(unit); } else if (unit.faction === 'enemy') { enemyUnitsScratch.push(unit); } } const currentTimeSeconds = currentTime * 0.001; intensityCalculator.calculateIntensity(playerUnitsScratch, enemyUnitsScratch, currentTimeSeconds); const intensity = intensityCalculator.update(deltaTime); musicManager.setIntensity(intensity); musicManager.update(deltaTime); // Update sound system (clean up finished sounds) soundManager.update(); }); if (runtimeRenderEnabled && waterRenderer) { measureStage('waterReflection', () => { reflectionAccumulator += deltaTime; if (reflectionAccumulator >= reflectionUpdateInterval) { reflectionAccumulator %= reflectionUpdateInterval; waterRenderer.updateReflection(camera); } }); } if (runtimeRenderEnabled && waterRenderer && enableWebglScene) { measureStage('waterRefraction', () => { waterRenderer.updateRefraction(scene, camera); }); } const renderStart = performance.now(); // 🔥 PHASE 1: Update terrain chunk visibility (frustum culling) measureStage('terrainCulling', () => { // Update mesh-based terrain (when enabled) if (modernTerrainRenderer) { modernTerrainRenderer.updateVisibility(camera); modernTerrainRenderer.updateLOD(camera.position); } // 🔥 PHASE 1: Update WebGPU terrain chunk visibility (50-75% GPU reduction) if (terrainChunkManager && webgpuReady) { terrainChunkManager.updateVisibility(camera); rendererService.updateChunkVisibility(terrainChunkManager.getVisibilityBuffer()); } }); measureStage('webgl', () => { if (runtimeRenderEnabled && enableWebglScene) { renderer.render(webglRenderScene, camera); } }); // Render WebGPU terrain FIRST (to the GPU canvas at z-index 1) measureStage('webgpu', () => { if (runtimeRenderEnabled && webgpuReady) { rendererService.renderFrame(currentFrame); } }); // Render damage numbers overlay (on top of everything) measureStage('damageNumbers', () => { damageNumbers.render(); }); // Debug: Log scene info occasionally if (!performanceDiagnosisActive && frameIndex % 300 === 0) { const buildingMeshes = entityRenderer ? (entityRenderer as any).buildingMeshes.size : 0; const unitMeshes = entityRenderer ? (entityRenderer as any).unitMeshes.size : 0; console.info(`[Debug] Scene children: ${scene.children.length}, Unit meshes: ${unitMeshes}, Building meshes: ${buildingMeshes}`); } // NOTE: THREE.js rendering is now disabled - all scatter objects are rendered in WebGPU // WebGPU renders terrain + scatter objects + entity proxies measureStage('postOverlay', () => { if (!ENABLE_FEATURE_OVERLAY || !highGroundOverlay || !chokeOverlay || !rampOverlay) { return; } // postProcessing.render(); // DISABLED - no longer needed const overlayPulse = 0.45 + Math.sin(currentTime * 0.0012) * 0.12; const overlayPulseSlow = 0.35 + Math.sin(currentTime * 0.0009) * 0.08; modulateOverlayOpacity(highGroundOverlay, THREE.MathUtils.clamp(overlayPulse, 0.3, 0.75)); modulateOverlayOpacity(chokeOverlay, THREE.MathUtils.clamp(overlayPulseSlow, 0.25, 0.65)); modulateOverlayOpacity( rampOverlay, THREE.MathUtils.clamp(overlayPulse * 0.85 + 0.05, 0.2, 0.6) ); }); const renderTime = performance.now() - renderStart; const frameDuration = performance.now() - frameStart; lastWebglDrawCalls = renderer.info.render.calls; lastWebglTriangles = renderer.info.render.triangles; frameProfiler.endFrame(frameDuration); frameIndex += 1; fpsLogFrames += 1; // Track instantaneous FPS and frame time const now = performance.now(); const frameTime = now - lastFrameTime; lastFrameTime = now; const instantFPS = frameTime > 0 ? 1000 / frameTime : 0; // Track min/max FPS if (instantFPS > 0 && instantFPS < 1000) { // Ignore unrealistic values minFPS = Math.min(minFPS, instantFPS); maxFPS = Math.max(maxFPS, instantFPS); } // Collect frame time samples frameTimeSamples.push(frameTime); if (frameTimeSamples.length > 60) { frameTimeSamples.shift(); } frameTimeHistory.push(frameTime); if (frameTimeHistory.length > FRAME_TIME_HISTORY_LIMIT) { frameTimeHistory.shift(); } // 🔥 PHASE 1: Log entity culling statistics periodically if (webgpuReady && !performanceDiagnosisActive) { cullingLogAccumulator += deltaTime; if (cullingLogAccumulator >= CULLING_LOG_INTERVAL) { cullingLogAccumulator = 0; const stats = renderWorldExtractor.getCullingStats(); console.info(`[EntityCulling] Total: ${stats.totalEntities}, Visible: ${stats.visibleEntities}, Culled: ${stats.culledEntities} (${stats.cullPercentage})`); } } if (debugProfilingEnabled) { const drawCalls = renderer.info.render.calls - callsStart; const triangleCount = renderer.info.render.triangles; logDebugProfile( 'frame', frameDuration, `calls=${drawCalls} tris=${triangleCount} render=${renderTime.toFixed(1)}ms stages=${stageTimings.join(' ')}` ); } tickAdaptiveQuality(frameDuration, now); tickAdaptiveFeatureThrottle(frameDuration, now); tickDynamicResolution(frameDuration, now); tickShadowAutoPolicy(frameDuration); if (webgpuReady) { rendererService.setFrameTimeMs(frameDuration); } // Record frame for performance benchmark (if initialized) if (performanceBenchmark) { performanceBenchmark.recordFrame(); } // Enhanced FPS logging every ~60 frames if (fpsLogFrames >= 60) { const elapsedMs = performance.now() - fpsLogStart; const avgFPS = elapsedMs > 0 ? (fpsLogFrames * 1000) / elapsedMs : 0; // Calculate frame time statistics const avgFrameTime = frameTimeSamples.reduce((a, b) => a + b, 0) / frameTimeSamples.length; const maxFrameTime = Math.max(...frameTimeSamples); const minFrameTime = Math.min(...frameTimeSamples); // Calculate 1% and 0.1% lows from a longer rolling history to avoid degenerate values. const sortedFrameHistory = [...frameTimeHistory].sort((a, b) => b - a); const onePercentIndex = Math.max(0, Math.ceil(sortedFrameHistory.length * 0.01) - 1); const pointOnePercentIndex = Math.max(0, Math.ceil(sortedFrameHistory.length * 0.001) - 1); const onePercentLow = sortedFrameHistory[onePercentIndex] ?? maxFrameTime; const pointOnePercentLow = sortedFrameHistory[pointOnePercentIndex] ?? maxFrameTime; // Convert to FPS const onePercentLowFPS = onePercentLow > 0 ? 1000 / onePercentLow : 0; const pointOnePercentLowFPS = pointOnePercentLow > 0 ? 1000 / pointOnePercentLow : 0; if (!performanceDiagnosisActive) { const latestFramegraphStats = webgpuReady ? rendererService.getLatestFrameStats() : null; const webglDrawCalls = lastWebglDrawCalls; const webglTriangles = lastWebglTriangles; const framegraphDrawCalls = latestFramegraphStats?.drawCalls ?? 0; const framegraphTriangles = latestFramegraphStats?.triangles ?? 0; const totalDrawCalls = webglDrawCalls + framegraphDrawCalls; const totalTriangles = webglTriangles + framegraphTriangles; console.info( `[Performance] ` + `AVG: ${avgFPS.toFixed(1)} FPS (${avgFrameTime.toFixed(1)}ms) | ` + `MIN: ${minFPS.toFixed(1)} FPS | ` + `MAX: ${maxFPS.toFixed(1)} FPS | ` + `1% Low: ${onePercentLowFPS.toFixed(1)} FPS | ` + `0.1% Low: ${pointOnePercentLowFPS.toFixed(1)} FPS | ` + `Frame time range: ${minFrameTime.toFixed(1)}-${maxFrameTime.toFixed(1)}ms | ` + `Draw calls: ${totalDrawCalls} (webgl=${webglDrawCalls}, framegraph=${framegraphDrawCalls}) | ` + `Triangles: ${totalTriangles.toLocaleString()}` ); } // Reset tracking fpsLogStart = performance.now(); fpsLogFrames = 0; minFPS = Infinity; maxFPS = 0; frameTimeSamples = []; } }; animate(); // Flatten/prepare terrain for building placement (window.__RTS as any).prepareTestTerrain = (radius: number = 150) => { console.info(`[Test] Preparing terrain in ${radius}m radius...`); // Flatten terrain in a grid pattern const flattenRadius = 4; // Flatten 4m around each point const gridStep = 12; // Place flattened areas every 12m let flattened = 0; for (let x = -radius; x <= radius; x += gridStep) { for (let z = -radius; z <= radius; z += gridStep) { // Get current height at this location const centerHeight = sim.terra.getHeightWorld(x, z); // Flatten a small area around this point for (let dx = -flattenRadius; dx <= flattenRadius; dx += 2) { for (let dz = -flattenRadius; dz <= flattenRadius; dz += 2) { const wx = x + dx; const wz = z + dz; const tile = sim.terra.worldToTile(wx, wz); if (tile.i >= 0 && tile.i < sim.terra.width && tile.k >= 0 && tile.k < sim.terra.height) { // Set height to center height (flatten) sim.terra.setHeightAtTile(tile.i, tile.k, centerHeight); flattened++; } } } } } // Recompute terrain features after modification // These are private methods, but we need to call them to update slopes/affordances (sim.terra as any).recomputeMinMax(); (sim.terra as any).computeSlopeMap(); (sim.terra as any).identifyTerracesAndRamps(); (sim.terra as any).identifyStrategicFeatures(); console.info(`[Test] Flattened ${flattened} terrain tiles`); console.info(`[Test] Terrain prepared! Now run: __RTS.spawnTestLights(50)`); }; // Expose test utilities for clustered lighting demo (window.__RTS as any).spawnTestLights = (count: number = 20) => { console.info(`[Test] Spawning ${count} test buildings for clustered lighting demo...`); console.info(`[Test] Scanning terrain for buildable locations...`); // Scan terrain for buildable locations const buildableLocations: Array<{x: number, z: number}> = []; const scanRadius = 200; // Larger scan area const scanStep = 8; // Grid spacing for (let x = -scanRadius; x <= scanRadius; x += scanStep) { for (let z = -scanRadius; z <= scanRadius; z += scanStep) { // Quick check using previewPlace const preview = sim.buildings.previewPlace('Factory', x, z, sim.terra); if (preview.ok) { buildableLocations.push({ x, z }); } } } console.info(`[Test] Found ${buildableLocations.length} buildable locations`); if (buildableLocations.length === 0) { console.error(`[Test] No buildable locations found! Try running: __RTS.prepareTestTerrain()`); return; } // Shuffle locations for variety for (let i = buildableLocations.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [buildableLocations[i], buildableLocations[j]] = [buildableLocations[j], buildableLocations[i]]; } const buildingTypes = ['Factory', 'Tower', 'ResearchLab', 'CommandCenter'] as const; let spawned = 0; const targetCount = Math.min(count, buildableLocations.length); for (let i = 0; i < targetCount; i++) { const loc = buildableLocations[i]; const buildType = buildingTypes[i % buildingTypes.length]; // Give resources to afford buildings sim.economy.refund({ mass: 1000, energy: 1000 }); const result = sim.buildings.place(buildType, loc.x, loc.z, sim.economy, sim.terra); if (result.ok && result.id !== undefined) { // Instantly complete the building for testing const building = sim.buildings.getBuildings().find((b: any) => b.id === result.id); if (building) { building.progress = 1.0; building.isComplete = true; // Trigger the onBuildingComplete callback to properly register the building const onComplete = (sim.buildings as any).onBuildingComplete; if (onComplete) { onComplete(building); } spawned++; if (spawned % 10 === 0) { console.info(`[Test] Progress: ${spawned}/${targetCount} buildings spawned...`); } } } } const completedBuildings = sim.buildings.getBuildings().filter((b: any) => b.isComplete); console.info(`[Test] Successfully spawned ${spawned}/${count} test buildings`); console.info(`[Test] Total buildings in sim: ${sim.buildings.getBuildings().length}`); console.info(`[Test] Completed buildings: ${completedBuildings.length}`); console.info(`[Test] Building positions:`, completedBuildings.map((b: any) => `${b.type} at (${b.x.toFixed(1)}, ${b.z.toFixed(1)})`)); if (spawned < count) { console.warn(`[Test] Only spawned ${spawned}/${count} buildings. Only ${buildableLocations.length} buildable locations available.`); } }; // Clear all test buildings (window.__RTS as any).clearTestBuildings = () => { const buildings = sim.buildings.getBuildings(); const count = buildings.length; // Remove all buildings by canceling them for (const building of [...buildings]) { sim.buildings.cancel(building.id, sim.economy, sim.terra); } console.info(`[Test] Cleared ${count} buildings`); }; // Move camera to see buildings (window.__RTS as any).viewBuildings = () => { const buildings = sim.buildings.getBuildings(); if (buildings.length === 0) { console.warn('[Test] No buildings to view!'); return; } // Calculate center of all buildings let sumX = 0, sumZ = 0; for (const b of buildings) { sumX += b.x; sumZ += b.z; } const centerX = sumX / buildings.length; const centerZ = sumZ / buildings.length; // Move camera to look at the center rtsCamera.focusOn(centerX, centerZ, true); rtsCamera.setDistance(200, true); rtsCamera.setRotation(0, 60, true); console.info(`[Test] Camera moved to view ${buildings.length} buildings at (${centerX.toFixed(1)}, ${centerZ.toFixed(1)})`); }; // Debug utility to check entity rendering (window.__RTS as any).debugEntities = () => { const units = sim.units.getUnits(); const buildings = sim.buildings.getBuildings(); console.info('[Debug] Entity Status:'); console.info(` Units: ${units.length}`); units.forEach((u: any, i: number) => { console.info(` Unit ${i}: pos=(${u.x.toFixed(1)}, ${u.z.toFixed(1)}), type=${u.unitType || 'Combat'}`); }); console.info(` Buildings: ${buildings.length}`); buildings.forEach((b: any, i: number) => { console.info(` Building ${i}: ${b.type} at (${b.x.toFixed(1)}, ${b.z.toFixed(1)}), complete=${b.isComplete}, progress=${(b.progress * 100).toFixed(0)}%`); }); const buildingMeshesCount = entityRenderer ? (entityRenderer as any).buildingMeshes.size : 0; const unitMeshesCount = entityRenderer ? (entityRenderer as any).unitMeshes.size : 0; console.info(` EntityRenderer meshes:`); console.info(` Building meshes: ${buildingMeshesCount}`); console.info(` Unit meshes: ${unitMeshesCount}`); console.info(` Scene children: ${scene.children.length}`); console.info(` Camera position: (${camera.position.x.toFixed(1)}, ${camera.position.y.toFixed(1)}, ${camera.position.z.toFixed(1)})`); }; // Draw call profiler - analyze scene for performance bottlenecks (window.__RTS as any).profileDrawCalls = () => { const activeWebglScene = webglRenderScene; const usingOverlayScene = activeWebglScene !== scene; console.info( `[DrawCallProfiler] Analyzing ${usingOverlayScene ? 'active WebGL overlay scene + base scene' : 'active scene'}...` ); const breakdown = DrawCallProfiler.analyzeScene(activeWebglScene, { camera, rendererDrawCalls: lastWebglDrawCalls }); if (usingOverlayScene) { console.info('[DrawCallProfiler] Active WebGL scene children:', activeWebglScene.children.length); DrawCallProfiler.printReport(breakdown); console.info('[DrawCallProfiler] Base scene comparison (for reference only):'); const baseBreakdown = DrawCallProfiler.analyzeScene(scene, { camera, rendererDrawCalls: lastWebglDrawCalls }); DrawCallProfiler.printReport(baseBreakdown); return { activeWebglScene: breakdown, baseScene: baseBreakdown }; } DrawCallProfiler.printReport(breakdown); return breakdown; }; // Simulation-side unit performance profiling (CPU hotspots in unit update loops). (window.__RTS as any).setUnitSimProfiling = (enabled: boolean = true) => { sim.units.setProfilingEnabled(Boolean(enabled)); console.info(`[Units] Simulation profiling ${enabled ? 'enabled' : 'disabled'}`); }; (window.__RTS as any).getUnitSimPerformance = () => { const stats = sim.units.getPerformanceStats(); console.info('[Units] Performance stats:', stats); return stats; }; (window.__RTS as any).getEntityCullingStats = () => { const stats = renderWorldExtractor.getCullingStats(); const config = renderWorldExtractor.getCullingConfig(); const result = { ...stats, ...config }; console.info('[EntityCulling] Current stats:', result); return result; }; (window.__RTS as any).setEntityFrustumCulling = (enabled: boolean = true) => { const value = Boolean(enabled); renderWorldExtractor.setFrustumCulling(value); try { window.localStorage.setItem('rts.render.entityFrustumCulling', value ? '1' : '0'); } catch { // ignore } console.info(`[EntityCulling] Frustum culling ${value ? 'enabled' : 'disabled'}`); }; (window.__RTS as any).setEntityDistanceCulling = (enabled: boolean = true) => { const value = Boolean(enabled); renderWorldExtractor.setDistanceCulling(value); try { window.localStorage.setItem('rts.render.entityDistanceCulling', value ? '1' : '0'); } catch { // ignore } console.info(`[EntityCulling] Distance culling ${value ? 'enabled' : 'disabled'}`); return value; }; (window.__RTS as any).setEntityCullDistance = (meters: number = 2300) => { const next = renderWorldExtractor.setDistanceCullMaxRange(Number(meters)); try { window.localStorage.setItem('rts.render.entityCullDistance', String(next)); } catch { // ignore } console.info(`[EntityCulling] Distance cull max range set to ${next}m`); return next; }; (window.__RTS as any).getEntityCullingConfig = () => { const config = renderWorldExtractor.getCullingConfig(); console.info('[EntityCulling] Config:', config); return config; }; (window.__RTS as any).getClusteredLightingStats = () => { const stats = rendererService.getLatestFrameStats(); if (!stats) { console.info('[ClusteredLighting] No frame stats available yet'); return null; } const result = { totalLights: stats.totalLights, pointLights: stats.pointLights, directionalLights: stats.directionalLights, clusteredLightingEnabled: stats.clusteredLightingEnabled, clusteredLightingEstimated: stats.clusteredLightingEstimated, activeClusters: stats.activeClusters, totalClusters: stats.totalClusters }; console.info('[ClusteredLighting] Current stats:', result); return result; }; (window.__RTS as any).getRendererFrameStats = () => { const stats = rendererService.getLatestFrameStats(); console.info('[Renderer] Latest frame stats:', stats); return stats; }; (window.__RTS as any).getBindingUploadStats = () => { const stats = rendererService.getBindingUploadStats(); console.info('[Renderer] Frame binding upload stats:', stats); return stats; }; // Debug utility to check terrain elevation (window.__RTS as any).debugTerrain = () => { const terra = sim.terra as any; console.info('[Debug] Terrain Status:'); console.info(` Dimensions: ${terra.width} x ${terra.height}`); console.info(` Tile size: ${terra.tileSize}`); console.info(` Vertical scale: ${terra.getVerticalScale()}`); console.info(` Height range: min=${terra.minHeight.toFixed(2)}, max=${terra.maxHeight.toFixed(2)}`); // Sample heights at different radii from center const centerX = Math.floor(terra.width / 2); const centerZ = Math.floor(terra.height / 2); const sampleRadii = [0, 50, 100, 200, 300, 400, 450, 480, 500]; console.info(' Sample heights at different distances from center:'); for (const radius of sampleRadii) { const x = centerX + radius; const z = centerZ; if (x >= 0 && x < terra.width) { const idx = x * terra.height + z; const height = terra.heights[idx]; const distFromCenter = radius / (terra.width / 2); console.info(` radius ${radius} (dist=${distFromCenter.toFixed(2)}): ${height.toFixed(2)}m`); } } // Check if terrain is flat const heightVariance = terra.maxHeight - terra.minHeight; if (heightVariance < 1.0) { console.warn(' ⚠️ TERRAIN IS FLAT! Height variance is only', heightVariance.toFixed(2)); console.warn(' This means the terrain has no elevation!'); } else { console.info(` ✅ Terrain has elevation! Variance: ${heightVariance.toFixed(2)}`); } // Count terrain types const typeCounts: Record = { 0: 0, 1: 0, 2: 0, 3: 0 }; const typeNames: Record = { 0: 'GRASS', 1: 'ROCK', 2: 'SAND', 3: 'DIRT' }; for (let i = 0; i < terra.terrainTypes.length; i++) { const type = terra.terrainTypes[i]; if (type in typeCounts) { typeCounts[type]++; } } const total = terra.terrainTypes.length; console.info(' Terrain type distribution:'); for (const [typeStr, count] of Object.entries(typeCounts)) { const type = parseInt(typeStr); const pct = ((count / total) * 100).toFixed(1); console.info(` ${typeNames[type]}: ${count} tiles (${pct}%)`); } }; const battlegroupScenario = { unitIds: new Set(), buildings: new Map() }; const clearBattlegroupBattle = (): void => { if (battlegroupScenario.unitIds.size > 0) { const units = sim.units.getUnits(); const survivors = units.filter(unit => !battlegroupScenario.unitIds.has(unit.id)); units.length = 0; units.push(...survivors); const unitIndex = (sim.units as any).unitById as Map; for (const id of battlegroupScenario.unitIds) { unitIndex.delete(id); } battlegroupScenario.unitIds.clear(); console.info('[Battle] Cleared previous battlegroup units'); } if (battlegroupScenario.buildings.size > 0) { const buildings = sim.buildings.getBuildings(); const getFootprint = (sim.buildings as any).getFootprint?.bind(sim.buildings); for (const building of battlegroupScenario.buildings.values()) { const index = buildings.findIndex(b => b.id === building.id); if (index >= 0) { const footprint = getFootprint?.(building.type) ?? { w: 1, h: 1 }; sim.terra.setBlockedFootprint(building.tileI, building.tileK, footprint.w, footprint.h, false); buildings.splice(index, 1); } } battlegroupScenario.buildings.clear(); console.info('[Battle] Removed previous battlegroup towers'); } }; (window.__RTS as any).clearBattlegroupBattle = () => { clearBattlegroupBattle(); }; (window.__RTS as any).spawnBattlegroupBattle = (options?: { unitsPerSide?: number }) => { const unitCountScale = Math.max(0.5, Math.min(4, combatTestPanel.getUnitCountScale())); const requestedPerSide = options?.unitsPerSide ?? 10; const unitsPerSide = Math.max(3, Math.min(96, Math.round(requestedPerSide * unitCountScale))); clearBattlegroupBattle(); const battleZ = 0; const playerBaseX = -130; const enemyBaseX = 130; const unitSpacing = 9; const productionRoster = Object.keys(UNIT_PRODUCTION) .filter((type) => type !== 'Gatherer') .map(type => type as UnitType); const spawnSideUnits = (owner: 'player' | 'enemy', centerX: number, moveTargetX: number): void => { const startZ = -(unitsPerSide - 1) * unitSpacing * 0.5; const ids: number[] = []; for (let i = 0; i < unitsPerSide; i++) { const spawnZ = startZ + i * unitSpacing; const spawnX = centerX + (owner === 'player' ? -10 : 10); const faction = owner === 'player' ? 'player' : 'enemy'; const exponent = productionRoster[i % productionRoster.length]; const id = sim.units.spawn(exponent, spawnX, spawnZ, undefined, faction); battlegroupScenario.unitIds.add(id); ids.push(id); } if (ids.length > 0) { sim.units.issueMove(ids, moveTargetX, battleZ); } }; const spawnTowerLine = (owner: 'player' | 'enemy', x: number, levels: number[]): void => { const spacing = 18; const startZ = -(levels.length - 1) * spacing * 0.5; for (let i = 0; i < levels.length; i++) { const tower = sim.buildings.spawnNeutral('Tower', x, startZ + i * spacing, sim.terra, { yaw: owner === 'enemy' ? Math.PI : 0, owner, snapToGrid: true, coverType: null }); if (!tower) { console.warn('[Battle] Unable to spawn battlegroup tower'); continue; } tower.isNeutral = false; tower.owner = owner; tower.level = levels[i]; battlegroupScenario.buildings.set(tower.id, tower); } }; spawnSideUnits('player', playerBaseX, enemyBaseX - 12); spawnSideUnits('enemy', enemyBaseX, playerBaseX + 12); spawnTowerLine('player', playerBaseX + 25, [2, 4]); spawnTowerLine('enemy', enemyBaseX - 25, [3, 4]); console.info( `[Battle] AAA battlegroup duel spawned (${unitsPerSide} units per side, scale=${unitCountScale.toFixed(2)}). ` + 'Watch the laser, rocket, and artillery fire!' ); console.info(' Clear the arena with __RTS.clearBattlegroupBattle() before spawning a fresh engagement.'); }; // Expose sim and camera for debugging (window.__RTS as any).sim = sim; (window.__RTS as any).camera = camera; (window.__RTS as any).rtsCamera = rtsCamera; (window.__RTS as any).ProceduralMeshLoader = ProceduralMeshLoader; // === SDF SHIELD EFFECT CONSOLE COMMANDS === (window.__RTS as any).enableShield = (unitId: number, radius: number = 2.0) => { if (!entityRenderer) { console.warn('[Shield] EntityRenderer not available'); return; } entityRenderer.enableUnitShield(unitId, radius); console.info(`[Shield] 🛡️ Enabled shield on unit ${unitId} with radius ${radius}`); }; (window.__RTS as any).disableShield = (unitId: number) => { if (!entityRenderer) { console.warn('[Shield] EntityRenderer not available'); return; } entityRenderer.disableShield(unitId); console.info(`[Shield] 🛡️ Disabled shield on unit ${unitId}`); }; (window.__RTS as any).toggleShieldsOnSelected = () => { const selectedUnitIds = Array.from(selectionManager.selectedUnitIds); if (selectedUnitIds.length === 0) { console.info('[Shield] ⚠️ No units selected'); return; } if (!entityRenderer) { console.warn('[Shield] EntityRenderer not available'); return; } const shieldedUnits = (window.__RTS as any).shieldedUnits as Set; selectedUnitIds.forEach((unitId: number) => { if (shieldedUnits.has(unitId)) { entityRenderer!.disableShield(unitId); shieldedUnits.delete(unitId); } else { entityRenderer!.enableUnitShield(unitId, 2.0); shieldedUnits.add(unitId); } }); console.info(`[Shield] 🛡️ Toggled shields on ${selectedUnitIds.length} units`); }; (window.__RTS as any).testShieldHit = (unitId: number, strength: number = 0.5) => { if (!entityRenderer) { console.warn('[Shield] EntityRenderer not available'); return; } entityRenderer.triggerShieldHit(unitId, strength); console.info(`[Shield] 💥 Triggered shield hit on unit ${unitId} with strength ${strength}`); }; (window.__RTS as any).getShieldCount = () => { if (!entityRenderer) { console.warn('[Shield] EntityRenderer not available'); return 0; } const manager = entityRenderer.getShieldEffectManager(); const count = (manager as any).shields?.size ?? 0; console.info(`[Shield] Active shields: ${count}`); return count; }; (window.__RTS as any).clearShields = () => { if (!entityRenderer) { console.warn('[Shield] EntityRenderer not available'); return; } entityRenderer.clearShields(); const shieldedUnits = (window.__RTS as any).shieldedUnits as Set | undefined; shieldedUnits?.clear?.(); console.info('[Shield] Cleared all active shield visuals.'); }; console.info('[Bootstrap] 🛡️ SDF Shield debug commands available:'); console.info(' __RTS.enableShield(unitId, radius?) - Enable shield on unit'); console.info(' __RTS.disableShield(unitId) - Disable shield on unit'); console.info(' __RTS.toggleShieldsOnSelected() - Toggle shields on selected units'); console.info(' __RTS.testShieldHit(unitId, strength?) - Test shield hit effect'); console.info(' __RTS.getShieldCount() - Get number of active shields'); console.info(' __RTS.clearShields() - Clear all active shields'); // === INTEGRATED ZOOM SYSTEM CONSOLE COMMANDS === (window.__RTS as any).setZoomLevel = (level: string, instant: boolean = false) => { const validLevels = ['close', 'near', 'medium', 'far', 'tactical', 'strategic', 'overview']; if (!validLevels.includes(level)) { console.error(`[Zoom] Invalid zoom level: "${level}". Valid levels: ${validLevels.join(', ')}`); return; } rtsCamera.setZoomLevel(level as any, instant); console.info(`[Zoom] Set zoom level to "${level}" (${instant ? 'instant' : 'smooth'})`); console.info(`[Zoom] Camera distance: ${rtsCamera.getDistance().toFixed(0)}m`); }; (window.__RTS as any).getZoomLevel = () => { const level = rtsCamera.getZoomLevel(); const distance = rtsCamera.getDistance(); console.info(`[Zoom] Current zoom level: "${level}"`); console.info(`[Zoom] Camera distance: ${distance.toFixed(0)}m`); console.info(`[Zoom] Icon visibility: ${distance >= 700 ? (distance >= 900 ? 'FULL' : 'FADING IN') : 'HIDDEN'}`); console.info(`[Zoom] Clustering: ${distance >= 10000 ? 'ACTIVE' : 'INACTIVE'}`); return { level, distance }; }; (window.__RTS as any).toggleIconRendering = () => { if (!entityRenderer) { console.warn('[Zoom] EntityRenderer not available'); return; } // Toggle by setting camera distance to force icon visibility change const currentDistance = rtsCamera.getDistance(); if (currentDistance < 700) { rtsCamera.setDistance(1500, false); console.info('[Zoom] Icons enabled (zoomed to 1500m)'); } else { rtsCamera.setDistance(200, false); console.info('[Zoom] Icons disabled (zoomed to 200m)'); } }; console.info('[Zoom] Integrated zoom system commands available:'); console.info(' __RTS.setZoomLevel("strategic") - Set camera to strategic zoom (5000m)'); console.info(' __RTS.setZoomLevel("overview") - Set camera to overview zoom (12000m)'); console.info(' __RTS.setZoomLevel("close", true) - Instant zoom to close view (30m)'); console.info(' __RTS.getZoomLevel() - Get current zoom level and info'); console.info(' __RTS.toggleIconRendering() - Toggle strategic icon rendering'); console.info(' Valid zoom levels: close, near, medium, far, tactical, strategic, overview'); // === TERRAIN CLIPMAP LOD CONSOLE COMMANDS === (window.__RTS as any).getClipmapInfo = () => { const cameraPos = rtsCamera.getPivotPosition(); const cameraDistance = rtsCamera.getDistance(); console.info('[Clipmap] Terrain Clipmap LOD System Status:'); console.info(` Camera Position: (${cameraPos.x.toFixed(1)}, ${cameraPos.y.toFixed(1)}, ${cameraPos.z.toFixed(1)})`); console.info(` Camera Distance: ${cameraDistance.toFixed(0)}m`); console.info(` Active Levels: 6 (256×256 grid each)`); console.info(` Vertex Spacing: 1m, 2m, 4m, 8m, 16m, 32m`); console.info(` Coverage: 256m to 8192m radius`); console.info(` Total Vertices: ~393,216 (vs 1,048,576 for single-LOD)`); console.info(` Memory Savings: ~62% fewer vertices`); console.info(` Visible Levels: ${cameraDistance < 256 ? '1-2' : cameraDistance < 1024 ? '1-3' : cameraDistance < 4096 ? '1-5' : 'All 6'}`); return { position: cameraPos, distance: cameraDistance, levels: 6, vertexSpacing: [1, 2, 4, 8, 16, 32], coverage: [256, 512, 1024, 2048, 4096, 8192] }; }; console.info('[Clipmap] Terrain Clipmap LOD system active!'); console.info(' __RTS.getClipmapInfo() - Get clipmap system status'); console.info(' System: 6 LOD levels, 256×256 grid, 1m-32m spacing, 256m-8192m coverage'); // Expose formation debug renderer if (formationDebugRenderer) { (window.__RTS as any).formationDebug = { toggle: () => formationDebugRenderer.toggleAll(), toggleShapes: () => formationDebugRenderer.toggleFormationShapes(), toggleSlots: () => formationDebugRenderer.toggleSlotMarkers(), toggleLines: () => formationDebugRenderer.toggleAssignmentLines(), setOptions: (opts: any) => formationDebugRenderer.setOptions(opts), getOptions: () => formationDebugRenderer.getOptions() }; console.info('[FormationDebug] Formation debug renderer available:'); console.info(' __RTS.formationDebug.toggle() - Toggle all formation visualizations'); console.info(' __RTS.formationDebug.toggleShapes() - Toggle formation shapes'); console.info(' __RTS.formationDebug.toggleSlots() - Toggle slot markers'); console.info(' __RTS.formationDebug.toggleLines() - Toggle assignment lines'); } // Create and expose procedural mesh tester const meshTester = new ProceduralMeshTester(scene, sim.terra); (window.__RTS as any).meshTester = meshTester; // === PERFORMANCE BENCHMARK SYSTEM === const { PerformanceBenchmark } = await import('./profiling/PerformanceBenchmark'); performanceBenchmark = new PerformanceBenchmark(); (window.__RTS as any).benchmark = performanceBenchmark; // Benchmark console commands (window.__RTS as any).startBenchmark = (name: string) => { performanceBenchmark.startSession(name); console.info(`[Benchmark] Started session: "${name}"`); console.info('[Benchmark] Recording frame times... Call __RTS.endBenchmark() when done'); }; (window.__RTS as any).endBenchmark = () => { const session = performanceBenchmark.endSession(); if (session) { console.info(`[Benchmark] Session "${session.name}" complete!`); console.info(` Duration: ${((session.endTime - session.startTime) / 1000).toFixed(1)}s`); console.info(` Avg FPS: ${session.metrics.avgFps.toFixed(1)}`); console.info(` Min FPS: ${session.metrics.minFps.toFixed(1)}`); console.info(` Max FPS: ${session.metrics.maxFps.toFixed(1)}`); console.info(` Frame Time: ${session.metrics.frameTimeMs.toFixed(2)}ms`); return session; } else { console.warn('[Benchmark] No active session to end'); } }; (window.__RTS as any).compareBenchmarks = (name1: string, name2: string) => { const sessions = performanceBenchmark.getSessions(); const session1 = sessions.find(s => s.name === name1); const session2 = sessions.find(s => s.name === name2); if (!session1 || !session2) { console.error(`[Benchmark] Could not find sessions: "${name1}" or "${name2}"`); console.info('[Benchmark] Available sessions:', sessions.map(s => s.name)); return; } const comparison = performanceBenchmark.compareSessions(session1, session2); console.info(`[Benchmark] Comparing "${name1}" vs "${name2}":`); console.info(` FPS Change: ${comparison.fpsImprovement >= 0 ? '+' : ''}${comparison.fpsImprovement.toFixed(1)} (${comparison.fpsImprovementPercent >= 0 ? '+' : ''}${comparison.fpsImprovementPercent.toFixed(1)}%)`); console.info(` Frame Time Change: ${comparison.frameTimeImprovement >= 0 ? '-' : '+'}${Math.abs(comparison.frameTimeImprovement).toFixed(2)}ms (${comparison.frameTimeImprovementPercent >= 0 ? '-' : '+'}${Math.abs(comparison.frameTimeImprovementPercent).toFixed(1)}%)`); if (comparison.fpsImprovementPercent > 0) { console.info(` 🎯 Performance IMPROVED by ${comparison.fpsImprovementPercent.toFixed(1)}%`); } else if (comparison.fpsImprovementPercent < 0) { console.warn(` ⚠️ Performance DEGRADED by ${Math.abs(comparison.fpsImprovementPercent).toFixed(1)}%`); } else { console.info(` ➡️ No significant change`); } return comparison; }; (window.__RTS as any).listBenchmarks = () => { const sessions = performanceBenchmark.getSessions(); if (sessions.length === 0) { console.info('[Benchmark] No sessions recorded yet'); console.info('[Benchmark] Start a session with: __RTS.startBenchmark("session-name")'); return; } console.info(`[Benchmark] Recorded Sessions (${sessions.length}):`); sessions.forEach((session, i) => { const duration = (session.endTime - session.startTime) / 1000; console.info(` ${i + 1}. "${session.name}"`); console.info(` Duration: ${duration.toFixed(1)}s | Avg FPS: ${session.metrics.avgFps.toFixed(1)} | Samples: ${session.samples.length}`); }); }; (window.__RTS as any).toggleBenchmarkOverlay = () => { performanceBenchmark.toggleOverlay(); }; // Backward-compatible alias for benchmark overlay. (window.__RTS as any).togglePerformanceOverlay = () => { performanceBenchmark.toggleOverlay(); }; (window.__RTS as any).exportBenchmarks = () => { const data = performanceBenchmark.exportData(); console.info('[Benchmark] Benchmark data:'); console.info(data); // Copy to clipboard if available if (navigator.clipboard) { navigator.clipboard.writeText(data).then(() => { console.info('[Benchmark] ✅ Data copied to clipboard!'); }).catch(() => { console.info('[Benchmark] Could not copy to clipboard'); }); } return data; }; (window.__RTS as any).resetBenchmarks = () => { performanceBenchmark.reset(); console.info('[Benchmark] All benchmark data cleared'); }; let lastPerformanceDiagnosis: unknown = null; let lastRenderFeatureSweep: unknown = null; let renderFeatureSweepActive = false; let renderFeatureSweepRunId = 0; const average = (values: number[]): number => { if (values.length === 0) return 0; let sum = 0; for (const value of values) { sum += value; } return sum / values.length; }; const percentile = (values: number[], p: number): number => { if (values.length === 0) return 0; const sorted = [...values].sort((a, b) => a - b); const clamped = Math.max(0, Math.min(1, p)); const index = Math.min(sorted.length - 1, Math.max(0, Math.floor(clamped * (sorted.length - 1)))); return sorted[index]; }; (window.__RTS as any).runPerformanceDiagnosis = async (options?: { durationSec?: number; warmupSec?: number; ignoreHidden?: boolean; requireFocus?: boolean; }) => { if (performanceDiagnosisActive) { const activeError = { generatedAt: new Date().toISOString(), error: 'runPerformanceDiagnosis is already active. Wait for the current capture to finish.' }; console.warn('[PerfDiagnosis] Capture already in progress.', activeError); return activeError; } const durationSec = Math.max(3, Math.min(30, options?.durationSec ?? 10)); const warmupSec = Math.max(0, Math.min(10, options?.warmupSec ?? 1.5)); const ignoreHidden = options?.ignoreHidden ?? true; const requireFocus = options?.requireFocus ?? true; const captureStart = performance.now() + warmupSec * 1000; const captureEnd = captureStart + durationSec * 1000; const previousFrameProfilerState = frameProfiler.isEnabled(); type PerfDiagnosisSample = { timestamp: number; frameMs: number; drawCalls: number; triangles: number; framegraphCpuMs: number; framegraphDrawCalls: number; framegraphTriangles: number; totalEntities: number; visibleEntities: number; culledEntities: number; pointLights: number; directionalLights: number; clusteredLightingEnabled: boolean; unitGridMs: number; unitFormationMs: number; unitMovementMs: number; unitCombatMs: number; }; const samples: PerfDiagnosisSample[] = []; let skippedHiddenSamples = 0; let skippedUnfocusedSamples = 0; let totalRafSamples = 0; type PerfLongTaskRecord = { startTime: number; duration: number; }; const longTaskRecords: PerfLongTaskRecord[] = []; let longTaskObserver: PerformanceObserver | null = null; let longTaskSupported = false; if (!previousFrameProfilerState) { frameProfiler.setEnabled(true); } frameProfiler.clearRecentFrames(); performanceDiagnosisActive = true; (window as any).__RTS_PERF_DIAG_ACTIVE = true; try { if (typeof PerformanceObserver !== 'undefined') { const supportedEntryTypes = (PerformanceObserver as any).supportedEntryTypes as string[] | undefined; longTaskSupported = Array.isArray(supportedEntryTypes) ? supportedEntryTypes.includes('longtask') : true; if (longTaskSupported) { try { longTaskObserver = new PerformanceObserver((entryList) => { for (const entry of entryList.getEntries()) { longTaskRecords.push({ startTime: entry.startTime, duration: entry.duration }); if (longTaskRecords.length > 4096) { longTaskRecords.shift(); } } }); longTaskObserver.observe({ entryTypes: ['longtask'] }); } catch { longTaskObserver = null; longTaskSupported = false; } } } console.info( `[PerfDiagnosis] Starting capture: warmup=${warmupSec.toFixed(1)}s, sample=${durationSec.toFixed(1)}s, ` + `ignoreHidden=${ignoreHidden ? 'on' : 'off'}, requireFocus=${requireFocus ? 'on' : 'off'}` ); let previousTimestamp = performance.now(); await new Promise((resolve) => { const sampleFrame = () => { const now = performance.now(); const frameMs = Math.max(0.0001, now - previousTimestamp); previousTimestamp = now; if (now >= captureStart && now <= captureEnd) { totalRafSamples += 1; const visible = typeof document === 'undefined' ? true : document.visibilityState === 'visible'; const focused = typeof document === 'undefined' ? true : (typeof document.hasFocus === 'function' ? document.hasFocus() : true); if ((ignoreHidden && !visible) || (requireFocus && !focused)) { if (ignoreHidden && !visible) skippedHiddenSamples += 1; if (requireFocus && !focused) skippedUnfocusedSamples += 1; requestAnimationFrame(sampleFrame); return; } const rendererStats = rendererService.getLatestFrameStats(); const cullingStats = renderWorldExtractor.getCullingStats(); const unitStats = sim.units.getPerformanceStats(); samples.push({ timestamp: now, frameMs, drawCalls: renderer.info.render.calls, triangles: renderer.info.render.triangles, framegraphCpuMs: rendererStats?.totalCpuTime ?? 0, framegraphDrawCalls: rendererStats?.drawCalls ?? 0, framegraphTriangles: rendererStats?.triangles ?? 0, totalEntities: cullingStats.totalEntities ?? 0, visibleEntities: cullingStats.visibleEntities ?? 0, culledEntities: cullingStats.culledEntities ?? 0, pointLights: rendererStats?.pointLights ?? 0, directionalLights: rendererStats?.directionalLights ?? 0, clusteredLightingEnabled: rendererStats?.clusteredLightingEnabled ?? false, unitGridMs: unitStats.gridRebuildTime ?? 0, unitFormationMs: unitStats.formationUpdateTime ?? 0, unitMovementMs: unitStats.movementUpdateTime ?? 0, unitCombatMs: unitStats.combatUpdateTime ?? 0 }); } if (now < captureEnd) { requestAnimationFrame(sampleFrame); } else { resolve(); } }; requestAnimationFrame(sampleFrame); }); if (samples.length === 0) { const emptyReport = { generatedAt: new Date().toISOString(), window: { warmupSec, durationSec, samples: 0, profiledFrames: 0, ignoreHidden, requireFocus, skippedHiddenSamples, skippedUnfocusedSamples, totalRafSamples }, longTasks: { supported: longTaskSupported, count: 0, totalMs: 0, avgMs: 0, maxMs: 0, coveragePercent: 0 }, error: 'No usable samples captured. Keep tab visible, or run with { ignoreHidden: false } and/or { requireFocus: false }.' }; lastPerformanceDiagnosis = emptyReport; console.warn('[PerfDiagnosis] No usable samples captured.', emptyReport); return emptyReport; } const profiledFrames = frameProfiler .getRecentFrames(2000) .filter((frame) => frame.timestamp >= captureStart && frame.timestamp <= captureEnd); const frameValues = samples.map((s) => s.frameMs); const avgFrameMs = average(frameValues); const p95FrameMs = percentile(frameValues, 0.95); const p99FrameMs = percentile(frameValues, 0.99); const maxFrameMs = frameValues.length > 0 ? Math.max(...frameValues) : 0; const minFrameMs = frameValues.length > 0 ? Math.min(...frameValues) : 0; const avgFps = avgFrameMs > 0 ? 1000 / avgFrameMs : 0; const descendingFrame = [...frameValues].sort((a, b) => b - a); const onePercentIndex = Math.max(0, Math.ceil(descendingFrame.length * 0.01) - 1); const pointOnePercentIndex = Math.max(0, Math.ceil(descendingFrame.length * 0.001) - 1); const onePercentLowFps = descendingFrame.length > 0 ? 1000 / Math.max(0.0001, descendingFrame[onePercentIndex]) : 0; const pointOnePercentLowFps = descendingFrame.length > 0 ? 1000 / Math.max(0.0001, descendingFrame[pointOnePercentIndex]) : 0; const captureLongTasks = longTaskRecords.filter( (entry) => entry.startTime >= captureStart && entry.startTime <= captureEnd ); const longTaskCount = captureLongTasks.length; const longTaskTotalMs = captureLongTasks.reduce((sum, entry) => sum + entry.duration, 0); const longTaskAvgMs = longTaskCount > 0 ? longTaskTotalMs / longTaskCount : 0; const longTaskMaxMs = longTaskCount > 0 ? Math.max(...captureLongTasks.map((entry) => entry.duration)) : 0; const longTaskCoveragePercent = durationSec > 0 ? (longTaskTotalMs / (durationSec * 1000)) * 100 : 0; const vsyncStepMs = 1000 / 60; const vsyncToleranceMs = 0.9; let vsyncQuantizedCount = 0; for (const frameMs of frameValues) { const nearestMultiple = Math.max(1, Math.round(frameMs / vsyncStepMs)); const nearestStep = nearestMultiple * vsyncStepMs; if (Math.abs(frameMs - nearestStep) <= vsyncToleranceMs) { vsyncQuantizedCount += 1; } } const vsyncQuantizedShare = frameValues.length > 0 ? vsyncQuantizedCount / frameValues.length : 0; const nearThirtyHzFrames = frameValues.filter((value) => Math.abs(value - 33.3333) <= 2.0).length; const nearThirtyHzShare = frameValues.length > 0 ? nearThirtyHzFrames / frameValues.length : 0; const drawCalls = samples.map((s) => s.drawCalls); const triangles = samples.map((s) => s.triangles); const framegraphCpu = samples.map((s) => s.framegraphCpuMs); const framegraphDrawCalls = samples.map((s) => s.framegraphDrawCalls); const framegraphTriangles = samples.map((s) => s.framegraphTriangles); const combinedDrawCalls = samples.map((s) => s.drawCalls + s.framegraphDrawCalls); const combinedTriangles = samples.map((s) => s.triangles + s.framegraphTriangles); const totalEntities = samples.map((s) => s.totalEntities); const visibleEntities = samples.map((s) => s.visibleEntities); const culledEntities = samples.map((s) => s.culledEntities); const pointLights = samples.map((s) => s.pointLights); const directionalLights = samples.map((s) => s.directionalLights); const clusteredEnabledCount = samples.reduce((sum, sample) => sum + (sample.clusteredLightingEnabled ? 1 : 0), 0); const avgTotalEntities = average(totalEntities); const avgVisibleEntities = average(visibleEntities); const avgCulledEntities = average(culledEntities); const avgCullRatio = avgTotalEntities > 0 ? avgCulledEntities / avgTotalEntities : 0; const avgUnitGridMs = average(samples.map((s) => s.unitGridMs)); const avgUnitFormationMs = average(samples.map((s) => s.unitFormationMs)); const avgUnitMovementMs = average(samples.map((s) => s.unitMovementMs)); const avgUnitCombatMs = average(samples.map((s) => s.unitCombatMs)); const avgUnitTotalMs = avgUnitGridMs + avgUnitFormationMs + avgUnitMovementMs + avgUnitCombatMs; const stageAgg = new Map(); let unaccountedSum = 0; let stageTotalSum = 0; for (const frame of profiledFrames) { unaccountedSum += frame.unaccountedMs; stageTotalSum += frame.stages.reduce((sum, stage) => sum + stage.duration, 0); for (const stage of frame.stages) { const current = stageAgg.get(stage.label); if (current) { current.sum += stage.duration; current.max = Math.max(current.max, stage.duration); current.frames += 1; } else { stageAgg.set(stage.label, { sum: stage.duration, max: stage.duration, frames: 1 }); } } } const stageBreakdown = Array.from(stageAgg.entries()) .map(([name, stats]) => { const avgMs = profiledFrames.length > 0 ? stats.sum / profiledFrames.length : 0; const share = avgFrameMs > 0 ? (avgMs / avgFrameMs) * 100 : 0; return { name, avgMs, maxMs: stats.max, sharePercent: share }; }) .sort((a, b) => b.avgMs - a.avgMs); const avgUnaccountedMs = profiledFrames.length > 0 ? unaccountedSum / profiledFrames.length : 0; const avgStageTotalMs = profiledFrames.length > 0 ? stageTotalSum / profiledFrames.length : 0; const avgPacingWaitMs = Math.max(0, avgFrameMs - avgStageTotalMs); const pacingWaitSharePercent = avgFrameMs > 0 ? (avgPacingWaitMs / avgFrameMs) * 100 : 0; const severeSpikeCount = frameValues.filter((value) => value >= 100).length; const severeSpikePercent = samples.length > 0 ? (severeSpikeCount / samples.length) * 100 : 0; const culprits: Array<{ severity: 'high' | 'medium' | 'low'; culprit: string; evidence: string; action: string; }> = []; const gameVisualsStage = stageBreakdown.find((stage) => stage.name === 'gameVisuals'); const simTickStage = stageBreakdown.find((stage) => stage.name === 'simTick'); if (avgFrameMs > 24) { culprits.push({ severity: 'high', culprit: 'Frame pacing above target', evidence: `avg=${avgFrameMs.toFixed(2)}ms p95=${p95FrameMs.toFixed(2)}ms p99=${p99FrameMs.toFixed(2)}ms`, action: 'Target stage hotspots first, then lower draw-call pressure in heavy scenes.' }); } if (gameVisualsStage && (gameVisualsStage.avgMs > 4 || gameVisualsStage.sharePercent > 25)) { culprits.push({ severity: 'high', culprit: 'WebGL entity visual update cost', evidence: `gameVisuals avg=${gameVisualsStage.avgMs.toFixed(2)}ms share=${gameVisualsStage.sharePercent.toFixed(1)}%`, action: 'Reduce entity-side per-frame work, increase culling, and trim expensive visuals in near zoom.' }); } if (simTickStage && (simTickStage.avgMs > 3 || avgUnitTotalMs > 3)) { culprits.push({ severity: 'medium', culprit: 'Simulation tick overhead', evidence: `simTick avg=${simTickStage.avgMs.toFixed(2)}ms, unit subsystems avg=${avgUnitTotalMs.toFixed(2)}ms`, action: 'Keep unit profiling enabled and optimize movement/combat hotspots with largest share.' }); } if (average(combinedDrawCalls) > 120 || percentile(combinedDrawCalls, 0.95) > 150) { culprits.push({ severity: 'high', culprit: 'High draw-call pressure', evidence: `drawCalls total avg=${average(combinedDrawCalls).toFixed(1)} p95=${percentile(combinedDrawCalls, 0.95).toFixed(1)}`, action: 'Batch/instance more entities and reduce multi-material or duplicate mesh submissions.' }); } if (avgFrameMs > 20 && average(framegraphCpu) < 2) { culprits.push({ severity: 'medium', culprit: 'Main thread cost is outside framegraph encode', evidence: `frame avg=${avgFrameMs.toFixed(2)}ms vs framegraph CPU avg=${average(framegraphCpu).toFixed(2)}ms`, action: 'Focus on WebGL entity path, simulation, input/UI work, and stutter sources.' }); } if (avgPacingWaitMs > Math.max(8, avgFrameMs * 0.5)) { culprits.push({ severity: 'high', culprit: 'Frame pacing / present wait dominates frame time', evidence: `wait=${avgPacingWaitMs.toFixed(2)}ms (${pacingWaitSharePercent.toFixed(1)}% of frame), cpuStages=${avgStageTotalMs.toFixed(2)}ms`, action: 'Treat as GPU-present/throttling bound: keep tab focused, disable browser power throttling, then A/B heavy GPU features (water/shadows/post).' }); } if (avgFrameMs > 24 && nearThirtyHzShare > 0.4 && avgStageTotalMs < avgFrameMs * 0.7) { culprits.push({ severity: 'high', culprit: 'Likely 30Hz pacing quantization', evidence: `near33.3ms=${(nearThirtyHzShare * 100).toFixed(1)}%, vsync-quantized=${(vsyncQuantizedShare * 100).toFixed(1)}%`, action: 'Check browser/OS power mode and tab throttling, test fullscreen, and verify no frame limiter is forcing half-rate present.' }); } if (longTaskCount > 0 && (longTaskMaxMs >= 120 || longTaskCoveragePercent >= 3)) { culprits.push({ severity: longTaskMaxMs >= 200 ? 'high' : 'medium', culprit: 'Main-thread long tasks', evidence: `count=${longTaskCount}, max=${longTaskMaxMs.toFixed(1)}ms, coverage=${longTaskCoveragePercent.toFixed(2)}%`, action: 'Profile long tasks in Chrome Performance: trim sync JS, reduce console spam, and remove any heavy one-off work from frame loop.' }); } if (severeSpikeCount > 0) { culprits.push({ severity: severeSpikePercent >= 1 ? 'medium' : 'low', culprit: 'Severe frame spikes', evidence: `${severeSpikeCount}/${samples.length} frames >= 100ms (${severeSpikePercent.toFixed(2)}%)`, action: 'Investigate allocation churn/asset uploads and long one-off work on hot paths.' }); } if (avgUnaccountedMs > Math.max(2, avgFrameMs * 0.3)) { culprits.push({ severity: 'medium', culprit: 'Unattributed frame time / spikes', evidence: `unaccounted avg=${avgUnaccountedMs.toFixed(2)}ms, 0.1% low=${pointOnePercentLowFps.toFixed(1)} FPS`, action: 'Investigate GC/allocation churn and one-off expensive callbacks during heavy scenes.' }); } if (avgTotalEntities > 100 && avgCullRatio < 0.2) { culprits.push({ severity: 'medium', culprit: 'Low culling efficiency for entity count', evidence: `entities avg=${avgTotalEntities.toFixed(0)}, cull ratio=${(avgCullRatio * 100).toFixed(1)}%`, action: 'Enable proxy capture + frustum culling and verify visible entity ratio drops at near zoom.' }); } if (average(pointLights) > 0 && clusteredEnabledCount < Math.max(1, Math.floor(samples.length * 0.8))) { culprits.push({ severity: 'low', culprit: 'Clustered lighting path not consistently active', evidence: `point lights avg=${average(pointLights).toFixed(1)}, clustered-enabled samples=${clusteredEnabledCount}/${samples.length}`, action: 'Enable clustered lighting capture and confirm LightCullingPass is running.' }); } const report = { generatedAt: new Date().toISOString(), window: { warmupSec, durationSec, samples: samples.length, profiledFrames: profiledFrames.length, ignoreHidden, requireFocus, skippedHiddenSamples, skippedUnfocusedSamples, totalRafSamples }, frame: { avgMs: avgFrameMs, p95Ms: p95FrameMs, p99Ms: p99FrameMs, minMs: minFrameMs, maxMs: maxFrameMs, avgFps, onePercentLowFps, pointOnePercentLowFps }, webgl: { drawCalls: { avg: average(drawCalls), p95: percentile(drawCalls, 0.95) }, triangles: { avg: average(triangles), p95: percentile(triangles, 0.95) } }, framegraph: { cpuEncodeMs: { avg: average(framegraphCpu), p95: percentile(framegraphCpu, 0.95) }, drawCalls: { avg: average(framegraphDrawCalls), p95: percentile(framegraphDrawCalls, 0.95) }, triangles: { avg: average(framegraphTriangles), p95: percentile(framegraphTriangles, 0.95) } }, combined: { drawCalls: { avg: average(combinedDrawCalls), p95: percentile(combinedDrawCalls, 0.95) }, triangles: { avg: average(combinedTriangles), p95: percentile(combinedTriangles, 0.95) } }, entities: { avgTotal: avgTotalEntities, avgVisible: avgVisibleEntities, avgCulled: avgCulledEntities, avgCullRatio: avgCullRatio }, lighting: { pointLightsAvg: average(pointLights), directionalLightsAvg: average(directionalLights), clusteredEnabledSamples: clusteredEnabledCount, sampleCount: samples.length }, unitSim: { avgGridMs: avgUnitGridMs, avgFormationMs: avgUnitFormationMs, avgMovementMs: avgUnitMovementMs, avgCombatMs: avgUnitCombatMs, avgTotalMs: avgUnitTotalMs }, longTasks: { supported: longTaskSupported, count: longTaskCount, totalMs: longTaskTotalMs, avgMs: longTaskAvgMs, maxMs: longTaskMaxMs, coveragePercent: longTaskCoveragePercent }, pacing: { avgStageCpuMs: avgStageTotalMs, avgWaitMs: avgPacingWaitMs, waitSharePercent: pacingWaitSharePercent, vsyncQuantizedSharePercent: vsyncQuantizedShare * 100, nearThirtyHzSharePercent: nearThirtyHzShare * 100, severeSpikeCount, severeSpikePercent }, stageBreakdown: stageBreakdown.slice(0, 12), avgUnaccountedMs, culprits: culprits.sort((a, b) => { const rank = { high: 3, medium: 2, low: 1 } as const; return rank[b.severity] - rank[a.severity]; }) }; lastPerformanceDiagnosis = report; console.group('[PerfDiagnosis] Result'); console.info( `Frame avg=${avgFrameMs.toFixed(2)}ms (${avgFps.toFixed(1)} FPS), p95=${p95FrameMs.toFixed(2)}ms, ` + `drawCalls total avg=${average(combinedDrawCalls).toFixed(1)}` ); if (report.culprits.length > 0) { console.info('[PerfDiagnosis] Ranked culprits:'); for (const item of report.culprits) { console.info(` [${item.severity.toUpperCase()}] ${item.culprit} | ${item.evidence} | Action: ${item.action}`); } } else { console.info('[PerfDiagnosis] No dominant culprit detected in this sample window.'); } console.info('[PerfDiagnosis] Full report available via __RTS.getLastPerformanceDiagnosis()'); console.groupEnd(); return report; } finally { if (longTaskObserver) { longTaskObserver.disconnect(); longTaskObserver = null; } performanceDiagnosisActive = false; (window as any).__RTS_PERF_DIAG_ACTIVE = false; if (!previousFrameProfilerState) { frameProfiler.setEnabled(false); } } }; (window.__RTS as any).getLastPerformanceDiagnosis = () => { if (!lastPerformanceDiagnosis) { console.info('[PerfDiagnosis] No report yet. Run __RTS.runPerformanceDiagnosis() first.'); return null; } console.info('[PerfDiagnosis] Last report:', lastPerformanceDiagnosis); return lastPerformanceDiagnosis; }; (window.__RTS as any).printLastPerformanceDiagnosis = () => { if (!lastPerformanceDiagnosis) { console.info('[PerfDiagnosis] No report yet. Run __RTS.runPerformanceDiagnosis() first.'); return null; } const json = JSON.stringify(lastPerformanceDiagnosis, null, 2); console.info(json); return json; }; let frameBudgetBreakdownActive = false; let lastFrameBudgetBreakdown: unknown = null; (window.__RTS as any).runFrameBudgetBreakdown = async (options?: { durationSec?: number; warmupSec?: number; targetFps?: number; ignoreHidden?: boolean; requireFocus?: boolean; }) => { if (frameBudgetBreakdownActive) { const activeError = { generatedAt: new Date().toISOString(), error: 'runFrameBudgetBreakdown is already active. Wait for the current capture to finish.' }; console.warn('[FrameBudget] Capture already in progress.', activeError); return activeError; } const durationSec = Math.max(3, Math.min(30, options?.durationSec ?? 8)); const warmupSec = Math.max(0, Math.min(10, options?.warmupSec ?? 1)); const targetFps = Math.max(24, Math.min(240, options?.targetFps ?? 60)); const budgetMs = 1000 / targetFps; const ignoreHidden = options?.ignoreHidden ?? true; const requireFocus = options?.requireFocus ?? true; const captureStart = performance.now() + warmupSec * 1000; const captureEnd = captureStart + durationSec * 1000; const previousFrameProfilerState = frameProfiler.isEnabled(); type PassAgg = { cpuSum: number; gpuSum: number; maxCpu: number; maxGpu: number; sampleCount: number }; const passAgg = new Map(); let skippedHiddenSamples = 0; let skippedUnfocusedSamples = 0; let totalRafSamples = 0; let sampledFrames = 0; let gpuTimingPassSamples = 0; const sampledFrameDurations: number[] = []; let previousSampleTimestamp = performance.now(); frameBudgetBreakdownActive = true; if (!previousFrameProfilerState) { frameProfiler.setEnabled(true); } frameProfiler.clearRecentFrames(); try { console.info( `[FrameBudget] Starting capture: warmup=${warmupSec.toFixed(1)}s, sample=${durationSec.toFixed(1)}s, ` + `target=${targetFps} FPS (${budgetMs.toFixed(2)}ms), ignoreHidden=${ignoreHidden ? 'on' : 'off'}, requireFocus=${requireFocus ? 'on' : 'off'}` ); await new Promise((resolve) => { const tick = () => { const now = performance.now(); const rafDeltaMs = Math.max(0.0001, now - previousSampleTimestamp); previousSampleTimestamp = now; if (now >= captureStart && now <= captureEnd) { totalRafSamples += 1; const visible = typeof document === 'undefined' ? true : document.visibilityState === 'visible'; const focused = typeof document === 'undefined' ? true : (typeof document.hasFocus === 'function' ? document.hasFocus() : true); if ((ignoreHidden && !visible) || (requireFocus && !focused)) { if (ignoreHidden && !visible) skippedHiddenSamples += 1; if (requireFocus && !focused) skippedUnfocusedSamples += 1; } else { sampledFrames += 1; sampledFrameDurations.push(rafDeltaMs); const stats = rendererService.getLatestFrameStats(); const passTiming = stats?.passTiming ?? []; for (const pass of passTiming) { const current = passAgg.get(pass.name) ?? { cpuSum: 0, gpuSum: 0, maxCpu: 0, maxGpu: 0, sampleCount: 0 }; const cpuTime = Number.isFinite(pass.cpuTime) ? pass.cpuTime : 0; const gpuTime = typeof pass.gpuTime === 'number' && Number.isFinite(pass.gpuTime) ? pass.gpuTime : 0; if (gpuTime > 0) { gpuTimingPassSamples += 1; } current.cpuSum += cpuTime; current.gpuSum += gpuTime; current.maxCpu = Math.max(current.maxCpu, cpuTime); current.maxGpu = Math.max(current.maxGpu, gpuTime); current.sampleCount += 1; passAgg.set(pass.name, current); } } } if (now < captureEnd) { requestAnimationFrame(tick); } else { resolve(); } }; requestAnimationFrame(tick); }); const profiledFrames = frameProfiler .getRecentFrames(2400) .filter((frame) => frame.timestamp >= captureStart && frame.timestamp <= captureEnd); if (profiledFrames.length === 0) { const emptyReport = { generatedAt: new Date().toISOString(), window: { warmupSec, durationSec, targetFps, budgetMs, sampledFrames: 0, profiledFrames: 0, skippedHiddenSamples, skippedUnfocusedSamples, totalRafSamples }, error: 'No profiled frames captured. Keep tab visible/focused or disable capture filters.' }; lastFrameBudgetBreakdown = emptyReport; console.warn('[FrameBudget] No profiled frames captured.', emptyReport); return emptyReport; } const frameValues = sampledFrameDurations.length > 0 ? sampledFrameDurations : profiledFrames.map((frame) => frame.frameDuration); const cpuActiveFrameValues = profiledFrames.map((frame) => frame.frameDuration); const avgFrameMs = average(frameValues); const p95FrameMs = percentile(frameValues, 0.95); const p99FrameMs = percentile(frameValues, 0.99); const avgCpuActiveFrameMs = average(cpuActiveFrameValues); const avgFps = avgFrameMs > 0 ? 1000 / avgFrameMs : 0; const budgetUsagePercent = budgetMs > 0 ? (avgFrameMs / budgetMs) * 100 : 0; const budgetHeadroomMs = budgetMs - avgFrameMs; const stageAgg = new Map(); let stageTotalSum = 0; let unaccountedSum = 0; for (const frame of profiledFrames) { const frameStageSum = frame.stages.reduce((sum, stage) => sum + stage.duration, 0); stageTotalSum += frameStageSum; unaccountedSum += frame.unaccountedMs; for (const stage of frame.stages) { const current = stageAgg.get(stage.label) ?? { sum: 0, max: 0 }; current.sum += stage.duration; current.max = Math.max(current.max, stage.duration); stageAgg.set(stage.label, current); } } const avgStageTotalMs = stageTotalSum / profiledFrames.length; const avgUnaccountedMs = unaccountedSum / profiledFrames.length; const avgWaitMs = Math.max(0, avgFrameMs - avgStageTotalMs); const stageBreakdown = Array.from(stageAgg.entries()) .map(([name, stats]) => { const avgMs = stats.sum / profiledFrames.length; return { name, avgMs, maxMs: stats.max, shareOfFramePercent: avgFrameMs > 0 ? (avgMs / avgFrameMs) * 100 : 0, shareOfBudgetPercent: budgetMs > 0 ? (avgMs / budgetMs) * 100 : 0 }; }) .sort((a, b) => b.avgMs - a.avgMs); const budgetConsumers = [ ...stageBreakdown, { name: 'unaccounted', avgMs: avgUnaccountedMs, maxMs: avgUnaccountedMs, shareOfFramePercent: avgFrameMs > 0 ? (avgUnaccountedMs / avgFrameMs) * 100 : 0, shareOfBudgetPercent: budgetMs > 0 ? (avgUnaccountedMs / budgetMs) * 100 : 0 }, { name: 'present/wait', avgMs: avgWaitMs, maxMs: avgWaitMs, shareOfFramePercent: avgFrameMs > 0 ? (avgWaitMs / avgFrameMs) * 100 : 0, shareOfBudgetPercent: budgetMs > 0 ? (avgWaitMs / budgetMs) * 100 : 0 } ] .sort((a, b) => b.avgMs - a.avgMs); const gpuTimingAvailable = gpuTimingPassSamples > 0; const passBreakdown = Array.from(passAgg.entries()) .map(([name, stats]) => { const avgCpuMs = sampledFrames > 0 ? stats.cpuSum / sampledFrames : 0; const avgGpuMs = sampledFrames > 0 ? stats.gpuSum / sampledFrames : 0; const sortMetricMs = gpuTimingAvailable ? avgGpuMs : avgCpuMs; const sortMetricName = gpuTimingAvailable ? 'gpuMs' : 'cpuMs'; const sortMetricBudgetSharePercent = gpuTimingAvailable ? (budgetMs > 0 ? (avgGpuMs / budgetMs) * 100 : 0) : (budgetMs > 0 ? (avgCpuMs / budgetMs) * 100 : 0); return { name, avgCpuMs, avgGpuMs, maxCpuMs: stats.maxCpu, maxGpuMs: stats.maxGpu, sampleCount: stats.sampleCount, gpuShareOfBudgetPercent: budgetMs > 0 ? (avgGpuMs / budgetMs) * 100 : 0, cpuShareOfBudgetPercent: budgetMs > 0 ? (avgCpuMs / budgetMs) * 100 : 0, sortMetricMs, sortMetricName, sortMetricBudgetSharePercent }; }) .sort((a, b) => b.sortMetricMs - a.sortMetricMs); const report = { generatedAt: new Date().toISOString(), window: { warmupSec, durationSec, targetFps, budgetMs, sampledFrames, profiledFrames: profiledFrames.length, skippedHiddenSamples, skippedUnfocusedSamples, totalRafSamples }, frame: { avgMs: avgFrameMs, p95Ms: p95FrameMs, p99Ms: p99FrameMs, avgCpuActiveMs: avgCpuActiveFrameMs, avgFps, budgetUsagePercent, budgetHeadroomMs }, cpu: { avgStageTotalMs, avgUnaccountedMs, avgWaitMs, topBudgetConsumers: budgetConsumers.slice(0, 12), stageBreakdown: stageBreakdown.slice(0, 20) }, gpu: { timingMode: gpuTimingAvailable ? 'gpu-timestamp' : 'cpu-fallback', gpuTimedPassSamples: gpuTimingPassSamples, topPasses: passBreakdown.slice(0, 16) } }; lastFrameBudgetBreakdown = report; console.group('[FrameBudget] Result'); console.info( `Target=${targetFps} FPS (${budgetMs.toFixed(2)}ms), ` + `avg=${avgFrameMs.toFixed(2)}ms (${avgFps.toFixed(1)} FPS), ` + `p95=${p95FrameMs.toFixed(2)}ms, usage=${budgetUsagePercent.toFixed(1)}%, ` + `headroom=${budgetHeadroomMs.toFixed(2)}ms, cpu-active=${avgCpuActiveFrameMs.toFixed(2)}ms` ); console.info('[FrameBudget] Top CPU budget consumers:'); console.table( budgetConsumers.slice(0, 8).map((entry) => ({ name: entry.name, avgMs: Number(entry.avgMs.toFixed(3)), frameSharePct: Number(entry.shareOfFramePercent.toFixed(1)), budgetSharePct: Number(entry.shareOfBudgetPercent.toFixed(1)) })) ); console.info( `[FrameBudget] Top passes by ${gpuTimingAvailable ? 'GPU' : 'CPU fallback'} timing ` + `(gpu timed samples=${gpuTimingPassSamples})` ); if (!gpuTimingAvailable) { console.warn('[FrameBudget] GPU pass timings unavailable (all gpuTime=0); ranking passes by CPU pass time instead.'); } console.table( passBreakdown.slice(0, 8).map((entry) => ({ name: entry.name, avgGpuMs: Number(entry.avgGpuMs.toFixed(3)), avgCpuMs: Number(entry.avgCpuMs.toFixed(3)), metric: entry.sortMetricName, metricMs: Number(entry.sortMetricMs.toFixed(3)), metricBudgetSharePct: Number(entry.sortMetricBudgetSharePercent.toFixed(1)) })) ); console.info('[FrameBudget] Full report available via __RTS.getLastFrameBudgetBreakdown()'); console.groupEnd(); return report; } finally { frameBudgetBreakdownActive = false; if (!previousFrameProfilerState) { frameProfiler.setEnabled(false); } } }; (window.__RTS as any).getLastFrameBudgetBreakdown = () => { if (!lastFrameBudgetBreakdown) { console.info('[FrameBudget] No report yet. Run __RTS.runFrameBudgetBreakdown() first.'); return null; } console.info('[FrameBudget] Last report:', lastFrameBudgetBreakdown); return lastFrameBudgetBreakdown; }; (window.__RTS as any).printLastFrameBudgetBreakdown = () => { if (!lastFrameBudgetBreakdown) { console.info('[FrameBudget] No report yet. Run __RTS.runFrameBudgetBreakdown() first.'); return null; } const json = JSON.stringify(lastFrameBudgetBreakdown, null, 2); console.info(json); return json; }; (window.__RTS as any).runRafPacingProbe = async (options?: { durationSec?: number }) => { const durationSec = Math.max(2, Math.min(20, options?.durationSec ?? 6)); const endAt = performance.now() + durationSec * 1000; const intervals: number[] = []; let previous = performance.now(); await new Promise((resolve) => { const tick = (now: number) => { const delta = Math.max(0.0001, now - previous); previous = now; intervals.push(delta); if (now < endAt) { requestAnimationFrame(tick); } else { resolve(); } }; requestAnimationFrame(tick); }); const sorted = [...intervals].sort((a, b) => a - b); const pct = (p: number): number => { if (sorted.length === 0) return 0; const idx = Math.min(sorted.length - 1, Math.max(0, Math.floor((sorted.length - 1) * p))); return sorted[idx]; }; const avgMs = intervals.length > 0 ? intervals.reduce((sum, value) => sum + value, 0) / intervals.length : 0; const minMs = sorted[0] ?? 0; const maxMs = sorted[sorted.length - 1] ?? 0; const near16Count = intervals.filter((value) => Math.abs(value - 16.6667) <= 1.0).length; const near33Count = intervals.filter((value) => Math.abs(value - 33.3333) <= 2.0).length; const quantizedCount = intervals.filter((value) => { const step = 1000 / 60; const nearest = Math.max(1, Math.round(value / step)) * step; return Math.abs(value - nearest) <= 1.0; }).length; const result = { generatedAt: new Date().toISOString(), durationSec, samples: intervals.length, avgMs, avgFps: avgMs > 0 ? 1000 / avgMs : 0, p50Ms: pct(0.5), p95Ms: pct(0.95), p99Ms: pct(0.99), minMs, maxMs, near16SharePercent: intervals.length > 0 ? (near16Count / intervals.length) * 100 : 0, near33SharePercent: intervals.length > 0 ? (near33Count / intervals.length) * 100 : 0, vsyncQuantizedSharePercent: intervals.length > 0 ? (quantizedCount / intervals.length) * 100 : 0 }; console.info('[RafProbe] Result:', result); return result; }; type FramegraphFeatureOverrideState = { shadows: boolean | null; ssao: boolean | null; bloom: boolean | null; water: boolean | null; lightCulling: boolean | null; }; type FeatureSweepProfileName = | 'baseline' | 'shadowsOff' | 'ssaoOff' | 'bloomOff' | 'postOff' | 'waterOff' | 'lightCullingOff' | 'terrainOff' | 'scatterOff' | 'terrainScatterOff' | 'taaOff' | 'heavyOff'; type FeatureSweepProfile = { name: FeatureSweepProfileName; description: string; overrides: FramegraphFeatureOverrideState; taaEnabled: boolean | null; disabledPasses: string[]; }; const sweepProfiles: FeatureSweepProfile[] = [ { name: 'baseline', description: 'Current settings (no forced pass overrides)', overrides: { shadows: null, ssao: null, bloom: null, water: null, lightCulling: null }, taaEnabled: null, disabledPasses: [] }, { name: 'shadowsOff', description: 'Disable shadow atlas rendering', overrides: { shadows: false, ssao: null, bloom: null, water: null, lightCulling: null }, taaEnabled: null, disabledPasses: [] }, { name: 'ssaoOff', description: 'Disable SSAO + blur passes', overrides: { shadows: null, ssao: false, bloom: null, water: null, lightCulling: null }, taaEnabled: null, disabledPasses: [] }, { name: 'bloomOff', description: 'Disable bloom extract/downsample/upsample', overrides: { shadows: null, ssao: null, bloom: false, water: null, lightCulling: null }, taaEnabled: null, disabledPasses: [] }, { name: 'postOff', description: 'Disable post stack (SSAO + bloom + TAA)', overrides: { shadows: null, ssao: false, bloom: false, water: null, lightCulling: null }, taaEnabled: false, disabledPasses: [] }, { name: 'waterOff', description: 'Disable framegraph water passes', overrides: { shadows: null, ssao: null, bloom: null, water: false, lightCulling: null }, taaEnabled: null, disabledPasses: [] }, { name: 'lightCullingOff', description: 'Disable clustered light culling compute pass', overrides: { shadows: null, ssao: null, bloom: null, water: null, lightCulling: false }, taaEnabled: null, disabledPasses: [] }, { name: 'terrainOff', description: 'Disable terrain raster pass', overrides: { shadows: null, ssao: null, bloom: null, water: null, lightCulling: null }, taaEnabled: null, disabledPasses: ['TerrainMeshPass'] }, { name: 'scatterOff', description: 'Disable scatter vegetation pass', overrides: { shadows: null, ssao: null, bloom: null, water: null, lightCulling: null }, taaEnabled: null, disabledPasses: ['ScatterMeshPass'] }, { name: 'terrainScatterOff', description: 'Disable terrain + scatter passes', overrides: { shadows: null, ssao: null, bloom: null, water: null, lightCulling: null }, taaEnabled: null, disabledPasses: ['TerrainMeshPass', 'ScatterMeshPass'] }, { name: 'taaOff', description: 'Disable temporal AA', overrides: { shadows: null, ssao: null, bloom: null, water: null, lightCulling: null }, taaEnabled: false, disabledPasses: [] }, { name: 'heavyOff', description: 'Disable shadows + SSAO + bloom + water + TAA', overrides: { shadows: false, ssao: false, bloom: false, water: false, lightCulling: false }, taaEnabled: false, disabledPasses: [] } ]; const defaultSweepProfiles: FeatureSweepProfileName[] = [ 'baseline', 'terrainOff', 'scatterOff', 'terrainScatterOff', 'shadowsOff', 'ssaoOff', 'bloomOff', 'postOff', 'waterOff', 'taaOff', 'heavyOff' ]; (window.__RTS as any).runRenderFeatureSweep = async (options?: { durationSec?: number; warmupSec?: number; ignoreHidden?: boolean; requireFocus?: boolean; lockCamera?: boolean; profiles?: string[]; retryInvalidProfiles?: boolean; invalidRetryCount?: number; retryWarmupSec?: number; }) => { if (renderFeatureSweepActive) { const activeError = { generatedAt: new Date().toISOString(), error: 'runRenderFeatureSweep is already active. Wait for the current sweep to finish.' }; console.warn('[FeatureSweep] Sweep already in progress.', activeError); return activeError; } renderFeatureSweepActive = true; const runId = ++renderFeatureSweepRunId; const durationSec = Math.max(4, Math.min(20, options?.durationSec ?? 6)); const warmupSec = Math.max(0.5, Math.min(6, options?.warmupSec ?? 1)); const ignoreHidden = options?.ignoreHidden ?? true; const requireFocus = options?.requireFocus ?? true; const lockCamera = options?.lockCamera ?? true; const retryInvalidProfiles = options?.retryInvalidProfiles ?? true; const invalidRetryCount = Math.max(0, Math.min(3, Math.round(options?.invalidRetryCount ?? 1))); const retryWarmupSec = Math.max(0.2, Math.min(6, options?.retryWarmupSec ?? Math.max(0.4, warmupSec * 0.75))); const profileLookup = new Map( sweepProfiles.map((profile) => [profile.name, profile]) ); const requestedNames = Array.isArray(options?.profiles) && options!.profiles!.length > 0 ? options!.profiles! .map((value) => String(value).trim() as FeatureSweepProfileName) .filter((value) => profileLookup.has(value)) : defaultSweepProfiles; const profilesToRun = requestedNames .map((name) => profileLookup.get(name)) .filter((profile): profile is FeatureSweepProfile => Boolean(profile)); if (profilesToRun.length === 0) { const message = '[FeatureSweep] No valid profiles requested.'; console.warn(message, { requested: options?.profiles, available: sweepProfiles.map((profile) => profile.name) }); return { error: message }; } const previousOverrides = rendererService.getFramegraphFeatureOverrides(); const previousTaaEnabled = rendererService.isTaaEnabled(); const previousDisabledPasses = rendererService.getDisabledFramegraphPasses(); const previousCameraEnabled = rtsCamera.isEnabled(); const baselinePivot = rtsCamera.getPivotPosition(); const baselineDistance = rtsCamera.getDistance(); const runDiagnosis = (window.__RTS as any).runPerformanceDiagnosis as ((opts?: { durationSec?: number; warmupSec?: number; ignoreHidden?: boolean; requireFocus?: boolean; }) => Promise); const toFiniteNumber = (value: unknown, fallback = 0): number => typeof value === 'number' && Number.isFinite(value) ? value : fallback; const results: Array<{ name: FeatureSweepProfileName; description: string; avgMs: number; avgFps: number; p95Ms: number; onePercentLowFps: number; waitMs: number; waitSharePercent: number; attempts: number; valid: boolean; error?: string; }> = []; console.info( `[FeatureSweep] [run:${runId}] Running ${profilesToRun.length} profiles ` + `(sample=${durationSec.toFixed(1)}s each, warmup=${warmupSec.toFixed(1)}s, lockCamera=${lockCamera ? 'on' : 'off'})...` ); console.info(`[FeatureSweep] [run:${runId}] Profile order: ${profilesToRun.map((profile) => profile.name).join(', ')}`); try { if (lockCamera) { rtsCamera.setEnabled(false); } for (const profile of profilesToRun) { if (lockCamera) { // Reset view each profile to suppress A/B noise from camera movement. rtsCamera.focusOn(baselinePivot.x, baselinePivot.z, true); rtsCamera.setDistance(baselineDistance, true); } rendererService.setFramegraphFeatureOverrides(profile.overrides); rendererService.setDisabledFramegraphPasses(profile.disabledPasses); if (profile.taaEnabled == null) { rendererService.setTaaEnabled(previousTaaEnabled); } else { rendererService.setTaaEnabled(profile.taaEnabled); } console.info(`[FeatureSweep] [run:${runId}] Profile "${profile.name}"...`); let attempts = 0; let avgMs = 0; let avgFps = 0; let p95Ms = 0; let onePercentLowFps = 0; let waitMs = 0; let waitSharePercent = 0; let valid = false; let error: string | undefined = 'invalid_or_empty_diagnosis_report'; let lastReport: any = null; const maxAttempts = 1 + (retryInvalidProfiles ? invalidRetryCount : 0); while (attempts < maxAttempts) { attempts += 1; const attemptWarmupSec = attempts === 1 ? warmupSec : retryWarmupSec; const report = await runDiagnosis({ durationSec, warmupSec: attemptWarmupSec, ignoreHidden, requireFocus }); lastReport = report; const frame = report?.frame ?? {}; const pacing = report?.pacing ?? {}; avgMs = toFiniteNumber(frame.avgMs, 0); avgFps = toFiniteNumber(frame.avgFps, 0); p95Ms = toFiniteNumber(frame.p95Ms, 0); onePercentLowFps = toFiniteNumber(frame.onePercentLowFps, 0); waitMs = toFiniteNumber(pacing.avgWaitMs, 0); waitSharePercent = toFiniteNumber(pacing.waitSharePercent, 0); const hasReportError = typeof report?.error === 'string' && report.error.length > 0; valid = !hasReportError && avgMs > 0 && avgFps > 0; error = hasReportError ? report.error : (!valid ? 'invalid_or_empty_diagnosis_report' : undefined); if (valid) { break; } if (attempts < maxAttempts) { console.warn( `[FeatureSweep] [run:${runId}] Profile "${profile.name}" invalid (attempt ${attempts}/${maxAttempts}); retrying...`, { error } ); } } results.push({ name: profile.name, description: profile.description, avgMs, avgFps, p95Ms, onePercentLowFps, waitMs, waitSharePercent, attempts, valid, error }); if (!valid) { console.warn( `[FeatureSweep] [run:${runId}] Profile "${profile.name}" produced invalid diagnosis data.`, { error, attempts, report: lastReport } ); } } } finally { rendererService.setFramegraphFeatureOverrides(previousOverrides); rendererService.setTaaEnabled(previousTaaEnabled); rendererService.setDisabledFramegraphPasses(previousDisabledPasses); if (lockCamera) { rtsCamera.setEnabled(previousCameraEnabled); } renderFeatureSweepActive = false; } const validResults = results.filter((result) => result.valid); const invalidResults = results.filter((result) => !result.valid); const baseline = validResults.find((result) => result.name === 'baseline') ?? validResults[0] ?? results[0]; const ranked = validResults .map((result) => ({ ...result, frameMsGainVsBaseline: baseline.avgMs - result.avgMs, fpsGainVsBaseline: result.avgFps - baseline.avgFps })) .sort((a, b) => b.frameMsGainVsBaseline - a.frameMsGainVsBaseline); const summary = { generatedAt: new Date().toISOString(), sampling: { durationSec, warmupSec, ignoreHidden, requireFocus }, baseline: { name: baseline.name, avgMs: baseline.avgMs, avgFps: baseline.avgFps }, ranked, invalidProfiles: invalidResults.map((result) => ({ name: result.name, description: result.description, attempts: result.attempts, error: result.error ?? 'invalid_or_empty_diagnosis_report' })) }; lastRenderFeatureSweep = summary; console.group('[FeatureSweep] Result'); if (invalidResults.length > 0) { console.warn( `[FeatureSweep] ${invalidResults.length}/${results.length} profiles had invalid captures. ` + 'See invalidProfiles in the sweep summary for details.' ); } for (const entry of ranked) { console.info( `${entry.name}: avg=${entry.avgMs.toFixed(2)}ms (${entry.avgFps.toFixed(1)} FPS), ` + `gain=${entry.frameMsGainVsBaseline.toFixed(2)}ms, wait=${entry.waitMs.toFixed(2)}ms (${entry.waitSharePercent.toFixed(1)}%), ` + `attempts=${entry.attempts}` ); } if (ranked.length === 0) { console.warn('[FeatureSweep] No valid profile captures were produced.'); } console.groupEnd(); return summary; }; (window.__RTS as any).getLastRenderFeatureSweep = () => { if (!lastRenderFeatureSweep) { console.info('[FeatureSweep] No sweep yet. Run __RTS.runRenderFeatureSweep() first.'); return null; } console.info('[FeatureSweep] Last sweep:', lastRenderFeatureSweep); return lastRenderFeatureSweep; }; (window.__RTS as any).run10msPerfGate = async (options?: { targetMs?: number; diagnosisDurationSec?: number; diagnosisWarmupSec?: number; runSweep?: boolean; sweepDurationSec?: number; sweepWarmupSec?: number; }) => { const targetMs = Math.max(8, Math.min(33, Number(options?.targetMs ?? 10))); const diagnosisDurationSec = Math.max(4, Math.min(25, Number(options?.diagnosisDurationSec ?? 8))); const diagnosisWarmupSec = Math.max(0.5, Math.min(8, Number(options?.diagnosisWarmupSec ?? 1.2))); const runSweep = options?.runSweep ?? true; const sweepDurationSec = Math.max(4, Math.min(18, Number(options?.sweepDurationSec ?? 5.5))); const sweepWarmupSec = Math.max(0.4, Math.min(6, Number(options?.sweepWarmupSec ?? 0.9))); const runDiagnosis = (window.__RTS as any).runPerformanceDiagnosis as ((opts?: { durationSec?: number; warmupSec?: number; ignoreHidden?: boolean; requireFocus?: boolean; }) => Promise); const runSweepFn = (window.__RTS as any).runRenderFeatureSweep as ((opts?: { durationSec?: number; warmupSec?: number; ignoreHidden?: boolean; requireFocus?: boolean; retryInvalidProfiles?: boolean; invalidRetryCount?: number; retryWarmupSec?: number; }) => Promise); const diagnosis = await runDiagnosis({ durationSec: diagnosisDurationSec, warmupSec: diagnosisWarmupSec, ignoreHidden: true, requireFocus: true }); const diagAvgMs = Number(diagnosis?.frame?.avgMs ?? Number.NaN); const diagValid = Number.isFinite(diagAvgMs); let sweep: any = null; if (runSweep) { sweep = await runSweepFn({ durationSec: sweepDurationSec, warmupSec: sweepWarmupSec, ignoreHidden: true, requireFocus: true, retryInvalidProfiles: true, invalidRetryCount: 2, retryWarmupSec: Math.max(0.4, sweepWarmupSec * 0.8) }); } const baselineMs = Number(sweep?.baseline?.avgMs ?? diagAvgMs); const pass = Number.isFinite(baselineMs) && baselineMs <= targetMs; const headroomMs = Number.isFinite(baselineMs) ? (targetMs - baselineMs) : Number.NaN; const blockers: string[] = []; if (!diagValid) { blockers.push('Diagnosis capture invalid; rerun with focused tab and no devtools throttling.'); } else if (diagAvgMs > targetMs) { blockers.push(`Frame avg ${diagAvgMs.toFixed(2)}ms is above target ${targetMs.toFixed(2)}ms.`); } const waitShare = Number(diagnosis?.pacing?.waitSharePercent ?? Number.NaN); if (Number.isFinite(waitShare) && waitShare > 75) { blockers.push(`Present/wait share is high (${waitShare.toFixed(1)}%).`); } const cullRatio = Number(diagnosis?.entities?.avgCullRatio ?? Number.NaN); if (Number.isFinite(cullRatio) && cullRatio < 0.2) { blockers.push(`Entity cull ratio is low (${(cullRatio * 100).toFixed(1)}%).`); } const invalidProfileCount = Array.isArray(sweep?.invalidProfiles) ? sweep.invalidProfiles.length : 0; if (invalidProfileCount > 0) { blockers.push(`${invalidProfileCount} feature-sweep profile(s) were invalid.`); } const report = { generatedAt: new Date().toISOString(), targetMs, pass, baselineMs, headroomMs, diagnosis, sweep, blockers }; console.group('[PerfGate10ms] Result'); console.info(`[PerfGate10ms] ${pass ? 'PASS' : 'FAIL'} | baseline=${Number.isFinite(baselineMs) ? baselineMs.toFixed(2) : 'n/a'}ms | target=${targetMs.toFixed(2)}ms`); if (blockers.length > 0) { for (const blocker of blockers) { console.warn(`[PerfGate10ms] ${blocker}`); } } else { console.info('[PerfGate10ms] No blockers detected.'); } console.groupEnd(); return report; }; // Keyboard shortcut: F3 to toggle performance overlay window.addEventListener('keydown', (event) => { if (event.key === 'F3') { event.preventDefault(); performanceBenchmark.toggleOverlay(); } }); console.info('[Benchmark] 📊 Performance Benchmark System Ready!'); console.info(' F3: Toggle performance overlay'); console.info(' __RTS.startBenchmark(name) - Start recording session'); console.info(' __RTS.endBenchmark() - End session and show results'); console.info(' __RTS.compareBenchmarks(name1, name2) - Compare two sessions'); console.info(' __RTS.listBenchmarks() - List all recorded sessions'); console.info(' __RTS.toggleBenchmarkOverlay() - Toggle benchmark overlay'); console.info(' __RTS.toggleRendererPerformanceOverlay() - Toggle framegraph GPU/CPU overlay'); console.info(' __RTS.toggleOverlayHubPanel() - Toggle unified overlay/debug control panel'); console.info(' __RTS.togglePerformanceOverlay() - Legacy alias for benchmark overlay'); console.info(' __RTS.exportBenchmarks() - Export data as JSON'); console.info(' __RTS.resetBenchmarks() - Clear all data'); console.info(' __RTS.runPerformanceDiagnosis({durationSec?, warmupSec?, ignoreHidden?, requireFocus?}) - Capture ranked bottleneck report'); console.info(' __RTS.getLastPerformanceDiagnosis() - Print latest diagnosis report'); console.info(' __RTS.printLastPerformanceDiagnosis() - Print latest diagnosis report as formatted JSON'); console.info(' __RTS.runFrameBudgetBreakdown({durationSec?, warmupSec?, targetFps?, ignoreHidden?, requireFocus?}) - Rank frame-budget spend (CPU stages + GPU passes)'); console.info(' __RTS.getLastFrameBudgetBreakdown() - Print latest frame-budget report'); console.info(' __RTS.printLastFrameBudgetBreakdown() - Print latest frame-budget report as formatted JSON'); console.info(' __RTS.runRafPacingProbe({durationSec?}) - Measure raw requestAnimationFrame cadence'); console.info(' __RTS.setDisabledFramegraphPasses([...]) - Disable exact framegraph pass names at runtime'); console.info(' __RTS.getFramegraphPassNames() - List pass names available for disabling'); console.info(' __RTS.setFramegraphFeatureOverrides({shadows?, ssao?, bloom?, water?, lightCulling?}) - Runtime pass overrides'); console.info(' __RTS.getFramegraphFeatureOverrides() - Show runtime pass overrides'); console.info(' __RTS.setScatterDistanceScale(scale, persist?) - Runtime scatter distance scaling (0.2-1.6)'); console.info(' __RTS.setRuntimeTerrainQualityTier(tier, persist?) - Runtime terrain mesh quality tier (0-2)'); console.info(' __RTS.getAdaptiveQualityState() / __RTS.setAdaptiveQualityEnabled(bool) - Inspect/toggle auto budget step-down'); console.info(' __RTS.setAdaptiveQualityTargets({targetMs?, overFrames?, cooldownMs?}) - Tune adaptive trigger thresholds'); console.info(' __RTS.getDynamicResolutionState() / __RTS.setDynamicResolutionEnabled(bool) - Inspect/toggle runtime dynamic resolution'); console.info(' __RTS.setDynamicResolutionScale(scale, persist?) - Force runtime render scale'); console.info(' __RTS.setDynamicResolutionTargets({...}) - Tune dynamic resolution behavior'); console.info(' __RTS.getAdaptiveFeatureThrottleState() / __RTS.setAdaptiveFeatureThrottleEnabled(bool) - Inspect/toggle auto feature throttling'); console.info(' __RTS.getShadowCasterAutoPolicyState() / __RTS.setShadowCasterAutoPolicyEnabled(bool) - Inspect/toggle shadow caster auto policy'); console.info(' __RTS.runRenderFeatureSweep({durationSec?, warmupSec?, profiles?, retryInvalidProfiles?}) - A/B sweep to rank GPU feature impact'); console.info(' __RTS.getLastRenderFeatureSweep() - Print latest feature sweep'); console.info(' __RTS.applyTenPointOptimizationSuite() / __RTS.getTenPointOptimizationSuite() - Apply/inspect 10-point perf suite'); console.info(' __RTS.run10msPerfGate({targetMs?, runSweep?}) - End-to-end pass/fail gate against frame target'); // Helper to view test meshes (window.__RTS as any).viewTestMesh = (position?: { x: number; y: number; z: number }) => { const pos = position ?? { x: 0, y: 5, z: 0 }; camera.position.set(pos.x + 15, pos.y + 10, pos.z + 15); camera.lookAt(pos.x, pos.y, pos.z); console.info('[Debug] Camera positioned to view test mesh at', pos); }; // Debug: Move camera to see trees (window.__RTS as any).viewTrees = () => { console.info('[Debug] Moving camera to view trees...'); // Move to a position where trees should be visible camera.position.set(0, 300, 500); camera.lookAt(0, 0, 0); console.info('[Debug] Camera position:', camera.position); console.info('[Debug] Look for trees in the terrain!'); }; // Check and flip terrain normals (window.__RTS as any).checkNormals = () => { const terrainMesh = (window.__RTS as any).checkTerrainMesh(); if (!terrainMesh) return; const normals = terrainMesh.geometry.attributes.normal; // Sample some normals const samples = [0, Math.floor(normals.count / 4), Math.floor(normals.count / 2)]; console.info('[Debug] Normal samples:'); for (const idx of samples) { const nx = normals.getX(idx); const ny = normals.getY(idx); const nz = normals.getZ(idx); console.info(` Vertex ${idx}: (${nx.toFixed(3)}, ${ny.toFixed(3)}, ${nz.toFixed(3)})`); } // Check material side console.info('[Debug] Material settings:', { side: terrainMesh.material.side, 'THREE.FrontSide': THREE.FrontSide, 'THREE.BackSide': THREE.BackSide, 'THREE.DoubleSide': THREE.DoubleSide }); console.info('If normals point down (negative Y), they are flipped!'); }; // Flip terrain normals (window.__RTS as any).flipNormals = () => { const terrainMesh = (window.__RTS as any).checkTerrainMesh(); if (!terrainMesh) return; const normals = terrainMesh.geometry.attributes.normal; console.info('[Debug] Flipping all normals...'); for (let i = 0; i < normals.count; i++) { normals.setXYZ(i, -normals.getX(i), -normals.getY(i), -normals.getZ(i)); } normals.needsUpdate = true; console.info(' ✅ Normals flipped!'); }; // Set material to double-sided (window.__RTS as any).setDoubleSided = () => { const terrainMesh = (window.__RTS as any).checkTerrainMesh(); if (!terrainMesh) return; terrainMesh.material.side = THREE.DoubleSide; terrainMesh.material.needsUpdate = true; console.info(' ✅ Material set to DoubleSide'); }; // Increase terrain vertical scale for more dramatic elevation (window.__RTS as any).setVerticalScale = (multiplier: number) => { const terra = sim.terra as any; const currentScale = terra.verticalScale; const newScale = currentScale * multiplier; console.info(`[Debug] Scaling terrain heights by ${multiplier}x`); console.info(` Current vertical scale: ${currentScale}`); console.info(` New vertical scale: ${newScale}`); // Scale all heights (keep base/delta in sync when available) const baseHeights = terra.baseHeights as Float32Array | undefined; const deltaHeights = terra.deltaHeights as Float32Array | undefined; const canScaleBase = baseHeights && deltaHeights && baseHeights.length === terra.heights.length && deltaHeights.length === terra.heights.length; if (canScaleBase) { for (let i = 0; i < terra.heights.length; i++) { baseHeights[i] *= multiplier; deltaHeights[i] *= multiplier; terra.heights[i] = baseHeights[i] + deltaHeights[i]; } } else { for (let i = 0; i < terra.heights.length; i++) { terra.heights[i] *= multiplier; } } // Update min/max terra.minHeight *= multiplier; terra.maxHeight *= multiplier; terra.verticalScale = newScale; // Keep water level and terrain types aligned with new height scale const waterLevel = typeof terra.getWaterLevel === 'function' ? terra.getWaterLevel() : (terra.waterLevel as number | undefined); if (Number.isFinite(waterLevel)) { (terra as any).waterLevel = (waterLevel as number) * multiplier; } if (typeof (terra as any).refreshTerrainTypesFromHeights === 'function') { (terra as any).refreshTerrainTypesFromHeights(); } else if (typeof (terra as any).classifyTerrainByHeight === 'function') { const classify = (terra as any).classifyTerrainByHeight.bind(terra); for (let i = 0; i < terra.terrainTypes.length; i++) { terra.terrainTypes[i] = classify(terra.heights[i]); } } if (terra.terrainTypes instanceof Uint8Array) { terra.terrainTypes = new Uint8Array(terra.terrainTypes); } if (typeof (terra as any).markHeightDirty === 'function') { (terra as any).markHeightDirty(); } console.info(` New height range: ${terra.minHeight.toFixed(2)}m to ${terra.maxHeight.toFixed(2)}m`); // Update the ModernTerrainRenderer mesh const terrainMesh = (window.__RTS as any).checkTerrainMesh(); if (terrainMesh) { const pos = terrainMesh.geometry.attributes.position; for (let i = 0; i < pos.count; i++) { const currentY = pos.getY(i); pos.setY(i, currentY * multiplier); } pos.needsUpdate = true; terrainMesh.geometry.computeVertexNormals(); terrainMesh.geometry.computeBoundingSphere(); console.info(' ✅ Terrain mesh updated!'); } refreshScatterAlignment(); console.info(' Try 2x-5x for AAA-quality dramatic terrain'); }; // Find and view elevated terrain (window.__RTS as any).findElevation = () => { const terra = sim.terra as any; console.info('[Debug] Scanning terrain for elevated areas...'); // Find the highest point let maxHeight = -Infinity; let maxX = 0; let maxZ = 0; // Sample every 10th tile to find high areas for (let x = 0; x < terra.width; x += 10) { for (let z = 0; z < terra.height; z += 10) { const idx = x * terra.height + z; const height = terra.heights[idx]; if (height > maxHeight) { maxHeight = height; maxX = x; maxZ = z; } } } // Convert tile coords to world coords const worldPos = { x: (maxX - terra.width / 2) * terra.tileSize, z: (maxZ - terra.height / 2) * terra.tileSize }; console.info(` Highest point: ${maxHeight.toFixed(2)}m at tile (${maxX}, ${maxZ})`); console.info(` World position: (${worldPos.x.toFixed(1)}, ${worldPos.z.toFixed(1)})`); // Move camera to view this area camera.position.set( worldPos.x + 100, maxHeight + 150, worldPos.z + 100 ); camera.lookAt(worldPos.x, maxHeight, worldPos.z); console.info(' ✅ Camera moved to view elevated terrain!'); console.info(' Use WASD to move around, mouse wheel to zoom'); }; // Check if terrain mesh is visible in the scene (window.__RTS as any).checkTerrainMesh = () => { console.info('[Debug] Checking terrain mesh in scene...'); // Find terrain meshes in the scene const terrainMeshes: any[] = []; scene.traverse((obj: any) => { if (obj.isMesh && obj.geometry && obj.geometry.attributes.position) { const vertexCount = obj.geometry.attributes.position.count; if (vertexCount > 100000) { // Terrain meshes have lots of vertices terrainMeshes.push(obj); } } }); console.info(` Found ${terrainMeshes.length} potential terrain mesh(es)`); for (let i = 0; i < terrainMeshes.length; i++) { const mesh = terrainMeshes[i]; const pos = mesh.geometry.attributes.position; // Find ACTUAL min/max in the entire mesh let minH = Infinity; let maxH = -Infinity; for (let j = 0; j < pos.count; j++) { const h = pos.getY(j); minH = Math.min(minH, h); maxH = Math.max(maxH, h); } console.info(` Mesh ${i}:`, { vertices: pos.count, visible: mesh.visible, ACTUAL_minHeight: minH.toFixed(2), ACTUAL_maxHeight: maxH.toFixed(2), ACTUAL_variance: (maxH - minH).toFixed(2), material: mesh.material.type }); console.info(` Terra data for comparison:`, { minHeight: (sim.terra as any).minHeight.toFixed(2), maxHeight: (sim.terra as any).maxHeight.toFixed(2), variance: ((sim.terra as any).maxHeight - (sim.terra as any).minHeight).toFixed(2) }); if (maxH - minH < 10) { console.error(` ❌ MESH IS FLAT! Only ${(maxH - minH).toFixed(2)}m variance!`); } else { console.info(` ✅ Mesh has elevation: ${(maxH - minH).toFixed(2)}m variance`); } } if (terrainMeshes.length === 0) { console.warn(' ⚠️ No terrain mesh found in scene!'); console.warn(' The terrain might be rendered by WebGPU only'); } return terrainMeshes[0]; // Return first terrain mesh for further debugging }; // Visualize terrain elevation with height-based colors (window.__RTS as any).visualizeElevation = () => { console.info('[Debug] Visualizing terrain elevation...'); const terrainMesh = (window.__RTS as any).checkTerrainMesh(); if (!terrainMesh) { console.error(' ❌ No terrain mesh found!'); return; } const pos = terrainMesh.geometry.attributes.position; const terra = sim.terra as any; // Find actual min/max heights in the mesh let minH = Infinity; let maxH = -Infinity; for (let i = 0; i < pos.count; i++) { const h = pos.getY(i); minH = Math.min(minH, h); maxH = Math.max(maxH, h); } console.info(` Mesh height range: ${minH.toFixed(2)}m to ${maxH.toFixed(2)}m (variance: ${(maxH - minH).toFixed(2)}m)`); console.info(` Terra height range: ${terra.minHeight.toFixed(2)}m to ${terra.maxHeight.toFixed(2)}m`); // Create or update vertex colors based on height const colors = new Float32Array(pos.count * 3); for (let i = 0; i < pos.count; i++) { const h = pos.getY(i); const t = (h - minH) / (maxH - minH); // Normalize to 0-1 // Color gradient: blue (low) -> green (mid) -> yellow (high) -> red (very high) let r, g, b; if (t < 0.33) { // Blue to green const localT = t / 0.33; r = 0; g = localT; b = 1 - localT; } else if (t < 0.66) { // Green to yellow const localT = (t - 0.33) / 0.33; r = localT; g = 1; b = 0; } else { // Yellow to red const localT = (t - 0.66) / 0.34; r = 1; g = 1 - localT; b = 0; } colors[i * 3] = r; colors[i * 3 + 1] = g; colors[i * 3 + 2] = b; } terrainMesh.geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); // Switch to vertex color material const oldMaterial = terrainMesh.material; terrainMesh.material = new THREE.MeshBasicMaterial({ vertexColors: true, wireframe: false }); console.info(' ✅ Terrain colored by elevation!'); console.info(' Blue = low, Green = medium, Yellow = high, Red = very high'); console.info(' Run __RTS.restoreTerrainMaterial() to restore original material'); // Store old material for restoration (window.__RTS as any)._oldTerrainMaterial = oldMaterial; }; // Restore original terrain material (window.__RTS as any).restoreTerrainMaterial = () => { const terrainMesh = (window.__RTS as any).checkTerrainMesh(); if (!terrainMesh) return; const oldMaterial = (window.__RTS as any)._oldTerrainMaterial; if (oldMaterial) { terrainMesh.material = oldMaterial; console.info(' ✅ Original terrain material restored'); } }; // Show current biome (now shows multi-biome distribution) (window.__RTS as any).showBiome = () => { console.info('[Biome] 🌍 MULTI-BIOME MAP ACTIVE'); console.info(' Each terrain tile has its own biome based on:'); console.info(' - Elevation (high = arctic, low = desert)'); console.info(' - Moisture (near water = temperate, far = desert)'); console.info(' - Temperature (center = warm, edges = cold)'); console.info(' - Slope (steep = volcanic, gentle = grassland)'); console.info(''); console.info('Available biomes:'); console.info(' 🌲 temperate - Green grass, forests (mid elevation, moderate moisture)'); console.info(' 🏜️ desert - Sandy dunes, canyons (low moisture, high temp)'); console.info(' ❄️ arctic - Snow, ice, glaciers (high elevation or low temp)'); console.info(' 🌋 volcanic - Lava, ash, craters (steep slopes, high elevation)'); console.info(' 👽 alien - Exotic colors, crystals (special zones)'); console.info(' 🏚️ urban_ruins - Concrete, overgrown (special zones)'); console.info(''); console.info('Use __RTS.visualizeBiomes() to see biome distribution on terrain'); }; // Visualize biomes on terrain (window.__RTS as any).visualizeBiomes = () => { const terrainMesh = (window.__RTS as any).checkTerrainMesh(); if (!terrainMesh) return; console.info('[Biome] Visualizing biome distribution...'); const pos = terrainMesh.geometry.attributes.position; const colors = new Float32Array(pos.count * 3); // Biome colors for visualization const biomeColors: Record = { temperate: new THREE.Color(0x4a7c3e), // Green desert: new THREE.Color(0xd4a574), // Sandy arctic: new THREE.Color(0xe0f2f7), // White/blue volcanic: new THREE.Color(0x8a5a3a), // Dark red/brown alien: new THREE.Color(0x6a4a8a), // Purple urban_ruins: new THREE.Color(0x5a5a5a) // Gray }; // Color each vertex by its biome for (let i = 0; i < pos.count; i++) { const x = pos.getX(i); const z = pos.getZ(i); const biome = sim.terra.getBiomeAt(x, z); const color = biomeColors[biome] || biomeColors.temperate; colors[i * 3] = color.r; colors[i * 3 + 1] = color.g; colors[i * 3 + 2] = color.b; } terrainMesh.geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); // Switch to vertex color material const oldMaterial = terrainMesh.material; terrainMesh.material = new THREE.MeshBasicMaterial({ vertexColors: true, wireframe: false }); console.info(' ✅ Terrain colored by biome!'); console.info(' 🌲 Green = Temperate'); console.info(' 🏜️ Sandy = Desert'); console.info(' ❄️ White = Arctic'); console.info(' 🌋 Brown = Volcanic'); console.info(' 👽 Purple = Alien'); console.info(' 🏚️ Gray = Urban Ruins'); console.info(' Run __RTS.restoreTerrainMaterial() to restore original material'); // Store old material for restoration (window.__RTS as any)._oldTerrainMaterial = oldMaterial; }; // Height Scale Sanity Test (window.__RTS as any).testHeightScale = () => { const result = runHeightScaleSanityTest(sim.terra); if (!result.passed) { console.error('⚠️ HEIGHT SCALE ISSUES DETECTED! Check the errors above.'); } return result; }; // AAA Phase 1.5: GPU Terrain Pipeline console commands (window.__RTS as any).enableGPUTerrainPipeline = () => { localStorage.setItem('rts.terrain.useGPUPipeline', 'true'); console.info('✅ GPU Terrain Pipeline enabled! Reload the page to use GPU-accelerated terrain generation.'); console.info(' This will run the complete AAA pipeline: GPU generation → Hydraulic erosion → Thermal erosion'); }; (window.__RTS as any).disableGPUTerrainPipeline = () => { localStorage.setItem('rts.terrain.useGPUPipeline', 'false'); console.info('✅ GPU Terrain Pipeline disabled. Reload the page to use CPU terrain generation.'); }; (window.__RTS as any).isGPUTerrainPipelineEnabled = () => { const enabled = localStorage.getItem('rts.terrain.useGPUPipeline') === 'true'; console.info(`GPU Terrain Pipeline: ${enabled ? '✅ ENABLED' : '❌ DISABLED'}`); if (!enabled) { console.info('To enable: __RTS.enableGPUTerrainPipeline()'); } return enabled; }; // AAA Phase 2.1: Virtual Texture Streaming console commands (window.__RTS as any).enableVirtualTextures = () => { localStorage.setItem('rts.terrain.useVirtualTextures', 'true'); console.info('✅ Virtual Textures enabled! Reload the page to apply.'); console.info(' This enables tile-based texture streaming (32K virtual → 8K physical cache)'); console.info(' Memory savings: ~1 GB → ~80 MB VRAM'); }; (window.__RTS as any).disableVirtualTextures = () => { localStorage.setItem('rts.terrain.useVirtualTextures', 'false'); console.info('✅ Virtual Textures disabled. Reload the page to use traditional textures.'); }; (window.__RTS as any).isVirtualTexturesEnabled = () => { const enabled = localStorage.getItem('rts.terrain.useVirtualTextures') !== 'false'; console.info(`Virtual Textures: ${enabled ? '✅ ENABLED' : '❌ DISABLED'}`); if (!enabled) { console.info('To enable: __RTS.enableVirtualTextures()'); } return enabled; }; (window.__RTS as any).checkVirtualTextureStatus = () => { const textureManager = (window.__RTS as any).textureManager; console.info('[VirtualTextures] 🔍 System Status:'); console.info(` Texture Manager: ${textureManager ? '✅ Available' : '❌ Not Available'}`); if (textureManager) { const vtCache = textureManager.getVirtualTextureCache?.(); console.info(` Virtual Texture Cache: ${vtCache ? '✅ Initialized' : '❌ Not Initialized'}`); if (vtCache) { console.info(' Status: ✅ Virtual textures are working!'); console.info(' Use __RTS.getVirtualTextureStats() to view statistics'); } else { console.info(' Status: ⚠️ Virtual textures disabled or not loaded'); console.info(' Enable with: __RTS.enableVirtualTextures()'); } } else { console.info(' Status: ⏳ Waiting for terrain to load...'); console.info(' The texture manager will be available after the first frame renders'); } return { textureManagerAvailable: !!textureManager, virtualTextureCacheAvailable: !!(textureManager?.getVirtualTextureCache?.()) }; }; (window.__RTS as any).getVirtualTextureStats = () => { const textureManager = (window.__RTS as any).textureManager; if (!textureManager) { console.error('[VirtualTextures] ❌ Texture manager not available.'); console.info('[VirtualTextures] 💡 This usually means:'); console.info(' 1. The game is still loading (wait a few seconds)'); console.info(' 2. The first frame hasn\'t rendered yet (wait for terrain to appear)'); console.info(' 3. The terrain rendering pass hasn\'t executed'); console.info('[VirtualTextures] 🔍 Check status with:'); console.info(' __RTS.checkVirtualTextureStatus()'); console.info('[VirtualTextures] Wait for this message in console:'); console.info(' "[VirtualTextures] Texture manager exposed on __RTS"'); return null; } const vtCache = textureManager.getVirtualTextureCache?.(); if (!vtCache) { console.error('[VirtualTextures] ❌ Virtual texture cache not initialized.'); console.info('[VirtualTextures] 💡 Possible reasons:'); console.info(' 1. Virtual textures are disabled'); console.info(' 2. Textures are still loading (check for "Textures loaded" message)'); console.info('[VirtualTextures] To enable virtual textures:'); console.info(' __RTS.enableVirtualTextures()'); console.info(' Then reload the page'); return null; } const stats = vtCache.getStats(); console.info('[VirtualTextures] 📊 Cache Statistics:'); console.info(` Cache Hit Rate: ${(stats.cacheHitRate * 100).toFixed(1)}%`); console.info(` Tiles Loaded: ${stats.tilesLoaded}`); console.info(` Tiles Evicted: ${stats.tilesEvicted}`); console.info(` Active Tiles: ${stats.tilesLoaded - stats.tilesEvicted}`); console.info(` VRAM Usage: ${stats.vramUsageMB.toFixed(1)} MB`); console.info(` Atlas Size: ${stats.atlasResolution}x${stats.atlasResolution}`); console.info(` Virtual Space: ${stats.virtualResolution}x${stats.virtualResolution}`); return stats; }; console.info('[Test] Debug utilities available:'); console.info(' __RTS.debugTerrain() - Check terrain elevation data'); console.info(' __RTS.checkTerrainMesh() - Check if terrain mesh is in scene'); console.info(' __RTS.checkNormals() - Check if normals are flipped'); console.info(' __RTS.flipNormals() - Flip all terrain normals'); console.info(' __RTS.setDoubleSided() - Render both sides of terrain'); console.info(' __RTS.setVerticalScale(multiplier) - Scale terrain height (try 3x-5x)'); console.info(' __RTS.findElevation() - Find and view elevated terrain'); console.info(' __RTS.debugEntities() - Show entity rendering debug info'); console.info(' __RTS.showBiome() - Show multi-biome system info'); console.info(' __RTS.visualizeBiomes() - 🌍 Visualize biome distribution on terrain'); console.info(' __RTS.setScatterDensity(0.1-2.0) - ⚡ Set scatter density (0.62 = default)'); console.info(' __RTS.spawnCombatScenario(id, {seed?, anchorX?, anchorZ?}) - Spawn deterministic combat scenario'); console.info(' __RTS.resetCombatScenario() - Reset last spawned combat scenario'); console.info(' __RTS.setCombatScenarioSeed(seed) - Set locked scenario seed'); console.info(' __RTS.setUnitCountScale(scale, persist?) - Scale scenario and battlegroup unit counts (0.5..4.0)'); console.info(' __RTS.getUnitCountScale() - Return active unit count scale'); console.info(' __RTS.getCombatScenarioState() - Inspect active scenario id/seed/anchor'); console.info(' __RTS.getCombatScenarioMetrics() - Return current/last scenario KPI artifact'); console.info(' __RTS.downloadCombatScenarioMetrics(filename?) - Download combat-map-gate JSON'); console.info(' __RTS.spawnBattlegroupBattle() - Spawn two opposing battlegroups with laser, kinetic, and artillery fire'); console.info(' __RTS.clearBattlegroupBattle() - Clear the AAA battlegroup engagement'); console.info(' __RTS.testHeightScale() - 🔍 Run comprehensive height scale sanity tests'); console.info(''); console.info(' 🎬 RENDERING QUALITY:'); console.info(' __RTS.toggleTaa() - Toggle Temporal Anti-Aliasing (reduces shimmering)'); console.info(' __RTS.isTaaEnabled() - Check if TAA is active'); console.info(' __RTS.toggleSsr() - Toggle full-screen SSR pass'); console.info(' __RTS.isSsrEnabled() - Check if full-screen SSR is active'); console.info(' __RTS.toggleRendererPerformanceOverlay() - Show framegraph CPU/GPU pass stats'); console.info(' __RTS.toggleBenchmarkOverlay() - Show benchmark FPS/frame overlay'); console.info(''); console.info(' 🏔️ TERRAIN LOOKS FLAT?'); console.info(' 1. __RTS.checkNormals() - Check if normals point up'); console.info(' 2. __RTS.setVerticalScale(3) - Make terrain 3x more dramatic'); console.info(''); console.info(' 🌍 WANT TO SEE BIOMES?'); console.info(' __RTS.visualizeBiomes() - Color terrain by biome type'); console.info(''); console.info(' 🚀 AAA GPU TERRAIN PIPELINE (Phase 1.5):'); console.info(' __RTS.enableGPUTerrainPipeline() - Enable complete GPU generation + erosion'); console.info(' __RTS.disableGPUTerrainPipeline() - Disable GPU pipeline (use CPU)'); console.info(' __RTS.isGPUTerrainPipelineEnabled() - Check if GPU pipeline is active'); console.info(''); console.info(' 🎨 VIRTUAL TEXTURE STREAMING (Phase 2.1):'); console.info(' __RTS.enableVirtualTextures() - Enable tile-based texture streaming (saves ~920 MB VRAM)'); console.info(' __RTS.disableVirtualTextures() - Disable virtual textures (use traditional textures)'); console.info(' __RTS.isVirtualTexturesEnabled() - Check if virtual textures are active'); console.info(' __RTS.checkVirtualTextureStatus() - Check if system is ready (use this first!)'); console.info(' __RTS.getVirtualTextureStats() - View cache statistics and performance'); console.info(''); console.info(' ⚡ LOW FPS? PERFORMANCE TIPS:'); console.info(' __RTS.setScatterDensity(0.1) - Reduce vegetation to 10%'); console.info(' __RTS.setScatterDensity(0.62) - Default 62% (dense)'); console.info(' __RTS.setScatterDensity(1.0) - Full 100% (high-end GPUs)'); console.info(''); console.info(' 🌫️ FOG OF WAR:'); console.info(' __RTS.toggleFogOfWar() - Toggle fog of war on/off'); console.info(' __RTS.revealMap() - Reveal entire map (cheat/debug)'); console.info(' __RTS.fogOfWarRenderer - Access fog of war renderer (debug)'); console.info(''); console.info(' 💥 GPU PARTICLES:'); console.info(' __RTS.spawnTestExplosion(x, z) - Spawn test explosion at position'); console.info(' __RTS.spawnTestImpact(x, z, type) - Spawn impact (type: "bullet" or "laser")'); console.info(' __RTS.getParticleStats() - Get active particle/decal counts'); console.info(' __RTS.setImpactGraphEnabled(true|false) - Toggle Impact Graph pipeline'); console.info(' __RTS.setImpactGraphQuality("high"|"medium"|"low") - Set Impact Graph quality'); console.info(' __RTS.setImpactGraphSdfDeflection(true|false) - Toggle obstacle-aware deflection hook'); console.info(' __RTS.getImpactGraphStats() - Print Impact Graph counters/timing'); const terrainDiagnostics = lastTerrainResolveDiagnostics; const runtimeOverrides: RuntimeOverrideEntry[] = []; const overrideKeys = [ 'rts.framegraph.entityMode', 'rts.framegraph.webglEntities', 'rts.framegraph.entityScreenspacePass', 'rts.terrain.useGPUHeightmap', 'rts.terrain.useGPUPipeline', 'rts.terrain.disableLayeredHeightmap', 'rts.terrain.forceGPUErosion', 'rts.tree.density', 'rts.scatter.density', 'rts.tree.lod', 'rts.tree.diagnostics' ]; for (const key of overrideKeys) { const value = window.localStorage.getItem(key); if (value !== null) { runtimeOverrides.push({ key, value, source: 'localStorage' }); } } const startupReport = buildSystemActivationReport({ build: { gitSha: __APP_GIT_SHA__, buildTime: __APP_BUILD_TIME__, buildMode: __APP_BUILD_MODE__ }, renderer: { backend: webgpuReady ? 'webgpu' : 'webgl', webgpuReady, entityRenderMode: FRAMEGRAPH_ENTITY_RENDER_MODE, useWebglEntities: FRAMEGRAPH_USE_WEBGL_ENTITIES, captureEntityProxyObjects, entityScreenspacePass: FRAMEGRAPH_ENTITY_SCREENSPACE_PASS, dynamicQuality: FRAMEGRAPH_DYNAMIC_QUALITY }, terrain: { mapId: terrainDiagnostics?.mapId ?? (normalizedSettings?.mapId ?? terrainMapPlan), mapPlan: terrainDiagnostics?.mapPlan ?? terrainMapPlan, biome: String(sim.terra.getBiomeType?.() ?? terrainDiagnostics?.biome ?? 'unknown'), layeredHeightmap: terrainDiagnostics?.useLayeredHeightmap ?? false, gpuErosion: terrainDiagnostics?.useGPUErosion ?? false, gpuTerrainPipeline: terrainDiagnostics?.useGPUTerrainPipeline ?? false, forcedGpuErosion: terrainDiagnostics?.forceGPUErosion ?? false, heightmapResolution: terrainDiagnostics?.heightmapResolution ?? 0, erosionResolution: terrainDiagnostics?.erosionResolution ?? 0 }, tree: { system: treeSystem.constructor?.name || 'ImprovedTreeSystem', placementMode: startupTreePlacementMode, diagnosticsEnabled: TREE_DIAGNOSTICS_ENABLED, treeDensityMultiplier: TREE_DENSITY_MULTIPLIER_EFFECTIVE, scatterDensityMultiplier: SCATTER_DENSITY_MULTIPLIER, lodPreference: TREE_LOD_PREFERENCE }, systems: [ { name: 'Terrain', implementation: sim.terra.constructor?.name || 'Terra', active: true, mode: terrainDiagnostics?.useGPUTerrainPipeline ? 'gpu_pipeline' : 'standard' }, { name: 'Simulation', implementation: sim.constructor?.name || 'Sim', active: true }, { name: 'Units', implementation: sim.units.constructor?.name || 'Units', active: true }, { name: 'Buildings', implementation: sim.buildings.constructor?.name || 'Buildings', active: true }, { name: 'RendererService', implementation: rendererService.constructor?.name || 'RendererService', active: true, mode: webgpuReady ? 'webgpu' : 'webgl' }, { name: 'TerrainChunkManager', implementation: terrainChunkManager?.constructor?.name || 'WebGPUTerrainChunkManager', active: terrainChunkManager !== null, mode: terrainChunkManager ? 'gpu_culling' : 'disabled' }, { name: 'EntityRenderer', implementation: entityRenderer?.constructor?.name || 'EntityRenderer', active: entityRenderer !== null, mode: FRAMEGRAPH_ENTITY_RENDER_MODE }, { name: 'TreeSystem', implementation: treeSystem.constructor?.name || 'ImprovedTreeSystem', active: true, mode: startupTreePlacementMode, details: { treeLod: TREE_LOD_PREFERENCE } } ], runtimeOverrides }); const rtsRuntime = ensureRTS(); rtsRuntime.getSystemActivationReport = () => startupReport; rtsRuntime.printSystemActivationReport = () => printSystemActivationReport(startupReport); printSystemActivationReport(startupReport); // Complete loading progress if (loadingProgress) { loadingProgress.setProgress(95, 'Starting game & enabling controls...'); stageLog('Loading complete, entering game', 95); setTimeout(() => loadingProgress?.complete(), 100); } } catch (err) { console.error('[Bootstrap] Failed during loading:', err); if (loadingProgress) { loadingProgress.setProgress(99, 'Error encountered, fallback in progress...'); } throw err; } finally { finishLoading(); } }; // Initialize menu system const menuManager = new MenuManager(); const settingsManager = new SettingsManager(); const profileManager = new ProfileManager(); const menuMusicPlayer = new MenuMusicPlayer({ initialVolumePercent: settingsManager.getSettings().musicVolume }); const jukeboxOverlayRoot = document.getElementById('jukebox-overlay'); if (jukeboxOverlayRoot) { document.body.classList.add('has-jukebox-overlay'); new Mp3PlayerOverlay(jukeboxOverlayRoot, { initialVolumePercent: settingsManager.getSettings().musicVolume }); } else { const mp3PlayerRoot = document.getElementById('settings-mp3-player'); if (mp3PlayerRoot) { new Mp3Player(mp3PlayerRoot, { initialVolumePercent: settingsManager.getSettings().musicVolume }); } } const menuBackgroundLayer = document.getElementById('menu-background-layer'); if (menuBackgroundLayer) { new MenuArtRotator(menuBackgroundLayer, DEFAULT_MENU_ART_ASSETS, { heroSelector: '#menu-hero-art', cycleIntervalMs: 14000 }); } // Set up game start callback menuManager.setStartGameCallback((settings) => { console.log('[Menu] Starting game with settings:', settings); bootstrap(settings).catch((err) => console.error('RTS bootstrap failed', err)); }); if (mapLabAutoStartRequest) { const settings = buildSettingsFromMapLabAutoStart(mapLabAutoStartRequest); console.info('[MapLab] Auto-starting game from MapLab request:', { mapName: settings.mapName, maxPlayers: settings.maxPlayers, sourceHash: mapLabAutoStartRequest.sourceHash ?? 'n/a' }); menuManager.launchFromExternalSettings(settings); } // Add global UI click sounds for all buttons document.addEventListener('click', (event) => { const target = event.target as HTMLElement; if (target.tagName === 'BUTTON' || target.closest('button')) { SoundManager.getInstance().playSound('ui_click', { volume: 0.6 }); } }, true); // Use capture phase to catch all button clicks // Show main menu on load console.log('[Menu] AAA RTS Menu System initialized'); console.log('[Menu] Navigate: Play → Settings → Profile'); console.log('[Menu] Press ESC to return to main menu from any screen');