diff --git a/src/rendering/world-shader-sky.ts b/src/rendering/world-shader-sky.ts new file mode 100644 index 00000000..8ce24f11 --- /dev/null +++ b/src/rendering/world-shader-sky.ts @@ -0,0 +1,305 @@ +import type { Vec2, Vec3 } from "../core/vector"; +import type { ProjectTimeSettings } from "../document/project-time-settings"; +import { + createDefaultWorldTimePhaseProfile, + type WorldBackgroundSettings, + type WorldSettings +} from "../document/world-settings"; +import { + resolveRuntimeDayNightPhaseWeights, + type RuntimeDayNightPhaseWeights, + 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; + }; + 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; + rotationRadians: number; + }; + clouds: { + coverage: number; + density: number; + softness: number; + scale: number; + height: number; + heightVariation: number; + tintHex: string; + opacity: number; + opacityRandomness: number; + driftOffset: Vec2; + }; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +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 + }; +} + +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 starVisibility = + clamp(phaseWeights.night + twilightFactor * 0.45, 0, 1) * + clamp(1 - resolvedWorld.daylightFactor * 0.85, 0, 1); + const cloudDriftDirectionRadians = + (world.shaderSky.clouds.driftDirectionDegrees * Math.PI) / 180; + const cloudDriftDistance = + continuousHours * world.shaderSky.clouds.driftSpeed; + + 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 + ) + }, + celestial: { + sunDirection, + sunColorHex: resolvedWorld.sunLight.colorHex, + sunIntensity: resolvedWorld.sunLight.intensity, + sunDiscSizeDegrees: world.shaderSky.celestial.sunDiscSizeDegrees, + sunVisible: + world.showCelestialBodies && + resolvedWorld.sunLight.intensity > 1e-4 && + sunDirection.y > -0.06, + moonDirection, + moonColorHex: resolvedWorld.moonLight?.colorHex ?? "#d7e4ff", + moonIntensity: resolvedWorld.moonLight?.intensity ?? 0, + moonDiscSizeDegrees: world.shaderSky.celestial.moonDiscSizeDegrees, + moonVisible: + world.showCelestialBodies && + (resolvedWorld.moonLight?.intensity ?? 0) > 1e-4 && + moonDirection.y > -0.06 + }, + stars: { + density: world.shaderSky.stars.density, + brightness: world.shaderSky.stars.brightness, + visibility: starVisibility, + rotationRadians: Math.atan2(sunDirection.z, sunDirection.x) + }, + 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 + } + } + }; +}