@jgengine/shell 0.18.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CHANGELOG.md +158 -7
  2. package/dist/GamePlayerShell.js +20 -3
  3. package/dist/Shell3dPresentation.d.ts +3 -9
  4. package/dist/Shell3dPresentation.js +26 -6
  5. package/dist/ShellHudPresentation.d.ts +9 -1
  6. package/dist/ShellHudPresentation.js +21 -0
  7. package/dist/audio/AudioComponents.js +6 -2
  8. package/dist/audio/audioEngine.d.ts +8 -0
  9. package/dist/audio/audioEngine.js +65 -6
  10. package/dist/audio/audioWire.d.ts +3 -0
  11. package/dist/audio/audioWire.js +7 -1
  12. package/dist/audio/loopParams.d.ts +39 -0
  13. package/dist/audio/loopParams.js +43 -0
  14. package/dist/camera/GameCameraRig.js +1 -1
  15. package/dist/camera/GameFirstPersonCamera.d.ts +4 -2
  16. package/dist/camera/GameFirstPersonCamera.js +52 -15
  17. package/dist/camera/Viewports.d.ts +34 -0
  18. package/dist/camera/Viewports.js +123 -0
  19. package/dist/camera/cameraRigs.js +70 -62
  20. package/dist/camera/orbitCameraMath.js +2 -2
  21. package/dist/camera/rigMath.d.ts +82 -4
  22. package/dist/camera/rigMath.js +133 -15
  23. package/dist/defineGame.d.ts +9 -0
  24. package/dist/defineGame.js +20 -3
  25. package/dist/devtools/DevtoolsOverlay.js +2 -0
  26. package/dist/devtools/agentBridge.d.ts +6 -0
  27. package/dist/devtools/agentBridge.js +15 -1
  28. package/dist/diagnostics/FpsCounter.d.ts +17 -0
  29. package/dist/diagnostics/FpsCounter.js +32 -0
  30. package/dist/drivers/FrameDriver.js +13 -2
  31. package/dist/drivers/FrameRateLimiter.d.ts +9 -0
  32. package/dist/drivers/FrameRateLimiter.js +39 -0
  33. package/dist/drivers/HudOnlyDriver.js +1 -1
  34. package/dist/environment/DayNightSky.js +5 -5
  35. package/dist/environment/Daylight.d.ts +35 -1
  36. package/dist/environment/Daylight.js +98 -28
  37. package/dist/environment/VolumetricClouds.js +20 -2
  38. package/dist/environment/daylightCycle.d.ts +20 -0
  39. package/dist/environment/daylightCycle.js +59 -7
  40. package/dist/environment/sunShadowMath.d.ts +45 -0
  41. package/dist/environment/sunShadowMath.js +50 -0
  42. package/dist/input/gamepadPoll.d.ts +47 -0
  43. package/dist/input/gamepadPoll.js +142 -0
  44. package/dist/input/gamepadSource.d.ts +11 -1
  45. package/dist/input/gamepadSource.js +117 -71
  46. package/dist/input/mouseLook.js +2 -1
  47. package/dist/input/padHaptics.d.ts +17 -0
  48. package/dist/input/padHaptics.js +32 -0
  49. package/dist/input/pointerLock.d.ts +11 -0
  50. package/dist/input/pointerLock.js +19 -0
  51. package/dist/postfx/PostProcessing.d.ts +1 -1
  52. package/dist/postfx/PostProcessing.js +5 -1
  53. package/dist/postfx/stylizePass.d.ts +23 -0
  54. package/dist/postfx/stylizePass.js +216 -0
  55. package/dist/render/SceneModels.js +1 -1
  56. package/dist/render/SkinnedInstances.d.ts +91 -0
  57. package/dist/render/SkinnedInstances.js +249 -0
  58. package/dist/render/TextureFiltering.d.ts +16 -0
  59. package/dist/render/TextureFiltering.js +64 -0
  60. package/dist/render/displayColor.d.ts +33 -0
  61. package/dist/render/displayColor.js +138 -0
  62. package/dist/render/modelLoad.d.ts +29 -0
  63. package/dist/render/modelLoad.js +61 -1
  64. package/dist/render/resolveModel.js +4 -0
  65. package/dist/render/useFootIk.d.ts +53 -4
  66. package/dist/render/useFootIk.js +197 -70
  67. package/dist/render/useModelAnimation.d.ts +24 -0
  68. package/dist/render/useModelAnimation.js +61 -40
  69. package/dist/scene/AuthoredScene.d.ts +9 -0
  70. package/dist/scene/AuthoredScene.js +17 -1
  71. package/dist/scene/CityRenderer.js +6 -6
  72. package/dist/settings/QuickControls.js +1 -1
  73. package/dist/settings/SettingsMenu.js +1 -1
  74. package/dist/settings/appliedSettings.d.ts +9 -3
  75. package/dist/settings/appliedSettings.js +6 -5
  76. package/dist/settings/settingsController.d.ts +3 -0
  77. package/dist/settings/settingsController.js +47 -3
  78. package/dist/structures/BuildingKitBatch.js +26 -22
  79. package/dist/structures/GeneratedBuilding.d.ts +3 -15
  80. package/dist/structures/GeneratedBuilding.js +21 -15
  81. package/dist/structures/buildingSurface.d.ts +21 -0
  82. package/dist/structures/buildingSurface.js +134 -0
  83. package/dist/useShellMultiplayerSync.d.ts +1 -1
  84. package/dist/useShellMultiplayerSync.js +5 -3
  85. package/dist/world/WorldParticles.js +1 -1
  86. package/package.json +4 -4
  87. package/skills/ce-handoff/SKILL.md +1 -1
  88. package/skills/game-design/SKILL.md +2 -2
  89. package/skills/jgengine/SKILL.md +7 -5
  90. package/skills/jgengine/api.md +118 -89
  91. package/skills/jgengine/capabilities.md +28 -0
  92. package/skills/jgengine/recipes/minimal-game.md +3 -0
  93. package/skills/jgengine-ui/SKILL.md +13 -16
  94. package/skills/jgengine-ui/api.md +418 -317
  95. package/skills/jgengine-ui/capabilities.md +16 -0
  96. package/skills/jgengine-ui/reference-primitives.md +89 -0
  97. package/skills/jgengine-ui/reference.md +2 -81
  98. package/skills/jgengine-world/SKILL.md +15 -3
  99. package/skills/jgengine-world/api.md +873 -626
  100. package/skills/jgengine-world/capabilities.md +130 -6
  101. package/skills/jgengine-world/recipes/character-animation.md +39 -0
  102. package/skills/jgengine-world/recipes/controls.md +44 -0
  103. package/skills/jgengine-world/recipes/flight-feel.md +76 -0
  104. package/skills/jgengine-world/recipes/npc-senses.md +43 -0
  105. package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
  106. package/skills/jgengine-world/reference.md +31 -2
  107. package/skills/jgengine-world/visibility.md +112 -0
  108. package/skills/level-design/SKILL.md +1 -1
  109. package/dist/render/testFixtures/proceduralSpriteAtlas.d.ts +0 -15
  110. package/dist/render/testFixtures/proceduralSpriteAtlas.js +0 -30
  111. package/dist/render/testFixtures/proceduralTileLayer.d.ts +0 -13
  112. package/dist/render/testFixtures/proceduralTileLayer.js +0 -30
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useFrame } from "@react-three/fiber";
3
+ import { useEffect, useRef } from "react";
4
+ /** Counts rendered frames. Mount inside the Canvas. @internal */
5
+ export function FpsProbe({ tally }) {
6
+ useFrame(() => {
7
+ tally.frames += 1;
8
+ });
9
+ return null;
10
+ }
11
+ /**
12
+ * Player-facing frames-per-second readout, sampled twice a second from rendered frames (not
13
+ * display refreshes), so it reflects the frame-rate limit and GPU load.
14
+ * @internal shell-internal; shown by the `graphics.showFps` setting.
15
+ */
16
+ export function FpsCounter({ tally }) {
17
+ const label = useRef(null);
18
+ useEffect(() => {
19
+ let last = performance.now();
20
+ tally.frames = 0;
21
+ const timer = setInterval(() => {
22
+ const now = performance.now();
23
+ const fps = (tally.frames * 1000) / Math.max(1, now - last);
24
+ tally.frames = 0;
25
+ last = now;
26
+ if (label.current !== null)
27
+ label.current.textContent = `${fps < 10 ? fps.toFixed(1) : Math.round(fps)} FPS`;
28
+ }, 500);
29
+ return () => clearInterval(timer);
30
+ }, [tally]);
31
+ return (_jsx("div", { className: "pointer-events-none absolute bottom-1 right-1 z-50 rounded bg-black/60 px-2 py-0.5 font-mono text-xs tabular-nums text-emerald-300", children: _jsx("span", { ref: label, children: "-- FPS" }) }));
32
+ }
@@ -6,6 +6,8 @@ import { pointerAimFor } from "../shellPointer.js";
6
6
  import { shellDrivesPlayerPose } from "../shellMovement.js";
7
7
  import { steerYaw } from "@jgengine/core/movement/steering";
8
8
  import { stepPlayerMovement, resolvePlayerMovementTuning } from "@jgengine/core/movement/playerMovement";
9
+ import { localPlayers } from "@jgengine/core/runtime/localPlayers";
10
+ import { seatCameraYaw } from "../camera/Viewports.js";
9
11
  import { isServerAuthoritative } from "@jgengine/core/runtime/adapter";
10
12
  import { resolveCommandSink } from "../commandSink.js";
11
13
  import { inputFramesEqual, resolveInputSink } from "../inputSink.js";
@@ -57,7 +59,6 @@ export function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryC
57
59
  }).send(frame),
58
60
  }), [multiplayer, serverAuthoritative, serverIdRef]);
59
61
  const lastSentInputRef = useRef(null);
60
- const inputActions = useMemo(() => Object.keys(playable.game.input ?? {}), [playable]);
61
62
  const predictionRef = useRef(createPredictionBuffer({
62
63
  initial: [0, 0, 0],
63
64
  maxTicks: 120,
@@ -114,7 +115,7 @@ export function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryC
114
115
  try {
115
116
  let endPhase = devtools.profile.begin("time+input");
116
117
  const dt = Math.min(rawDt, 0.05);
117
- ctx.input.publish(heldActionsFor(tracker, inputActions));
118
+ ctx.input.publish(heldActionsFor(tracker, tracker.actions()));
118
119
  ctx.input.publishPointer(pointerAxisRef.current);
119
120
  ctx.input.publishAnalog(analogRef.current);
120
121
  sendInput();
@@ -138,6 +139,16 @@ export function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryC
138
139
  }
139
140
  endPose();
140
141
  }
142
+ if (drivesPose && !serverAuthoritative) {
143
+ const table = localPlayers(ctx);
144
+ const seats = table.slots();
145
+ for (let index = 1; index < seats.length; index += 1) {
146
+ const seat = table.local(seats[index].slotId);
147
+ if (seat === null)
148
+ continue;
149
+ stepPlayerMovement(ctx, seat.userId, { held: seat.input.held(), pointer: null, analog: seat.input.analog() }, stepDt, movementTuning, seatCameraYaw(ctx, seat.userId));
150
+ }
151
+ }
141
152
  ctx.sim.runStages("afterMovement", stepDt);
142
153
  if (autoPickupRadius !== null && !serverAuthoritative) {
143
154
  const endPickup = devtools.profile.begin("pickup");
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Drives the Canvas at a capped frame rate. Mount only with `frameloop="never"`: it runs its
3
+ * own requestAnimationFrame loop and advances R3F on the refreshes `paceFrame` admits, so
4
+ * simulation, useFrame and rendering all run at the cap with correct deltas.
5
+ * @internal shell-internal; driven by the player's frame-rate limit setting.
6
+ */
7
+ export declare function FrameRateLimiter({ fps }: {
8
+ fps: number;
9
+ }): null;
@@ -0,0 +1,39 @@
1
+ import { useThree } from "@react-three/fiber";
2
+ import { useEffect } from "react";
3
+ import { paceFrame } from "@jgengine/core/settings/frameRateLimit";
4
+ /**
5
+ * Drives the Canvas at a capped frame rate. Mount only with `frameloop="never"`: it runs its
6
+ * own requestAnimationFrame loop and advances R3F on the refreshes `paceFrame` admits, so
7
+ * simulation, useFrame and rendering all run at the cap with correct deltas.
8
+ * @internal shell-internal; driven by the player's frame-rate limit setting.
9
+ */
10
+ export function FrameRateLimiter({ fps }) {
11
+ const advance = useThree((state) => state.advance);
12
+ const get = useThree((state) => state.get);
13
+ useEffect(() => {
14
+ if (fps <= 0)
15
+ return;
16
+ // R3F's "never" mode reads the timestamp as seconds on its own clock; continue that clock.
17
+ const base = get().clock.elapsedTime;
18
+ let start = null;
19
+ let anchor = 0;
20
+ let frame = requestAnimationFrame(function tick(now) {
21
+ frame = requestAnimationFrame(tick);
22
+ if (start === null) {
23
+ start = now;
24
+ anchor = now;
25
+ }
26
+ const next = paceFrame(now, anchor, fps);
27
+ if (next === null)
28
+ return;
29
+ anchor = next;
30
+ advance(base + (now - start) / 1000, true);
31
+ });
32
+ return () => {
33
+ cancelAnimationFrame(frame);
34
+ // Hand the clock back to R3F's own loop in milliseconds so its next delta is one frame.
35
+ get().clock.oldTime = performance.now();
36
+ };
37
+ }, [fps, advance, get]);
38
+ return null;
39
+ }
@@ -26,7 +26,7 @@ export function HudOnlyDriver({ ctx, playable, tracker, pointerAxisRef, gateRef,
26
26
  try {
27
27
  let endPhase = devtools.profile.begin("time+input");
28
28
  const dt = Math.min(rawDt, 0.05);
29
- ctx.input.publish(heldActionsFor(tracker, inputActions));
29
+ ctx.input.publish(heldActionsFor(tracker, tracker.actions()));
30
30
  ctx.input.publishPointer(pointerAxisRef.current);
31
31
  endPhase();
32
32
  ctx.sim.advance(dt, (stepDt) => {
@@ -2,7 +2,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { useFrame } from "@react-three/fiber";
3
3
  import { useMemo, useRef } from "react";
4
4
  import * as THREE from "three";
5
- import { SkyDome } from "./Daylight.js";
5
+ import { setSkyDomeColors, SkyDome, SunLight } from "./Daylight.js";
6
6
  import { daylightStateAt } from "./daylightCycle.js";
7
7
  const HEMI_GROUND = "#4c6b34";
8
8
  /**
@@ -22,22 +22,22 @@ export function DayNightSky({ cycle, radius, hazeStrength, sunGlowStrength, ligh
22
22
  const hemiRef = useRef(null);
23
23
  const initial = useMemo(() => cycle.sample(), [cycle]);
24
24
  const initialSun = useMemo(() => daylightStateAt(initial.dayFraction).sunPosition, [initial.dayFraction]);
25
+ const sunDirectionRef = useRef(initialSun);
25
26
  useFrame(() => {
26
27
  const sample = cycle.sample();
27
28
  const geometry = daylightStateAt(sample.dayFraction);
28
29
  const material = skyMaterialRef.current;
29
30
  if (material !== null) {
30
- material.uniforms.topColor.value.set(sample.lightColor);
31
- material.uniforms.bottomColor.value.set(sample.color);
31
+ setSkyDomeColors(material, sample.lightColor, sample.color);
32
32
  material.uniforms.uSunColor.value.set(sample.lightColor);
33
33
  material.uniforms.uSunDirection.value
34
34
  .set(geometry.sunPosition[0], geometry.sunPosition[1], geometry.sunPosition[2])
35
35
  .normalize();
36
36
  material.uniforms.uSunIntensity.value = sample.intensity;
37
37
  }
38
+ sunDirectionRef.current = geometry.sunPosition;
38
39
  const sun = sunRef.current;
39
40
  if (sun !== null) {
40
- sun.position.set(geometry.sunPosition[0], geometry.sunPosition[1], geometry.sunPosition[2]);
41
41
  sun.intensity = sample.intensity * keyLightIntensity;
42
42
  sun.color.set(sample.lightColor);
43
43
  }
@@ -47,5 +47,5 @@ export function DayNightSky({ cycle, radius, hazeStrength, sunGlowStrength, ligh
47
47
  hemi.color.set(sample.lightColor);
48
48
  }
49
49
  });
50
- return (_jsxs(_Fragment, { children: [_jsx(SkyDome, { topColor: initial.lightColor, horizonColor: initial.color, sunDirection: initialSun, sunColor: initial.lightColor, sunIntensity: initial.intensity, materialRef: skyMaterialRef, ...(radius === undefined ? {} : { radius }), ...(hazeStrength === undefined ? {} : { hazeStrength }), ...(sunGlowStrength === undefined ? {} : { sunGlowStrength }) }), lights ? (_jsxs(_Fragment, { children: [_jsx("hemisphereLight", { ref: hemiRef, args: [initial.lightColor, HEMI_GROUND, initial.intensity * ambientIntensity] }), _jsx("directionalLight", { ref: sunRef, position: initialSun, intensity: initial.intensity * keyLightIntensity, color: initial.lightColor, castShadow: true, "shadow-mapSize-width": 2048, "shadow-mapSize-height": 2048, "shadow-camera-left": -90, "shadow-camera-right": 90, "shadow-camera-top": 90, "shadow-camera-bottom": -90, "shadow-camera-near": 10, "shadow-camera-far": 520, "shadow-bias": -0.0004, "shadow-normalBias": 0.02 })] })) : null] }));
50
+ return (_jsxs(_Fragment, { children: [_jsx(SkyDome, { topColor: initial.lightColor, horizonColor: initial.color, sunDirection: initialSun, sunColor: initial.lightColor, sunIntensity: initial.intensity, materialRef: skyMaterialRef, ...(radius === undefined ? {} : { radius }), ...(hazeStrength === undefined ? {} : { hazeStrength }), ...(sunGlowStrength === undefined ? {} : { sunGlowStrength }) }), lights ? (_jsxs(_Fragment, { children: [_jsx("hemisphereLight", { ref: hemiRef, args: [initial.lightColor, HEMI_GROUND, initial.intensity * ambientIntensity] }), _jsx(SunLight, { direction: initialSun, directionRef: sunDirectionRef, lightRef: sunRef, intensity: initial.intensity * keyLightIntensity, color: initial.lightColor })] })) : null] }));
51
51
  }
@@ -1,4 +1,4 @@
1
- import { type MutableRefObject } from "react";
1
+ import { type MutableRefObject, type RefObject } from "react";
2
2
  import * as THREE from "three";
3
3
  import type { BiomeBand, SkyEnvironmentDescriptor } from "@jgengine/core/world/features";
4
4
  import { type VolumetricCloudsConfig } from "@jgengine/core/world/volumetricClouds";
@@ -23,6 +23,40 @@ export interface SkyDomeProps {
23
23
  /** Exposes the created shader material so a time-of-day driver can mutate its uniforms per frame without recreating it. */
24
24
  materialRef?: MutableRefObject<THREE.ShaderMaterial | null>;
25
25
  }
26
+ /**
27
+ * Shadow-casting sun whose shadow box leads the camera each frame ({@link sunShadowFocus}), so
28
+ * grounded shadows stay crisp and soft-edged under the player anywhere in a large world. Time-of-day
29
+ * drivers aim it through `directionRef` and retint it through `lightRef`.
30
+ * @internal
31
+ */
32
+ export declare function SunLight({ direction, directionRef, lightRef, intensity, color, }: {
33
+ direction: readonly [number, number, number];
34
+ directionRef?: MutableRefObject<readonly [number, number, number]>;
35
+ lightRef?: RefObject<THREE.DirectionalLight | null>;
36
+ intensity: number;
37
+ color: string;
38
+ }): import("react").JSX.Element;
39
+ /**
40
+ * Sets the zenith and horizon colors a sky dome should show on screen. The dome converts them each
41
+ * frame through the renderer's tone mapping ({@link DisplayColorWriter}), so a picked swatch renders
42
+ * as picked instead of greyed by the output curve.
43
+ * @internal
44
+ */
45
+ export declare function setSkyDomeColors(material: THREE.ShaderMaterial, top: THREE.ColorRepresentation, bottom: THREE.ColorRepresentation): void;
46
+ /**
47
+ * Scene fog whose color is a display color, converted through the renderer's tone mapping like the
48
+ * dome's horizon so fogged ground meets the sky without a seam. Drivers retint it with
49
+ * {@link setSkyFogColor}.
50
+ * @internal
51
+ */
52
+ export declare function SkyFog({ color, near, far, fogRef }: {
53
+ color: string;
54
+ near: number;
55
+ far: number;
56
+ fogRef?: RefObject<THREE.Fog | null>;
57
+ }): import("react").JSX.Element;
58
+ /** Sets the display color a {@link SkyFog} converts each frame. @internal */
59
+ export declare function setSkyFogColor(fog: THREE.Fog, color: THREE.ColorRepresentation): void;
26
60
  /** @internal */
27
61
  export declare function SkyDome({ topColor, horizonColor, radius, offset, exponent, sunDirection, sunColor, sunIntensity, hazeStrength, sunGlowStrength, cloudiness, materialRef, }?: SkyDomeProps): import("react").JSX.Element;
28
62
  export interface DaylightProps {
@@ -1,10 +1,13 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useFrame } from "@react-three/fiber";
3
- import { useEffect, useMemo, useRef } from "react";
2
+ import { useFrame, useThree } from "@react-three/fiber";
3
+ import { useEffect, useLayoutEffect, useMemo, useRef } from "react";
4
4
  import * as THREE from "three";
5
5
  import { createBiomeFogSampler, createBiomeSkySampler } from "@jgengine/core/world/terrain";
6
6
  import { resolveVolumetricClouds } from "@jgengine/core/world/volumetricClouds";
7
+ import { warnOnce } from "@jgengine/core/devtools/warnOnce";
8
+ import { DisplayColorWriter } from "../render/displayColor.js";
7
9
  import { daylightStateAt, SKY_PRESET_DAY_FRACTION } from "./daylightCycle.js";
10
+ import { SUN_SHADOW, sunLightPosition, sunShadowFocus } from "./sunShadowMath.js";
8
11
  import { VolumetricClouds } from "./VolumetricClouds.js";
9
12
  function normalizeVec3(v) {
10
13
  const out = new THREE.Vector3(v[0], v[1], v[2]);
@@ -17,29 +20,77 @@ const SUN_COLOR = "#fff1c9";
17
20
  const HEMI_SKY = "#bfe3ff";
18
21
  const HEMI_GROUND = "#4c6b34";
19
22
  /**
20
- * Sun directional light whose high-resolution shadow camera follows the view each
21
- * frame, so grounded shadows stay crisp under the player anywhere in a large world
22
- * instead of only near the origin.
23
+ * Shadow-casting sun whose shadow box leads the camera each frame ({@link sunShadowFocus}), so
24
+ * grounded shadows stay crisp and soft-edged under the player anywhere in a large world. Time-of-day
25
+ * drivers aim it through `directionRef` and retint it through `lightRef`.
26
+ * @internal
23
27
  */
24
- function ShadowCastingSun({ position, intensity, color, }) {
25
- const ref = useRef(null);
28
+ export function SunLight({ direction, directionRef, lightRef, intensity, color, }) {
29
+ const ownRef = useRef(null);
30
+ const ref = lightRef ?? ownRef;
31
+ const scratch = useMemo(() => ({ forward: new THREE.Vector3(), focus: { x: 0, z: 0 }, position: [0, 0, 0] }), []);
26
32
  useFrame((state) => {
27
33
  const light = ref.current;
28
34
  if (light === null)
29
35
  return;
30
- const cx = state.camera.position.x;
31
- const cz = state.camera.position.z;
32
- light.position.set(cx + position[0], position[1], cz + position[2]);
33
- light.target.position.set(cx, 0, cz);
36
+ state.camera.getWorldDirection(scratch.forward);
37
+ const focus = sunShadowFocus(state.camera.position, scratch.forward, scratch.focus);
38
+ const [x, y, z] = sunLightPosition(focus, directionRef?.current ?? direction, scratch.position);
39
+ light.position.set(x, y, z);
40
+ light.target.position.set(focus.x, 0, focus.z);
34
41
  light.target.updateMatrixWorld();
35
42
  });
36
- return (_jsx("directionalLight", { ref: ref, position: [position[0], position[1], position[2]], intensity: intensity, color: color, castShadow: true, "shadow-mapSize-width": 2048, "shadow-mapSize-height": 2048, "shadow-camera-left": -90, "shadow-camera-right": 90, "shadow-camera-top": 90, "shadow-camera-bottom": -90, "shadow-camera-near": 10, "shadow-camera-far": 520, "shadow-bias": -0.0004, "shadow-normalBias": 0.02 }));
43
+ const initial = sunLightPosition({ x: 0, z: 0 }, direction);
44
+ return (_jsx("directionalLight", { ref: ref, position: initial, intensity: intensity, color: color, castShadow: true, "shadow-mapSize-width": SUN_SHADOW.mapSize, "shadow-mapSize-height": SUN_SHADOW.mapSize, "shadow-camera-left": -SUN_SHADOW.halfExtent, "shadow-camera-right": SUN_SHADOW.halfExtent, "shadow-camera-top": SUN_SHADOW.halfExtent, "shadow-camera-bottom": -SUN_SHADOW.halfExtent, "shadow-camera-near": SUN_SHADOW.near, "shadow-camera-far": SUN_SHADOW.far, "shadow-bias": SUN_SHADOW.bias, "shadow-normalBias": SUN_SHADOW.normalBias, "shadow-radius": SUN_SHADOW.radius }));
45
+ }
46
+ /**
47
+ * Sets the zenith and horizon colors a sky dome should show on screen. The dome converts them each
48
+ * frame through the renderer's tone mapping ({@link DisplayColorWriter}), so a picked swatch renders
49
+ * as picked instead of greyed by the output curve.
50
+ * @internal
51
+ */
52
+ export function setSkyDomeColors(material, top, bottom) {
53
+ const display = material.userData;
54
+ (display.top ??= new THREE.Color()).set(top);
55
+ (display.bottom ??= new THREE.Color()).set(bottom);
56
+ }
57
+ /**
58
+ * Scene fog whose color is a display color, converted through the renderer's tone mapping like the
59
+ * dome's horizon so fogged ground meets the sky without a seam. Drivers retint it with
60
+ * {@link setSkyFogColor}.
61
+ * @internal
62
+ */
63
+ export function SkyFog({ color, near, far, fogRef }) {
64
+ const ownRef = useRef(null);
65
+ const ref = fogRef ?? ownRef;
66
+ const writer = useMemo(() => new DisplayColorWriter(), []);
67
+ useLayoutEffect(() => {
68
+ if (ref.current !== null)
69
+ setSkyFogColor(ref.current, color);
70
+ }, [ref, color]);
71
+ useFrame((state) => {
72
+ const fog = ref.current;
73
+ const display = fog === null ? undefined : fogDisplayColors.get(fog);
74
+ if (fog === null || display === undefined)
75
+ return;
76
+ writer.write(fog.color, display, state.gl);
77
+ });
78
+ return _jsx("fog", { attach: "fog", ref: ref, args: [color, near, far] });
37
79
  }
80
+ /** Sets the display color a {@link SkyFog} converts each frame. @internal */
81
+ export function setSkyFogColor(fog, color) {
82
+ const display = fogDisplayColors.get(fog);
83
+ if (display === undefined)
84
+ fogDisplayColors.set(fog, new THREE.Color(color));
85
+ else
86
+ display.set(color);
87
+ }
88
+ const fogDisplayColors = new WeakMap();
38
89
  /** @internal */
39
90
  export function SkyDome({ topColor = SKY_TOP, horizonColor = SKY_HORIZON, radius = 260, offset = 24, exponent = 0.65, sunDirection, sunColor = "#fff4d6", sunIntensity = 1, hazeStrength = 0.5, sunGlowStrength = 1, cloudiness = 0.35, materialRef, } = {}) {
40
91
  const meshRef = useRef(null);
41
92
  const material = useMemo(() => {
42
- return new THREE.ShaderMaterial({
93
+ const dome = new THREE.ShaderMaterial({
43
94
  uniforms: {
44
95
  topColor: { value: new THREE.Color(topColor) },
45
96
  bottomColor: { value: new THREE.Color(horizonColor) },
@@ -93,12 +144,16 @@ export function SkyDome({ topColor = SKY_TOP, horizonColor = SKY_HORIZON, radius
93
144
  float glow = pow(sd, 8.0) * 0.35 + pow(sd, 128.0) * 2.6;
94
145
  col += uSunColor * glow * uSunIntensity * uSunGlow;
95
146
  gl_FragColor = vec4(col, 1.0);
147
+ #include <tonemapping_fragment>
148
+ #include <colorspace_fragment>
96
149
  }
97
150
  `,
98
151
  side: THREE.BackSide,
99
152
  depthWrite: false,
100
153
  fog: false,
101
154
  });
155
+ setSkyDomeColors(dome, topColor, horizonColor);
156
+ return dome;
102
157
  }, [topColor, horizonColor, offset, exponent, sunColor, sunDirection, sunIntensity, hazeStrength, sunGlowStrength, cloudiness]);
103
158
  useEffect(() => {
104
159
  if (materialRef !== undefined)
@@ -109,7 +164,18 @@ export function SkyDome({ topColor = SKY_TOP, horizonColor = SKY_HORIZON, radius
109
164
  materialRef.current = null;
110
165
  };
111
166
  }, [material, materialRef]);
167
+ const camera = useThree((state) => state.camera);
168
+ const writers = useMemo(() => ({ top: new DisplayColorWriter(), bottom: new DisplayColorWriter() }), []);
169
+ useEffect(() => {
170
+ const far = camera.far;
171
+ if (typeof far !== "number" || radius < far)
172
+ return;
173
+ 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.`);
174
+ }, [camera, radius]);
112
175
  useFrame((state) => {
176
+ const display = material.userData;
177
+ writers.top.write(material.uniforms.topColor.value, display.top, state.gl);
178
+ writers.bottom.write(material.uniforms.bottomColor.value, display.bottom, state.gl);
113
179
  const mesh = meshRef.current;
114
180
  if (mesh === null)
115
181
  return;
@@ -118,10 +184,14 @@ export function SkyDome({ topColor = SKY_TOP, horizonColor = SKY_HORIZON, radius
118
184
  });
119
185
  return (_jsx("mesh", { ref: meshRef, material: material, renderOrder: -1, frustumCulled: false, children: _jsx("sphereGeometry", { args: [radius, 32, 16] }) }));
120
186
  }
187
+ /** Points the dome's sun glow at `position` and keeps the disc lit; called per frame by the time-of-day drivers. */
188
+ function aimSkySun(material, position) {
189
+ material.uniforms.uSunDirection.value.set(position[0], position[1], position[2]).normalize();
190
+ }
121
191
  /** @internal */
122
192
  export function Daylight({ sky, fog, sun, ambient, lights = true, clouds } = {}) {
123
193
  const sunPosition = sun?.position ?? [120, 160, 70];
124
- return (_jsxs(_Fragment, { children: [sky === false ? null : (_jsx(SkyDome, { ...(sky ?? {}), sunDirection: sunPosition, sunColor: sun?.color ?? SUN_COLOR, sunIntensity: sun?.intensity ?? 1 })), clouds === undefined ? null : (_jsx(VolumetricClouds, { rules: resolveVolumetricClouds(clouds), sunDirection: sunPosition })), fog === false ? null : (_jsx("fog", { attach: "fog", args: [fog?.color ?? FOG_COLOR, fog?.near ?? 70, fog?.far ?? 260] })), lights ? (_jsxs(_Fragment, { children: [_jsx("hemisphereLight", { args: [ambient?.skyColor ?? HEMI_SKY, ambient?.groundColor ?? HEMI_GROUND, ambient?.intensity ?? 0.55] }), _jsx(ShadowCastingSun, { position: sunPosition, intensity: sun?.intensity ?? 0.85, color: sun?.color ?? SUN_COLOR })] })) : null] }));
194
+ return (_jsxs(_Fragment, { children: [sky === false ? null : (_jsx(SkyDome, { ...(sky ?? {}), sunDirection: sunPosition, sunColor: sun?.color ?? SUN_COLOR, sunIntensity: sun?.intensity ?? 1 })), clouds === undefined ? null : (_jsx(VolumetricClouds, { rules: resolveVolumetricClouds(clouds), sunDirection: sunPosition })), fog === false ? null : (_jsx(SkyFog, { color: fog?.color ?? FOG_COLOR, near: fog?.near ?? 70, far: fog?.far ?? 260 })), lights ? (_jsxs(_Fragment, { children: [_jsx("hemisphereLight", { args: [ambient?.skyColor ?? HEMI_SKY, ambient?.groundColor ?? HEMI_GROUND, ambient?.intensity ?? 0.55] }), _jsx(SunLight, { direction: sunPosition, intensity: sun?.intensity ?? 0.85, color: sun?.color ?? SUN_COLOR })] })) : null] }));
125
195
  }
126
196
  /** True when at least one band overrides fog or sky — the trigger for the per-frame biome driver. */
127
197
  function bandsDriveSkyOrFog(bands) {
@@ -172,6 +242,7 @@ function BiomeDaylight({ sky, bands, clock, lights, }) {
172
242
  const baseFraction = timeOfDay ? clock.calendar().dayFraction : SKY_PRESET_DAY_FRACTION[sky.preset];
173
243
  const initial = useMemo(() => daylightStateAt(baseFraction, sky), [sky, baseFraction]);
174
244
  const sunRef = useRef(null);
245
+ const sunDirectionRef = useRef(initial.sunPosition);
175
246
  const hemiRef = useRef(null);
176
247
  const fogRef = useRef(null);
177
248
  const skyMaterialRef = useRef(null);
@@ -196,50 +267,49 @@ function BiomeDaylight({ sky, bands, clock, lights, }) {
196
267
  const skyValue = skySampler(z);
197
268
  const fogNode = fogRef.current;
198
269
  if (fogNode !== null) {
199
- fogNode.color.set(fog.color);
270
+ setSkyFogColor(fogNode, fog.color);
200
271
  fogNode.near = fog.near;
201
272
  fogNode.far = fog.far;
202
273
  }
203
274
  const skyMaterial = skyMaterialRef.current;
204
275
  if (skyMaterial !== null) {
205
- skyMaterial.uniforms.topColor.value.set(skyValue.zenithColor);
206
- skyMaterial.uniforms.bottomColor.value.set(skyValue.horizonColor);
276
+ setSkyDomeColors(skyMaterial, skyValue.zenithColor, skyValue.horizonColor);
277
+ aimSkySun(skyMaterial, base.sunPosition);
207
278
  }
279
+ sunDirectionRef.current = base.sunPosition;
208
280
  const sun = sunRef.current;
209
- if (sun !== null) {
210
- sun.position.set(base.sunPosition[0], base.sunPosition[1], base.sunPosition[2]);
281
+ if (sun !== null)
211
282
  sun.intensity = skyValue.sunIntensity;
212
- }
213
283
  const hemi = hemiRef.current;
214
284
  if (hemi !== null)
215
285
  hemi.intensity = skyValue.ambientIntensity;
216
286
  });
217
- return (_jsxs(_Fragment, { children: [_jsx(SkyDome, { topColor: initial.skyTop, horizonColor: initial.skyBottom, materialRef: skyMaterialRef, ...skyDomeShape(sky) }), sky.volumetricClouds === undefined ? null : (_jsx(VolumetricClouds, { rules: resolveVolumetricClouds(sky.volumetricClouds), sunDirection: initial.sunPosition })), _jsx("fog", { attach: "fog", ref: fogRef, args: [fogFallback.color, fogFallback.near, fogFallback.far] }), lights ? (_jsxs(_Fragment, { children: [_jsx("hemisphereLight", { ref: hemiRef, args: [HEMI_SKY, HEMI_GROUND, initial.ambientIntensity] }), _jsx("directionalLight", { ref: sunRef, position: initial.sunPosition, intensity: initial.sunIntensity, color: SUN_COLOR, castShadow: true })] })) : null] }));
287
+ return (_jsxs(_Fragment, { children: [_jsx(SkyDome, { topColor: initial.skyTop, horizonColor: initial.skyBottom, sunDirection: initial.sunPosition, sunColor: SUN_COLOR, materialRef: skyMaterialRef, ...skyDomeShape(sky) }), sky.volumetricClouds === undefined ? null : (_jsx(VolumetricClouds, { rules: resolveVolumetricClouds(sky.volumetricClouds), sunDirection: initial.sunPosition })), _jsx(SkyFog, { fogRef: fogRef, color: fogFallback.color, near: fogFallback.near, far: fogFallback.far }), lights ? (_jsxs(_Fragment, { children: [_jsx("hemisphereLight", { ref: hemiRef, args: [HEMI_SKY, HEMI_GROUND, initial.ambientIntensity] }), _jsx(SunLight, { direction: initial.sunPosition, directionRef: sunDirectionRef, lightRef: sunRef, intensity: initial.sunIntensity, color: SUN_COLOR })] })) : null] }));
218
288
  }
219
289
  function DrivenDaylight({ sky, clock, lights, }) {
220
290
  const initial = useMemo(() => daylightStateAt(clock.calendar().dayFraction, sky), [clock, sky]);
221
291
  const sunRef = useRef(null);
292
+ const sunDirectionRef = useRef(initial.sunPosition);
222
293
  const hemiRef = useRef(null);
223
294
  const fogRef = useRef(null);
224
295
  const skyMaterialRef = useRef(null);
225
296
  useFrame(() => {
226
297
  const state = daylightStateAt(clock.calendar().dayFraction, sky);
298
+ sunDirectionRef.current = state.sunPosition;
227
299
  const sun = sunRef.current;
228
- if (sun !== null) {
229
- sun.position.set(state.sunPosition[0], state.sunPosition[1], state.sunPosition[2]);
300
+ if (sun !== null)
230
301
  sun.intensity = state.sunIntensity;
231
- }
232
302
  const hemi = hemiRef.current;
233
303
  if (hemi !== null)
234
304
  hemi.intensity = state.ambientIntensity;
235
305
  const fog = fogRef.current;
236
306
  if (fog !== null)
237
- fog.color.set(sky.fog?.color ?? state.background);
307
+ setSkyFogColor(fog, sky.fog?.color ?? state.background);
238
308
  const skyMaterial = skyMaterialRef.current;
239
309
  if (skyMaterial !== null) {
240
- skyMaterial.uniforms.topColor.value.set(state.skyTop);
241
- skyMaterial.uniforms.bottomColor.value.set(state.skyBottom);
310
+ setSkyDomeColors(skyMaterial, state.skyTop, state.skyBottom);
311
+ aimSkySun(skyMaterial, state.sunPosition);
242
312
  }
243
313
  });
244
- return (_jsxs(_Fragment, { children: [_jsx(SkyDome, { topColor: initial.skyTop, horizonColor: initial.skyBottom, materialRef: skyMaterialRef, ...skyDomeShape(sky) }), sky.volumetricClouds === undefined ? null : (_jsx(VolumetricClouds, { rules: resolveVolumetricClouds(sky.volumetricClouds), sunDirection: initial.sunPosition })), _jsx("fog", { attach: "fog", ref: fogRef, args: [sky.fog?.color ?? initial.background, sky.fog?.near ?? 70, sky.fog?.far ?? 260] }), lights ? (_jsxs(_Fragment, { children: [_jsx("hemisphereLight", { ref: hemiRef, args: [HEMI_SKY, HEMI_GROUND, initial.ambientIntensity] }), _jsx("directionalLight", { ref: sunRef, position: initial.sunPosition, intensity: initial.sunIntensity, color: SUN_COLOR, castShadow: true })] })) : null] }));
314
+ return (_jsxs(_Fragment, { children: [_jsx(SkyDome, { topColor: initial.skyTop, horizonColor: initial.skyBottom, sunDirection: initial.sunPosition, sunColor: SUN_COLOR, materialRef: skyMaterialRef, ...skyDomeShape(sky) }), sky.volumetricClouds === undefined ? null : (_jsx(VolumetricClouds, { rules: resolveVolumetricClouds(sky.volumetricClouds), sunDirection: initial.sunPosition })), _jsx(SkyFog, { fogRef: fogRef, color: sky.fog?.color ?? initial.background, near: sky.fog?.near ?? 70, far: sky.fog?.far ?? 260 }), lights ? (_jsxs(_Fragment, { children: [_jsx("hemisphereLight", { ref: hemiRef, args: [HEMI_SKY, HEMI_GROUND, initial.ambientIntensity] }), _jsx(SunLight, { direction: initial.sunPosition, directionRef: sunDirectionRef, lightRef: sunRef, intensity: initial.sunIntensity, color: SUN_COLOR })] })) : null] }));
245
315
  }
@@ -7,7 +7,8 @@ import { useDisposable } from "../render/useDisposable.js";
7
7
  * Camera-centered proxy dome radius. Must sit inside the default camera far plane
8
8
  * (`CAMERA_FRUSTUM_DEFAULTS.far` = 300) — a bigger sphere is clipped whole and renders nothing. The
9
9
  * raymarch works in world space against the absolute cloud slab, so this size only has to cover the
10
- * screen (camera is always at the dome center), never bound the clouds.
10
+ * screen (camera is always at the dome center), never bound the clouds; each fragment writes its own
11
+ * depth at the first cloud hit, so the dome's radius never decides what occludes the clouds.
11
12
  */
12
13
  const INSIDE_FRUSTUM_DOME_RADIUS = 240;
13
14
  function hashSeed(text) {
@@ -43,8 +44,18 @@ uniform float uScale;
43
44
  uniform float uSpeed;
44
45
  uniform float uTime;
45
46
  uniform float uSeedOffset;
47
+ uniform mat4 uProjection;
48
+ uniform mat4 uView;
46
49
  varying vec3 vJgCloudWorldPos;
47
50
 
51
+ // Depth-buffer value of a world point, so the proxy dome tests against the scene at the cloud's
52
+ // own distance instead of the dome's: a tower in front of the slab entry then occludes the cloud.
53
+ float jgCloudDepthAt(vec3 worldPos){
54
+ vec4 clip = uProjection * uView * vec4(worldPos, 1.0);
55
+ float ndc = clip.z / max(clip.w, 1e-5);
56
+ return clamp(ndc * 0.5 + 0.5, 0.0, 0.999999);
57
+ }
58
+
48
59
  float jgCloudHash13(vec3 p){
49
60
  p = fract(p * 0.1031 + uSeedOffset * 0.0007);
50
61
  p += dot(p, p.yzx + 33.33);
@@ -128,11 +139,13 @@ void main() {
128
139
  vec3 color = vec3(0.0);
129
140
  float sunDot = max(dot(rayDir, normalize(uSunDirection)), 0.0);
130
141
  float silver = pow(sunDot, 5.0) * uSunScatter;
142
+ float tHit = -1.0;
131
143
 
132
144
  for (int i = 0; i < STEPS; i++) {
133
145
  vec3 pos = rayOrigin + rayDir * t;
134
146
  float dens = jgCloudDensity(pos);
135
147
  if (dens > 0.002) {
148
+ if (tHit < 0.0) tHit = t;
136
149
  float light = jgCloudLight(pos);
137
150
  float powder = 1.0 - exp(-dens * 3.0);
138
151
  vec3 lit = mix(uColor, uSunColor, light) * (0.55 + 0.45 * powder) + uSunColor * silver * 0.5;
@@ -146,7 +159,8 @@ void main() {
146
159
  }
147
160
 
148
161
  float alpha = (1.0 - transmittance) * smoothstep(0.015, 0.22, rayDir.y);
149
- if (alpha < 0.01) discard;
162
+ if (alpha < 0.01 || tHit < 0.0) discard;
163
+ gl_FragDepthEXT = jgCloudDepthAt(rayOrigin + rayDir * tHit);
150
164
  gl_FragColor = vec4(color, alpha);
151
165
  }
152
166
  `;
@@ -178,6 +192,8 @@ export function VolumetricClouds({ rules, sunDirection }) {
178
192
  uSpeed: { value: rules.speed },
179
193
  uTime: { value: 0 },
180
194
  uSeedOffset: { value: hashSeed(rules.seed) },
195
+ uProjection: { value: new THREE.Matrix4() },
196
+ uView: { value: new THREE.Matrix4() },
181
197
  },
182
198
  vertexShader: CLOUD_VERTEX_SHADER,
183
199
  fragmentShader: CLOUD_FRAGMENT_SHADER,
@@ -196,6 +212,8 @@ export function VolumetricClouds({ rules, sunDirection }) {
196
212
  }
197
213
  const uniforms = material.uniforms;
198
214
  uniforms.uCameraPos.value.copy(state.camera.position);
215
+ uniforms.uProjection.value.copy(state.camera.projectionMatrix);
216
+ uniforms.uView.value.copy(state.camera.matrixWorldInverse);
199
217
  uniforms.uTime.value = timeRef.current;
200
218
  });
201
219
  return (_jsx("mesh", { ref: meshRef, material: material, renderOrder: -0.5, frustumCulled: false, children: _jsx("sphereGeometry", { args: [INSIDE_FRUSTUM_DOME_RADIUS, 32, 16] }) }));
@@ -1,8 +1,11 @@
1
+ import type { SkySunConfig } from "@jgengine/core/world/features";
1
2
  export interface DaylightCycleConfig {
2
3
  horizonColor?: string;
3
4
  zenithColor?: string;
4
5
  sunIntensity?: number;
5
6
  ambientIntensity?: number;
7
+ /** Fixed sun placement, or the noon placement the day arc swings around. Unset keeps the engine arc. */
8
+ sun?: SkySunConfig;
6
9
  }
7
10
  export interface DaylightState {
8
11
  sunPosition: [number, number, number];
@@ -16,6 +19,23 @@ export declare const DEFAULT_DAY_SUN_INTENSITY = 1;
16
19
  export declare const DEFAULT_DAY_AMBIENT_INTENSITY = 0.6;
17
20
  export declare const DEFAULT_DAY_SKY_TOP = "#3fa4f2";
18
21
  export declare const DEFAULT_DAY_SKY_BOTTOM = "#e3f4ff";
22
+ /** The engine arc's noon height: `atan(1 / SUN_DEPTH_RATIO)`, ~68 degrees. */
23
+ export declare const DEFAULT_SUN_ELEVATION_DEG: number;
24
+ /**
25
+ * Unit direction toward a sun at compass `azimuth` (0 = -Z, 90 = +X) and `elevation` above the
26
+ * horizon, both in degrees. Shared by the dome glow, the sky-owned sun light, and the editor.
27
+ * @internal
28
+ */
29
+ export declare function sunDirectionFromBearing(azimuth: number, elevation: number): [number, number, number];
30
+ /**
31
+ * Inverse of {@link sunDirectionFromBearing}: the bearing/elevation (degrees) of a world-space
32
+ * direction such as an authored directional light's position. A zero vector reads as overhead.
33
+ * @internal
34
+ */
35
+ export declare function bearingFromDirection(direction: readonly [number, number, number]): {
36
+ azimuth: number;
37
+ elevation: number;
38
+ };
19
39
  export declare const SKY_PRESET_DAY_FRACTION: Record<"day" | "dusk" | "night", number>;
20
40
  /** @internal */
21
41
  export declare function lerpHexColor(a: string, b: string, t: number): string;
@@ -16,6 +16,34 @@ export const DEFAULT_DAY_SKY_TOP = "#3fa4f2";
16
16
  export const DEFAULT_DAY_SKY_BOTTOM = "#e3f4ff";
17
17
  const SUN_DISTANCE = 200;
18
18
  const SUN_DEPTH_RATIO = 0.4;
19
+ const DEG = Math.PI / 180;
20
+ /** The engine arc's noon height: `atan(1 / SUN_DEPTH_RATIO)`, ~68 degrees. */
21
+ export const DEFAULT_SUN_ELEVATION_DEG = Math.atan2(1, SUN_DEPTH_RATIO) / DEG;
22
+ /**
23
+ * Unit direction toward a sun at compass `azimuth` (0 = -Z, 90 = +X) and `elevation` above the
24
+ * horizon, both in degrees. Shared by the dome glow, the sky-owned sun light, and the editor.
25
+ * @internal
26
+ */
27
+ export function sunDirectionFromBearing(azimuth, elevation) {
28
+ const az = azimuth * DEG;
29
+ const el = elevation * DEG;
30
+ const flat = Math.cos(el);
31
+ return [Math.sin(az) * flat, Math.sin(el), -Math.cos(az) * flat];
32
+ }
33
+ /**
34
+ * Inverse of {@link sunDirectionFromBearing}: the bearing/elevation (degrees) of a world-space
35
+ * direction such as an authored directional light's position. A zero vector reads as overhead.
36
+ * @internal
37
+ */
38
+ export function bearingFromDirection(direction) {
39
+ const [x, y, z] = direction;
40
+ const length = Math.hypot(x, y, z);
41
+ if (length === 0)
42
+ return { azimuth: 0, elevation: 90 };
43
+ const azimuth = ((Math.atan2(x, -z) / DEG) + 360) % 360;
44
+ const elevation = Math.asin(Math.max(-1, Math.min(1, y / length))) / DEG;
45
+ return { azimuth, elevation };
46
+ }
19
47
  export const SKY_PRESET_DAY_FRACTION = {
20
48
  night: 0,
21
49
  day: 0.5,
@@ -106,13 +134,37 @@ export function daylightStateAt(dayFraction, config = {}) {
106
134
  const ambientIntensity = lerp(from.ambientIntensity, to.ambientIntensity, localT);
107
135
  const skyTop = lerpHexColor(from.skyTop, to.skyTop, localT);
108
136
  const skyBottom = lerpHexColor(from.skyBottom, to.skyBottom, localT);
137
+ const sunPosition = sunPositionAt(wrapped, config.sun);
138
+ return { sunPosition, sunIntensity, ambientIntensity, skyTop, skyBottom, background: skyBottom };
139
+ }
140
+ /**
141
+ * Sun position for a wrapped day fraction. Without `sun`, the legacy arc: east at dawn, ~68 degrees
142
+ * up at noon, west at dusk, tilted 0.4 toward +Z. With `sun`, the arc is rotated so noon lands
143
+ * exactly on the authored bearing and elevation and the sun still rises 90 degrees before it and
144
+ * sets 90 degrees after it, so a fixed preset (noon, dusk, night fractions) samples the same arc.
145
+ */
146
+ function sunPositionAt(wrapped, sun) {
109
147
  const angle = (wrapped - 0.25) * 2 * Math.PI;
110
- const elevation = Math.sin(angle);
111
- const azimuth = Math.cos(angle);
112
- const sunPosition = [
113
- azimuth * SUN_DISTANCE,
114
- elevation * SUN_DISTANCE,
115
- SUN_DISTANCE * SUN_DEPTH_RATIO,
148
+ const along = Math.cos(angle);
149
+ const up = Math.sin(angle);
150
+ if (sun === undefined) {
151
+ return [along * SUN_DISTANCE, up * SUN_DISTANCE, SUN_DISTANCE * SUN_DEPTH_RATIO];
152
+ }
153
+ const noon = sunDirectionFromBearing(sun.azimuth ?? 0, sun.elevation ?? DEFAULT_SUN_ELEVATION_DEG);
154
+ const horizon = normalize([noon[0], 0, noon[2]], [0, 0, -1]);
155
+ const east = [-horizon[2], 0, horizon[0]];
156
+ const zenith = normalize(cross(east, noon), [0, 1, 0]);
157
+ const rise = normalize(cross(noon, zenith), east);
158
+ return [
159
+ (rise[0] * along + noon[0] * up) * SUN_DISTANCE,
160
+ (rise[1] * along + noon[1] * up) * SUN_DISTANCE,
161
+ (rise[2] * along + noon[2] * up) * SUN_DISTANCE,
116
162
  ];
117
- return { sunPosition, sunIntensity, ambientIntensity, skyTop, skyBottom, background: skyBottom };
163
+ }
164
+ function cross(a, b) {
165
+ 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]];
166
+ }
167
+ function normalize(v, fallback) {
168
+ const length = Math.hypot(v[0], v[1], v[2]);
169
+ return length < 1e-6 ? [fallback[0], fallback[1], fallback[2]] : [v[0] / length, v[1] / length, v[2] / length];
118
170
  }