import type { Vec2, Vec3 } from "../core/vector"; import { HOURS_PER_DAY, normalizeTimeOfDayHours, type ProjectTimeSettings } from "../document/project-time-settings"; import { createDefaultWorldTimePhaseProfile, resolveWorldCelestialOrbitPeakDirection, type WorldCelestialOrbitSettings, type WorldBackgroundSettings, type WorldSettings } from "../document/world-settings"; import { resolveRuntimeDayNightWorldState, resolveRuntimeDayNightPhaseWeights, resolveRuntimeTimeState, type RuntimeDayNightPhaseWeights, type RuntimeDayPhase, type RuntimeDayNightWorldState, type RuntimeResolvedTimeState } from "../runtime-three/runtime-project-time"; export interface WorldShaderSkyRenderState { presetId: "defaultSky"; time: { dayCount: number; timeOfDayHours: number; dayPhase: RuntimeResolvedTimeState["dayPhase"]; daylightFactor: number; twilightFactor: number; phaseWeights: RuntimeDayNightPhaseWeights; }; sky: { topColorHex: string; bottomColorHex: string; horizonHeight: number; }; celestial: { sunDirection: Vec3; sunColorHex: string; sunIntensity: number; sunDiscSizeDegrees: number; sunVisible: boolean; moonDirection: Vec3; moonColorHex: string; moonIntensity: number; moonDiscSizeDegrees: number; moonVisible: boolean; }; stars: { density: number; brightness: number; visibility: number; horizonFadeOffset: number; rotationAxis: Vec3; rotationRadians: number; }; clouds: { coverage: number; density: number; softness: number; scale: number; height: number; heightVariation: number; tintHex: string; opacity: number; opacityRandomness: number; driftOffset: Vec2; }; aurora: { visibility: number; intensity: number; height: number; thickness: number; speed: number; primaryColorHex: string; secondaryColorHex: string; rotationRadians: number; timeHours: number; }; } export interface WorldShaderSkyEnvironmentPhaseStates { day: WorldShaderSkyRenderState | null; dawn: WorldShaderSkyRenderState | null; dusk: WorldShaderSkyRenderState | null; night: WorldShaderSkyRenderState | null; } export interface WorldShaderSkyEnvironmentPhaseBlend { basePhase: RuntimeDayPhase; overlayPhase: RuntimeDayPhase | null; blendAmount: number; } function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } function wrapTimeForward(hours: number, originHours: number): number { let wrappedHours = normalizeTimeOfDayHours(hours); while (wrappedHours < originHours) { wrappedHours += HOURS_PER_DAY; } return wrappedHours; } function resolveTimeWindowMidpoint( startTimeOfDayHours: number, endTimeOfDayHours: number ): number { const wrappedEndTime = wrapTimeForward( endTimeOfDayHours, startTimeOfDayHours ); return normalizeTimeOfDayHours( startTimeOfDayHours + (wrappedEndTime - startTimeOfDayHours) / 2 ); } function quantizeNumberToBucket(value: number, step: number): number { return Math.round(value / step); } const AURORA_WORLD_ROTATION_RADIANS = 0; const DEFAULT_SKY_ROTATION_AXIS: Vec3 = { x: 0, y: 1, z: 0 }; function parseHexColor(colorHex: string): { r: number; g: number; b: number } { return { r: Number.parseInt(colorHex.slice(1, 3), 16), g: Number.parseInt(colorHex.slice(3, 5), 16), b: Number.parseInt(colorHex.slice(5, 7), 16) }; } function formatHexColor(color: { r: number; g: number; b: number }): string { const toHex = (value: number) => Math.round(clamp(value, 0, 255)) .toString(16) .padStart(2, "0"); return `#${toHex(color.r)}${toHex(color.g)}${toHex(color.b)}`; } function blendHexColorsByWeights( colors: { day: string; dawn: string; dusk: string; night: string; }, weights: RuntimeDayNightPhaseWeights ): string { const totalWeight = weights.day + weights.dawn + weights.dusk + weights.night; if (totalWeight <= 1e-6) { return colors.day; } const day = parseHexColor(colors.day); const dawn = parseHexColor(colors.dawn); const dusk = parseHexColor(colors.dusk); const night = parseHexColor(colors.night); return formatHexColor({ r: (day.r * weights.day + dawn.r * weights.dawn + dusk.r * weights.dusk + night.r * weights.night) / totalWeight, g: (day.g * weights.day + dawn.g * weights.dawn + dusk.g * weights.dusk + night.g * weights.night) / totalWeight, b: (day.b * weights.day + dawn.b * weights.dawn + dusk.b * weights.dusk + night.b * weights.night) / totalWeight }); } function normalizeDirection(direction: Vec3, fallback: Vec3): Vec3 { const length = Math.hypot(direction.x, direction.y, direction.z); if (length <= 1e-6) { return { ...fallback }; } return { x: direction.x / length, y: direction.y / length, z: direction.z / length }; } export function resolveWorldShaderSkyStarRotation( timeOfDayHours: number, sunOrbit: WorldCelestialOrbitSettings ): { rotationAxis: Vec3; rotationRadians: number; } { const sunPeakDirection = resolveWorldCelestialOrbitPeakDirection(sunOrbit); const rotationAxis = normalizeDirection( { x: -sunPeakDirection.x * sunPeakDirection.y, y: 1 - sunPeakDirection.y * sunPeakDirection.y, z: -sunPeakDirection.z * sunPeakDirection.y }, DEFAULT_SKY_ROTATION_AXIS ); return { rotationAxis, rotationRadians: (normalizeTimeOfDayHours(timeOfDayHours) / HOURS_PER_DAY) * Math.PI * 2 }; } function offsetDirectionForHorizon( direction: Vec3, horizonHeight: number ): Vec3 { if (Math.abs(horizonHeight) <= 1e-6) { return direction; } return normalizeDirection( { x: direction.x, y: direction.y - horizonHeight, z: direction.z }, direction ); } function resolveGradientBasis( background: WorldBackgroundSettings, fallbackTopColorHex: string, fallbackBottomColorHex: string ): { topColorHex: string; bottomColorHex: string; } { if (background.mode === "solid") { return { topColorHex: background.colorHex, bottomColorHex: background.colorHex }; } if (background.mode === "verticalGradient") { return { topColorHex: background.topColorHex, bottomColorHex: background.bottomColorHex }; } return { topColorHex: fallbackTopColorHex, bottomColorHex: fallbackBottomColorHex }; } export function resolveWorldShaderSkyRenderState( world: WorldSettings, resolvedWorld: RuntimeDayNightWorldState, resolvedTime: RuntimeResolvedTimeState | null, timeSettings: ProjectTimeSettings | null ): WorldShaderSkyRenderState | null { if (world.background.mode !== "shader") { return null; } const phaseWeights = world.projectTimeLightingEnabled && resolvedTime !== null && timeSettings !== null ? resolveRuntimeDayNightPhaseWeights( timeSettings, resolvedTime.timeOfDayHours ) : { day: 1, dawn: 0, dusk: 0, night: 0 }; const twilightFactor = clamp(phaseWeights.dawn + phaseWeights.dusk, 0, 1); const dayCount = resolvedTime?.dayCount ?? 0; const timeOfDayHours = resolvedTime?.timeOfDayHours ?? 12; const continuousHours = dayCount * 24 + timeOfDayHours; const dawnGradient = resolveGradientBasis( world.timeOfDay.dawn.background, world.timeOfDay.dawn.skyTopColorHex, world.timeOfDay.dawn.skyBottomColorHex ); const duskGradient = resolveGradientBasis( world.timeOfDay.dusk.background, world.timeOfDay.dusk.skyTopColorHex, world.timeOfDay.dusk.skyBottomColorHex ); const defaultNightGradient = createDefaultWorldTimePhaseProfile("night"); const nightGradient = resolveGradientBasis( world.timeOfDay.night.background, defaultNightGradient.skyTopColorHex, defaultNightGradient.skyBottomColorHex ); const sunDirection = normalizeDirection( resolvedWorld.sunLight.direction, world.sunLight.direction ); const moonDirection = normalizeDirection( resolvedWorld.moonLight?.direction ?? { x: -sunDirection.x, y: -sunDirection.y, z: -sunDirection.z }, { x: -sunDirection.x, y: -sunDirection.y, z: -sunDirection.z } ); const sunRenderDirection = offsetDirectionForHorizon( sunDirection, world.shaderSky.horizonHeight ); const moonRenderDirection = offsetDirectionForHorizon( moonDirection, world.shaderSky.horizonHeight ); const starVisibility = clamp(phaseWeights.night + twilightFactor * 0.45, 0, 1) * clamp(1 - resolvedWorld.daylightFactor * 0.85, 0, 1); const auroraVisibility = world.shaderSky.aurora.enabled ? clamp(phaseWeights.night + twilightFactor * 0.32, 0, 1) * clamp(1 - resolvedWorld.daylightFactor * 1.15, 0, 1) : 0; const cloudDriftDirectionRadians = (world.shaderSky.clouds.driftDirectionDegrees * Math.PI) / 180; const cloudDriftDistance = continuousHours * world.shaderSky.clouds.driftSpeed; const starRotation = resolveWorldShaderSkyStarRotation( timeOfDayHours, world.celestialOrbits.sun ); return { presetId: world.shaderSky.presetId, time: { dayCount, timeOfDayHours, dayPhase: resolvedTime?.dayPhase ?? "day", daylightFactor: resolvedWorld.daylightFactor, twilightFactor, phaseWeights }, sky: { topColorHex: blendHexColorsByWeights( { day: world.shaderSky.dayTopColorHex, dawn: dawnGradient.topColorHex, dusk: duskGradient.topColorHex, night: nightGradient.topColorHex }, phaseWeights ), bottomColorHex: blendHexColorsByWeights( { day: world.shaderSky.dayBottomColorHex, dawn: dawnGradient.bottomColorHex, dusk: duskGradient.bottomColorHex, night: nightGradient.bottomColorHex }, phaseWeights ), horizonHeight: world.shaderSky.horizonHeight }, celestial: { sunDirection: sunRenderDirection, sunColorHex: resolvedWorld.sunLight.colorHex, sunIntensity: resolvedWorld.sunLight.intensity, sunDiscSizeDegrees: world.shaderSky.celestial.sunDiscSizeDegrees, sunVisible: world.showCelestialBodies && resolvedWorld.sunLight.intensity > 1e-4, moonDirection: moonRenderDirection, moonColorHex: resolvedWorld.moonLight?.colorHex ?? "#d7e4ff", moonIntensity: resolvedWorld.moonLight?.intensity ?? 0, moonDiscSizeDegrees: world.shaderSky.celestial.moonDiscSizeDegrees, moonVisible: world.showCelestialBodies && (resolvedWorld.moonLight?.intensity ?? 0) > 1e-4 }, stars: { density: world.shaderSky.stars.density, brightness: world.shaderSky.stars.brightness, visibility: starVisibility, horizonFadeOffset: world.shaderSky.stars.horizonFadeOffset, rotationAxis: starRotation.rotationAxis, rotationRadians: starRotation.rotationRadians }, clouds: { coverage: world.shaderSky.clouds.coverage, density: world.shaderSky.clouds.density, softness: world.shaderSky.clouds.softness, scale: world.shaderSky.clouds.scale, height: world.shaderSky.clouds.height, heightVariation: world.shaderSky.clouds.heightVariation, tintHex: world.shaderSky.clouds.tintHex, opacity: world.shaderSky.clouds.opacity, opacityRandomness: world.shaderSky.clouds.opacityRandomness, driftOffset: { x: Math.cos(cloudDriftDirectionRadians) * cloudDriftDistance, y: Math.sin(cloudDriftDirectionRadians) * cloudDriftDistance * 0.35 } }, aurora: { visibility: auroraVisibility, intensity: world.shaderSky.aurora.intensity, height: world.shaderSky.aurora.height, thickness: world.shaderSky.aurora.thickness, speed: world.shaderSky.aurora.speed, primaryColorHex: world.shaderSky.aurora.primaryColorHex, secondaryColorHex: world.shaderSky.aurora.secondaryColorHex, rotationRadians: AURORA_WORLD_ROTATION_RADIANS, timeHours: continuousHours } }; } export function resolveWorldShaderSkyEnvironmentPhaseStates( world: WorldSettings, timeSettings: ProjectTimeSettings | null ): WorldShaderSkyEnvironmentPhaseStates { if (world.background.mode !== "shader") { return { day: null, dawn: null, dusk: null, night: null }; } if (!world.projectTimeLightingEnabled || timeSettings === null) { const resolvedWorld: RuntimeDayNightWorldState = { ambientLight: { ...world.ambientLight }, sunLight: { ...world.sunLight, direction: { ...world.sunLight.direction } }, moonLight: null, background: { ...world.background }, nightBackgroundOverlay: null, daylightFactor: 1 }; const dayState = resolveWorldShaderSkyRenderState( world, resolvedWorld, null, null ); return { day: dayState, dawn: dayState, dusk: dayState, night: dayState }; } const dawnHalfDuration = Math.max(timeSettings.dawnDurationHours, 0.001) / 2; const duskHalfDuration = Math.max(timeSettings.duskDurationHours, 0.001) / 2; const dawnStart = timeSettings.sunriseTimeOfDayHours - dawnHalfDuration; const dawnEnd = timeSettings.sunriseTimeOfDayHours + dawnHalfDuration; const duskStart = timeSettings.sunsetTimeOfDayHours - duskHalfDuration; const duskEnd = timeSettings.sunsetTimeOfDayHours + duskHalfDuration; const representativeTimes = { dawn: resolveTimeWindowMidpoint(dawnStart, dawnEnd), day: resolveTimeWindowMidpoint(dawnEnd, duskStart), dusk: resolveTimeWindowMidpoint(duskStart, duskEnd), night: resolveTimeWindowMidpoint(duskEnd, dawnStart) }; const resolveRepresentativeState = ( timeOfDayHours: number ): WorldShaderSkyRenderState | null => { const resolvedTime = resolveRuntimeTimeState(timeSettings, { timeOfDayHours, dayCount: 0, dayLengthMinutes: timeSettings.dayLengthMinutes }); const resolvedWorld = resolveRuntimeDayNightWorldState( world, timeSettings, { timeOfDayHours, dayCount: 0, dayLengthMinutes: timeSettings.dayLengthMinutes }, resolvedTime ); return resolveWorldShaderSkyRenderState( world, resolvedWorld, resolvedTime, timeSettings ); }; return { day: resolveRepresentativeState(representativeTimes.day), dawn: resolveRepresentativeState(representativeTimes.dawn), dusk: resolveRepresentativeState(representativeTimes.dusk), night: resolveRepresentativeState(representativeTimes.night) }; } export function createWorldShaderSkyEnvironmentCacheKey( state: WorldShaderSkyRenderState ): string { return JSON.stringify({ presetId: state.presetId, sky: [ state.sky.topColorHex, state.sky.bottomColorHex, quantizeNumberToBucket(state.sky.horizonHeight, 0.01) ], sun: [ state.celestial.sunColorHex, quantizeNumberToBucket(state.celestial.sunDirection.x, 0.01), quantizeNumberToBucket(state.celestial.sunDirection.y, 0.01), quantizeNumberToBucket(state.celestial.sunDirection.z, 0.01), quantizeNumberToBucket(state.celestial.sunIntensity, 0.05), quantizeNumberToBucket(state.celestial.sunDiscSizeDegrees, 0.05), state.celestial.sunVisible ? 1 : 0 ], moon: [ state.celestial.moonColorHex, quantizeNumberToBucket(state.celestial.moonDirection.x, 0.01), quantizeNumberToBucket(state.celestial.moonDirection.y, 0.01), quantizeNumberToBucket(state.celestial.moonDirection.z, 0.01), quantizeNumberToBucket(state.celestial.moonIntensity, 0.05), quantizeNumberToBucket(state.celestial.moonDiscSizeDegrees, 0.05), state.celestial.moonVisible ? 1 : 0 ], stars: [ quantizeNumberToBucket(state.stars.density, 0.05), quantizeNumberToBucket(state.stars.brightness, 0.05), quantizeNumberToBucket(state.stars.visibility, 0.05), quantizeNumberToBucket(state.stars.horizonFadeOffset, 0.01), quantizeNumberToBucket(state.stars.rotationAxis.x, 0.01), quantizeNumberToBucket(state.stars.rotationAxis.y, 0.01), quantizeNumberToBucket(state.stars.rotationAxis.z, 0.01), quantizeNumberToBucket(state.stars.rotationRadians, 0.02) ], clouds: [ state.clouds.tintHex, quantizeNumberToBucket(state.clouds.coverage, 0.05), quantizeNumberToBucket(state.clouds.density, 0.05), quantizeNumberToBucket(state.clouds.softness, 0.05), quantizeNumberToBucket(state.clouds.scale, 0.05), quantizeNumberToBucket(state.clouds.height, 0.05), quantizeNumberToBucket(state.clouds.heightVariation, 0.05), quantizeNumberToBucket(state.clouds.opacity, 0.05), quantizeNumberToBucket(state.clouds.opacityRandomness, 0.05), quantizeNumberToBucket(state.clouds.driftOffset.x, 0.02), quantizeNumberToBucket(state.clouds.driftOffset.y, 0.02) ], aurora: [ state.aurora.primaryColorHex, state.aurora.secondaryColorHex, quantizeNumberToBucket(state.aurora.visibility, 0.05), quantizeNumberToBucket(state.aurora.intensity, 0.05), quantizeNumberToBucket(state.aurora.height, 0.05), quantizeNumberToBucket(state.aurora.thickness, 0.05), quantizeNumberToBucket(state.aurora.speed, 0.05), quantizeNumberToBucket(state.aurora.rotationRadians, 0.02) ] }); } export function createWorldShaderSkyEnvironmentPhaseCacheKey( states: WorldShaderSkyEnvironmentPhaseStates ): string { return JSON.stringify({ day: states.day === null ? null : createWorldShaderSkyEnvironmentCacheKey(states.day), dawn: states.dawn === null ? null : createWorldShaderSkyEnvironmentCacheKey(states.dawn), dusk: states.dusk === null ? null : createWorldShaderSkyEnvironmentCacheKey(states.dusk), night: states.night === null ? null : createWorldShaderSkyEnvironmentCacheKey(states.night) }); } export function resolveWorldShaderSkyEnvironmentPhaseBlend( state: Pick ): WorldShaderSkyEnvironmentPhaseBlend | null { const activePhases = (["day", "dawn", "dusk", "night"] as const).filter( (phase) => state.time.phaseWeights[phase] > 1e-4 ); if (activePhases.length === 0) { return null; } if (activePhases.length === 1) { return { basePhase: activePhases[0], overlayPhase: null, blendAmount: 0 }; } const [basePhase, overlayPhase] = activePhases; const baseWeight = state.time.phaseWeights[basePhase]; const overlayWeight = state.time.phaseWeights[overlayPhase]; const totalWeight = Math.max(baseWeight + overlayWeight, 1e-6); return { basePhase, overlayPhase, blendAmount: overlayWeight / totalWeight }; }