@jgengine/shell 0.18.0 → 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 (148) hide show
  1. package/CHANGELOG.md +265 -2
  2. package/dist/GamePlayerShell.js +27 -4
  3. package/dist/JoinGate.d.ts +17 -0
  4. package/dist/JoinGate.js +14 -0
  5. package/dist/Shell3dPresentation.d.ts +9 -8
  6. package/dist/Shell3dPresentation.js +57 -8
  7. package/dist/ShellChrome.d.ts +2 -0
  8. package/dist/ShellChrome.js +6 -0
  9. package/dist/ShellHudPresentation.d.ts +9 -1
  10. package/dist/ShellHudPresentation.js +21 -0
  11. package/dist/TerritoryOverlay.d.ts +21 -0
  12. package/dist/TerritoryOverlay.js +9 -0
  13. package/dist/audio/AudioComponents.js +13 -2
  14. package/dist/audio/audioEngine.d.ts +15 -1
  15. package/dist/audio/audioEngine.js +132 -11
  16. package/dist/audio/audioWire.d.ts +3 -0
  17. package/dist/audio/audioWire.js +7 -1
  18. package/dist/audio/loopParams.d.ts +39 -0
  19. package/dist/audio/loopParams.js +43 -0
  20. package/dist/camera/GameCameraRig.js +1 -1
  21. package/dist/camera/GameFirstPersonCamera.d.ts +4 -2
  22. package/dist/camera/GameFirstPersonCamera.js +52 -15
  23. package/dist/camera/Viewports.d.ts +34 -0
  24. package/dist/camera/Viewports.js +123 -0
  25. package/dist/camera/cameraRigs.js +77 -62
  26. package/dist/camera/orbitCameraMath.js +2 -2
  27. package/dist/camera/rigMath.d.ts +82 -4
  28. package/dist/camera/rigMath.js +133 -15
  29. package/dist/defineGame.d.ts +9 -0
  30. package/dist/defineGame.js +38 -6
  31. package/dist/devtools/DevtoolsOverlay.js +2 -0
  32. package/dist/devtools/KeysPanel.js +3 -3
  33. package/dist/devtools/agentBridge.d.ts +6 -0
  34. package/dist/devtools/agentBridge.js +15 -1
  35. package/dist/diagnostics/FpsCounter.d.ts +17 -0
  36. package/dist/diagnostics/FpsCounter.js +32 -0
  37. package/dist/drivers/FrameDriver.d.ts +5 -1
  38. package/dist/drivers/FrameDriver.js +74 -26
  39. package/dist/drivers/FrameRateLimiter.d.ts +9 -0
  40. package/dist/drivers/FrameRateLimiter.js +39 -0
  41. package/dist/drivers/HudOnlyDriver.js +10 -5
  42. package/dist/environment/DayNightSky.js +5 -5
  43. package/dist/environment/Daylight.d.ts +35 -1
  44. package/dist/environment/Daylight.js +98 -28
  45. package/dist/environment/EnvironmentScene.js +1 -1
  46. package/dist/environment/VolumetricClouds.js +20 -2
  47. package/dist/environment/daylightCycle.d.ts +20 -0
  48. package/dist/environment/daylightCycle.js +59 -7
  49. package/dist/environment/sunShadowMath.d.ts +45 -0
  50. package/dist/environment/sunShadowMath.js +50 -0
  51. package/dist/input/gamepadMerge.d.ts +10 -0
  52. package/dist/input/gamepadMerge.js +12 -0
  53. package/dist/input/gamepadPoll.d.ts +47 -0
  54. package/dist/input/gamepadPoll.js +142 -0
  55. package/dist/input/gamepadSource.d.ts +22 -0
  56. package/dist/input/gamepadSource.js +153 -0
  57. package/dist/input/mouseLook.js +2 -1
  58. package/dist/input/padHaptics.d.ts +17 -0
  59. package/dist/input/padHaptics.js +32 -0
  60. package/dist/input/pointerLock.d.ts +11 -0
  61. package/dist/input/pointerLock.js +19 -0
  62. package/dist/materialOverride.d.ts +4 -1
  63. package/dist/materialOverride.js +10 -1
  64. package/dist/postfx/PostProcessing.d.ts +11 -8
  65. package/dist/postfx/PostProcessing.js +38 -16
  66. package/dist/postfx/gradeShader.js +13 -0
  67. package/dist/postfx/stylizePass.d.ts +23 -0
  68. package/dist/postfx/stylizePass.js +216 -0
  69. package/dist/render/CascadedShadows.d.ts +11 -0
  70. package/dist/render/CascadedShadows.js +19 -8
  71. package/dist/render/EnvironmentLighting.d.ts +4 -1
  72. package/dist/render/EnvironmentLighting.js +45 -9
  73. package/dist/render/SceneLighting.d.ts +16 -2
  74. package/dist/render/SceneLighting.js +56 -6
  75. package/dist/render/SceneModels.js +45 -9
  76. package/dist/render/SkinnedInstances.d.ts +91 -0
  77. package/dist/render/SkinnedInstances.js +249 -0
  78. package/dist/render/TextureFiltering.d.ts +16 -0
  79. package/dist/render/TextureFiltering.js +64 -0
  80. package/dist/render/displayColor.d.ts +33 -0
  81. package/dist/render/displayColor.js +138 -0
  82. package/dist/render/modelLoad.d.ts +39 -0
  83. package/dist/render/modelLoad.js +89 -1
  84. package/dist/render/modelRender.d.ts +3 -0
  85. package/dist/render/modelRender.js +6 -2
  86. package/dist/render/resolveModel.js +4 -0
  87. package/dist/render/useFootIk.d.ts +58 -0
  88. package/dist/render/useFootIk.js +216 -0
  89. package/dist/render/useModelAnimation.d.ts +26 -0
  90. package/dist/render/useModelAnimation.js +184 -107
  91. package/dist/scene/AuthoredScene.d.ts +9 -0
  92. package/dist/scene/AuthoredScene.js +17 -1
  93. package/dist/scene/CityRenderer.js +666 -241
  94. package/dist/scene/cityWindowMaterial.d.ts +8 -6
  95. package/dist/scene/cityWindowMaterial.js +80 -21
  96. package/dist/settings/QuickControls.js +1 -1
  97. package/dist/settings/SettingsMenu.js +1 -1
  98. package/dist/settings/appliedSettings.d.ts +10 -2
  99. package/dist/settings/appliedSettings.js +9 -5
  100. package/dist/settings/settingsController.d.ts +3 -0
  101. package/dist/settings/settingsController.js +47 -3
  102. package/dist/structures/BuildingKitBatch.js +26 -22
  103. package/dist/structures/GeneratedBuilding.d.ts +3 -15
  104. package/dist/structures/GeneratedBuilding.js +21 -15
  105. package/dist/structures/buildingSurface.d.ts +21 -0
  106. package/dist/structures/buildingSurface.js +134 -0
  107. package/dist/terrain/terrainDetailMaterial.js +7 -3
  108. package/dist/useShellMultiplayerSync.d.ts +9 -2
  109. package/dist/useShellMultiplayerSync.js +30 -8
  110. package/dist/visibility/CullingProvider.d.ts +2 -1
  111. package/dist/visibility/CullingProvider.js +7 -2
  112. package/dist/world/GridWorldScene.js +4 -0
  113. package/dist/world/TileLayerRenderer.d.ts +9 -0
  114. package/dist/world/TileLayerRenderer.js +64 -0
  115. package/dist/world/WorldParticles.d.ts +31 -0
  116. package/dist/world/WorldParticles.js +128 -0
  117. package/dist/world/WorldScene.d.ts +2 -1
  118. package/dist/world/WorldScene.js +46 -4
  119. package/dist/world/entityPose.d.ts +4 -0
  120. package/dist/world/entityPose.js +9 -0
  121. package/dist/world/tileLayerInstances.d.ts +11 -0
  122. package/dist/world/tileLayerInstances.js +28 -0
  123. package/dist/worldSync.d.ts +5 -1
  124. package/dist/worldSync.js +25 -4
  125. package/package.json +4 -4
  126. package/skills/ce-handoff/SKILL.md +2 -2
  127. package/skills/game-design/SKILL.md +11 -1
  128. package/skills/jgengine/SKILL.md +9 -5
  129. package/skills/jgengine/api.md +205 -101
  130. package/skills/jgengine/capabilities.md +56 -0
  131. package/skills/jgengine/recipes/minimal-game.md +3 -0
  132. package/skills/jgengine-ui/SKILL.md +20 -15
  133. package/skills/jgengine-ui/api.md +538 -326
  134. package/skills/jgengine-ui/capabilities.md +37 -0
  135. package/skills/jgengine-ui/reference-primitives.md +89 -0
  136. package/skills/jgengine-ui/reference.md +2 -78
  137. package/skills/jgengine-world/SKILL.md +21 -3
  138. package/skills/jgengine-world/api.md +1172 -625
  139. package/skills/jgengine-world/capabilities.md +229 -3
  140. package/skills/jgengine-world/recipes/character-animation.md +39 -0
  141. package/skills/jgengine-world/recipes/controls.md +44 -0
  142. package/skills/jgengine-world/recipes/flight-feel.md +76 -0
  143. package/skills/jgengine-world/recipes/npc-senses.md +43 -0
  144. package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
  145. package/skills/jgengine-world/reference.md +31 -2
  146. package/skills/jgengine-world/visibility.md +112 -0
  147. package/skills/level-design/SKILL.md +1 -1
  148. package/skills/jgengine-ui/references/visual-scorecard.md +0 -73
@@ -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";
@@ -17,9 +19,10 @@ import { devtools } from "@jgengine/core/devtools/devtools";
17
19
  import { collisionDebug } from "../devtools/collisionDebug.js";
18
20
  import { GAME_SIM_FRAME_PRIORITY } from "../camera/index.js";
19
21
  import { NO_ACTIONS } from "../shellConstants.js";
22
+ import { createPredictionBuffer } from "@jgengine/core/runtime/prediction";
20
23
  const TURN_SPEED = 2.4;
21
24
  export const POSTER_SETTLE_SECONDS = 1.6;
22
- export function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef, pointerAxisRef, analogRef, gateRef, onRuntimeError, multiplayer, serverIdRef, pointerService, pointerAim, pingCommand, poster, onPosterSettled, }) {
25
+ export function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef, pointerAxisRef, analogRef, gateRef, onRuntimeError, multiplayer, serverIdRef, pointerService, pointerAim, pingCommand, poster, onPosterSettled, authoritativeFrameRef, }) {
23
26
  const posterElapsedRef = useRef(0);
24
27
  const posterDoneRef = useRef(false);
25
28
  const hasReportedTickError = useRef(false);
@@ -56,7 +59,26 @@ export function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryC
56
59
  }).send(frame),
57
60
  }), [multiplayer, serverAuthoritative, serverIdRef]);
58
61
  const lastSentInputRef = useRef(null);
59
- const inputActions = useMemo(() => Object.keys(playable.game.input ?? {}), [playable]);
62
+ const predictionRef = useRef(createPredictionBuffer({
63
+ initial: [0, 0, 0],
64
+ maxTicks: 120,
65
+ step: (state) => state,
66
+ snapThreshold: 0.1,
67
+ }));
68
+ const predictionHandler = useMemo(() => (_revision) => {
69
+ const playerId = ctx.player.possession.active(ctx.player.userId);
70
+ const player = ctx.scene.entity.get(playerId);
71
+ if (player === null)
72
+ return;
73
+ const prediction = predictionRef.current;
74
+ const current = player.position;
75
+ const snapshot = prediction.snapshot();
76
+ const tick = snapshot.records.at(-1)?.tick ?? snapshot.confirmedTick;
77
+ const result = prediction.reconcile(tick, current);
78
+ if (result.snapped)
79
+ ctx.scene.entity.setPose(playerId, { position: current });
80
+ }, [ctx]);
81
+ authoritativeFrameRef.current = serverAuthoritative ? predictionHandler : null;
60
82
  useFrame((_state, rawDt) => {
61
83
  if (poster) {
62
84
  if (posterDoneRef.current)
@@ -71,7 +93,12 @@ export function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryC
71
93
  const sendInput = () => {
72
94
  if (!serverAuthoritative)
73
95
  return;
74
- const frame = { held: ctx.input.held(), pointer: ctx.input.pointer(), analog: ctx.input.analog() };
96
+ const frame = {
97
+ held: ctx.input.held(),
98
+ pointer: ctx.input.pointer(),
99
+ analog: ctx.input.analog(),
100
+ tick: ctx.sim.tick(),
101
+ };
75
102
  const last = lastSentInputRef.current;
76
103
  if (last !== null && inputFramesEqual(last, frame))
77
104
  return;
@@ -88,8 +115,7 @@ export function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryC
88
115
  try {
89
116
  let endPhase = devtools.profile.begin("time+input");
90
117
  const dt = Math.min(rawDt, 0.05);
91
- const gameDt = ctx.time.advance(dt);
92
- ctx.input.publish(heldActionsFor(tracker, inputActions));
118
+ ctx.input.publish(heldActionsFor(tracker, tracker.actions()));
93
119
  ctx.input.publishPointer(pointerAxisRef.current);
94
120
  ctx.input.publishAnalog(analogRef.current);
95
121
  sendInput();
@@ -98,28 +124,50 @@ export function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryC
98
124
  yawRef.current = steerYaw(yawRef.current, turnInput, TURN_SPEED, dt);
99
125
  endPhase();
100
126
  const playerId = ctx.player.possession.active(ctx.player.userId);
101
- const player = ctx.scene.entity.get(playerId);
102
- if (player !== null && drivesPose && !serverAuthoritative) {
103
- endPhase = devtools.profile.begin("pose");
104
- stepPlayerMovement(ctx, ctx.player.userId, { held: ctx.input.held(), pointer: ctx.input.pointer(), analog: ctx.input.analog() }, rawDt, movementTuning, yawRef.current);
105
- endPhase();
106
- }
107
- if (autoPickupRadius !== null && !serverAuthoritative) {
108
- endPhase = devtools.profile.begin("pickup");
109
- const self = ctx.scene.entity.get(playerId);
110
- if (self !== null) {
111
- const nearest = ctx.scene.worldItem.nearestInRadius(self.position, autoPickupRadius);
112
- if (nearest !== null)
113
- ctx.scene.worldItem.pickup(nearest, ctx.player.userId);
127
+ ctx.sim.advance(dt, (stepDt) => {
128
+ const gameDt = ctx.time.advance(stepDt);
129
+ ctx.sim.runStages("beforeMovement", stepDt);
130
+ const player = ctx.scene.entity.get(playerId);
131
+ // Server-authoritative sessions still run the deterministic local step as a prediction; the
132
+ // replicated pose is the confirmation that the transport reconciles on the next world diff.
133
+ if (player !== null && drivesPose) {
134
+ const endPose = devtools.profile.begin("pose");
135
+ stepPlayerMovement(ctx, ctx.player.userId, { held: ctx.input.held(), pointer: ctx.input.pointer(), analog: ctx.input.analog() }, stepDt, movementTuning, yawRef.current, pitchRef.current);
136
+ const predicted = ctx.scene.entity.get(playerId);
137
+ if (predicted !== null) {
138
+ predictionRef.current.record({ held: ctx.input.held(), pointer: ctx.input.pointer(), analog: ctx.input.analog(), tick: ctx.sim.tick() }, stepDt, predicted.position);
139
+ }
140
+ endPose();
114
141
  }
115
- endPhase();
116
- }
117
- if (!serverAuthoritative) {
118
- devtools.profile.measure("onTick", () => {
119
- playable.loop.onTick(ctx, gameDt);
120
- });
121
- advanceBehaviors(ctx, gameDt);
122
- }
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
+ }
152
+ ctx.sim.runStages("afterMovement", stepDt);
153
+ if (autoPickupRadius !== null && !serverAuthoritative) {
154
+ const endPickup = devtools.profile.begin("pickup");
155
+ const self = ctx.scene.entity.get(playerId);
156
+ if (self !== null) {
157
+ const nearest = ctx.scene.worldItem.nearestInRadius(self.position, autoPickupRadius);
158
+ if (nearest !== null)
159
+ ctx.scene.worldItem.pickup(nearest, ctx.player.userId);
160
+ }
161
+ endPickup();
162
+ }
163
+ if (!serverAuthoritative) {
164
+ devtools.profile.measure("onTick", () => {
165
+ playable.loop.onTick(ctx, gameDt);
166
+ });
167
+ advanceBehaviors(ctx, gameDt);
168
+ }
169
+ ctx.sim.runStages("afterTick", stepDt);
170
+ });
123
171
  endPhase = devtools.profile.begin("actions");
124
172
  if (tracker.wasPressed("tabTarget")) {
125
173
  if (ctx.game.commands.has("target.cycle"))
@@ -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,14 +26,19 @@ 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
- const gameDt = ctx.time.advance(dt);
30
- ctx.input.publish(heldActionsFor(tracker, inputActions));
29
+ ctx.input.publish(heldActionsFor(tracker, tracker.actions()));
31
30
  ctx.input.publishPointer(pointerAxisRef.current);
32
31
  endPhase();
33
- devtools.profile.measure("onTick", () => {
34
- playable.loop.onTick(ctx, gameDt);
32
+ ctx.sim.advance(dt, (stepDt) => {
33
+ const gameDt = ctx.time.advance(stepDt);
34
+ ctx.sim.runStages("beforeMovement", stepDt);
35
+ ctx.sim.runStages("afterMovement", stepDt);
36
+ devtools.profile.measure("onTick", () => {
37
+ playable.loop.onTick(ctx, gameDt);
38
+ });
39
+ advanceBehaviors(ctx, gameDt);
40
+ ctx.sim.runStages("afterTick", stepDt);
35
41
  });
36
- advanceBehaviors(ctx, gameDt);
37
42
  endPhase = devtools.profile.begin("actions");
38
43
  const nowMs = performance.now();
39
44
  for (const action of inputActions) {
@@ -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
  }
@@ -85,7 +85,7 @@ function Vegetation({ grass, field, groundColor, }) {
85
85
  const wind = useMemo(() => ({ strength: grass.windStrength }), [grass.windStrength]);
86
86
  const colorBase = grass.colors[0];
87
87
  const colorTip = grass.colors[grass.colors.length - 1];
88
- 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, 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 }) }));
88
+ 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 }) }));
89
89
  }
90
90
  /** Terrain color under a grass patch center — so blade roots blend into the ground they stand on. */
91
91
  function vegetationGroundColor(terrain, field, grass) {
@@ -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] }) }));