2026-04-12 04:29:39 +02:00
|
|
|
import type { Vec3 } from "../core/vector";
|
|
|
|
|
import {
|
|
|
|
|
formatTimeOfDayHours,
|
|
|
|
|
HOURS_PER_DAY,
|
|
|
|
|
normalizeTimeOfDayHours,
|
|
|
|
|
type ProjectTimeSettings
|
|
|
|
|
} from "../document/project-time-settings";
|
|
|
|
|
import {
|
|
|
|
|
cloneWorldBackgroundSettings,
|
2026-04-22 16:54:35 +02:00
|
|
|
resolveWorldCelestialOrbitPeakDirection,
|
2026-04-12 04:29:39 +02:00
|
|
|
type WorldAmbientLightSettings,
|
|
|
|
|
type WorldBackgroundSettings,
|
2026-04-13 14:57:17 +02:00
|
|
|
type WorldTimeOfDaySettings,
|
|
|
|
|
type WorldTimePhaseProfile,
|
2026-04-12 04:29:39 +02:00
|
|
|
type WorldSettings,
|
|
|
|
|
type WorldSunLightSettings
|
|
|
|
|
} from "../document/world-settings";
|
|
|
|
|
|
|
|
|
|
const DEFAULT_NOON_DIRECTION: Vec3 = {
|
|
|
|
|
x: 0.45,
|
|
|
|
|
y: 0.88,
|
|
|
|
|
z: 0.15
|
|
|
|
|
};
|
2026-04-22 17:22:31 +02:00
|
|
|
const UP_AXIS: Vec3 = {
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 1,
|
|
|
|
|
z: 0
|
|
|
|
|
};
|
2026-04-28 14:27:28 +02:00
|
|
|
const CELESTIAL_LIGHT_HORIZON_FADE_START = -0.28;
|
|
|
|
|
const CELESTIAL_LIGHT_HORIZON_FADE_END = 0.04;
|
2026-04-12 04:29:39 +02:00
|
|
|
export interface RuntimeClockState {
|
|
|
|
|
timeOfDayHours: number;
|
|
|
|
|
dayCount: number;
|
|
|
|
|
dayLengthMinutes: number;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 15:54:16 +02:00
|
|
|
export type RuntimeDayPhase = "night" | "dawn" | "day" | "dusk";
|
|
|
|
|
|
|
|
|
|
export interface RuntimeResolvedTimeState {
|
|
|
|
|
timeOfDayHours: number;
|
|
|
|
|
dayCount: number;
|
|
|
|
|
dayLengthMinutes: number;
|
|
|
|
|
dayPhase: RuntimeDayPhase;
|
|
|
|
|
isNight: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 04:29:39 +02:00
|
|
|
export interface RuntimeDayNightWorldState {
|
|
|
|
|
ambientLight: WorldAmbientLightSettings;
|
|
|
|
|
sunLight: WorldSunLightSettings;
|
2026-04-12 14:09:26 +02:00
|
|
|
moonLight: WorldSunLightSettings | null;
|
2026-04-12 04:29:39 +02:00
|
|
|
background: WorldBackgroundSettings;
|
2026-04-12 14:32:45 +02:00
|
|
|
nightBackgroundOverlay: {
|
|
|
|
|
assetId: string;
|
|
|
|
|
environmentIntensity: number;
|
|
|
|
|
opacity: number;
|
|
|
|
|
} | null;
|
2026-04-12 04:29:39 +02:00
|
|
|
daylightFactor: number;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 15:16:23 +02:00
|
|
|
export interface RuntimeDayNightPhaseWeights {
|
2026-04-12 14:09:26 +02:00
|
|
|
day: number;
|
|
|
|
|
dawn: number;
|
|
|
|
|
dusk: number;
|
|
|
|
|
night: number;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 15:54:16 +02:00
|
|
|
interface RuntimeDayPhaseWindowBoundaries {
|
|
|
|
|
dawnStart: number;
|
|
|
|
|
dawnEnd: number;
|
|
|
|
|
duskStart: number;
|
|
|
|
|
duskEnd: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const MIN_RUNTIME_TIME_WINDOW_HOURS = 0.001;
|
|
|
|
|
const TIME_WINDOW_EPSILON = 1e-6;
|
|
|
|
|
|
2026-04-12 04:29:39 +02:00
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
|
|
|
return Math.min(max, Math.max(min, value));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function smoothstep(edge0: number, edge1: number, value: number): number {
|
|
|
|
|
if (edge0 === edge1) {
|
|
|
|
|
return value < edge0 ? 0 : 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const t = clamp((value - edge0) / (edge1 - edge0), 0, 1);
|
|
|
|
|
return t * t * (3 - 2 * t);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeVec3(vector: Vec3): Vec3 {
|
|
|
|
|
const length = Math.hypot(vector.x, vector.y, vector.z);
|
|
|
|
|
|
|
|
|
|
if (length <= 1e-6) {
|
|
|
|
|
return {
|
|
|
|
|
...DEFAULT_NOON_DIRECTION
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
x: vector.x / length,
|
|
|
|
|
y: vector.y / length,
|
|
|
|
|
z: vector.z / length
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cross(left: Vec3, right: Vec3): Vec3 {
|
|
|
|
|
return {
|
|
|
|
|
x: left.y * right.z - left.z * right.y,
|
|
|
|
|
y: left.z * right.x - left.x * right.z,
|
|
|
|
|
z: left.x * right.y - left.y * right.x
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function dot(left: Vec3, right: Vec3): number {
|
|
|
|
|
return left.x * right.x + left.y * right.y + left.z * right.z;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function scaleVec3(vector: Vec3, scale: number): Vec3 {
|
|
|
|
|
return {
|
|
|
|
|
x: vector.x * scale,
|
|
|
|
|
y: vector.y * scale,
|
|
|
|
|
z: vector.z * scale
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addVec3(left: Vec3, right: Vec3): Vec3 {
|
|
|
|
|
return {
|
|
|
|
|
x: left.x + right.x,
|
|
|
|
|
y: left.y + right.y,
|
|
|
|
|
z: left.z + right.z
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) =>
|
2026-04-22 15:30:37 +02:00
|
|
|
Math.round(clamp(value, 0, 255))
|
|
|
|
|
.toString(16)
|
|
|
|
|
.padStart(2, "0");
|
2026-04-12 04:29:39 +02:00
|
|
|
|
|
|
|
|
return `#${toHex(color.r)}${toHex(color.g)}${toHex(color.b)}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 14:09:26 +02:00
|
|
|
function blendHexColorsByWeights(
|
|
|
|
|
dayHex: string,
|
|
|
|
|
dawnHex: string,
|
|
|
|
|
duskHex: string,
|
|
|
|
|
nightHex: string,
|
|
|
|
|
weights: RuntimeDayNightPhaseWeights
|
|
|
|
|
): string {
|
2026-04-22 15:30:37 +02:00
|
|
|
const totalWeight = weights.day + weights.dawn + weights.dusk + weights.night;
|
2026-04-12 14:09:26 +02:00
|
|
|
|
|
|
|
|
if (totalWeight <= 1e-6) {
|
|
|
|
|
return dayHex;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const day = parseHexColor(dayHex);
|
|
|
|
|
const dawn = parseHexColor(dawnHex);
|
|
|
|
|
const dusk = parseHexColor(duskHex);
|
|
|
|
|
const night = parseHexColor(nightHex);
|
|
|
|
|
|
|
|
|
|
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 blendScalarByWeights(
|
|
|
|
|
dayValue: number,
|
|
|
|
|
dawnValue: number,
|
|
|
|
|
duskValue: number,
|
|
|
|
|
nightValue: number,
|
|
|
|
|
weights: RuntimeDayNightPhaseWeights
|
|
|
|
|
): number {
|
2026-04-22 15:30:37 +02:00
|
|
|
const totalWeight = weights.day + weights.dawn + weights.dusk + weights.night;
|
2026-04-12 14:09:26 +02:00
|
|
|
|
|
|
|
|
if (totalWeight <= 1e-6) {
|
|
|
|
|
return dayValue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-04-22 15:30:37 +02:00
|
|
|
(dayValue * weights.day +
|
|
|
|
|
dawnValue * weights.dawn +
|
|
|
|
|
duskValue * weights.dusk +
|
|
|
|
|
nightValue * weights.night) /
|
|
|
|
|
totalWeight
|
|
|
|
|
);
|
2026-04-12 14:09:26 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-12 14:32:45 +02:00
|
|
|
function wrapTimeForward(hours: number, originHours: number): number {
|
|
|
|
|
let wrappedHours = normalizeTimeOfDayHours(hours);
|
2026-04-12 14:09:26 +02:00
|
|
|
|
2026-04-12 14:32:45 +02:00
|
|
|
while (wrappedHours < originHours) {
|
|
|
|
|
wrappedHours += HOURS_PER_DAY;
|
2026-04-12 14:09:26 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-12 14:32:45 +02:00
|
|
|
return wrappedHours;
|
2026-04-12 14:09:26 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-13 15:54:16 +02:00
|
|
|
function areTimesEquivalent(leftHours: number, rightHours: number): boolean {
|
|
|
|
|
return (
|
|
|
|
|
Math.abs(
|
|
|
|
|
normalizeTimeOfDayHours(leftHours) - normalizeTimeOfDayHours(rightHours)
|
|
|
|
|
) <= TIME_WINDOW_EPSILON
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveRuntimeDayPhaseWindowBoundaries(
|
|
|
|
|
settings: ProjectTimeSettings
|
|
|
|
|
): RuntimeDayPhaseWindowBoundaries {
|
|
|
|
|
const dawnHalfDuration =
|
|
|
|
|
Math.max(settings.dawnDurationHours, MIN_RUNTIME_TIME_WINDOW_HOURS) / 2;
|
|
|
|
|
const duskHalfDuration =
|
|
|
|
|
Math.max(settings.duskDurationHours, MIN_RUNTIME_TIME_WINDOW_HOURS) / 2;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
dawnStart: settings.sunriseTimeOfDayHours - dawnHalfDuration,
|
|
|
|
|
dawnEnd: settings.sunriseTimeOfDayHours + dawnHalfDuration,
|
|
|
|
|
duskStart: settings.sunsetTimeOfDayHours - duskHalfDuration,
|
|
|
|
|
duskEnd: settings.sunsetTimeOfDayHours + duskHalfDuration
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hasTimeBoundaryBeenCrossed(
|
|
|
|
|
previousTimeOfDayHours: number,
|
|
|
|
|
currentTimeOfDayHours: number,
|
|
|
|
|
boundaryTimeOfDayHours: number
|
|
|
|
|
): boolean {
|
2026-04-22 15:30:37 +02:00
|
|
|
const normalizedPreviousTime = normalizeTimeOfDayHours(
|
|
|
|
|
previousTimeOfDayHours
|
|
|
|
|
);
|
2026-04-13 15:54:16 +02:00
|
|
|
|
|
|
|
|
if (areTimesEquivalent(normalizedPreviousTime, boundaryTimeOfDayHours)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const wrappedCurrentTime = wrapTimeForward(
|
|
|
|
|
currentTimeOfDayHours,
|
|
|
|
|
normalizedPreviousTime
|
|
|
|
|
);
|
|
|
|
|
const wrappedBoundaryTime = wrapTimeForward(
|
|
|
|
|
boundaryTimeOfDayHours,
|
|
|
|
|
normalizedPreviousTime
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
wrappedBoundaryTime - normalizedPreviousTime > TIME_WINDOW_EPSILON &&
|
|
|
|
|
wrappedBoundaryTime <= wrappedCurrentTime + TIME_WINDOW_EPSILON
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 17:22:02 +02:00
|
|
|
function resolveWrappedRelativeHours(
|
|
|
|
|
timeOfDayHours: number,
|
|
|
|
|
referenceTimeOfDayHours: number
|
|
|
|
|
): number {
|
|
|
|
|
let relativeHours =
|
|
|
|
|
wrapTimeForward(timeOfDayHours, referenceTimeOfDayHours) -
|
|
|
|
|
referenceTimeOfDayHours;
|
|
|
|
|
|
|
|
|
|
if (relativeHours > HOURS_PER_DAY / 2) {
|
|
|
|
|
relativeHours -= HOURS_PER_DAY;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return relativeHours;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 15:54:16 +02:00
|
|
|
export function isWithinTimeWindow(
|
|
|
|
|
startTimeOfDayHours: number,
|
|
|
|
|
endTimeOfDayHours: number,
|
|
|
|
|
timeOfDayHours: number
|
|
|
|
|
): boolean {
|
|
|
|
|
const normalizedStartTime = normalizeTimeOfDayHours(startTimeOfDayHours);
|
|
|
|
|
const normalizedEndTime = normalizeTimeOfDayHours(endTimeOfDayHours);
|
|
|
|
|
const normalizedTime = normalizeTimeOfDayHours(timeOfDayHours);
|
|
|
|
|
|
|
|
|
|
if (areTimesEquivalent(normalizedStartTime, normalizedEndTime)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (normalizedStartTime < normalizedEndTime) {
|
|
|
|
|
return (
|
|
|
|
|
normalizedTime >= normalizedStartTime &&
|
|
|
|
|
normalizedTime < normalizedEndTime
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
normalizedTime >= normalizedStartTime || normalizedTime < normalizedEndTime
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function hasTimeWindowJustStarted(
|
|
|
|
|
previousTimeOfDayHours: number,
|
|
|
|
|
currentTimeOfDayHours: number,
|
|
|
|
|
startTimeOfDayHours: number,
|
|
|
|
|
endTimeOfDayHours: number
|
|
|
|
|
): boolean {
|
|
|
|
|
if (areTimesEquivalent(startTimeOfDayHours, endTimeOfDayHours)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return hasTimeBoundaryBeenCrossed(
|
|
|
|
|
previousTimeOfDayHours,
|
|
|
|
|
currentTimeOfDayHours,
|
|
|
|
|
startTimeOfDayHours
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function hasTimeWindowJustEnded(
|
|
|
|
|
previousTimeOfDayHours: number,
|
|
|
|
|
currentTimeOfDayHours: number,
|
|
|
|
|
startTimeOfDayHours: number,
|
|
|
|
|
endTimeOfDayHours: number
|
|
|
|
|
): boolean {
|
|
|
|
|
if (areTimesEquivalent(startTimeOfDayHours, endTimeOfDayHours)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return hasTimeBoundaryBeenCrossed(
|
|
|
|
|
previousTimeOfDayHours,
|
|
|
|
|
currentTimeOfDayHours,
|
|
|
|
|
endTimeOfDayHours
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function resolveRuntimeDayPhase(
|
|
|
|
|
settings: ProjectTimeSettings,
|
|
|
|
|
timeOfDayHours: number
|
|
|
|
|
): RuntimeDayPhase {
|
|
|
|
|
const normalizedTime = normalizeTimeOfDayHours(timeOfDayHours);
|
|
|
|
|
const boundaries = resolveRuntimeDayPhaseWindowBoundaries(settings);
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
isWithinTimeWindow(boundaries.dawnStart, boundaries.dawnEnd, normalizedTime)
|
|
|
|
|
) {
|
|
|
|
|
return "dawn";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
isWithinTimeWindow(boundaries.duskStart, boundaries.duskEnd, normalizedTime)
|
|
|
|
|
) {
|
|
|
|
|
return "dusk";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
isWithinTimeWindow(boundaries.dawnEnd, boundaries.duskStart, normalizedTime)
|
|
|
|
|
) {
|
|
|
|
|
return "day";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return "night";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function resolveRuntimeTimeState(
|
|
|
|
|
settings: ProjectTimeSettings,
|
|
|
|
|
clock: RuntimeClockState
|
|
|
|
|
): RuntimeResolvedTimeState {
|
|
|
|
|
const timeOfDayHours = normalizeTimeOfDayHours(clock.timeOfDayHours);
|
|
|
|
|
const dayPhase = resolveRuntimeDayPhase(settings, timeOfDayHours);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
timeOfDayHours,
|
|
|
|
|
dayCount: clock.dayCount,
|
|
|
|
|
dayLengthMinutes: clock.dayLengthMinutes,
|
|
|
|
|
dayPhase,
|
|
|
|
|
isNight: dayPhase === "night"
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 15:16:23 +02:00
|
|
|
export function resolveRuntimeDayNightPhaseWeights(
|
2026-04-12 14:09:26 +02:00
|
|
|
settings: ProjectTimeSettings,
|
|
|
|
|
timeOfDayHours: number
|
|
|
|
|
): RuntimeDayNightPhaseWeights {
|
2026-04-13 15:54:16 +02:00
|
|
|
const boundaries = resolveRuntimeDayPhaseWindowBoundaries(settings);
|
|
|
|
|
const dawnStart = boundaries.dawnStart;
|
2026-04-12 14:32:45 +02:00
|
|
|
const currentTime = wrapTimeForward(timeOfDayHours, dawnStart);
|
|
|
|
|
const sunrise = wrapTimeForward(settings.sunriseTimeOfDayHours, dawnStart);
|
2026-04-13 15:54:16 +02:00
|
|
|
const dawnEnd = wrapTimeForward(boundaries.dawnEnd, dawnStart);
|
2026-04-12 14:32:45 +02:00
|
|
|
const sunset = wrapTimeForward(settings.sunsetTimeOfDayHours, dawnStart);
|
2026-04-13 15:54:16 +02:00
|
|
|
const duskStart = wrapTimeForward(boundaries.duskStart, dawnStart);
|
|
|
|
|
const duskEnd = wrapTimeForward(boundaries.duskEnd, dawnStart);
|
2026-04-12 14:09:26 +02:00
|
|
|
|
2026-04-12 14:32:45 +02:00
|
|
|
if (currentTime < sunrise) {
|
|
|
|
|
const amount = smoothstep(dawnStart, sunrise, currentTime);
|
2026-04-12 14:09:26 +02:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
day: 0,
|
|
|
|
|
dawn: amount,
|
|
|
|
|
dusk: 0,
|
|
|
|
|
night: 1 - amount
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 14:32:45 +02:00
|
|
|
if (currentTime < dawnEnd) {
|
|
|
|
|
const amount = smoothstep(sunrise, dawnEnd, currentTime);
|
2026-04-12 14:09:26 +02:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
day: amount,
|
|
|
|
|
dawn: 1 - amount,
|
|
|
|
|
dusk: 0,
|
|
|
|
|
night: 0
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 14:32:45 +02:00
|
|
|
if (currentTime < duskStart) {
|
2026-04-12 14:09:26 +02:00
|
|
|
return {
|
|
|
|
|
day: 1,
|
|
|
|
|
dawn: 0,
|
|
|
|
|
dusk: 0,
|
|
|
|
|
night: 0
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 14:32:45 +02:00
|
|
|
if (currentTime < sunset) {
|
|
|
|
|
const amount = smoothstep(duskStart, sunset, currentTime);
|
2026-04-12 14:09:26 +02:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
day: 1 - amount,
|
|
|
|
|
dawn: 0,
|
|
|
|
|
dusk: amount,
|
|
|
|
|
night: 0
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 14:32:45 +02:00
|
|
|
if (currentTime < duskEnd) {
|
|
|
|
|
const amount = smoothstep(sunset, duskEnd, currentTime);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
day: 0,
|
|
|
|
|
dawn: 0,
|
|
|
|
|
dusk: 1 - amount,
|
|
|
|
|
night: amount
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-04-12 14:09:26 +02:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
day: 0,
|
|
|
|
|
dawn: 0,
|
2026-04-12 14:32:45 +02:00
|
|
|
dusk: 0,
|
|
|
|
|
night: 1
|
2026-04-12 14:09:26 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 17:22:02 +02:00
|
|
|
function resolveTimeDrivenCelestialPhaseRadians(
|
|
|
|
|
peakTimeOfDayHours: number,
|
2026-04-12 14:09:26 +02:00
|
|
|
timeOfDayHours: number
|
|
|
|
|
): number {
|
2026-04-22 17:22:02 +02:00
|
|
|
return (
|
|
|
|
|
(resolveWrappedRelativeHours(timeOfDayHours, peakTimeOfDayHours) /
|
|
|
|
|
HOURS_PER_DAY) *
|
|
|
|
|
Math.PI *
|
|
|
|
|
2
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveTimeDrivenCelestialVisibleDurationHours(
|
|
|
|
|
visibleStartTimeOfDayHours: number,
|
|
|
|
|
visibleEndTimeOfDayHours: number
|
|
|
|
|
): number {
|
|
|
|
|
return Math.max(
|
2026-04-22 16:54:35 +02:00
|
|
|
wrapTimeForward(visibleEndTimeOfDayHours, visibleStartTimeOfDayHours) -
|
|
|
|
|
visibleStartTimeOfDayHours,
|
2026-04-22 16:28:36 +02:00
|
|
|
0.001
|
|
|
|
|
);
|
2026-04-12 14:09:26 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-22 17:23:51 +02:00
|
|
|
function resolveTimeDrivenCelestialOrbitAxis(basePeakDirection: Vec3): Vec3 {
|
2026-04-22 17:07:59 +02:00
|
|
|
return normalizeVec3({
|
2026-04-22 17:22:02 +02:00
|
|
|
x: -basePeakDirection.x * basePeakDirection.y,
|
|
|
|
|
y: 1 - basePeakDirection.y * basePeakDirection.y,
|
|
|
|
|
z: -basePeakDirection.z * basePeakDirection.y
|
2026-04-22 17:07:59 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 17:22:02 +02:00
|
|
|
function resolveTimeDrivenCelestialOrbitAxialOffset(
|
|
|
|
|
orbitAxis: Vec3,
|
|
|
|
|
basePeakDirection: Vec3,
|
|
|
|
|
visibleDurationHours: number
|
|
|
|
|
): number {
|
|
|
|
|
const visibleFraction = clamp(visibleDurationHours / HOURS_PER_DAY, 0, 1);
|
2026-04-22 17:22:31 +02:00
|
|
|
const axisHeight = Math.max(Math.abs(dot(orbitAxis, UP_AXIS)), 1e-6);
|
|
|
|
|
const peakHeight = clamp(dot(basePeakDirection, UP_AXIS), -1, 1);
|
2026-04-22 17:22:02 +02:00
|
|
|
const offsetRatio =
|
|
|
|
|
(-Math.cos(visibleFraction * Math.PI) * peakHeight) / axisHeight;
|
|
|
|
|
|
|
|
|
|
return offsetRatio / Math.sqrt(1 + offsetRatio * offsetRatio);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 16:54:35 +02:00
|
|
|
function resolveTimeDrivenCelestialDirection(
|
2026-04-22 17:22:02 +02:00
|
|
|
basePeakDirection: Vec3,
|
2026-04-22 16:54:35 +02:00
|
|
|
visibleStartTimeOfDayHours: number,
|
|
|
|
|
visibleEndTimeOfDayHours: number,
|
2026-04-12 04:29:39 +02:00
|
|
|
timeOfDayHours: number
|
|
|
|
|
): Vec3 {
|
2026-04-22 17:22:02 +02:00
|
|
|
const orbitAxis = resolveTimeDrivenCelestialOrbitAxis(basePeakDirection);
|
|
|
|
|
const peakDirection = normalizeVec3({
|
|
|
|
|
x: basePeakDirection.x - orbitAxis.x * dot(basePeakDirection, orbitAxis),
|
|
|
|
|
y: basePeakDirection.y - orbitAxis.y * dot(basePeakDirection, orbitAxis),
|
|
|
|
|
z: basePeakDirection.z - orbitAxis.z * dot(basePeakDirection, orbitAxis)
|
|
|
|
|
});
|
2026-04-22 17:23:51 +02:00
|
|
|
const tangentDirection = normalizeVec3(cross(peakDirection, orbitAxis));
|
2026-04-22 17:22:02 +02:00
|
|
|
const visibleDurationHours = resolveTimeDrivenCelestialVisibleDurationHours(
|
2026-04-22 16:54:35 +02:00
|
|
|
visibleStartTimeOfDayHours,
|
2026-04-22 17:22:02 +02:00
|
|
|
visibleEndTimeOfDayHours
|
|
|
|
|
);
|
|
|
|
|
const axialOffset = resolveTimeDrivenCelestialOrbitAxialOffset(
|
|
|
|
|
orbitAxis,
|
|
|
|
|
peakDirection,
|
|
|
|
|
visibleDurationHours
|
|
|
|
|
);
|
2026-04-22 17:22:31 +02:00
|
|
|
const radialScale = Math.sqrt(Math.max(1 - axialOffset * axialOffset, 0));
|
2026-04-22 17:22:02 +02:00
|
|
|
const peakTimeOfDayHours = normalizeTimeOfDayHours(
|
|
|
|
|
visibleStartTimeOfDayHours + visibleDurationHours / 2
|
|
|
|
|
);
|
|
|
|
|
const phaseRadians = resolveTimeDrivenCelestialPhaseRadians(
|
|
|
|
|
peakTimeOfDayHours,
|
2026-04-12 14:09:26 +02:00
|
|
|
timeOfDayHours
|
|
|
|
|
);
|
2026-04-12 04:29:39 +02:00
|
|
|
|
2026-04-22 17:22:02 +02:00
|
|
|
return normalizeVec3(
|
|
|
|
|
addVec3(
|
|
|
|
|
scaleVec3(orbitAxis, axialOffset),
|
|
|
|
|
addVec3(
|
|
|
|
|
scaleVec3(peakDirection, radialScale * Math.cos(phaseRadians)),
|
|
|
|
|
scaleVec3(tangentDirection, radialScale * Math.sin(phaseRadians))
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-04-12 04:29:39 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-22 12:29:38 +02:00
|
|
|
function createFallbackPhaseGradientBackground(
|
2026-04-13 14:57:17 +02:00
|
|
|
profile: WorldTimePhaseProfile
|
2026-04-12 04:29:39 +02:00
|
|
|
): WorldBackgroundSettings {
|
2026-04-13 14:57:17 +02:00
|
|
|
return {
|
|
|
|
|
mode: "verticalGradient",
|
|
|
|
|
topColorHex: profile.skyTopColorHex,
|
|
|
|
|
bottomColorHex: profile.skyBottomColorHex
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 12:29:38 +02:00
|
|
|
function resolveTimePhaseBackground(
|
|
|
|
|
profile: WorldTimePhaseProfile
|
|
|
|
|
): WorldBackgroundSettings {
|
|
|
|
|
if (profile.background.mode === "image") {
|
|
|
|
|
return {
|
|
|
|
|
...profile.background
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return cloneWorldBackgroundSettings(
|
|
|
|
|
profile.background.mode === "solid" ||
|
|
|
|
|
profile.background.mode === "verticalGradient"
|
|
|
|
|
? profile.background
|
|
|
|
|
: createFallbackPhaseGradientBackground(profile)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hasConfiguredImageBackground(
|
|
|
|
|
background: WorldBackgroundSettings
|
|
|
|
|
): background is Extract<WorldBackgroundSettings, { mode: "image" }> {
|
|
|
|
|
return background.mode === "image" && background.assetId.trim().length > 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 15:30:37 +02:00
|
|
|
function resolveBackgroundTopColor(
|
|
|
|
|
background: WorldBackgroundSettings
|
|
|
|
|
): string {
|
2026-04-12 04:29:39 +02:00
|
|
|
if (background.mode === "solid") {
|
2026-04-13 14:57:17 +02:00
|
|
|
return background.colorHex;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (background.mode === "verticalGradient") {
|
|
|
|
|
return background.topColorHex;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return "#000000";
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 15:30:37 +02:00
|
|
|
function resolveBackgroundBottomColor(
|
|
|
|
|
background: WorldBackgroundSettings
|
|
|
|
|
): string {
|
2026-04-13 14:57:17 +02:00
|
|
|
if (background.mode === "solid") {
|
|
|
|
|
return background.colorHex;
|
2026-04-12 04:29:39 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (background.mode === "verticalGradient") {
|
2026-04-13 14:57:17 +02:00
|
|
|
return background.bottomColorHex;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return "#000000";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function blendNonImageBackgrounds(
|
|
|
|
|
contributions: Array<{
|
|
|
|
|
background: WorldBackgroundSettings;
|
|
|
|
|
weight: number;
|
|
|
|
|
}>
|
|
|
|
|
): WorldBackgroundSettings {
|
|
|
|
|
const activeContributions = contributions.filter(
|
|
|
|
|
({ background, weight }) => background.mode !== "image" && weight > 1e-6
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (activeContributions.length === 0) {
|
|
|
|
|
const fallbackBackground = contributions.find(
|
|
|
|
|
({ background }) => background.mode !== "image"
|
|
|
|
|
)?.background;
|
|
|
|
|
|
|
|
|
|
return fallbackBackground === undefined
|
|
|
|
|
? {
|
|
|
|
|
mode: "solid",
|
|
|
|
|
colorHex: "#000000"
|
|
|
|
|
}
|
|
|
|
|
: cloneWorldBackgroundSettings(fallbackBackground);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const totalWeight = activeContributions.reduce(
|
|
|
|
|
(sum, { weight }) => sum + weight,
|
|
|
|
|
0
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (totalWeight <= 1e-6) {
|
|
|
|
|
return cloneWorldBackgroundSettings(activeContributions[0].background);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 14:57:46 +02:00
|
|
|
const blendColor = (
|
|
|
|
|
resolveColor: (background: WorldBackgroundSettings) => string
|
|
|
|
|
): string => {
|
|
|
|
|
let red = 0;
|
|
|
|
|
let green = 0;
|
|
|
|
|
let blue = 0;
|
|
|
|
|
|
|
|
|
|
for (const { background, weight } of activeContributions) {
|
|
|
|
|
const color = parseHexColor(resolveColor(background));
|
|
|
|
|
red += color.r * weight;
|
|
|
|
|
green += color.g * weight;
|
|
|
|
|
blue += color.b * weight;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return formatHexColor({
|
|
|
|
|
r: red / totalWeight,
|
|
|
|
|
g: green / totalWeight,
|
|
|
|
|
b: blue / totalWeight
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-13 14:57:17 +02:00
|
|
|
const hasGradient = activeContributions.some(
|
|
|
|
|
({ background }) => background.mode === "verticalGradient"
|
|
|
|
|
);
|
2026-04-13 14:57:46 +02:00
|
|
|
const blendedTopColorHex = blendColor(resolveBackgroundTopColor);
|
2026-04-13 14:57:17 +02:00
|
|
|
|
|
|
|
|
if (!hasGradient) {
|
2026-04-12 04:29:39 +02:00
|
|
|
return {
|
2026-04-13 14:57:17 +02:00
|
|
|
mode: "solid",
|
|
|
|
|
colorHex: blendedTopColorHex
|
2026-04-12 04:29:39 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 14:57:46 +02:00
|
|
|
const blendedBottomColorHex = blendColor(resolveBackgroundBottomColor);
|
2026-04-13 14:57:17 +02:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
mode: "verticalGradient",
|
|
|
|
|
topColorHex: blendedTopColorHex,
|
|
|
|
|
bottomColorHex: blendedBottomColorHex
|
|
|
|
|
};
|
2026-04-12 04:29:39 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-13 14:57:17 +02:00
|
|
|
function resolveBackgroundImageOverlay(
|
|
|
|
|
background: WorldBackgroundSettings,
|
|
|
|
|
opacity: number
|
2026-04-12 14:32:45 +02:00
|
|
|
): RuntimeDayNightWorldState["nightBackgroundOverlay"] {
|
2026-04-22 12:29:38 +02:00
|
|
|
if (!hasConfiguredImageBackground(background)) {
|
2026-04-12 14:32:45 +02:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 14:57:17 +02:00
|
|
|
const clampedOpacity = clamp(opacity, 0, 1);
|
2026-04-12 14:32:45 +02:00
|
|
|
|
2026-04-13 14:57:17 +02:00
|
|
|
if (clampedOpacity <= 1e-4) {
|
2026-04-12 14:32:45 +02:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
2026-04-13 14:57:17 +02:00
|
|
|
assetId: background.assetId,
|
|
|
|
|
environmentIntensity: background.environmentIntensity,
|
|
|
|
|
opacity: clampedOpacity
|
2026-04-12 14:32:45 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 12:29:38 +02:00
|
|
|
function resolvePreferredDaylikeImageBackground(
|
|
|
|
|
contributions: Array<{
|
|
|
|
|
background: WorldBackgroundSettings;
|
|
|
|
|
weight: number;
|
|
|
|
|
}>
|
|
|
|
|
): Extract<WorldBackgroundSettings, { mode: "image" }> | null {
|
|
|
|
|
let bestContribution: {
|
|
|
|
|
background: Extract<WorldBackgroundSettings, { mode: "image" }>;
|
|
|
|
|
weight: number;
|
|
|
|
|
} | null = null;
|
|
|
|
|
|
|
|
|
|
for (const contribution of contributions) {
|
|
|
|
|
if (!hasConfiguredImageBackground(contribution.background)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
bestContribution === null ||
|
|
|
|
|
contribution.weight > bestContribution.weight + 1e-6
|
|
|
|
|
) {
|
|
|
|
|
bestContribution = {
|
|
|
|
|
background: contribution.background,
|
|
|
|
|
weight: contribution.weight
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return bestContribution === null
|
|
|
|
|
? null
|
2026-04-22 12:34:02 +02:00
|
|
|
: {
|
|
|
|
|
mode: "image",
|
|
|
|
|
assetId: bestContribution.background.assetId,
|
|
|
|
|
environmentIntensity: bestContribution.background.environmentIntensity
|
|
|
|
|
};
|
2026-04-22 12:29:38 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-13 14:57:17 +02:00
|
|
|
function resolveTimeDrivenBackground(
|
|
|
|
|
dayBackground: WorldBackgroundSettings,
|
|
|
|
|
timeOfDay: WorldTimeOfDaySettings,
|
|
|
|
|
weights: RuntimeDayNightPhaseWeights
|
|
|
|
|
): {
|
|
|
|
|
background: WorldBackgroundSettings;
|
|
|
|
|
nightBackgroundOverlay: RuntimeDayNightWorldState["nightBackgroundOverlay"];
|
|
|
|
|
} {
|
2026-04-22 15:16:23 +02:00
|
|
|
if (dayBackground.mode === "shader") {
|
|
|
|
|
return {
|
|
|
|
|
background: cloneWorldBackgroundSettings(dayBackground),
|
|
|
|
|
nightBackgroundOverlay: null
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 12:29:38 +02:00
|
|
|
const dawnBackground = resolveTimePhaseBackground(timeOfDay.dawn);
|
|
|
|
|
const duskBackground = resolveTimePhaseBackground(timeOfDay.dusk);
|
2026-04-13 14:57:17 +02:00
|
|
|
const nightBackground = timeOfDay.night.background;
|
|
|
|
|
const twilightNightOpacity =
|
|
|
|
|
weights.night + weights.dawn * 0.5 + weights.dusk * 0.5;
|
2026-04-22 12:36:07 +02:00
|
|
|
const daylikeImageBackground =
|
|
|
|
|
weights.dawn > 1e-6 && hasConfiguredImageBackground(dawnBackground)
|
2026-04-22 12:36:45 +02:00
|
|
|
? {
|
|
|
|
|
mode: "image" as const,
|
|
|
|
|
assetId: dawnBackground.assetId,
|
|
|
|
|
environmentIntensity: dawnBackground.environmentIntensity
|
|
|
|
|
}
|
2026-04-22 12:36:07 +02:00
|
|
|
: weights.dusk > 1e-6 && hasConfiguredImageBackground(duskBackground)
|
2026-04-22 12:36:45 +02:00
|
|
|
? {
|
|
|
|
|
mode: "image" as const,
|
|
|
|
|
assetId: duskBackground.assetId,
|
|
|
|
|
environmentIntensity: duskBackground.environmentIntensity
|
|
|
|
|
}
|
2026-04-22 12:36:07 +02:00
|
|
|
: hasConfiguredImageBackground(dayBackground)
|
2026-04-22 12:36:45 +02:00
|
|
|
? {
|
|
|
|
|
mode: "image" as const,
|
|
|
|
|
assetId: dayBackground.assetId,
|
|
|
|
|
environmentIntensity: dayBackground.environmentIntensity
|
|
|
|
|
}
|
2026-04-22 12:36:07 +02:00
|
|
|
: resolvePreferredDaylikeImageBackground([
|
|
|
|
|
{
|
|
|
|
|
background: dayBackground,
|
|
|
|
|
weight: weights.day
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
background: dawnBackground,
|
|
|
|
|
weight: weights.dawn
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
background: duskBackground,
|
|
|
|
|
weight: weights.dusk
|
|
|
|
|
}
|
|
|
|
|
]);
|
2026-04-13 14:57:17 +02:00
|
|
|
|
2026-04-22 12:29:38 +02:00
|
|
|
if (daylikeImageBackground !== null) {
|
2026-04-22 12:33:35 +02:00
|
|
|
const nightOverlay =
|
|
|
|
|
hasConfiguredImageBackground(nightBackground) &&
|
|
|
|
|
nightBackground.assetId !== daylikeImageBackground.assetId
|
|
|
|
|
? resolveBackgroundImageOverlay(nightBackground, twilightNightOpacity)
|
|
|
|
|
: null;
|
|
|
|
|
|
2026-04-13 14:57:17 +02:00
|
|
|
return {
|
2026-04-22 12:29:38 +02:00
|
|
|
background: daylikeImageBackground,
|
2026-04-22 12:33:35 +02:00
|
|
|
nightBackgroundOverlay: nightOverlay
|
2026-04-13 14:57:17 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 12:29:38 +02:00
|
|
|
if (hasConfiguredImageBackground(nightBackground)) {
|
2026-04-13 14:57:17 +02:00
|
|
|
return {
|
|
|
|
|
background: blendNonImageBackgrounds([
|
|
|
|
|
{
|
|
|
|
|
background: dayBackground,
|
|
|
|
|
weight: weights.day
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
background: dawnBackground,
|
|
|
|
|
weight: weights.dawn
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
background: duskBackground,
|
|
|
|
|
weight: weights.dusk
|
|
|
|
|
}
|
|
|
|
|
]),
|
|
|
|
|
nightBackgroundOverlay: resolveBackgroundImageOverlay(
|
|
|
|
|
nightBackground,
|
|
|
|
|
twilightNightOpacity
|
|
|
|
|
)
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
background: blendNonImageBackgrounds([
|
|
|
|
|
{
|
|
|
|
|
background: dayBackground,
|
|
|
|
|
weight: weights.day
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
background: dawnBackground,
|
|
|
|
|
weight: weights.dawn
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
background: duskBackground,
|
|
|
|
|
weight: weights.dusk
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
background: nightBackground,
|
|
|
|
|
weight: weights.night
|
|
|
|
|
}
|
|
|
|
|
]),
|
|
|
|
|
nightBackgroundOverlay: null
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 04:29:39 +02:00
|
|
|
export function createRuntimeClockState(
|
|
|
|
|
settings: ProjectTimeSettings
|
|
|
|
|
): RuntimeClockState {
|
|
|
|
|
return {
|
|
|
|
|
timeOfDayHours: normalizeTimeOfDayHours(settings.startTimeOfDayHours),
|
2026-04-12 14:09:26 +02:00
|
|
|
dayCount: settings.startDayNumber - 1,
|
2026-04-12 04:29:39 +02:00
|
|
|
dayLengthMinutes: settings.dayLengthMinutes
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function cloneRuntimeClockState(
|
|
|
|
|
state: RuntimeClockState
|
|
|
|
|
): RuntimeClockState {
|
|
|
|
|
return {
|
|
|
|
|
timeOfDayHours: state.timeOfDayHours,
|
|
|
|
|
dayCount: state.dayCount,
|
|
|
|
|
dayLengthMinutes: state.dayLengthMinutes
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function areRuntimeClockStatesEqual(
|
|
|
|
|
left: RuntimeClockState,
|
|
|
|
|
right: RuntimeClockState
|
|
|
|
|
): boolean {
|
|
|
|
|
return (
|
|
|
|
|
left.timeOfDayHours === right.timeOfDayHours &&
|
|
|
|
|
left.dayCount === right.dayCount &&
|
|
|
|
|
left.dayLengthMinutes === right.dayLengthMinutes
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function reconfigureRuntimeClockState(
|
|
|
|
|
state: RuntimeClockState,
|
|
|
|
|
settings: ProjectTimeSettings
|
|
|
|
|
): RuntimeClockState {
|
|
|
|
|
return {
|
|
|
|
|
...cloneRuntimeClockState(state),
|
|
|
|
|
dayLengthMinutes: settings.dayLengthMinutes
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function advanceRuntimeClockState(
|
|
|
|
|
state: RuntimeClockState,
|
|
|
|
|
dtSeconds: number
|
|
|
|
|
): RuntimeClockState {
|
|
|
|
|
if (dtSeconds <= 0) {
|
|
|
|
|
return cloneRuntimeClockState(state);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const safeDayLengthMinutes = Math.max(state.dayLengthMinutes, 0.001);
|
|
|
|
|
const hoursAdvanced =
|
|
|
|
|
(dtSeconds / (safeDayLengthMinutes * 60)) * HOURS_PER_DAY;
|
|
|
|
|
const absoluteHours = state.timeOfDayHours + hoursAdvanced;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
timeOfDayHours: normalizeTimeOfDayHours(absoluteHours),
|
|
|
|
|
dayCount: state.dayCount + Math.floor(absoluteHours / HOURS_PER_DAY),
|
|
|
|
|
dayLengthMinutes: state.dayLengthMinutes
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function formatRuntimeClockTime(state: RuntimeClockState): string {
|
|
|
|
|
return formatTimeOfDayHours(state.timeOfDayHours);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function resolveRuntimeDayNightWorldState(
|
|
|
|
|
world: WorldSettings,
|
2026-04-12 14:09:26 +02:00
|
|
|
settings: ProjectTimeSettings,
|
2026-04-13 15:54:16 +02:00
|
|
|
clock: RuntimeClockState | null,
|
2026-04-22 15:30:37 +02:00
|
|
|
resolvedTime: RuntimeResolvedTimeState | null = clock === null
|
|
|
|
|
? null
|
|
|
|
|
: resolveRuntimeTimeState(settings, clock)
|
2026-04-12 04:29:39 +02:00
|
|
|
): RuntimeDayNightWorldState {
|
2026-04-22 15:30:37 +02:00
|
|
|
if (
|
|
|
|
|
clock === null ||
|
|
|
|
|
resolvedTime === null ||
|
|
|
|
|
!world.projectTimeLightingEnabled
|
|
|
|
|
) {
|
2026-04-12 04:29:39 +02:00
|
|
|
return {
|
|
|
|
|
ambientLight: {
|
|
|
|
|
colorHex: world.ambientLight.colorHex,
|
|
|
|
|
intensity: world.ambientLight.intensity
|
|
|
|
|
},
|
|
|
|
|
sunLight: {
|
|
|
|
|
colorHex: world.sunLight.colorHex,
|
|
|
|
|
intensity: world.sunLight.intensity,
|
|
|
|
|
direction: {
|
|
|
|
|
...world.sunLight.direction
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-04-12 14:09:26 +02:00
|
|
|
moonLight: null,
|
2026-04-12 04:29:39 +02:00
|
|
|
background: cloneWorldBackgroundSettings(world.background),
|
2026-04-12 14:32:45 +02:00
|
|
|
nightBackgroundOverlay: null,
|
2026-04-12 04:29:39 +02:00
|
|
|
daylightFactor: 1
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 15:54:16 +02:00
|
|
|
const normalizedTime = resolvedTime.timeOfDayHours;
|
2026-04-12 14:09:26 +02:00
|
|
|
const phaseWeights = resolveRuntimeDayNightPhaseWeights(
|
|
|
|
|
settings,
|
|
|
|
|
normalizedTime
|
|
|
|
|
);
|
2026-04-22 16:54:35 +02:00
|
|
|
const boundaries = resolveRuntimeDayPhaseWindowBoundaries(settings);
|
2026-04-13 14:57:17 +02:00
|
|
|
const timeOfDay = world.timeOfDay;
|
2026-04-22 16:54:35 +02:00
|
|
|
const sunPeakDirection = resolveWorldCelestialOrbitPeakDirection(
|
|
|
|
|
world.celestialOrbits.sun
|
|
|
|
|
);
|
|
|
|
|
const moonPeakDirection = resolveWorldCelestialOrbitPeakDirection(
|
|
|
|
|
world.celestialOrbits.moon
|
|
|
|
|
);
|
|
|
|
|
const sunDirection = resolveTimeDrivenCelestialDirection(
|
|
|
|
|
sunPeakDirection,
|
|
|
|
|
boundaries.dawnEnd,
|
|
|
|
|
boundaries.duskEnd,
|
|
|
|
|
normalizedTime
|
|
|
|
|
);
|
|
|
|
|
const moonDirection = resolveTimeDrivenCelestialDirection(
|
|
|
|
|
moonPeakDirection,
|
|
|
|
|
boundaries.duskEnd,
|
|
|
|
|
boundaries.dawnEnd,
|
2026-04-12 14:09:26 +02:00
|
|
|
normalizedTime
|
2026-04-12 04:29:39 +02:00
|
|
|
);
|
2026-04-12 14:09:26 +02:00
|
|
|
const daylightFactor = clamp(
|
|
|
|
|
phaseWeights.day +
|
|
|
|
|
phaseWeights.dawn * 0.7 +
|
|
|
|
|
phaseWeights.dusk * 0.5 +
|
|
|
|
|
phaseWeights.night * 0.08,
|
|
|
|
|
0,
|
|
|
|
|
1
|
|
|
|
|
);
|
|
|
|
|
const sunFactor =
|
|
|
|
|
blendScalarByWeights(
|
|
|
|
|
1,
|
2026-04-13 14:57:17 +02:00
|
|
|
timeOfDay.dawn.lightIntensityFactor,
|
|
|
|
|
timeOfDay.dusk.lightIntensityFactor,
|
2026-04-12 14:09:26 +02:00
|
|
|
0,
|
|
|
|
|
phaseWeights
|
2026-04-28 14:27:34 +02:00
|
|
|
) *
|
|
|
|
|
smoothstep(
|
|
|
|
|
CELESTIAL_LIGHT_HORIZON_FADE_START,
|
|
|
|
|
CELESTIAL_LIGHT_HORIZON_FADE_END,
|
|
|
|
|
sunDirection.y
|
|
|
|
|
);
|
2026-04-12 14:09:26 +02:00
|
|
|
const ambientFactor = blendScalarByWeights(
|
|
|
|
|
1,
|
2026-04-13 14:57:17 +02:00
|
|
|
timeOfDay.dawn.ambientIntensityFactor,
|
|
|
|
|
timeOfDay.dusk.ambientIntensityFactor,
|
|
|
|
|
timeOfDay.night.ambientIntensityFactor,
|
|
|
|
|
phaseWeights
|
|
|
|
|
);
|
|
|
|
|
const resolvedBackground = resolveTimeDrivenBackground(
|
|
|
|
|
world.background,
|
|
|
|
|
timeOfDay,
|
2026-04-12 14:09:26 +02:00
|
|
|
phaseWeights
|
|
|
|
|
);
|
|
|
|
|
let moonLight: WorldSunLightSettings | null = null;
|
|
|
|
|
|
2026-04-12 14:56:35 +02:00
|
|
|
const moonVisibilityFactor = clamp(
|
|
|
|
|
phaseWeights.night + phaseWeights.dawn * 0.45 + phaseWeights.dusk * 0.45,
|
|
|
|
|
0,
|
|
|
|
|
1
|
|
|
|
|
);
|
2026-04-12 14:09:26 +02:00
|
|
|
|
2026-04-12 14:56:35 +02:00
|
|
|
if (moonVisibilityFactor > 1e-4) {
|
|
|
|
|
moonLight = {
|
|
|
|
|
colorHex: blendHexColorsByWeights(
|
2026-04-13 14:57:17 +02:00
|
|
|
timeOfDay.night.lightColorHex,
|
|
|
|
|
timeOfDay.dawn.lightColorHex,
|
|
|
|
|
timeOfDay.dusk.lightColorHex,
|
|
|
|
|
timeOfDay.night.lightColorHex,
|
2026-04-12 14:56:35 +02:00
|
|
|
{
|
|
|
|
|
day: 0,
|
|
|
|
|
dawn: phaseWeights.dawn,
|
|
|
|
|
dusk: phaseWeights.dusk,
|
|
|
|
|
night: phaseWeights.night
|
|
|
|
|
}
|
|
|
|
|
),
|
|
|
|
|
intensity:
|
|
|
|
|
world.sunLight.intensity *
|
2026-04-13 14:57:17 +02:00
|
|
|
timeOfDay.night.lightIntensityFactor *
|
2026-04-12 14:56:35 +02:00
|
|
|
moonVisibilityFactor,
|
2026-04-22 16:54:35 +02:00
|
|
|
direction: moonDirection
|
2026-04-12 14:56:35 +02:00
|
|
|
};
|
2026-04-12 14:09:26 +02:00
|
|
|
}
|
2026-04-12 04:29:39 +02:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
ambientLight: {
|
2026-04-12 14:09:26 +02:00
|
|
|
colorHex: blendHexColorsByWeights(
|
2026-04-12 04:29:39 +02:00
|
|
|
world.ambientLight.colorHex,
|
2026-04-13 14:57:17 +02:00
|
|
|
timeOfDay.dawn.ambientColorHex,
|
|
|
|
|
timeOfDay.dusk.ambientColorHex,
|
|
|
|
|
timeOfDay.night.ambientColorHex,
|
2026-04-12 14:09:26 +02:00
|
|
|
phaseWeights
|
2026-04-12 04:29:39 +02:00
|
|
|
),
|
|
|
|
|
intensity: world.ambientLight.intensity * ambientFactor
|
|
|
|
|
},
|
|
|
|
|
sunLight: {
|
2026-04-12 14:09:26 +02:00
|
|
|
colorHex: blendHexColorsByWeights(
|
2026-04-12 04:29:39 +02:00
|
|
|
world.sunLight.colorHex,
|
2026-04-13 14:57:17 +02:00
|
|
|
timeOfDay.dawn.lightColorHex,
|
|
|
|
|
timeOfDay.dusk.lightColorHex,
|
|
|
|
|
timeOfDay.night.lightColorHex,
|
2026-04-12 14:09:26 +02:00
|
|
|
phaseWeights
|
2026-04-12 04:29:39 +02:00
|
|
|
),
|
|
|
|
|
intensity: world.sunLight.intensity * sunFactor,
|
|
|
|
|
direction: sunDirection
|
|
|
|
|
},
|
2026-04-12 14:09:26 +02:00
|
|
|
moonLight,
|
2026-04-13 14:57:17 +02:00
|
|
|
background: resolvedBackground.background,
|
|
|
|
|
nightBackgroundOverlay: resolvedBackground.nightBackgroundOverlay,
|
2026-04-12 04:29:39 +02:00
|
|
|
daylightFactor
|
|
|
|
|
};
|
2026-04-22 12:29:38 +02:00
|
|
|
}
|