486 lines
13 KiB
TypeScript
486 lines
13 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { createDefaultProjectTimeSettings } from "../../src/document/project-time-settings";
|
|
import { createDefaultWorldSettings } from "../../src/document/world-settings";
|
|
import {
|
|
createWorldShaderSkyEnvironmentCacheKey,
|
|
resolveWorldShaderSkyEnvironmentPhaseStates,
|
|
resolveWorldShaderSkyRenderState,
|
|
resolveWorldShaderSkyStarRotation
|
|
} from "../../src/rendering/world-shader-sky";
|
|
import {
|
|
resolveRuntimeDayNightWorldState,
|
|
resolveRuntimeTimeState
|
|
} from "../../src/runtime-three/runtime-project-time";
|
|
|
|
describe("resolveWorldShaderSkyRenderState", () => {
|
|
it("blends authored day, dawn, dusk, and night color bases continuously", () => {
|
|
const world = createDefaultWorldSettings();
|
|
const time = createDefaultProjectTimeSettings();
|
|
world.background = {
|
|
mode: "shader"
|
|
};
|
|
world.showCelestialBodies = true;
|
|
world.shaderSky.dayTopColorHex = "#88ccff";
|
|
world.shaderSky.dayBottomColorHex = "#dff3ff";
|
|
world.shaderSky.horizonHeight = -0.08;
|
|
world.shaderSky.aurora.enabled = true;
|
|
world.shaderSky.aurora.intensity = 1.4;
|
|
world.shaderSky.stars.horizonFadeOffset = 0.06;
|
|
world.timeOfDay.dawn.background = {
|
|
mode: "verticalGradient",
|
|
topColorHex: "#ff8855",
|
|
bottomColorHex: "#ffd4a8"
|
|
};
|
|
world.timeOfDay.dusk.background = {
|
|
mode: "verticalGradient",
|
|
topColorHex: "#5e62c8",
|
|
bottomColorHex: "#ff9966"
|
|
};
|
|
world.timeOfDay.night.background = {
|
|
mode: "verticalGradient",
|
|
topColorHex: "#04101d",
|
|
bottomColorHex: "#17263b"
|
|
};
|
|
time.sunriseTimeOfDayHours = 7;
|
|
time.sunsetTimeOfDayHours = 20;
|
|
time.dawnDurationHours = 2;
|
|
time.duskDurationHours = 2;
|
|
|
|
const dawnTime = resolveRuntimeTimeState(time, {
|
|
timeOfDayHours: 6.5,
|
|
dayCount: 0,
|
|
dayLengthMinutes: 24
|
|
});
|
|
const dawnWorld = resolveRuntimeDayNightWorldState(
|
|
world,
|
|
time,
|
|
{
|
|
timeOfDayHours: 6.5,
|
|
dayCount: 0,
|
|
dayLengthMinutes: 24
|
|
},
|
|
dawnTime
|
|
);
|
|
const noonTime = resolveRuntimeTimeState(time, {
|
|
timeOfDayHours: 12,
|
|
dayCount: 0,
|
|
dayLengthMinutes: 24
|
|
});
|
|
const noonWorld = resolveRuntimeDayNightWorldState(
|
|
world,
|
|
time,
|
|
{
|
|
timeOfDayHours: 12,
|
|
dayCount: 0,
|
|
dayLengthMinutes: 24
|
|
},
|
|
noonTime
|
|
);
|
|
const midnightTime = resolveRuntimeTimeState(time, {
|
|
timeOfDayHours: 0,
|
|
dayCount: 0,
|
|
dayLengthMinutes: 24
|
|
});
|
|
const midnightWorld = resolveRuntimeDayNightWorldState(
|
|
world,
|
|
time,
|
|
{
|
|
timeOfDayHours: 0,
|
|
dayCount: 0,
|
|
dayLengthMinutes: 24
|
|
},
|
|
midnightTime
|
|
);
|
|
|
|
const dawnSky = resolveWorldShaderSkyRenderState(
|
|
world,
|
|
dawnWorld,
|
|
dawnTime,
|
|
time
|
|
);
|
|
const noonSky = resolveWorldShaderSkyRenderState(
|
|
world,
|
|
noonWorld,
|
|
noonTime,
|
|
time
|
|
);
|
|
const midnightSky = resolveWorldShaderSkyRenderState(
|
|
world,
|
|
midnightWorld,
|
|
midnightTime,
|
|
time
|
|
);
|
|
|
|
expect(dawnSky).not.toBeNull();
|
|
expect(noonSky?.sky.topColorHex).toBe("#88ccff");
|
|
expect(noonSky?.sky.bottomColorHex).toBe("#dff3ff");
|
|
expect(noonSky?.sky.horizonHeight).toBe(-0.08);
|
|
expect(noonSky?.stars.horizonFadeOffset).toBe(0.06);
|
|
expect(dawnSky?.sky.topColorHex).not.toBe(noonSky?.sky.topColorHex);
|
|
expect(dawnSky?.sky.topColorHex).not.toBe(midnightSky?.sky.topColorHex);
|
|
expect(midnightSky?.stars.visibility ?? 0).toBeGreaterThan(
|
|
dawnSky?.stars.visibility ?? 0
|
|
);
|
|
expect(dawnSky?.stars.visibility ?? 0).toBeGreaterThan(0);
|
|
expect(noonSky?.stars.visibility ?? 1).toBe(0);
|
|
expect(midnightSky?.aurora.visibility ?? 0).toBeGreaterThan(
|
|
dawnSky?.aurora.visibility ?? 0
|
|
);
|
|
expect(dawnSky?.aurora.visibility ?? 0).toBeGreaterThan(0);
|
|
expect(noonSky?.aurora.visibility ?? 1).toBe(0);
|
|
});
|
|
|
|
it("keeps shader sky drift and celestial visibility coherent across days", () => {
|
|
const world = createDefaultWorldSettings();
|
|
const time = createDefaultProjectTimeSettings();
|
|
world.background = {
|
|
mode: "shader"
|
|
};
|
|
world.showCelestialBodies = true;
|
|
|
|
const firstNightTime = resolveRuntimeTimeState(time, {
|
|
timeOfDayHours: 0,
|
|
dayCount: 0,
|
|
dayLengthMinutes: 24
|
|
});
|
|
const firstNightWorld = resolveRuntimeDayNightWorldState(
|
|
world,
|
|
time,
|
|
{
|
|
timeOfDayHours: 0,
|
|
dayCount: 0,
|
|
dayLengthMinutes: 24
|
|
},
|
|
firstNightTime
|
|
);
|
|
const secondNightTime = resolveRuntimeTimeState(time, {
|
|
timeOfDayHours: 0,
|
|
dayCount: 1,
|
|
dayLengthMinutes: 24
|
|
});
|
|
const secondNightWorld = resolveRuntimeDayNightWorldState(
|
|
world,
|
|
time,
|
|
{
|
|
timeOfDayHours: 0,
|
|
dayCount: 1,
|
|
dayLengthMinutes: 24
|
|
},
|
|
secondNightTime
|
|
);
|
|
|
|
const firstNightSky = resolveWorldShaderSkyRenderState(
|
|
world,
|
|
firstNightWorld,
|
|
firstNightTime,
|
|
time
|
|
);
|
|
const secondNightSky = resolveWorldShaderSkyRenderState(
|
|
world,
|
|
secondNightWorld,
|
|
secondNightTime,
|
|
time
|
|
);
|
|
|
|
expect(firstNightSky?.celestial.sunVisible).toBe(false);
|
|
expect(firstNightSky?.celestial.moonVisible).toBe(true);
|
|
expect(secondNightSky?.clouds.driftOffset.x).not.toBeCloseTo(
|
|
firstNightSky?.clouds.driftOffset.x ?? 0
|
|
);
|
|
});
|
|
|
|
it("keeps aurora orientation stable while animation advances linearly with time", () => {
|
|
const world = createDefaultWorldSettings();
|
|
const time = createDefaultProjectTimeSettings();
|
|
world.background = {
|
|
mode: "shader"
|
|
};
|
|
world.shaderSky.aurora.enabled = true;
|
|
|
|
const eveningTime = resolveRuntimeTimeState(time, {
|
|
timeOfDayHours: 21,
|
|
dayCount: 0,
|
|
dayLengthMinutes: 24
|
|
});
|
|
const eveningWorld = resolveRuntimeDayNightWorldState(
|
|
world,
|
|
time,
|
|
{
|
|
timeOfDayHours: 21,
|
|
dayCount: 0,
|
|
dayLengthMinutes: 24
|
|
},
|
|
eveningTime
|
|
);
|
|
const lateNightTime = resolveRuntimeTimeState(time, {
|
|
timeOfDayHours: 3,
|
|
dayCount: 1,
|
|
dayLengthMinutes: 24
|
|
});
|
|
const lateNightWorld = resolveRuntimeDayNightWorldState(
|
|
world,
|
|
time,
|
|
{
|
|
timeOfDayHours: 3,
|
|
dayCount: 1,
|
|
dayLengthMinutes: 24
|
|
},
|
|
lateNightTime
|
|
);
|
|
|
|
const eveningSky = resolveWorldShaderSkyRenderState(
|
|
world,
|
|
eveningWorld,
|
|
eveningTime,
|
|
time
|
|
);
|
|
const lateNightSky = resolveWorldShaderSkyRenderState(
|
|
world,
|
|
lateNightWorld,
|
|
lateNightTime,
|
|
time
|
|
);
|
|
|
|
expect(eveningSky?.aurora.rotationRadians).toBe(
|
|
lateNightSky?.aurora.rotationRadians
|
|
);
|
|
expect(lateNightSky?.aurora.timeHours ?? 0).toBeGreaterThan(
|
|
eveningSky?.aurora.timeHours ?? 0
|
|
);
|
|
});
|
|
|
|
it("rotates shader stars linearly around the authored sun orbit axis", () => {
|
|
const world = createDefaultWorldSettings();
|
|
const time = createDefaultProjectTimeSettings();
|
|
world.background = {
|
|
mode: "shader"
|
|
};
|
|
world.celestialOrbits.sun = {
|
|
azimuthDegrees: 35,
|
|
peakAltitudeDegrees: 38
|
|
};
|
|
|
|
const resolveSkyAtHour = (timeOfDayHours: number) => {
|
|
const resolvedTime = resolveRuntimeTimeState(time, {
|
|
timeOfDayHours,
|
|
dayCount: 0,
|
|
dayLengthMinutes: 24
|
|
});
|
|
const resolvedWorld = resolveRuntimeDayNightWorldState(
|
|
world,
|
|
time,
|
|
{
|
|
timeOfDayHours,
|
|
dayCount: 0,
|
|
dayLengthMinutes: 24
|
|
},
|
|
resolvedTime
|
|
);
|
|
|
|
return resolveWorldShaderSkyRenderState(
|
|
world,
|
|
resolvedWorld,
|
|
resolvedTime,
|
|
time
|
|
);
|
|
};
|
|
|
|
const midnightSky = resolveSkyAtHour(0);
|
|
const morningSky = resolveSkyAtHour(6);
|
|
const noonSky = resolveSkyAtHour(12);
|
|
const expectedMorningRotation = resolveWorldShaderSkyStarRotation(
|
|
6,
|
|
world.celestialOrbits.sun
|
|
);
|
|
const axis = morningSky?.stars.rotationAxis ?? { x: 0, y: 0, z: 0 };
|
|
const axisLength = Math.hypot(axis.x, axis.y, axis.z);
|
|
|
|
expect(midnightSky).not.toBeNull();
|
|
expect(morningSky).not.toBeNull();
|
|
expect(noonSky).not.toBeNull();
|
|
expect(
|
|
(morningSky?.stars.rotationRadians ?? 0) -
|
|
(midnightSky?.stars.rotationRadians ?? 0)
|
|
).toBeCloseTo(Math.PI / 2);
|
|
expect(
|
|
(noonSky?.stars.rotationRadians ?? 0) -
|
|
(morningSky?.stars.rotationRadians ?? 0)
|
|
).toBeCloseTo(Math.PI / 2);
|
|
expect(morningSky?.stars.rotationRadians).toBeCloseTo(
|
|
expectedMorningRotation.rotationRadians
|
|
);
|
|
expect(axis.x).toBeCloseTo(expectedMorningRotation.rotationAxis.x);
|
|
expect(axis.y).toBeCloseTo(expectedMorningRotation.rotationAxis.y);
|
|
expect(axis.z).toBeCloseTo(expectedMorningRotation.rotationAxis.z);
|
|
expect(axisLength).toBeCloseTo(1);
|
|
expect(Math.abs(axis.x) + Math.abs(axis.z)).toBeGreaterThan(0.1);
|
|
});
|
|
|
|
it("offsets shader-rendered celestial positions when the horizon height changes", () => {
|
|
const world = createDefaultWorldSettings();
|
|
const time = createDefaultProjectTimeSettings();
|
|
world.background = {
|
|
mode: "shader"
|
|
};
|
|
world.showCelestialBodies = true;
|
|
|
|
const noonTime = resolveRuntimeTimeState(time, {
|
|
timeOfDayHours: 12,
|
|
dayCount: 0,
|
|
dayLengthMinutes: 24
|
|
});
|
|
const noonWorld = resolveRuntimeDayNightWorldState(
|
|
world,
|
|
time,
|
|
{
|
|
timeOfDayHours: 12,
|
|
dayCount: 0,
|
|
dayLengthMinutes: 24
|
|
},
|
|
noonTime
|
|
);
|
|
const baseSky = resolveWorldShaderSkyRenderState(
|
|
world,
|
|
noonWorld,
|
|
noonTime,
|
|
time
|
|
);
|
|
|
|
world.shaderSky.horizonHeight = -0.12;
|
|
|
|
const shiftedSky = resolveWorldShaderSkyRenderState(
|
|
world,
|
|
noonWorld,
|
|
noonTime,
|
|
time
|
|
);
|
|
|
|
expect(baseSky).not.toBeNull();
|
|
expect(shiftedSky).not.toBeNull();
|
|
expect(shiftedSky?.celestial.sunDirection.y ?? 0).toBeGreaterThan(
|
|
baseSky?.celestial.sunDirection.y ?? 0
|
|
);
|
|
expect(shiftedSky?.celestial.moonDirection.y ?? 0).toBeGreaterThan(
|
|
baseSky?.celestial.moonDirection.y ?? 0
|
|
);
|
|
});
|
|
|
|
it("resolves representative shader environment states for day, dawn, dusk, and night", () => {
|
|
const world = createDefaultWorldSettings();
|
|
const time = createDefaultProjectTimeSettings();
|
|
world.background = {
|
|
mode: "shader"
|
|
};
|
|
|
|
const states = resolveWorldShaderSkyEnvironmentPhaseStates(world, time);
|
|
|
|
expect(states.day?.time.dayPhase).toBe("day");
|
|
expect(states.dawn?.time.dayPhase).toBe("dawn");
|
|
expect(states.dusk?.time.dayPhase).toBe("dusk");
|
|
expect(states.night?.time.dayPhase).toBe("night");
|
|
expect(states.day?.sky.topColorHex).not.toBe(states.night?.sky.topColorHex);
|
|
});
|
|
|
|
it("quantizes small shader-environment state changes into the same cache key", () => {
|
|
const world = createDefaultWorldSettings();
|
|
const time = createDefaultProjectTimeSettings();
|
|
world.background = {
|
|
mode: "shader"
|
|
};
|
|
const noonTime = resolveRuntimeTimeState(time, {
|
|
timeOfDayHours: 12,
|
|
dayCount: 0,
|
|
dayLengthMinutes: 24
|
|
});
|
|
const noonWorld = resolveRuntimeDayNightWorldState(
|
|
world,
|
|
time,
|
|
{
|
|
timeOfDayHours: 12,
|
|
dayCount: 0,
|
|
dayLengthMinutes: 24
|
|
},
|
|
noonTime
|
|
);
|
|
const baseSky = resolveWorldShaderSkyRenderState(
|
|
world,
|
|
noonWorld,
|
|
noonTime,
|
|
time
|
|
);
|
|
const nearSky =
|
|
baseSky === null
|
|
? null
|
|
: {
|
|
...baseSky,
|
|
celestial: {
|
|
...baseSky.celestial,
|
|
sunDirection: {
|
|
...baseSky.celestial.sunDirection,
|
|
x: baseSky.celestial.sunDirection.x + 0.004
|
|
}
|
|
},
|
|
clouds: {
|
|
...baseSky.clouds,
|
|
driftOffset: {
|
|
x: baseSky.clouds.driftOffset.x + 0.004,
|
|
y: baseSky.clouds.driftOffset.y
|
|
}
|
|
}
|
|
};
|
|
const shiftedSky =
|
|
baseSky === null
|
|
? null
|
|
: {
|
|
...baseSky,
|
|
celestial: {
|
|
...baseSky.celestial,
|
|
sunIntensity: baseSky.celestial.sunIntensity + 0.2
|
|
}
|
|
};
|
|
const auroraSky =
|
|
baseSky === null
|
|
? null
|
|
: {
|
|
...baseSky,
|
|
aurora: {
|
|
...baseSky.aurora,
|
|
visibility: baseSky.aurora.visibility + 0.12
|
|
}
|
|
};
|
|
|
|
expect(baseSky).not.toBeNull();
|
|
expect(nearSky).not.toBeNull();
|
|
expect(shiftedSky).not.toBeNull();
|
|
expect(auroraSky).not.toBeNull();
|
|
expect(
|
|
createWorldShaderSkyEnvironmentCacheKey(
|
|
nearSky ?? (baseSky as NonNullable<typeof baseSky>)
|
|
)
|
|
).toBe(
|
|
createWorldShaderSkyEnvironmentCacheKey(
|
|
baseSky as NonNullable<typeof baseSky>
|
|
)
|
|
);
|
|
expect(
|
|
createWorldShaderSkyEnvironmentCacheKey(
|
|
shiftedSky ?? (baseSky as NonNullable<typeof baseSky>)
|
|
)
|
|
).not.toBe(
|
|
createWorldShaderSkyEnvironmentCacheKey(
|
|
baseSky as NonNullable<typeof baseSky>
|
|
)
|
|
);
|
|
expect(
|
|
createWorldShaderSkyEnvironmentCacheKey(
|
|
auroraSky ?? (baseSky as NonNullable<typeof baseSky>)
|
|
)
|
|
).not.toBe(
|
|
createWorldShaderSkyEnvironmentCacheKey(
|
|
baseSky as NonNullable<typeof baseSky>
|
|
)
|
|
);
|
|
});
|
|
});
|