@jgengine/shell 0.25.0 → 0.27.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 (74) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/dist/GamePlayerShell.d.ts +8 -0
  3. package/dist/GamePlayerShell.js +15 -1
  4. package/dist/Shell3dPresentation.js +29 -11
  5. package/dist/devtools/FrameTelemetry.d.ts +12 -0
  6. package/dist/devtools/FrameTelemetry.js +68 -0
  7. package/dist/devtools/agentBridge.js +2 -0
  8. package/dist/devtools/devtoolsReports.d.ts +2 -0
  9. package/dist/devtools/devtoolsReports.js +3 -0
  10. package/dist/devtools/frameSampler.d.ts +127 -0
  11. package/dist/devtools/frameSampler.js +345 -0
  12. package/dist/environment/AuthoredDaylight.d.ts +28 -0
  13. package/dist/environment/AuthoredDaylight.js +42 -0
  14. package/dist/environment/Daylight.d.ts +55 -7
  15. package/dist/environment/Daylight.js +126 -29
  16. package/dist/environment/EnvironmentScene.js +51 -8
  17. package/dist/environment/daylightCycle.d.ts +26 -4
  18. package/dist/environment/daylightCycle.js +36 -5
  19. package/dist/postfx/PostProcessing.d.ts +7 -1
  20. package/dist/postfx/PostProcessing.js +28 -2
  21. package/dist/postfx/autoExposure.d.ts +49 -0
  22. package/dist/postfx/autoExposure.js +189 -0
  23. package/dist/postfx/gradeShader.js +43 -4
  24. package/dist/presentationRecovery3d.js +2 -1
  25. package/dist/render/CascadedShadows.d.ts +3 -1
  26. package/dist/render/CascadedShadows.js +9 -1
  27. package/dist/render/EnvironmentLighting.d.ts +10 -1
  28. package/dist/render/EnvironmentLighting.js +11 -3
  29. package/dist/render/IrradianceVolumeLighting.d.ts +18 -0
  30. package/dist/render/IrradianceVolumeLighting.js +35 -0
  31. package/dist/render/SceneLighting.d.ts +14 -1
  32. package/dist/render/SceneLighting.js +29 -8
  33. package/dist/render/authoredGrade.d.ts +11 -0
  34. package/dist/render/authoredGrade.js +26 -0
  35. package/dist/render/decalGeometry.d.ts +26 -0
  36. package/dist/render/decalGeometry.js +54 -0
  37. package/dist/render/decalTexture.d.ts +13 -0
  38. package/dist/render/decalTexture.js +114 -0
  39. package/dist/render/modelWind.d.ts +28 -0
  40. package/dist/render/modelWind.js +103 -0
  41. package/dist/render/presentationLook.d.ts +15 -0
  42. package/dist/render/presentationLook.js +18 -0
  43. package/dist/render/stableTextureSet.d.ts +9 -0
  44. package/dist/render/stableTextureSet.js +24 -0
  45. package/dist/render/triplanarMaterial.d.ts +52 -0
  46. package/dist/render/triplanarMaterial.js +169 -0
  47. package/dist/render/useModelInstance.d.ts +1 -1
  48. package/dist/render/useModelInstance.js +3 -1
  49. package/dist/scatter/scatterModels.js +2 -0
  50. package/dist/scene/DecalRenderer.d.ts +2 -0
  51. package/dist/scene/DecalRenderer.js +74 -0
  52. package/dist/scene/GrassFieldRenderer.js +3 -2
  53. package/dist/scene/builtinSceneKindRenderers.js +2 -0
  54. package/dist/settings/appliedSettings.js +16 -13
  55. package/dist/terrain/GrassCover.d.ts +21 -0
  56. package/dist/terrain/GrassCover.js +84 -0
  57. package/dist/terrain/grassCoverGeometry.d.ts +40 -0
  58. package/dist/terrain/grassCoverGeometry.js +112 -0
  59. package/dist/terrain/terrainDetailMaterial.d.ts +12 -0
  60. package/dist/terrain/terrainDetailMaterial.js +54 -1
  61. package/dist/terrain/terrainSurfaces.d.ts +6 -0
  62. package/dist/terrain/terrainSurfaces.js +10 -0
  63. package/dist/world/WorldScene.js +2 -1
  64. package/package.json +4 -4
  65. package/skills/jgengine/api.md +59 -6
  66. package/skills/jgengine/capabilities.md +4 -0
  67. package/skills/jgengine-ui/api.md +114 -23
  68. package/skills/jgengine-ui/capabilities.md +14 -0
  69. package/skills/jgengine-ui/reference-primitives.md +4 -2
  70. package/skills/jgengine-world/SKILL.md +7 -1
  71. package/skills/jgengine-world/api.md +222 -8
  72. package/skills/jgengine-world/capabilities.md +42 -2
  73. package/skills/jgengine-world/recipes/npc-senses.md +8 -2
  74. package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
@@ -1,6 +1,6 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useFrame, useThree } from "@react-three/fiber";
3
- import { useEffect, useLayoutEffect, useMemo, useRef } from "react";
3
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } 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";
@@ -17,15 +17,23 @@ const SKY_TOP = "#3fa4f2";
17
17
  const SKY_HORIZON = "#e3f4ff";
18
18
  const FOG_COLOR = "#e9f6ff";
19
19
  const SUN_COLOR = "#fff1c9";
20
+ const MOON_COLOR = "#cfdcf2";
20
21
  const HEMI_SKY = "#bfe3ff";
21
22
  const HEMI_GROUND = "#4c6b34";
23
+ /** Aim the dome's moon disc and dial its brightness; called per frame by the time-of-day drivers. @internal */
24
+ export function setSkyMoon(material, direction, color, intensity, starOpacity) {
25
+ material.uniforms.uMoonDirection.value.set(direction[0], direction[1], direction[2]).normalize();
26
+ material.uniforms.uMoonColor.value.set(color);
27
+ material.uniforms.uMoonIntensity.value = intensity;
28
+ material.uniforms.uStars.value = starOpacity;
29
+ }
22
30
  /**
23
31
  * Shadow-casting sun whose shadow box leads the camera each frame ({@link sunShadowFocus}), so
24
32
  * grounded shadows stay crisp and soft-edged under the player anywhere in a large world. Time-of-day
25
33
  * drivers aim it through `directionRef` and retint it through `lightRef`.
26
34
  * @internal
27
35
  */
28
- export function SunLight({ direction, directionRef, lightRef, intensity, color, }) {
36
+ export function SunLight({ direction, directionRef, lightRef, intensity, color, castShadow = true, }) {
29
37
  const ownRef = useRef(null);
30
38
  const ref = lightRef ?? ownRef;
31
39
  const scratch = useMemo(() => ({ forward: new THREE.Vector3(), focus: { x: 0, z: 0 }, position: [0, 0, 0] }), []);
@@ -41,7 +49,15 @@ export function SunLight({ direction, directionRef, lightRef, intensity, color,
41
49
  light.target.updateMatrixWorld();
42
50
  });
43
51
  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 }));
52
+ return (_jsx("directionalLight", { ref: ref, position: initial, intensity: intensity, color: color, castShadow: castShadow, "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 }));
53
+ }
54
+ /**
55
+ * The moon as a dim, shadowless key light opposite the sun, so a night scene keeps a lit and a
56
+ * shaded side instead of collapsing into flat ambient. Same camera-following aim as the sun.
57
+ * @internal
58
+ */
59
+ export function MoonLight({ direction, directionRef, lightRef, intensity, color }) {
60
+ return _jsx(SunLight, { direction: direction, directionRef: directionRef, lightRef: lightRef, intensity: intensity, color: color, castShadow: false });
45
61
  }
46
62
  /**
47
63
  * Sets the zenith and horizon colors a sky dome should show on screen. The dome converts them each
@@ -87,7 +103,7 @@ export function setSkyFogColor(fog, color) {
87
103
  }
88
104
  const fogDisplayColors = new WeakMap();
89
105
  /** @internal */
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, } = {}) {
106
+ export function SkyDome({ topColor = SKY_TOP, horizonColor = SKY_HORIZON, radius = 260, offset = 24, exponent = 0.65, sunDirection, sunColor = "#fff4d6", sunIntensity = 1, moonDirection, moonColor = MOON_COLOR, moonIntensity = 0, starOpacity = 0, hazeStrength = 0.5, sunGlowStrength = 1, cloudiness = 0.35, materialRef, } = {}) {
91
107
  const meshRef = useRef(null);
92
108
  const material = useMemo(() => {
93
109
  const dome = new THREE.ShaderMaterial({
@@ -99,6 +115,10 @@ export function SkyDome({ topColor = SKY_TOP, horizonColor = SKY_HORIZON, radius
99
115
  uSunColor: { value: new THREE.Color(sunColor) },
100
116
  uSunDirection: { value: sunDirection === undefined ? new THREE.Vector3(0, 1, 0) : normalizeVec3(sunDirection) },
101
117
  uSunIntensity: { value: sunDirection === undefined ? 0 : sunIntensity },
118
+ uMoonColor: { value: new THREE.Color(moonColor) },
119
+ uMoonDirection: { value: moonDirection === undefined ? new THREE.Vector3(0, -1, 0) : normalizeVec3(moonDirection) },
120
+ uMoonIntensity: { value: moonDirection === undefined ? 0 : moonIntensity },
121
+ uStars: { value: starOpacity },
102
122
  uHazeStrength: { value: hazeStrength },
103
123
  uSunGlow: { value: sunGlowStrength },
104
124
  uCloudiness: { value: cloudiness },
@@ -117,6 +137,10 @@ export function SkyDome({ topColor = SKY_TOP, horizonColor = SKY_HORIZON, radius
117
137
  uniform vec3 uSunColor;
118
138
  uniform vec3 uSunDirection;
119
139
  uniform float uSunIntensity;
140
+ uniform vec3 uMoonColor;
141
+ uniform vec3 uMoonDirection;
142
+ uniform float uMoonIntensity;
143
+ uniform float uStars;
120
144
  uniform float uHazeStrength;
121
145
  uniform float uSunGlow;
122
146
  uniform float uCloudiness;
@@ -130,6 +154,16 @@ export function SkyDome({ topColor = SKY_TOP, horizonColor = SKY_HORIZON, radius
130
154
  mix(sHash(i + vec2(0.0, 1.0)), sHash(i + vec2(1.0, 1.0)), u.x), u.y);
131
155
  }
132
156
  float sFbm(vec2 p){ float v = 0.0; float a = 0.5; for (int i = 0; i < 5; i++){ v += a * sNoise(p); p *= 2.0; a *= 0.5; } return v; }
157
+ float h3(vec3 p){ return fract(sin(dot(p, vec3(12.9898, 78.233, 37.719))) * 43758.5453); }
158
+ float starField(vec3 dir){
159
+ vec3 q = dir * 170.0;
160
+ vec3 cell = floor(q);
161
+ float seed = h3(cell);
162
+ if (seed < 0.984) return 0.0;
163
+ vec3 jitter = fract(vec3(seed * 137.0, seed * 311.0, seed * 531.0)) - 0.5;
164
+ float d = length(fract(q) - 0.5 - jitter * 0.7);
165
+ return smoothstep(0.18, 0.0, d) * smoothstep(0.984, 0.999, seed);
166
+ }
133
167
  void main() {
134
168
  vec3 dir = normalize(vWorldPosition + vec3(0.0, offset, 0.0));
135
169
  float h = max(dir.y, 0.0);
@@ -143,6 +177,16 @@ export function SkyDome({ topColor = SKY_TOP, horizonColor = SKY_HORIZON, radius
143
177
  float sd = max(dot(vd, normalize(uSunDirection)), 0.0);
144
178
  float glow = pow(sd, 8.0) * 0.35 + pow(sd, 128.0) * 2.6;
145
179
  col += uSunColor * glow * uSunIntensity * uSunGlow;
180
+ if (uStars > 0.001) {
181
+ float stars = starField(vd) * smoothstep(0.02, 0.22, vd.y) * smoothstep(0.35, 0.9, uStars);
182
+ col += vec3(0.86, 0.9, 1.0) * stars * uStars * 1.6;
183
+ }
184
+ if (uMoonIntensity > 0.001) {
185
+ float md = max(dot(vd, normalize(uMoonDirection)), 0.0);
186
+ float disc = smoothstep(0.99955, 0.99985, md);
187
+ float halo = pow(md, 512.0) * 0.5 + pow(md, 24.0) * 0.05;
188
+ col += uMoonColor * (disc * 2.2 + halo) * uMoonIntensity;
189
+ }
146
190
  gl_FragColor = vec4(col, 1.0);
147
191
  #include <tonemapping_fragment>
148
192
  #include <colorspace_fragment>
@@ -154,7 +198,7 @@ export function SkyDome({ topColor = SKY_TOP, horizonColor = SKY_HORIZON, radius
154
198
  });
155
199
  setSkyDomeColors(dome, topColor, horizonColor);
156
200
  return dome;
157
- }, [topColor, horizonColor, offset, exponent, sunColor, sunDirection, sunIntensity, hazeStrength, sunGlowStrength, cloudiness]);
201
+ }, [topColor, horizonColor, offset, exponent, sunColor, sunDirection, sunIntensity, moonDirection, moonColor, moonIntensity, starOpacity, hazeStrength, sunGlowStrength, cloudiness]);
158
202
  useEffect(() => {
159
203
  if (materialRef !== undefined)
160
204
  materialRef.current = material;
@@ -189,21 +233,46 @@ function aimSkySun(material, position) {
189
233
  material.uniforms.uSunDirection.value.set(position[0], position[1], position[2]).normalize();
190
234
  }
191
235
  /** @internal */
192
- export function Daylight({ sky, fog, sun, ambient, lights = true, clouds } = {}) {
236
+ export function Daylight({ sky, fog, sun, ambient, moon, lights = true, clouds } = {}) {
193
237
  const sunPosition = sun?.position ?? [120, 160, 70];
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] }));
238
+ const moonIntensity = moon?.intensity ?? 0;
239
+ return (_jsxs(_Fragment, { children: [sky === false ? null : (_jsx(SkyDome, { ...(sky ?? {}), sunDirection: sunPosition, sunColor: sun?.color ?? SUN_COLOR, sunIntensity: sun?.intensity ?? 1, ...(moon?.position === undefined ? {} : { moonDirection: moon.position }), ...(moon?.color === undefined ? {} : { moonColor: moon.color }), moonIntensity: moonIntensity, starOpacity: moon?.stars ?? 0 })), 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 }), moonIntensity > 0 && moon?.position !== undefined ? (_jsx(MoonLight, { direction: moon.position, intensity: moonIntensity, color: moon.color ?? MOON_COLOR })) : null] })) : null] }));
195
240
  }
196
241
  /** True when at least one band overrides fog or sky — the trigger for the per-frame biome driver. */
197
242
  function bandsDriveSkyOrFog(bands) {
198
243
  return bands !== undefined && bands.some((band) => band.fog !== undefined || band.sky !== undefined);
199
244
  }
245
+ /**
246
+ * Mount state for the moon: it re-renders only when the moon crosses the horizon. A zero-intensity
247
+ * directional light still costs a per-fragment loop in every lit material, so a day sky must not
248
+ * carry it.
249
+ * @internal
250
+ */
251
+ export function useMoonAboveHorizon(initialUp) {
252
+ const [up, setUp] = useState(initialUp);
253
+ const upRef = useRef(up);
254
+ return [
255
+ up,
256
+ (intensity) => {
257
+ const next = intensity > 0;
258
+ if (next === upRef.current)
259
+ return;
260
+ upRef.current = next;
261
+ setUp(next);
262
+ },
263
+ ];
264
+ }
265
+ /** Fog range: the sky's own fields win, then the authored backdrop, then the caller's default. */
266
+ function skyFogRange(sky, fogRange, fallback) {
267
+ return { near: sky.fog?.near ?? fogRange?.near ?? fallback.near, far: sky.fog?.far ?? fogRange?.far ?? fallback.far };
268
+ }
200
269
  /** Renders a fixed sky/sun/fog look sampled from `sky`'s preset (or, when `timeOfDay` is on but no clock drives it, its noon look). No per-frame updates.
201
270
  * @internal
202
271
  */
203
- export function SkyDaylight({ sky, lights = true, bands }) {
272
+ export function SkyDaylight({ sky, lights = true, bands, fogRange }) {
204
273
  if (bandsDriveSkyOrFog(bands))
205
- return _jsx(BiomeDaylight, { sky: sky, bands: bands, lights: lights });
206
- return _jsx(StaticSkyDaylight, { sky: sky, lights: lights });
274
+ return _jsx(BiomeDaylight, { sky: sky, bands: bands, lights: lights, fogRange: fogRange });
275
+ return _jsx(StaticSkyDaylight, { sky: sky, lights: lights, fogRange: fogRange });
207
276
  }
208
277
  /** Dome-shape overrides (radius, haze, sun-glow) carried by the sky descriptor, spread onto `SkyDome`; unset fields keep the dome defaults. */
209
278
  function skyDomeShape(sky) {
@@ -215,43 +284,48 @@ function skyDomeShape(sky) {
215
284
  ...(sky.cloudiness === undefined ? {} : { cloudiness: sky.cloudiness }),
216
285
  };
217
286
  }
218
- function StaticSkyDaylight({ sky, lights }) {
287
+ function StaticSkyDaylight({ sky, lights, fogRange, }) {
219
288
  const state = useMemo(() => daylightStateAt(SKY_PRESET_DAY_FRACTION[sky.preset], sky), [sky]);
220
- return (_jsx(Daylight, { sky: { topColor: state.skyTop, horizonColor: state.skyBottom, ...skyDomeShape(sky) }, fog: { color: sky.fog?.color ?? state.background, near: sky.fog?.near, far: sky.fog?.far }, sun: { position: state.sunPosition, intensity: state.sunIntensity }, ambient: { intensity: state.ambientIntensity }, lights: lights, clouds: sky.volumetricClouds }));
289
+ const range = skyFogRange(sky, fogRange, { near: 70, far: 260 });
290
+ return (_jsx(Daylight, { sky: { topColor: state.skyTop, horizonColor: state.skyBottom, ...skyDomeShape(sky) }, fog: { color: sky.fog?.color ?? state.fogColor, ...range }, sun: { position: state.sunPosition, intensity: state.sunIntensity, color: state.sunColor }, moon: { position: state.moonPosition, intensity: state.moonIntensity, color: state.moonColor, stars: state.starOpacity }, ambient: { intensity: state.ambientIntensity }, lights: lights, clouds: sky.volumetricClouds }));
221
291
  }
222
292
  /**
223
293
  * Drives sky/fog (and optional default lights) from the world clock when `sky.timeOfDay` and `clock`
224
294
  * are both present. Authored `PlayableGame.lighting` is never rewritten — pass `lights={false}` so
225
- * only dome colors and fog track the day fraction.
295
+ * only dome colors and fog track the day fraction; `AuthoredDaylight` scales those authored lights
296
+ * from the same state.
226
297
  * @internal
227
298
  */
228
- export function TimeOfDayDaylight({ sky, clock, lights = true, bands }) {
299
+ export function TimeOfDayDaylight({ sky, clock, lights = true, bands, fogRange }) {
229
300
  if (bandsDriveSkyOrFog(bands))
230
- return _jsx(BiomeDaylight, { sky: sky, bands: bands, clock: clock, lights: lights });
301
+ return _jsx(BiomeDaylight, { sky: sky, bands: bands, clock: clock, lights: lights, fogRange: fogRange });
231
302
  if (!sky.timeOfDay || clock === undefined)
232
- return _jsx(SkyDaylight, { sky: sky, lights: lights });
233
- return _jsx(DrivenDaylight, { sky: sky, clock: clock, lights: lights });
303
+ return _jsx(SkyDaylight, { sky: sky, lights: lights, bands: bands, fogRange: fogRange });
304
+ return _jsx(DrivenDaylight, { sky: sky, clock: clock, lights: lights, fogRange: fogRange });
234
305
  }
235
306
  /**
236
307
  * Per-frame biome fog/sky driver: cross-fades fog color/range, dome colors, and sun/ambient intensity
237
308
  * along the camera's z through the terrain's `biomeBands`. Base look is the sky preset (or, when
238
309
  * `sky.timeOfDay` and a `clock` are present, the live day fraction); bands ride on top.
239
310
  */
240
- function BiomeDaylight({ sky, bands, clock, lights, }) {
311
+ function BiomeDaylight({ sky, bands, clock, lights, fogRange, }) {
241
312
  const timeOfDay = sky.timeOfDay && clock !== undefined;
242
313
  const baseFraction = timeOfDay ? clock.calendar().dayFraction : SKY_PRESET_DAY_FRACTION[sky.preset];
243
314
  const initial = useMemo(() => daylightStateAt(baseFraction, sky), [sky, baseFraction]);
244
315
  const sunRef = useRef(null);
245
316
  const sunDirectionRef = useRef(initial.sunPosition);
317
+ const moonRef = useRef(null);
318
+ const moonDirectionRef = useRef(initial.moonPosition);
246
319
  const hemiRef = useRef(null);
247
320
  const fogRef = useRef(null);
248
321
  const skyMaterialRef = useRef(null);
322
+ const range = skyFogRange(sky, fogRange, { near: 70, far: 260 });
249
323
  const fogFallback = useMemo(() => ({
250
- color: sky.fog?.color ?? initial.background,
251
- near: sky.fog?.near ?? 70,
252
- far: sky.fog?.far ?? 260,
324
+ color: sky.fog?.color ?? initial.fogColor,
325
+ near: range.near,
326
+ far: range.far,
253
327
  density: 0,
254
- }), [sky.fog?.color, sky.fog?.near, sky.fog?.far, initial.background]);
328
+ }), [sky.fog?.color, range.near, range.far, initial.fogColor]);
255
329
  const skyFallback = useMemo(() => ({
256
330
  horizonColor: initial.skyBottom,
257
331
  zenithColor: initial.skyTop,
@@ -260,14 +334,18 @@ function BiomeDaylight({ sky, bands, clock, lights, }) {
260
334
  }), [initial.skyBottom, initial.skyTop, initial.sunIntensity, initial.ambientIntensity]);
261
335
  const fogSampler = useMemo(() => createBiomeFogSampler(bands, fogFallback), [bands, fogFallback]);
262
336
  const skySampler = useMemo(() => createBiomeSkySampler(bands, skyFallback), [bands, skyFallback]);
337
+ const [moonUp, syncMoon] = useMoonAboveHorizon(initial.moonIntensity > 0);
263
338
  useFrame((state) => {
264
339
  const base = timeOfDay ? daylightStateAt(clock.calendar().dayFraction, sky) : initial;
340
+ syncMoon(base.moonIntensity);
265
341
  const z = state.camera.position.z;
266
342
  const fog = fogSampler(z);
267
343
  const skyValue = skySampler(z);
268
344
  const fogNode = fogRef.current;
269
345
  if (fogNode !== null) {
270
- setSkyFogColor(fogNode, fog.color);
346
+ // Under a day cycle the sky owns the fog color (bands still own its range): a fixed noon
347
+ // tint would leave bright haze hanging in a black night sky.
348
+ setSkyFogColor(fogNode, sky.fog?.color ?? (timeOfDay ? base.fogColor : fog.color));
271
349
  fogNode.near = fog.near;
272
350
  fogNode.far = fog.far;
273
351
  }
@@ -275,41 +353,60 @@ function BiomeDaylight({ sky, bands, clock, lights, }) {
275
353
  if (skyMaterial !== null) {
276
354
  setSkyDomeColors(skyMaterial, skyValue.zenithColor, skyValue.horizonColor);
277
355
  aimSkySun(skyMaterial, base.sunPosition);
356
+ setSkyMoon(skyMaterial, base.moonPosition, base.moonColor, base.moonIntensity, base.starOpacity);
278
357
  }
279
358
  sunDirectionRef.current = base.sunPosition;
359
+ moonDirectionRef.current = base.moonPosition;
360
+ const moon = moonRef.current;
361
+ if (moon !== null)
362
+ moon.intensity = base.moonIntensity;
280
363
  const sun = sunRef.current;
281
- if (sun !== null)
364
+ if (sun !== null) {
282
365
  sun.intensity = skyValue.sunIntensity;
366
+ sun.color.set(base.sunColor);
367
+ }
283
368
  const hemi = hemiRef.current;
284
369
  if (hemi !== null)
285
370
  hemi.intensity = skyValue.ambientIntensity;
286
371
  });
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] }));
372
+ return (_jsxs(_Fragment, { children: [_jsx(SkyDome, { topColor: initial.skyTop, horizonColor: initial.skyBottom, sunDirection: initial.sunPosition, sunColor: initial.sunColor, materialRef: skyMaterialRef, moonDirection: initial.moonPosition, moonColor: initial.moonColor, moonIntensity: initial.moonIntensity, starOpacity: initial.starOpacity, ...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: initial.sunColor }), moonUp ? (_jsx(MoonLight, { direction: initial.moonPosition, directionRef: moonDirectionRef, lightRef: moonRef, intensity: initial.moonIntensity, color: initial.moonColor })) : null] })) : null] }));
288
373
  }
289
- function DrivenDaylight({ sky, clock, lights, }) {
374
+ function DrivenDaylight({ sky, clock, lights, fogRange, }) {
290
375
  const initial = useMemo(() => daylightStateAt(clock.calendar().dayFraction, sky), [clock, sky]);
291
376
  const sunRef = useRef(null);
292
377
  const sunDirectionRef = useRef(initial.sunPosition);
378
+ const moonRef = useRef(null);
379
+ const moonDirectionRef = useRef(initial.moonPosition);
293
380
  const hemiRef = useRef(null);
294
381
  const fogRef = useRef(null);
295
382
  const skyMaterialRef = useRef(null);
383
+ const range = skyFogRange(sky, fogRange, { near: 70, far: 260 });
384
+ const [moonUp, syncMoon] = useMoonAboveHorizon(initial.moonIntensity > 0);
296
385
  useFrame(() => {
297
386
  const state = daylightStateAt(clock.calendar().dayFraction, sky);
298
387
  sunDirectionRef.current = state.sunPosition;
388
+ moonDirectionRef.current = state.moonPosition;
389
+ syncMoon(state.moonIntensity);
299
390
  const sun = sunRef.current;
300
- if (sun !== null)
391
+ if (sun !== null) {
301
392
  sun.intensity = state.sunIntensity;
393
+ sun.color.set(state.sunColor);
394
+ }
395
+ const moon = moonRef.current;
396
+ if (moon !== null)
397
+ moon.intensity = state.moonIntensity;
302
398
  const hemi = hemiRef.current;
303
399
  if (hemi !== null)
304
400
  hemi.intensity = state.ambientIntensity;
305
401
  const fog = fogRef.current;
306
402
  if (fog !== null)
307
- setSkyFogColor(fog, sky.fog?.color ?? state.background);
403
+ setSkyFogColor(fog, sky.fog?.color ?? state.fogColor);
308
404
  const skyMaterial = skyMaterialRef.current;
309
405
  if (skyMaterial !== null) {
310
406
  setSkyDomeColors(skyMaterial, state.skyTop, state.skyBottom);
311
407
  aimSkySun(skyMaterial, state.sunPosition);
408
+ setSkyMoon(skyMaterial, state.moonPosition, state.moonColor, state.moonIntensity, state.starOpacity);
312
409
  }
313
410
  });
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] }));
411
+ return (_jsxs(_Fragment, { children: [_jsx(SkyDome, { topColor: initial.skyTop, horizonColor: initial.skyBottom, sunDirection: initial.sunPosition, sunColor: initial.sunColor, materialRef: skyMaterialRef, moonDirection: initial.moonPosition, moonColor: initial.moonColor, moonIntensity: initial.moonIntensity, starOpacity: initial.starOpacity, ...skyDomeShape(sky) }), sky.volumetricClouds === undefined ? null : (_jsx(VolumetricClouds, { rules: resolveVolumetricClouds(sky.volumetricClouds), sunDirection: initial.sunPosition })), _jsx(SkyFog, { fogRef: fogRef, color: sky.fog?.color ?? initial.fogColor, near: range.near, far: range.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: initial.sunColor }), moonUp ? (_jsx(MoonLight, { direction: initial.moonPosition, directionRef: moonDirectionRef, lightRef: moonRef, intensity: initial.moonIntensity, color: initial.moonColor })) : null] })) : null] }));
315
412
  }
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useMemo, useRef } from "react";
2
+ import { useCallback, useEffect, useMemo, useRef } from "react";
3
3
  import { useFrame } from "@react-three/fiber";
4
4
  import { PresentationAssetBoundary } from "../presentationRecovery.js";
5
5
  import { PresentationRecoveredDraw, useRecoverableTexture } from "../presentationRecovery3d.js";
@@ -18,14 +18,16 @@ import { buildAuthoredRoadNetwork, RoadRibbons } from "./RoadRibbons.js";
18
18
  import { terrainGroundColorSampler } from "./terrainGroundColor.js";
19
19
  import { InstancedBuildings } from "../structures/GeneratedBuilding.js";
20
20
  import { GrassField } from "../terrain/GrassField.js";
21
+ import { GrassCover } from "../terrain/GrassCover.js";
21
22
  import { CarvedTerrain } from "../terrain/CarvedTerrain.js";
22
23
  import { createTerrainDetailMaterial } from "../terrain/terrainDetailMaterial.js";
24
+ import { MAX_TERRAIN_LAYER_MAPS } from "../terrain/terrainDetailMaterial.js";
23
25
  import { useTerrainQuality } from "../terrain/terrainQuality.js";
24
26
  import { createTerrainPaintTextures, packTerrainPaintWeights } from "../terrain/terrainSurfaces.js";
25
27
  import { Ocean } from "../water/Ocean.js";
26
28
  import { RainField } from "../weather/RainField.js";
27
29
  import { SnowField } from "../weather/SnowField.js";
28
- function TexturedTerrainGround({ detail, layout, }) {
30
+ function TexturedTerrainGround({ detail, layout, layerMaps, }) {
29
31
  const maps = detail.material.maps;
30
32
  const textures = useRecoverableTexture({
31
33
  color: maps.color,
@@ -42,9 +44,44 @@ function TexturedTerrainGround({ detail, layout, }) {
42
44
  }
43
45
  }, [textures]);
44
46
  const quality = useTerrainQuality();
45
- const material = useDisposable(() => createTerrainDetailMaterial(detail, textures, { paint: layout.paint, quality }).material, [detail, textures, layout.paint, quality]);
47
+ const material = useDisposable(() => createTerrainDetailMaterial(detail, textures, { paint: layout.paint, quality, ...(layerMaps === undefined ? {} : { layerMaps }) }).material, [detail, textures, layout.paint, quality, layerMaps]);
46
48
  return (_jsxs(_Fragment, { children: [_jsx(PresentationRecoveredDraw, {}), _jsx(CarvedTerrain, { field: layout.field, size: layout.size, segments: layout.segments, colors: layout.colors, heightRange: layout.heightRange, paletteAt: layout.paletteAt, center: layout.center, roughness: 0.94, surfaceMaterial: material })] }));
47
49
  }
50
+ /** 1×1 white PNG: binds a valid sampler for a painted layer that has no scanned map. */
51
+ const NO_LAYER_MAP = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
52
+ /**
53
+ * Scanned color maps for up to {@link MAX_TERRAIN_LAYER_MAPS} painted terrain layers, loaded through
54
+ * the shared recoverable texture path and tiled at each layer's own authored metres. The URL is
55
+ * authored data (`TerrainMaterialLayer.materialUrl`), resolved by the editor from a library name, so
56
+ * the renderer never reaches into the asset pipeline.
57
+ */
58
+ function useTerrainLayerMaps(paint) {
59
+ const urls = useMemo(() => Array.from({ length: MAX_TERRAIN_LAYER_MAPS }, (_, index) => {
60
+ const url = paint?.slots[index + 1]?.materialUrl;
61
+ return url === undefined || url.length === 0 ? NO_LAYER_MAP : url;
62
+ }), [paint]);
63
+ const textures = useRecoverableTexture({ layer1: urls[0], layer2: urls[1], layer3: urls[2], layer4: urls[3] });
64
+ useEffect(() => {
65
+ for (const texture of Object.values(textures)) {
66
+ texture.colorSpace = THREE.SRGBColorSpace;
67
+ texture.wrapS = THREE.RepeatWrapping;
68
+ texture.wrapT = THREE.RepeatWrapping;
69
+ texture.needsUpdate = true;
70
+ }
71
+ }, [textures]);
72
+ const slots = paint?.slots;
73
+ return useMemo(() => {
74
+ if (slots === undefined)
75
+ return undefined;
76
+ return Array.from({ length: MAX_TERRAIN_LAYER_MAPS }, (_, index) => {
77
+ const slot = slots[index + 1];
78
+ if (slot?.materialUrl === undefined)
79
+ return undefined;
80
+ const texture = textures[`layer${index + 1}`];
81
+ return texture === undefined ? undefined : { color: texture, tileMetres: slot.tileMetres };
82
+ });
83
+ }, [slots, textures]);
84
+ }
48
85
  function TerrainGround({ terrain, field, center, sculpt, }) {
49
86
  const detailEnabled = terrain.detail !== false;
50
87
  const paint = useMemo(() => {
@@ -79,12 +116,13 @@ function TerrainGround({ terrain, field, center, sculpt, }) {
79
116
  }, [terrain.baseHeight, terrain.height]);
80
117
  const resolvedDetail = useMemo(() => (terrain.detail === false ? undefined : resolveTerrainDetail(terrain.detail, terrain.waterLevel)), [terrain.detail, terrain.waterLevel]);
81
118
  const quality = useTerrainQuality();
119
+ const layerMaps = useTerrainLayerMaps(paint);
82
120
  const proceduralMaterial = useMemo(() => resolvedDetail === undefined || resolvedDetail.material !== undefined
83
121
  ? undefined
84
- : createTerrainDetailMaterial(resolvedDetail, undefined, { paint, quality }).material, [resolvedDetail, paint, quality]);
122
+ : createTerrainDetailMaterial(resolvedDetail, undefined, { paint, quality, ...(layerMaps === undefined ? {} : { layerMaps }) }).material, [resolvedDetail, paint, quality, layerMaps]);
85
123
  useEffect(() => () => proceduralMaterial?.dispose(), [proceduralMaterial]);
86
124
  if (resolvedDetail?.material !== undefined) {
87
- return (_jsx(PresentationAssetBoundary, { children: _jsx(TexturedTerrainGround, { detail: resolvedDetail, layout: { field, size, segments: terrain.segments, colors, heightRange, paletteAt, paint, center } }) }));
125
+ return (_jsx(PresentationAssetBoundary, { children: _jsx(TexturedTerrainGround, { detail: resolvedDetail, layout: { field, size, segments: terrain.segments, colors, heightRange, paletteAt, paint, center }, ...(layerMaps === undefined ? {} : { layerMaps }) }) }));
88
126
  }
89
127
  return (_jsx(CarvedTerrain, { field: field, size: size, segments: terrain.segments, colors: colors, heightRange: heightRange, paletteAt: paletteAt, paintColorAt: paintColorAt, center: center, roughness: 0.94, surfaceMaterial: proceduralMaterial }));
90
128
  }
@@ -100,7 +138,7 @@ function Vegetation({ grass, field, groundColor, }) {
100
138
  const wind = useMemo(() => ({ strength: grass.windStrength }), [grass.windStrength]);
101
139
  const colorBase = grass.colors[0];
102
140
  const colorTip = grass.colors[grass.colors.length - 1];
103
- return (_jsx("group", { position: [cx, 0, cz], children: _jsx(GrassField, { area: area, count: Math.max(1500, Math.min(250000, Math.ceil(grass.density * grass.area.w * grass.area.d))), density: grass.density, seed: grass.seed, bladeHeight: grass.bladeHeight, bladeWidth: grass.bladeWidth, bladeBend: grass.bladeBend, tuftRadius: grass.tuftRadius, colorVariation: grass.colorVariation, normalLift: grass.normalLift, heightAt: heightAt, colorBase: colorBase, colorTip: colorTip, colorGround: groundColor, wind: wind, edgeFeather: Math.min(8, Math.max(1.5, Math.min(grass.area.w, grass.area.d) * 0.12)), frustumCulled: true }) }));
141
+ return (_jsxs("group", { position: [cx, 0, cz], children: [_jsx(GrassCover, { area: area, seed: typeof grass.seed === "number" ? grass.seed : 1, colorBase: colorBase ?? "#3f7d2d", colorTip: colorTip ?? "#66a83f", strength: grass.groundCover ?? 0.9, edgeFeather: Math.min(8, Math.max(1.5, Math.min(grass.area.w, grass.area.d) * 0.12)), heightAt: heightAt, coverageAt: () => 1 }), _jsx(GrassField, { area: area, count: Math.max(1500, Math.min(250000, Math.ceil(grass.density * grass.area.w * grass.area.d))), density: grass.density, seed: grass.seed, bladeHeight: grass.bladeHeight, bladeWidth: grass.bladeWidth, bladeBend: grass.bladeBend, tuftRadius: grass.tuftRadius, colorVariation: grass.colorVariation, normalLift: grass.normalLift, heightAt: heightAt, colorBase: colorBase, colorTip: colorTip, colorGround: groundColor, wind: wind, edgeFeather: Math.min(8, Math.max(1.5, Math.min(grass.area.w, grass.area.d) * 0.12)), frustumCulled: true })] }));
104
142
  }
105
143
  /** Terrain color under a grass patch center — so blade roots blend into the ground they stand on. */
106
144
  function vegetationGroundColor(terrain, field, grass, sculpt) {
@@ -142,19 +180,24 @@ function DynamicWater({ ocean }) {
142
180
  const groupRef = useRef(null);
143
181
  const [x, z] = ocean.position ?? [0, 0];
144
182
  const config = useOceanConfig(ocean);
183
+ const depthAt = useCallback((lx, lz) => ocean.levelAt(ctx.time.now()) - ctx.world.ground.sampleHeight(x + lx, z + lz), [ctx.time.now, ctx.world.ground, ocean.levelAt, x, z]);
145
184
  useFrame(() => {
146
185
  if (groupRef.current !== null)
147
186
  groupRef.current.position.y = ocean.levelAt(ctx.time.now());
148
187
  });
149
- return (_jsx("group", { ref: groupRef, position: [0, ocean.level, 0], children: _jsx(Ocean, { position: [x, 0, z], config: config }) }));
188
+ return (_jsx("group", { ref: groupRef, position: [0, ocean.level, 0], children: _jsx(Ocean, { position: [x, 0, z], config: config, depthAt: depthAt }) }));
150
189
  }
151
190
  function Water({ ocean }) {
191
+ const ctx = useGameContext();
152
192
  const [x, z] = ocean.position ?? [0, 0];
153
193
  const config = useOceanConfig(ocean);
194
+ // The ocean descriptor has no depth field: sample the live ground so the sheet shades
195
+ // shallow→deep and the shoreline foam band lands where the bed meets the surface.
196
+ const depthAt = useCallback((lx, lz) => ocean.level - ctx.world.ground.sampleHeight(x + lx, z + lz), [ctx.world.ground, ocean.level, x, z]);
154
197
  if (ocean.levelAt !== undefined) {
155
198
  return _jsx(DynamicWater, { ocean: ocean });
156
199
  }
157
- return _jsx(Ocean, { position: [x, ocean.level, z], config: config });
200
+ return _jsx(Ocean, { position: [x, ocean.level, z], config: config, depthAt: depthAt });
158
201
  }
159
202
  function Structures({ structures, field }) {
160
203
  const placements = useMemo(() => resolveStructureBuildings(structures).map((building) => ({
@@ -6,19 +6,40 @@ export interface DaylightCycleConfig {
6
6
  ambientIntensity?: number;
7
7
  /** Fixed sun placement, or the noon placement the day arc swings around. Unset keeps the engine arc. */
8
8
  sun?: SkySunConfig;
9
+ /** Authored noon sun colour, reddened toward the horizon by the atmosphere. Default warm white. */
10
+ sunColor?: string;
11
+ /** Authored noon fog colour; dawn, dusk and night cross-fade to their own haze. Default the noon horizon. */
12
+ fogColor?: string;
13
+ /** Peak moonlight as a fraction of `sunIntensity`. Default `MOONLIGHT_PEAK`. */
14
+ moonIntensity?: number;
15
+ /** Moonlight colour. Default `MOONLIGHT_COLOR`. */
16
+ moonColor?: string;
9
17
  }
10
18
  export interface DaylightState {
11
19
  sunPosition: [number, number, number];
12
20
  sunIntensity: number;
21
+ /** The sun's colour at this hour: authored tint high up, atmosphere-reddened near the horizon. */
22
+ sunColor: string;
13
23
  ambientIntensity: number;
14
24
  skyTop: string;
15
25
  skyBottom: string;
16
26
  background: string;
27
+ /** Scene fog colour matched to the sky at this hour, so fogged ground meets the horizon. */
28
+ fogColor: string;
29
+ /** The moon's direction — the sun's arc half a day away, so it is up whenever the sun is down. */
30
+ moonPosition: [number, number, number];
31
+ /** Moonlight intensity, already scaled by the authored noon sun intensity. */
32
+ moonIntensity: number;
33
+ moonColor: string;
34
+ /** Star visibility, 0 in daylight and 1 with the sun well under the horizon. */
35
+ starOpacity: number;
17
36
  }
18
37
  export declare const DEFAULT_DAY_SUN_INTENSITY = 1;
19
38
  export declare const DEFAULT_DAY_AMBIENT_INTENSITY = 0.6;
20
39
  export declare const DEFAULT_DAY_SKY_TOP = "#3fa4f2";
21
40
  export declare const DEFAULT_DAY_SKY_BOTTOM = "#e3f4ff";
41
+ /** Warm-white noon sun used when the game authored no sun tint (the shell's `SUN_COLOR`). */
42
+ export declare const DEFAULT_SUN_COLOR = "#fff1c9";
22
43
  /** The engine arc's noon height: `atan(1 / SUN_DEPTH_RATIO)`, ~68 degrees. */
23
44
  export declare const DEFAULT_SUN_ELEVATION_DEG: number;
24
45
  /**
@@ -41,8 +62,9 @@ export declare const SKY_PRESET_DAY_FRACTION: Record<"day" | "dusk" | "night", n
41
62
  export declare function lerpHexColor(a: string, b: string, t: number): string;
42
63
  /**
43
64
  * Samples the daylight cycle at a point in the day (0 = midnight, 0.25 = dawn, 0.5 = noon, 0.75 = dusk),
44
- * lerping sun position/intensity, ambient intensity, and sky colors through a dawn/day/dusk/night
45
- * keyframe table. `config` overrides the noon (peak-day) colors and intensities only.
46
- * @internal
47
- */
65
+ * lerping sun position/intensity, ambient intensity, sky and fog colors through a dawn/day/dusk/night
66
+ * keyframe table, and deriving sun tint, moonlight and star visibility from the sun's elevation.
67
+ * `config` overrides the noon (peak-day) colors and intensities only.
68
+ * @internal
69
+ */
48
70
  export declare function daylightStateAt(dayFraction: number, config?: DaylightCycleConfig): DaylightState;
@@ -1,3 +1,4 @@
1
+ import { elevationOf, MOONLIGHT_COLOR, moonIllumination, NIGHT_FOG_COLOR, starOpacity, sunlightColor, } from "@jgengine/core/render/physicalSky";
1
2
  const NIGHT_SUN_INTENSITY = 0.02;
2
3
  const NIGHT_AMBIENT_INTENSITY = 0.08;
3
4
  const NIGHT_SKY_TOP = "#02030a";
@@ -14,6 +15,8 @@ export const DEFAULT_DAY_SUN_INTENSITY = 1;
14
15
  export const DEFAULT_DAY_AMBIENT_INTENSITY = 0.6;
15
16
  export const DEFAULT_DAY_SKY_TOP = "#3fa4f2";
16
17
  export const DEFAULT_DAY_SKY_BOTTOM = "#e3f4ff";
18
+ /** Warm-white noon sun used when the game authored no sun tint (the shell's `SUN_COLOR`). */
19
+ export const DEFAULT_SUN_COLOR = "#fff1c9";
17
20
  const SUN_DISTANCE = 200;
18
21
  const SUN_DEPTH_RATIO = 0.4;
19
22
  const DEG = Math.PI / 180;
@@ -79,6 +82,7 @@ function buildKeyframes(config) {
79
82
  ambientIntensity: NIGHT_AMBIENT_INTENSITY,
80
83
  skyTop: NIGHT_SKY_TOP,
81
84
  skyBottom: NIGHT_SKY_BOTTOM,
85
+ fog: NIGHT_FOG_COLOR,
82
86
  };
83
87
  const dawn = {
84
88
  t: 0.25,
@@ -86,6 +90,7 @@ function buildKeyframes(config) {
86
90
  ambientIntensity: DAWN_AMBIENT_INTENSITY,
87
91
  skyTop: DAWN_SKY_TOP,
88
92
  skyBottom: DAWN_SKY_BOTTOM,
93
+ fog: DAWN_SKY_BOTTOM,
89
94
  };
90
95
  const day = {
91
96
  t: 0.5,
@@ -93,6 +98,7 @@ function buildKeyframes(config) {
93
98
  ambientIntensity: config.ambientIntensity ?? DEFAULT_DAY_AMBIENT_INTENSITY,
94
99
  skyTop: config.zenithColor ?? DEFAULT_DAY_SKY_TOP,
95
100
  skyBottom: config.horizonColor ?? DEFAULT_DAY_SKY_BOTTOM,
101
+ fog: config.fogColor ?? config.horizonColor ?? DEFAULT_DAY_SKY_BOTTOM,
96
102
  };
97
103
  const dusk = {
98
104
  t: 0.75,
@@ -100,6 +106,7 @@ function buildKeyframes(config) {
100
106
  ambientIntensity: DUSK_AMBIENT_INTENSITY,
101
107
  skyTop: DUSK_SKY_TOP,
102
108
  skyBottom: DUSK_SKY_BOTTOM,
109
+ fog: DUSK_SKY_BOTTOM,
103
110
  };
104
111
  const midnight = { ...night, t: 1 };
105
112
  return [night, dawn, day, dusk, midnight];
@@ -122,10 +129,11 @@ function segmentAt(wrapped, keyframes) {
122
129
  }
123
130
  /**
124
131
  * Samples the daylight cycle at a point in the day (0 = midnight, 0.25 = dawn, 0.5 = noon, 0.75 = dusk),
125
- * lerping sun position/intensity, ambient intensity, and sky colors through a dawn/day/dusk/night
126
- * keyframe table. `config` overrides the noon (peak-day) colors and intensities only.
127
- * @internal
128
- */
132
+ * lerping sun position/intensity, ambient intensity, sky and fog colors through a dawn/day/dusk/night
133
+ * keyframe table, and deriving sun tint, moonlight and star visibility from the sun's elevation.
134
+ * `config` overrides the noon (peak-day) colors and intensities only.
135
+ * @internal
136
+ */
129
137
  export function daylightStateAt(dayFraction, config = {}) {
130
138
  const wrapped = wrapFraction(dayFraction);
131
139
  const keyframes = buildKeyframes(config);
@@ -134,8 +142,31 @@ export function daylightStateAt(dayFraction, config = {}) {
134
142
  const ambientIntensity = lerp(from.ambientIntensity, to.ambientIntensity, localT);
135
143
  const skyTop = lerpHexColor(from.skyTop, to.skyTop, localT);
136
144
  const skyBottom = lerpHexColor(from.skyBottom, to.skyBottom, localT);
145
+ const fogColor = lerpHexColor(from.fog, to.fog, localT);
137
146
  const sunPosition = sunPositionAt(wrapped, config.sun);
138
- return { sunPosition, sunIntensity, ambientIntensity, skyTop, skyBottom, background: skyBottom };
147
+ const moonPosition = sunPositionAt(wrapped + 0.5, config.sun);
148
+ const sunColor = mixHex(config.sunColor ?? DEFAULT_SUN_COLOR, sunlightColor(elevationOf(sunPosition)), lowSunWeight(wrapped));
149
+ return {
150
+ sunPosition,
151
+ sunIntensity,
152
+ sunColor,
153
+ ambientIntensity,
154
+ skyTop,
155
+ skyBottom,
156
+ background: skyBottom,
157
+ fogColor,
158
+ moonPosition,
159
+ moonIntensity: moonIllumination(elevationOf(moonPosition), config.moonIntensity) * (config.sunIntensity ?? DEFAULT_DAY_SUN_INTENSITY),
160
+ moonColor: config.moonColor ?? MOONLIGHT_COLOR,
161
+ starOpacity: starOpacity(elevationOf(sunPosition)),
162
+ };
163
+ }
164
+ function mixHex(from, to, t) {
165
+ return t <= 0 ? from : t >= 1 ? to : lerpHexColor(from, to, t);
166
+ }
167
+ /** How much of the sun's colour comes from the atmosphere rather than the authored tint: 0 with the sun high, 1 once it is on the horizon. */
168
+ function lowSunWeight(wrapped) {
169
+ return Math.min(1, Math.abs(wrapped - 0.5) / 0.25);
139
170
  }
140
171
  /**
141
172
  * Sun position for a wrapped day fraction. Without `sun`, the legacy arc: east at dawn, ~68 degrees
@@ -5,6 +5,12 @@ interface BuiltGraph {
5
5
  composer: EffectComposer;
6
6
  grade: ShaderPass | null;
7
7
  }
8
+ /**
9
+ * Rounds an adapted exposure to 1/32 of a stop. Without it the sky's display-colour cache (which keys
10
+ * off the renderer's exposure) re-solves its swatches every frame the meter nudges.
11
+ * @internal
12
+ */
13
+ export declare function quantizeExposure(value: number): number;
8
14
  /** Release each pass and the composer's owned targets. @internal */
9
15
  export declare function disposeGraph(built: BuiltGraph): void;
10
16
  /** Resize the composer and its passes at the renderer's pixel ratio. @internal */
@@ -14,7 +20,7 @@ export declare function resolveAoStage(ao: PostProcessingConfig["ao"], tierAllow
14
20
  /**
15
21
  * Mounts an `EffectComposer` inside the shell Canvas and takes over rendering
16
22
  * (priority-1 `useFrame`, which disables R3F auto-render) to run the configured
17
- * post chain: RenderPass → GTAO → UnrealBloom → SMAA → OutputPass → Stylize → Grade. Rendered only
23
+ * post chain: RenderPass → GTAO → UnrealBloom → SMAA → exposure meter → OutputPass → Stylize → Grade. Rendered only
18
24
  * when `PlayableGame.postProcessing` is set, so games without it draw unchanged.
19
25
  *
20
26
  * `stages` (resolved from the player's graphics profile) controls expensive passes