From 05c3e1af5365165522089fb1dc55ec8541afdf69 Mon Sep 17 00:00:00 2001 From: NoisemakerJon <139656120+Noisemaker111@users.noreply.github.com> Date: Sat, 5 Sep 2026 04:21:12 -0400 Subject: [PATCH] Author the sky sun by bearing and warn on clipped or crossfaded skies sky({ sun: { azimuth, elevation } }) places the dome glow and sky-owned sun light; under timeOfDay the day arc swings around that noon position. Unset, the shell derives it from the first authored directional light so the dome sun agrees with the shadows. Time-of-day domes now draw the sun disc. Dev warnings once per site for sky.radius past the camera far plane and for noon-only tints under timeOfDay. Editor lighting panel gains bearing/height sliders. Refs #1660 Co-Authored-By: Claude Fable 5.1 --- .claude/skills/jgengine-ui/api.md | 1 + .claude/skills/jgengine-world/api.md | 1 + .claude/skills/jgengine/api.md | 4 + .claude/skills/jgengine/capabilities.md | 4 + CHANGELOG.md | 7 ++ packages/core/src/devtools/warnOnce.ts | 20 +++++ packages/core/src/editor/document.ts | 4 +- packages/core/src/editor/editor.test.ts | 2 + packages/core/src/editor/environment.test.ts | 3 + packages/core/src/editor/environment.ts | 8 ++ packages/core/src/editor/types.ts | 4 + packages/core/src/world/features.test.ts | 21 ++++++ packages/core/src/world/features.ts | 33 ++++++++- packages/editor/src/LightingPanel.tsx | 22 ++++++ packages/shell/src/Shell3dPresentation.tsx | 13 +++- packages/shell/src/environment/Daylight.tsx | 38 +++++++++- .../src/environment/daylightCycle.test.ts | 58 +++++++++++++++ .../shell/src/environment/daylightCycle.ts | 74 +++++++++++++++++-- scripts/export-manifest.json | 1 + 19 files changed, 304 insertions(+), 14 deletions(-) create mode 100644 packages/core/src/devtools/warnOnce.ts diff --git a/.claude/skills/jgengine-ui/api.md b/.claude/skills/jgengine-ui/api.md index 907d8973f..76156f065 100644 --- a/.claude/skills/jgengine-ui/api.md +++ b/.claude/skills/jgengine-ui/api.md @@ -2107,6 +2107,7 @@ - `DEFAULT_DAY_SKY_BOTTOM` (const): const DEFAULT_DAY_SKY_BOTTOM: "#e3f4ff" — ⚠ undocumented - `DEFAULT_DAY_SKY_TOP` (const): const DEFAULT_DAY_SKY_TOP: "#3fa4f2" — ⚠ undocumented - `DEFAULT_DAY_SUN_INTENSITY` (const): const DEFAULT_DAY_SUN_INTENSITY: 1 — ⚠ undocumented +- `DEFAULT_SUN_ELEVATION_DEG` (const): const DEFAULT_SUN_ELEVATION_DEG: number — The engine arc's noon height: `atan(1 / SUN_DEPTH_RATIO)`, ~68 degrees. - `DaylightCycleConfig` (interface): interface DaylightCycleConfig — ⚠ undocumented - `DaylightState` (interface): interface DaylightState — ⚠ undocumented - `SKY_PRESET_DAY_FRACTION` (const): const SKY_PRESET_DAY_FRACTION: Record<"day" | "dusk" | "night", number> — ⚠ undocumented diff --git a/.claude/skills/jgengine-world/api.md b/.claude/skills/jgengine-world/api.md index fc753b5f8..ad9992c08 100644 --- a/.claude/skills/jgengine-world/api.md +++ b/.claude/skills/jgengine-world/api.md @@ -3036,6 +3036,7 @@ - `RoadEnvironmentDescriptor` (type): type RoadEnvironmentDescriptor = { kind: "road" } & Required< Pick > & { /** Resolved sidewalk band, or `false` when the road has none. */ sidewalk: { width: number; color: string } | false; } — Resolved road descriptor produced by {@link road} and rendered by the shell environment scene. - `SkyEnvironmentConfig` (interface): interface SkyEnvironmentConfig — ⚠ undocumented - `SkyEnvironmentDescriptor` (type): type SkyEnvironmentDescriptor = { kind: "sky" } & Required< Pick > & Omit — ⚠ undocumented +- `SkySunConfig` (interface): interface SkySunConfig — Where the sun sits, as a compass bearing and a height above the horizon, both in degrees. `azimuth` 0 points toward -Z (north), 90 toward +X (east); `elevation` 90 is straight overhead. - `SnowEnvironmentConfig` (interface): interface SnowEnvironmentConfig — ⚠ undocumented - `SnowEnvironmentDescriptor` (type): type SnowEnvironmentDescriptor = { kind: "snow" } & Required< Pick > — ⚠ undocumented - `StructureEnvironmentDescriptor` (type): type StructureEnvironmentDescriptor = BuildingEnvironmentDescriptor — ⚠ undocumented diff --git a/.claude/skills/jgengine/api.md b/.claude/skills/jgengine/api.md index 98bf9525a..dc74f6ac7 100644 --- a/.claude/skills/jgengine/api.md +++ b/.claude/skills/jgengine/api.md @@ -224,6 +224,10 @@ - `subscribeUrlChange` (function): function subscribeUrlChange(listener: () => void): () => void — Fires `listener` whenever the query string may have changed out from under a flag — browser back/forward (`popstate`). Manual address-bar edits reload the page, so initial reads cover those; this keeps in-app state honest across history navigation. Returns an unsubscribe. - `writeUrlParam` (function): function writeUrlParam(param: string, value: string | null): void — Sets `param` to `value`, or removes it when `value` is null, rewriting the URL in place with `history.replaceState` so the rest of the query and the hash survive and no history entry is pushed. No-ops without a DOM, and skips the write when the URL already matches. +## @jgengine/core/devtools/warnOnce + +- `warnOnce` (function): function warnOnce(site: string, message: string): void — Logs a dev-mode `console.warn` once per `site`, and never in a production build. Reach for it where a permissive config would otherwise fail silently — a clipped sky dome, an ignored tint. + ## @jgengine/core/meta/changelog - `CHANGELOG` (const): const CHANGELOG: Record — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`). diff --git a/.claude/skills/jgengine/capabilities.md b/.claude/skills/jgengine/capabilities.md index 1c1b32b24..a4c96e259 100644 --- a/.claude/skills/jgengine/capabilities.md +++ b/.claude/skills/jgengine/capabilities.md @@ -178,6 +178,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `defineStore` (function) · `import { defineStore } from "@jgengine/shell/gameKit"` +## warn-once — emit a dev-only warning a single time per call site + +- `warnOnce` (function) · `import { warnOnce } from "@jgengine/core/devtools/warnOnce"` + ## world-chunk-key — map world positions to persisted chunk keys at a declared cell size - `chunkKeyOf` (function) · `import { chunkKeyOf } from "@jgengine/core/runtime/worldChunks"` diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dc9e5881..6b184a167 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,15 @@ between (`--json` for structured output). ## [Unreleased] +### Added + +- `sky({ sun: { azimuth, elevation } })` places the dome sun and the sky-owned sun light by compass bearing and height; under `timeOfDay` the day arc swings around that noon position. Unset, the shell derives it from the first authored `lighting.directional` light so the dome glow agrees with the shadows. Editor lighting panel gains bearing/height sliders (`sunAzimuth`/`sunElevation` on the scene document). +- `warnOnce` in `@jgengine/core/devtools/warnOnce`: dev-only, once-per-site console warnings. Used for a `sky.radius` at or past the camera far plane (the dome depth-clips to black) and for `zenithColor`/`horizonColor` under `timeOfDay` (they only set the noon keyframe). + ### Fixed +- Time-of-day and biome-driven sky domes now draw the sun disc and glow, tracking the sun through the day; previously only the fixed-preset dome had one. + - Separate SDK publication validation from external Games quality checks while retaining the full local and main-branch gates. - Correct foot-IK endpoint recomputation after hip rotation; verify reachable targets and clamping without changing bone lengths. diff --git a/packages/core/src/devtools/warnOnce.ts b/packages/core/src/devtools/warnOnce.ts new file mode 100644 index 000000000..3487605b7 --- /dev/null +++ b/packages/core/src/devtools/warnOnce.ts @@ -0,0 +1,20 @@ +import { isProductionEnvironment } from "./saveEndpoint"; + +const warned = new Set(); + +/** + * Logs a dev-mode `console.warn` once per `site`, and never in a production build. Reach for it + * where a permissive config would otherwise fail silently — a clipped sky dome, an ignored tint. + * @capability warn-once emit a dev-only warning a single time per call site + */ +export function warnOnce(site: string, message: string): void { + if (warned.has(site) || isProductionEnvironment()) return; + warned.add(site); + const host = globalThis as { console?: { warn?: (text: string) => void } }; + host.console?.warn?.(message); +} + +/** Clears the once-per-site record so a test can observe the warning again. @internal */ +export function resetWarnOnce(): void { + warned.clear(); +} diff --git a/packages/core/src/editor/document.ts b/packages/core/src/editor/document.ts index db07c5548..c1209de9e 100644 --- a/packages/core/src/editor/document.ts +++ b/packages/core/src/editor/document.ts @@ -55,6 +55,8 @@ function cloneEnvironment(environment: EditorEnvironment): EditorEnvironment { ...(environment.zenithColor === undefined ? {} : { zenithColor: environment.zenithColor }), ...(environment.sunIntensity === undefined ? {} : { sunIntensity: environment.sunIntensity }), ...(environment.ambientIntensity === undefined ? {} : { ambientIntensity: environment.ambientIntensity }), + ...(environment.sunAzimuth === undefined ? {} : { sunAzimuth: environment.sunAzimuth }), + ...(environment.sunElevation === undefined ? {} : { sunElevation: environment.sunElevation }), ...(environment.fog === undefined ? {} : { fog: { ...environment.fog } }), }; } @@ -1143,7 +1145,7 @@ function decodeEnvironment( failed = true; } } - for (const key of ["sunIntensity", "ambientIntensity"] as const) { + for (const key of ["sunIntensity", "ambientIntensity", "sunAzimuth", "sunElevation"] as const) { if (value[key] === undefined) continue; if (typeof value[key] === "number" && Number.isFinite(value[key])) env[key] = value[key] as number; else { diff --git a/packages/core/src/editor/editor.test.ts b/packages/core/src/editor/editor.test.ts index b1834e432..9691e8dac 100644 --- a/packages/core/src/editor/editor.test.ts +++ b/packages/core/src/editor/editor.test.ts @@ -989,6 +989,8 @@ describe("document.environment (#1110 lighting)", () => { timeOfDay: true, sunIntensity: 0.7, ambientIntensity: 0.4, + sunAzimuth: 129, + sunElevation: 29, horizonColor: "#ff8a5c", zenithColor: "#1a2b4a", fog: { color: "#ffb37a", near: 40, far: 220 }, diff --git a/packages/core/src/editor/environment.test.ts b/packages/core/src/editor/environment.test.ts index 43745860c..75d5befc7 100644 --- a/packages/core/src/editor/environment.test.ts +++ b/packages/core/src/editor/environment.test.ts @@ -131,12 +131,15 @@ describe("lakebedFromWaterVolumes", () => { preset: "night", fog: { near: 50, far: 200, color: "#001122" }, sunIntensity: 0.2, + sunAzimuth: 129, + sunElevation: 29, }, }; expect(skyFromDocument(doc)).toEqual({ preset: "night", fog: { near: 50, far: 200, color: "#001122" }, sunIntensity: 0.2, + sun: { azimuth: 129, elevation: 29 }, }); expect(environmentContentFromDocument(doc).sky).toEqual(skyFromDocument(doc)); }); diff --git a/packages/core/src/editor/environment.ts b/packages/core/src/editor/environment.ts index 2d8509fe3..3c89320dd 100644 --- a/packages/core/src/editor/environment.ts +++ b/packages/core/src/editor/environment.ts @@ -64,6 +64,14 @@ export function skyConfigFromEnvironment(env: EditorEnvironment): SkyEnvironment ...(env.zenithColor === undefined ? {} : { zenithColor: env.zenithColor }), ...(env.sunIntensity === undefined ? {} : { sunIntensity: env.sunIntensity }), ...(env.ambientIntensity === undefined ? {} : { ambientIntensity: env.ambientIntensity }), + ...(env.sunAzimuth === undefined && env.sunElevation === undefined + ? {} + : { + sun: { + ...(env.sunAzimuth === undefined ? {} : { azimuth: env.sunAzimuth }), + ...(env.sunElevation === undefined ? {} : { elevation: env.sunElevation }), + }, + }), ...(env.fog === undefined ? {} : { fog: { ...env.fog } }), }; } diff --git a/packages/core/src/editor/types.ts b/packages/core/src/editor/types.ts index 089e7666e..b7d2f5ba7 100644 --- a/packages/core/src/editor/types.ts +++ b/packages/core/src/editor/types.ts @@ -237,6 +237,10 @@ export interface EditorEnvironment { zenithColor?: string; sunIntensity?: number; ambientIntensity?: number; + /** Sun compass bearing in degrees (0 = -Z, 90 = +X); runtime `SkySunConfig.azimuth`. */ + sunAzimuth?: number; + /** Sun height above the horizon in degrees; runtime `SkySunConfig.elevation`. */ + sunElevation?: number; fog?: EditorFogConfig; } diff --git a/packages/core/src/world/features.test.ts b/packages/core/src/world/features.test.ts index 311d851c9..584ab9345 100644 --- a/packages/core/src/world/features.test.ts +++ b/packages/core/src/world/features.test.ts @@ -1,5 +1,7 @@ import { describe, expect, spyOn, test } from "bun:test"; +import { resetWarnOnce } from "../devtools/warnOnce"; + import { biomes, building, @@ -208,6 +210,7 @@ describe("world features", () => { radius: 2600, hazeStrength: 0.62, sunGlowStrength: 0.6, + sun: { azimuth: 129, elevation: 29 }, fog: { color: "#ffb37a", near: 40, far: 220 }, }); expect(descriptor).toEqual({ @@ -218,6 +221,7 @@ describe("world features", () => { zenithColor: "#1a2b4a", sunIntensity: 0.7, ambientIntensity: 0.4, + sun: { azimuth: 129, elevation: 29 }, radius: 2600, hazeStrength: 0.62, sunGlowStrength: 0.6, @@ -228,6 +232,23 @@ describe("world features", () => { expect(world).toEqual({ kind: "environment", sky: descriptor }); }); + test("sky warns once when authored tints ride a time-of-day crossfade", () => { + resetWarnOnce(); + const warn = console.warn; + const messages: string[] = []; + console.warn = (text: string) => { + messages.push(text); + }; + try { + sky({ timeOfDay: true, zenithColor: "#3fa4f2" }); + sky({ timeOfDay: true, horizonColor: "#e3f4ff" }); + sky({ timeOfDay: false, zenithColor: "#3fa4f2" }); + } finally { + console.warn = warn; + } + expect(messages.filter((m) => m.includes("noon keyframe"))).toHaveLength(1); + }); + test("terrain carries flatten masks", () => { const descriptor = terrain({ height: 4, diff --git a/packages/core/src/world/features.ts b/packages/core/src/world/features.ts index b500d7479..d6087e06a 100644 --- a/packages/core/src/world/features.ts +++ b/packages/core/src/world/features.ts @@ -6,6 +6,7 @@ import type { PlaceWorldFeature } from "./place"; import type { TerraformSnapshot } from "./terraform"; import type { SpriteAtlas } from "../assets/spriteAtlas"; import type { VolumetricCloudsConfig } from "./volumetricClouds"; +import { warnOnce } from "../devtools/warnOnce"; export interface WorldBounds { w: number; @@ -316,16 +317,39 @@ export interface OceanEnvironmentConfig { color?: string; } +/** + * Where the sun sits, as a compass bearing and a height above the horizon, both in degrees. + * `azimuth` 0 points toward -Z (north), 90 toward +X (east); `elevation` 90 is straight overhead. + */ +export interface SkySunConfig { + /** Compass bearing of the sun in degrees; 0 = -Z, 90 = +X. Default 0. */ + azimuth?: number; + /** Height above the horizon in degrees; 0 sits on the horizon, 90 is overhead. Default 68. */ + elevation?: number; +} + export interface SkyEnvironmentConfig { /** Fixed look used when `timeOfDay` is off (or no clock is available); default "day". */ preset?: "day" | "dusk" | "night"; /** Drive sun/sky from the world clock's `calendar().dayFraction` instead of the fixed `preset`. */ timeOfDay?: boolean; + /** + * Horizon tint at noon. Under `timeOfDay` this only sets the noon keyframe; dawn, dusk, and night + * still crossfade through the engine presets, so an authored tint is visible for part of the day. + */ horizonColor?: string; + /** Zenith tint at noon; same `timeOfDay` crossfade caveat as `horizonColor`. */ zenithColor?: string; sunIntensity?: number; ambientIntensity?: number; - /** Sky-dome sphere radius in world units. Enlarge for a playfield sited far from the origin so the viewer never exits the dome. Default 260. */ + /** + * Sun placement for the dome glow and the sky-owned sun light. With a fixed `preset` this is the + * sun's exact direction; under `timeOfDay` it is where the sun stands at noon and the day arc + * swings around that bearing. Unset with an authored `lighting.directional` light, the shell takes + * the first directional light's direction so the dome sun agrees with the shadows. + */ + sun?: SkySunConfig; + /** Sky-dome sphere radius in world units. Enlarge for a playfield sited far from the origin so the viewer never exits the dome. Must stay below the camera far plane or the dome depth-clips to black. Default 260. */ radius?: number; /** Horizon haze-band strength: 0 removes the dusty band, ~1 makes it heavy. Default 0.5. */ hazeStrength?: number; @@ -728,6 +752,12 @@ export function terrain(config: TerrainEnvironmentConfig = {}): TerrainEnvironme } export function sky(config: SkyEnvironmentConfig = {}): SkyEnvironmentDescriptor { + if (config.timeOfDay === true && (config.zenithColor !== undefined || config.horizonColor !== undefined)) { + warnOnce( + "sky-timeofday-tint", + "[jgengine] sky(): zenithColor/horizonColor only set the noon keyframe under timeOfDay; dawn, dusk, and night crossfade through the engine presets. Drop timeOfDay for a fixed tint.", + ); + } return withOptional( { kind: "sky" as const, @@ -739,6 +769,7 @@ export function sky(config: SkyEnvironmentConfig = {}): SkyEnvironmentDescriptor ...(config.zenithColor === undefined ? {} : { zenithColor: config.zenithColor }), ...(config.sunIntensity === undefined ? {} : { sunIntensity: config.sunIntensity }), ...(config.ambientIntensity === undefined ? {} : { ambientIntensity: config.ambientIntensity }), + ...(config.sun === undefined ? {} : { sun: { ...config.sun } }), ...(config.radius === undefined ? {} : { radius: config.radius }), ...(config.hazeStrength === undefined ? {} : { hazeStrength: config.hazeStrength }), ...(config.sunGlowStrength === undefined ? {} : { sunGlowStrength: config.sunGlowStrength }), diff --git a/packages/editor/src/LightingPanel.tsx b/packages/editor/src/LightingPanel.tsx index 727f24183..930cecebc 100644 --- a/packages/editor/src/LightingPanel.tsx +++ b/packages/editor/src/LightingPanel.tsx @@ -17,6 +17,8 @@ const DISPLAY_DEFAULTS = { timeOfDay: false, sunIntensity: 1, ambientIntensity: 0.45, + sunAzimuth: 0, + sunElevation: 68, fogNear: 80, fogFar: 400, fogColor: "", @@ -30,6 +32,8 @@ function readDisplay(env: EditorEnvironment | undefined) { timeOfDay: env?.timeOfDay ?? DISPLAY_DEFAULTS.timeOfDay, sunIntensity: env?.sunIntensity ?? DISPLAY_DEFAULTS.sunIntensity, ambientIntensity: env?.ambientIntensity ?? DISPLAY_DEFAULTS.ambientIntensity, + sunAzimuth: env?.sunAzimuth ?? DISPLAY_DEFAULTS.sunAzimuth, + sunElevation: env?.sunElevation ?? DISPLAY_DEFAULTS.sunElevation, fogNear: env?.fog?.near ?? DISPLAY_DEFAULTS.fogNear, fogFar: env?.fog?.far ?? DISPLAY_DEFAULTS.fogFar, fogColor: env?.fog?.color ?? DISPLAY_DEFAULTS.fogColor, @@ -192,6 +196,24 @@ export function LightingPanel({ session }: { session: EditorSession }) { onChange={(value) => patch({ ambientIntensity: value }, "env:ambient")} format={(v) => v.toFixed(2)} /> + patch({ sunAzimuth: value }, "env:sunAzimuth")} + format={(v) => `${Math.round(v)}°`} + /> + patch({ sunElevation: value }, "env:sunElevation")} + format={(v) => `${Math.round(v)}°`} + />
diff --git a/packages/shell/src/Shell3dPresentation.tsx b/packages/shell/src/Shell3dPresentation.tsx index ea2d56537..b7dcafc0f 100644 --- a/packages/shell/src/Shell3dPresentation.tsx +++ b/packages/shell/src/Shell3dPresentation.tsx @@ -70,6 +70,7 @@ import { } from "./camera"; import { CullingProvider } from "./visibility/CullingProvider"; import { SkyDaylight, TimeOfDayDaylight } from "./environment"; +import { bearingFromDirection } from "./environment/daylightCycle"; import { resolveSkyLightOwnership, skyEmitsLights } from "./environment/skyLightingPolicy"; import { EnvironmentScene } from "./environment/EnvironmentScene"; import { PointerProbe } from "./pointer/PointerProbe"; @@ -252,9 +253,17 @@ export function Shell3dPresentation({ const cinematicLook = (playable.look ?? "cinematic") !== "flat"; const backdrop = resolvedLook.backdrop; const backdropSky = backdrop?.sky !== undefined ? resolveSkyDescriptor(backdrop.sky) : undefined; - const effectiveSky = backdropSky ?? worldSky; - const backgroundColor = backdrop?.background ?? (effectiveSky === undefined ? DEFAULT_BACKGROUND_COLOR : undefined); + const authoredSky = backdropSky ?? worldSky; const lighting = resolvedLook.lighting; + const sunFromLighting = lighting?.directional?.[0]?.position; + const effectiveSky = useMemo( + () => + authoredSky === undefined || authoredSky.sun !== undefined || authoredSky.timeOfDay || sunFromLighting === undefined + ? authoredSky + : { ...authoredSky, sun: bearingFromDirection(sunFromLighting) }, + [authoredSky, sunFromLighting], + ); + const backgroundColor = backdrop?.background ?? (effectiveSky === undefined ? DEFAULT_BACKGROUND_COLOR : undefined); const orthographic = playable.camera?.projection === "orthographic"; const pointerDownRef = useRef<{ x: number; y: number } | null>(null); diff --git a/packages/shell/src/environment/Daylight.tsx b/packages/shell/src/environment/Daylight.tsx index e122574a9..5205822d6 100644 --- a/packages/shell/src/environment/Daylight.tsx +++ b/packages/shell/src/environment/Daylight.tsx @@ -1,4 +1,4 @@ -import { useFrame } from "@react-three/fiber"; +import { useFrame, useThree } from "@react-three/fiber"; import { useEffect, useMemo, useRef, type MutableRefObject } from "react"; import * as THREE from "three"; @@ -6,6 +6,8 @@ import type { BiomeBand, SkyEnvironmentDescriptor } from "@jgengine/core/world/f import { createBiomeFogSampler, createBiomeSkySampler } from "@jgengine/core/world/terrain"; import { resolveVolumetricClouds, type VolumetricCloudsConfig } from "@jgengine/core/world/volumetricClouds"; +import { warnOnce } from "@jgengine/core/devtools/warnOnce"; + import { daylightStateAt, SKY_PRESET_DAY_FRACTION } from "./daylightCycle"; import { VolumetricClouds } from "./VolumetricClouds"; @@ -173,6 +175,15 @@ export function SkyDome({ if (materialRef !== undefined) materialRef.current = null; }; }, [material, materialRef]); + const camera = useThree((state) => state.camera); + useEffect(() => { + const far = (camera as THREE.PerspectiveCamera).far; + if (typeof far !== "number" || radius < far) return; + warnOnce( + "sky-dome-radius", + `[jgengine] sky radius ${radius} is at or beyond the camera far plane ${far}; the dome depth-clips to black. Lower sky.radius or raise camera.frustum.far.`, + ); + }, [camera, radius]); useFrame((state) => { const mesh = meshRef.current; if (mesh === null) return; @@ -186,6 +197,11 @@ export function SkyDome({ ); } +/** Points the dome's sun glow at `position` and keeps the disc lit; called per frame by the time-of-day drivers. */ +function aimSkySun(material: THREE.ShaderMaterial, position: readonly [number, number, number]): void { + (material.uniforms.uSunDirection!.value as THREE.Vector3).set(position[0], position[1], position[2]).normalize(); +} + export interface DaylightProps { sky?: SkyDomeProps | false; fog?: { color?: string; near?: number; far?: number } | false; @@ -360,6 +376,7 @@ function BiomeDaylight({ if (skyMaterial !== null) { (skyMaterial.uniforms.topColor!.value as THREE.Color).set(skyValue.zenithColor); (skyMaterial.uniforms.bottomColor!.value as THREE.Color).set(skyValue.horizonColor); + aimSkySun(skyMaterial, base.sunPosition); } const sun = sunRef.current; if (sun !== null) { @@ -372,7 +389,14 @@ function BiomeDaylight({ return ( <> - + {sky.volumetricClouds === undefined ? null : ( )} @@ -423,12 +447,20 @@ function DrivenDaylight({ if (skyMaterial !== null) { (skyMaterial.uniforms.topColor!.value as THREE.Color).set(state.skyTop); (skyMaterial.uniforms.bottomColor!.value as THREE.Color).set(state.skyBottom); + aimSkySun(skyMaterial, state.sunPosition); } }); return ( <> - + {sky.volumetricClouds === undefined ? null : ( )} diff --git a/packages/shell/src/environment/daylightCycle.test.ts b/packages/shell/src/environment/daylightCycle.test.ts index c1554535a..0ca3c4619 100644 --- a/packages/shell/src/environment/daylightCycle.test.ts +++ b/packages/shell/src/environment/daylightCycle.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test } from "bun:test"; import { + bearingFromDirection, DEFAULT_DAY_AMBIENT_INTENSITY, + DEFAULT_SUN_ELEVATION_DEG, + sunDirectionFromBearing, DEFAULT_DAY_SUN_INTENSITY, daylightStateAt, lerpHexColor, @@ -89,3 +92,58 @@ describe("lerpHexColor", () => { expect(lerpHexColor("#000000", "#ffffff", 0.5)).toBe("#808080"); }); }); + +describe("authored sun bearing", () => { + const unit = (v: readonly [number, number, number]) => { + const len = Math.hypot(v[0], v[1], v[2]); + return [v[0] / len, v[1] / len, v[2] / len] as const; + }; + + test("bearing 0 points toward -Z and 90 toward +X", () => { + const north = sunDirectionFromBearing(0, 0); + expect(north[0]).toBeCloseTo(0, 6); + expect(north[2]).toBeCloseTo(-1, 6); + const east = sunDirectionFromBearing(90, 0); + expect(east[0]).toBeCloseTo(1, 6); + expect(east[2]).toBeCloseTo(0, 6); + expect(sunDirectionFromBearing(45, 90)[1]).toBeCloseTo(1, 6); + }); + + test("bearingFromDirection inverts sunDirectionFromBearing", () => { + for (const [az, el] of [[129, 29], [0, 68], [270, 5], [359, 80]] as const) { + const dir = sunDirectionFromBearing(az, el); + const back = bearingFromDirection([dir[0] * 120, dir[1] * 120, dir[2] * 120]); + expect(back.azimuth).toBeCloseTo(az, 4); + expect(back.elevation).toBeCloseTo(el, 4); + } + expect(bearingFromDirection([0, 0, 0])).toEqual({ azimuth: 0, elevation: 90 }); + }); + + test("a fixed sun lands the noon preset exactly on the authored bearing", () => { + const noon = daylightStateAt(0.5, { sun: { azimuth: 129, elevation: 29 } }); + const expected = sunDirectionFromBearing(129, 29); + const got = unit(noon.sunPosition); + expect(got[0]).toBeCloseTo(expected[0], 6); + expect(got[1]).toBeCloseTo(expected[1], 6); + expect(got[2]).toBeCloseTo(expected[2], 6); + }); + + test("the day arc swings around the authored noon: dawn and dusk sit on the horizon 90 degrees away", () => { + const sun = { azimuth: 129, elevation: 29 }; + const noon = unit(daylightStateAt(0.5, { sun }).sunPosition); + const dawn = unit(daylightStateAt(0.25, { sun }).sunPosition); + const dusk = unit(daylightStateAt(0.75, { sun }).sunPosition); + expect(dawn[1]).toBeCloseTo(0, 6); + expect(dusk[1]).toBeCloseTo(0, 6); + const dot = (a: readonly number[], b: readonly number[]) => a[0]! * b[0]! + a[1]! * b[1]! + a[2]! * b[2]!; + expect(dot(dawn, noon)).toBeCloseTo(0, 6); + expect(dot(dawn, dusk)).toBeCloseTo(-1, 6); + expect(daylightStateAt(0, { sun }).sunPosition[1]).toBeLessThan(0); + }); + + test("the default elevation reproduces the legacy noon height", () => { + const legacy = unit(daylightStateAt(0.5).sunPosition); + const authored = unit(daylightStateAt(0.5, { sun: { azimuth: 180, elevation: DEFAULT_SUN_ELEVATION_DEG } }).sunPosition); + expect(authored[1]).toBeCloseTo(legacy[1], 6); + }); +}); diff --git a/packages/shell/src/environment/daylightCycle.ts b/packages/shell/src/environment/daylightCycle.ts index 8e118e820..e5b3cc399 100644 --- a/packages/shell/src/environment/daylightCycle.ts +++ b/packages/shell/src/environment/daylightCycle.ts @@ -1,8 +1,12 @@ +import type { SkySunConfig } from "@jgengine/core/world/features"; + export interface DaylightCycleConfig { horizonColor?: string; zenithColor?: string; sunIntensity?: number; ambientIntensity?: number; + /** Fixed sun placement, or the noon placement the day arc swings around. Unset keeps the engine arc. */ + sun?: SkySunConfig; } export interface DaylightState { @@ -44,6 +48,35 @@ export const DEFAULT_DAY_SKY_BOTTOM = "#e3f4ff"; const SUN_DISTANCE = 200; const SUN_DEPTH_RATIO = 0.4; +const DEG = Math.PI / 180; +/** The engine arc's noon height: `atan(1 / SUN_DEPTH_RATIO)`, ~68 degrees. */ +export const DEFAULT_SUN_ELEVATION_DEG = Math.atan2(1, SUN_DEPTH_RATIO) / DEG; + +/** + * Unit direction toward a sun at compass `azimuth` (0 = -Z, 90 = +X) and `elevation` above the + * horizon, both in degrees. Shared by the dome glow, the sky-owned sun light, and the editor. + * @internal + */ +export function sunDirectionFromBearing(azimuth: number, elevation: number): [number, number, number] { + const az = azimuth * DEG; + const el = elevation * DEG; + const flat = Math.cos(el); + return [Math.sin(az) * flat, Math.sin(el), -Math.cos(az) * flat]; +} + +/** + * Inverse of {@link sunDirectionFromBearing}: the bearing/elevation (degrees) of a world-space + * direction such as an authored directional light's position. A zero vector reads as overhead. + * @internal + */ +export function bearingFromDirection(direction: readonly [number, number, number]): { azimuth: number; elevation: number } { + const [x, y, z] = direction; + const length = Math.hypot(x, y, z); + if (length === 0) return { azimuth: 0, elevation: 90 }; + const azimuth = ((Math.atan2(x, -z) / DEG) + 360) % 360; + const elevation = Math.asin(Math.max(-1, Math.min(1, y / length))) / DEG; + return { azimuth, elevation }; +} export const SKY_PRESET_DAY_FRACTION: Record<"day" | "dusk" | "night", number> = { night: 0, @@ -150,14 +183,41 @@ export function daylightStateAt(dayFraction: number, config: DaylightCycleConfig const skyTop = lerpHexColor(from.skyTop, to.skyTop, localT); const skyBottom = lerpHexColor(from.skyBottom, to.skyBottom, localT); + const sunPosition = sunPositionAt(wrapped, config.sun); + + return { sunPosition, sunIntensity, ambientIntensity, skyTop, skyBottom, background: skyBottom }; +} + +/** + * Sun position for a wrapped day fraction. Without `sun`, the legacy arc: east at dawn, ~68 degrees + * up at noon, west at dusk, tilted 0.4 toward +Z. With `sun`, the arc is rotated so noon lands + * exactly on the authored bearing and elevation and the sun still rises 90 degrees before it and + * sets 90 degrees after it, so a fixed preset (noon, dusk, night fractions) samples the same arc. + */ +function sunPositionAt(wrapped: number, sun: SkySunConfig | undefined): [number, number, number] { const angle = (wrapped - 0.25) * 2 * Math.PI; - const elevation = Math.sin(angle); - const azimuth = Math.cos(angle); - const sunPosition: [number, number, number] = [ - azimuth * SUN_DISTANCE, - elevation * SUN_DISTANCE, - SUN_DISTANCE * SUN_DEPTH_RATIO, + const along = Math.cos(angle); + const up = Math.sin(angle); + if (sun === undefined) { + return [along * SUN_DISTANCE, up * SUN_DISTANCE, SUN_DISTANCE * SUN_DEPTH_RATIO]; + } + const noon = sunDirectionFromBearing(sun.azimuth ?? 0, sun.elevation ?? DEFAULT_SUN_ELEVATION_DEG); + const horizon = normalize([noon[0], 0, noon[2]], [0, 0, -1]); + const east: [number, number, number] = [-horizon[2], 0, horizon[0]]; + const zenith = normalize(cross(east, noon), [0, 1, 0]); + const rise = normalize(cross(noon, zenith), east); + return [ + (rise[0] * along + noon[0] * up) * SUN_DISTANCE, + (rise[1] * along + noon[1] * up) * SUN_DISTANCE, + (rise[2] * along + noon[2] * up) * SUN_DISTANCE, ]; +} - return { sunPosition, sunIntensity, ambientIntensity, skyTop, skyBottom, background: skyBottom }; +function cross(a: readonly [number, number, number], b: readonly [number, number, number]): [number, number, number] { + return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; +} + +function normalize(v: readonly [number, number, number], fallback: readonly [number, number, number]): [number, number, number] { + const length = Math.hypot(v[0], v[1], v[2]); + return length < 1e-6 ? [fallback[0], fallback[1], fallback[2]] : [v[0] / length, v[1] / length, v[2] / length]; } diff --git a/scripts/export-manifest.json b/scripts/export-manifest.json index 49eb41fec..3080e936a 100644 --- a/scripts/export-manifest.json +++ b/scripts/export-manifest.json @@ -98,6 +98,7 @@ "./devtools/tunableSchema", "./devtools/types", "./devtools/urlFlags", + "./devtools/warnOnce", "./economy/auctionBook", "./economy/currency", "./economy/listingBook",