@jgengine/shell 0.26.0 → 0.28.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 (91) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/dist/GamePlayerShell.d.ts +8 -0
  3. package/dist/GamePlayerShell.js +15 -1
  4. package/dist/GameUiPreview.js +3 -1
  5. package/dist/Shell3dPresentation.js +6 -4
  6. package/dist/ShellChrome.js +3 -1
  7. package/dist/camera/GameCameraRig.js +2 -2
  8. package/dist/camera/GameFirstPersonCamera.js +39 -2
  9. package/dist/camera/cameraCollision.d.ts +66 -1
  10. package/dist/camera/cameraCollision.js +72 -1
  11. package/dist/camera/index.d.ts +1 -1
  12. package/dist/camera/index.js +1 -1
  13. package/dist/camera/rigResolve.d.ts +13 -0
  14. package/dist/camera/rigResolve.js +16 -0
  15. package/dist/environment/EnvironmentScene.js +118 -10
  16. package/dist/postfx/shadowPrepass.d.ts +8 -1
  17. package/dist/postfx/shadowPrepass.js +18 -2
  18. package/dist/postfx/stylizePass.js +2 -1
  19. package/dist/render/CascadedShadows.d.ts +31 -1
  20. package/dist/render/CascadedShadows.js +71 -5
  21. package/dist/render/CharacterCrowd.d.ts +20 -0
  22. package/dist/render/CharacterCrowd.js +176 -0
  23. package/dist/render/SceneLighting.js +27 -3
  24. package/dist/render/SceneWarmupGate.d.ts +20 -0
  25. package/dist/render/SceneWarmupGate.js +48 -0
  26. package/dist/render/characterLod.d.ts +62 -0
  27. package/dist/render/characterLod.js +165 -0
  28. package/dist/render/crowdRegistry.d.ts +48 -0
  29. package/dist/render/crowdRegistry.js +97 -0
  30. package/dist/render/decalGeometry.d.ts +26 -0
  31. package/dist/render/decalGeometry.js +54 -0
  32. package/dist/render/decalTexture.d.ts +13 -0
  33. package/dist/render/decalTexture.js +114 -0
  34. package/dist/render/materialAsset.js +2 -1
  35. package/dist/render/modelLoad.js +3 -0
  36. package/dist/render/modelMeshMerge.d.ts +49 -0
  37. package/dist/render/modelMeshMerge.js +269 -0
  38. package/dist/render/modelWind.d.ts +28 -0
  39. package/dist/render/modelWind.js +103 -0
  40. package/dist/render/shaderWarmup.d.ts +69 -0
  41. package/dist/render/shaderWarmup.js +120 -0
  42. package/dist/render/shadowCasters.d.ts +28 -0
  43. package/dist/render/shadowCasters.js +95 -0
  44. package/dist/render/staticTransforms.d.ts +20 -0
  45. package/dist/render/staticTransforms.js +32 -0
  46. package/dist/render/useFootIk.d.ts +11 -3
  47. package/dist/render/useFootIk.js +35 -7
  48. package/dist/render/useModelAnimation.d.ts +16 -2
  49. package/dist/render/useModelAnimation.js +73 -4
  50. package/dist/render/useModelInstance.d.ts +1 -1
  51. package/dist/render/useModelInstance.js +26 -1
  52. package/dist/render/useRagdoll.d.ts +32 -0
  53. package/dist/render/useRagdoll.js +105 -0
  54. package/dist/scatter/scatterModels.js +2 -0
  55. package/dist/scene/DecalRenderer.d.ts +2 -0
  56. package/dist/scene/DecalRenderer.js +74 -0
  57. package/dist/scene/GrassFieldRenderer.js +3 -2
  58. package/dist/scene/builtinSceneKindRenderers.js +2 -0
  59. package/dist/settings/SettingsMenu.d.ts +1 -1
  60. package/dist/settings/SettingsMenu.js +10 -79
  61. package/dist/settings/settingsController.js +3 -0
  62. package/dist/terrain/GrassCover.d.ts +21 -0
  63. package/dist/terrain/GrassCover.js +84 -0
  64. package/dist/terrain/grassCoverGeometry.d.ts +40 -0
  65. package/dist/terrain/grassCoverGeometry.js +112 -0
  66. package/dist/terrain/terrainDetailMaterial.d.ts +12 -0
  67. package/dist/terrain/terrainDetailMaterial.js +54 -1
  68. package/dist/terrain/terrainSurfaces.d.ts +6 -0
  69. package/dist/terrain/terrainSurfaces.js +10 -0
  70. package/dist/world/StaticObjectModels.d.ts +13 -0
  71. package/dist/world/StaticObjectModels.js +28 -3
  72. package/dist/world/WorldParticles.d.ts +2 -1
  73. package/dist/world/WorldParticles.js +29 -10
  74. package/dist/world/WorldScene.js +52 -14
  75. package/dist/world/entityPose.d.ts +17 -0
  76. package/dist/world/entityPose.js +42 -0
  77. package/dist/world/staticObjectBatches.d.ts +33 -5
  78. package/dist/world/staticObjectBatches.js +40 -6
  79. package/package.json +4 -4
  80. package/skills/jgengine/api.md +2 -2
  81. package/skills/jgengine-ui/SKILL.md +1 -0
  82. package/skills/jgengine-ui/api.md +231 -9
  83. package/skills/jgengine-ui/capabilities.md +107 -1
  84. package/skills/jgengine-ui/reference-primitives.md +2 -0
  85. package/skills/jgengine-ui/reference.md +8 -0
  86. package/skills/jgengine-world/SKILL.md +8 -0
  87. package/skills/jgengine-world/api.md +243 -11
  88. package/skills/jgengine-world/capabilities.md +61 -1
  89. package/skills/jgengine-world/recipes/character-animation.md +25 -2
  90. package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
  91. package/skills/jgengine-world/reference.md +1 -1
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Camera spring-arm occlusion filtering.
2
+ * Camera spring-arm occlusion filtering and camera clearance.
3
3
  *
4
4
  * The orbit rig raycasts target→camera each frame and pulls the boom in past
5
5
  * occluders. Author decor mounted through the `WorldOverlay` presentation seam
@@ -7,6 +7,10 @@
7
7
  * decor group flags itself camera-transparent once (`jgCameraTransparent`) and
8
8
  * every descendant inherits it — a child can opt back into blocking the camera
9
9
  * with `jgCameraCollide`.
10
+ *
11
+ * `clearCameraBoom` is the collision half every rig (and a game-owned camera)
12
+ * shares: one query against the world's existing collision, one boom length that
13
+ * cannot leave the camera inside geometry.
10
14
  */
11
15
  /** userData applied to the group wrapping author `WorldOverlay` decor so the spring-arm ignores it by default. */
12
16
  export const CAMERA_TRANSPARENT_USERDATA = {
@@ -32,3 +36,70 @@ export function isCameraOccluderTransparent(object) {
32
36
  }
33
37
  return false;
34
38
  }
39
+ function clampNumber(value, min, max) {
40
+ return value < min ? min : value > max ? max : value;
41
+ }
42
+ /**
43
+ * Pull a camera in along its boom so it never sits inside world collision.
44
+ * A blocking surface rejects the desired position: the camera moves to
45
+ * `clearance` before it, and onto the pivot when the surface is closer than that
46
+ * (the pivot is inside, so no camera position is clear). Pulling in is immediate
47
+ * — a camera must not spend a frame inside a trunk — while releasing eases back
48
+ * at `releaseRate`, which is what makes the boom read as a spring arm instead of
49
+ * a hard stop.
50
+ *
51
+ * @capability camera-clearance keep any camera out of world collision with a spring-arm pull-in
52
+ */
53
+ export function clearCameraBoom(input) {
54
+ const { pivot, desired, query } = input;
55
+ const dx = desired.x - pivot.x;
56
+ const dy = desired.y - pivot.y;
57
+ const dz = desired.z - pivot.z;
58
+ const length = Math.hypot(dx, dy, dz);
59
+ if (!(length > 1e-6) || !Number.isFinite(length)) {
60
+ return { position: { ...desired }, arm: 0, pulled: false };
61
+ }
62
+ const clearance = Math.max(0, input.clearance ?? 0.3);
63
+ const direction = { x: dx / length, y: dy / length, z: dz / length };
64
+ const hit = query.firstHit(pivot, direction, length + clearance);
65
+ // A zero hit means the boom origin is itself inside collision, so the ray says nothing
66
+ // about the eye; moving to it would teleport the camera into whatever holds the origin.
67
+ if (hit !== null && hit <= 0) {
68
+ return { position: { ...desired }, arm: length, pulled: false };
69
+ }
70
+ const allowed = hit === null ? length : clampNumber(hit - clearance, 0, length);
71
+ let arm = allowed;
72
+ const previous = input.previousArm;
73
+ const dt = input.deltaSeconds ?? 0;
74
+ if (previous !== null && previous !== undefined && previous < allowed && dt > 0) {
75
+ const rate = Math.max(0, input.releaseRate ?? 8);
76
+ arm = Math.min(allowed, previous + (allowed - previous) * (1 - Math.exp(-rate * dt)));
77
+ }
78
+ return {
79
+ position: { x: pivot.x + direction.x * arm, y: pivot.y + direction.y * arm, z: pivot.z + direction.z * arm },
80
+ arm,
81
+ pulled: hit !== null && arm < length - 1e-9,
82
+ };
83
+ }
84
+ /**
85
+ * `CameraCollisionQuery` over the scene's existing collision query (`ctx.scene.raycast`),
86
+ * which already reads world solids, scene-object colliders and walls. Entities and
87
+ * terrain stay out by default: the followed body must not push its own camera, and
88
+ * looking down at the ground must not pull the boom.
89
+ *
90
+ * @capability camera-clearance keep any camera out of world collision with a spring-arm pull-in
91
+ */
92
+ export function sceneCameraQuery(raycast, options = {}) {
93
+ const filter = { entities: options.entities ?? false, terrain: options.terrain ?? false };
94
+ return {
95
+ firstHit(origin, direction, maxDistance) {
96
+ const hit = raycast({
97
+ origin: [origin.x, origin.y, origin.z],
98
+ direction: [direction.x, direction.y, direction.z],
99
+ maxDistance,
100
+ filter,
101
+ });
102
+ return hit === null ? null : hit.distance;
103
+ },
104
+ };
105
+ }
@@ -8,5 +8,5 @@ export { CameraShakeContext, cameraShake, createCameraShakeChannel, defaultCamer
8
8
  export { ControllerCameraShake, type ControllerCameraShakeProps } from "./ControllerCameraShake.js";
9
9
  export { rtsPanKeysConflict } from "./rigMath.js";
10
10
  export { GAME_SIM_FRAME_PRIORITY } from "./orbitCameraMath.js";
11
- export { CAMERA_TRANSPARENT_USERDATA, isCameraOccluderTransparent, type CameraOccluder, } from "./cameraCollision.js";
11
+ export { CAMERA_TRANSPARENT_USERDATA, clearCameraBoom, isCameraOccluderTransparent, sceneCameraQuery, type CameraClearanceInput, type CameraClearanceResult, type CameraCollisionQuery, type CameraOccluder, type CameraPoint, } from "./cameraCollision.js";
12
12
  export { registerFirstPersonMuzzle, type FirstPersonMuzzleReader } from "./customMuzzle.js";
@@ -8,5 +8,5 @@ export { CameraShakeContext, cameraShake, createCameraShakeChannel, defaultCamer
8
8
  export { ControllerCameraShake } from "./ControllerCameraShake.js";
9
9
  export { rtsPanKeysConflict } from "./rigMath.js";
10
10
  export { GAME_SIM_FRAME_PRIORITY } from "./orbitCameraMath.js";
11
- export { CAMERA_TRANSPARENT_USERDATA, isCameraOccluderTransparent, } from "./cameraCollision.js";
11
+ export { CAMERA_TRANSPARENT_USERDATA, clearCameraBoom, isCameraOccluderTransparent, sceneCameraQuery, } from "./cameraCollision.js";
12
12
  export { registerFirstPersonMuzzle } from "./customMuzzle.js";
@@ -2,6 +2,19 @@ import type { CameraRigKind, GameCameraConfig } from "@jgengine/core/game/playab
2
2
  import type { CameraRigOverride } from "@jgengine/core/runtime/cameraDirector";
3
3
  /** @internal */
4
4
  export declare function resolveRuntimeCameraConfig(config: GameCameraConfig | undefined, override: CameraRigOverride | null | undefined): GameCameraConfig | undefined;
5
+ /**
6
+ * The camera config that renders this frame, with the capture camera winning over everything.
7
+ *
8
+ * A game owns its camera three ways: the static `camera` block, a runtime `ctx.camera.setRig`
9
+ * selection, and a director cinematic. A capture harness can rewrite the first, but the other two
10
+ * run afterwards and quietly photograph the game's own camera. `installCaptureCamera` is read here,
11
+ * above all three, so `?look=`/`--look` frames what it was asked for; with nothing installed this is
12
+ * exactly `resolveRuntimeCameraConfig` and every game behaves as before.
13
+ *
14
+ * @capability capture render a framed capture camera over a game that owns its camera
15
+ * @internal
16
+ */
17
+ export declare function resolveCaptureCameraConfig(config: GameCameraConfig | undefined, override: CameraRigOverride | null | undefined): GameCameraConfig | undefined;
5
18
  /**
6
19
  * Resolves which rig mounts from a `GameCameraConfig`. Precedence, most to least
7
20
  * specific: an explicit `rig` field always wins; then `perspective: "first"`
@@ -1,3 +1,4 @@
1
+ import { readCaptureCamera } from "@jgengine/core/world/captureCamera";
1
2
  /** @internal */
2
3
  export function resolveRuntimeCameraConfig(config, override) {
3
4
  if (override === null || override === undefined)
@@ -9,6 +10,21 @@ export function resolveRuntimeCameraConfig(config, override) {
9
10
  }
10
11
  return merged;
11
12
  }
13
+ /**
14
+ * The camera config that renders this frame, with the capture camera winning over everything.
15
+ *
16
+ * A game owns its camera three ways: the static `camera` block, a runtime `ctx.camera.setRig`
17
+ * selection, and a director cinematic. A capture harness can rewrite the first, but the other two
18
+ * run afterwards and quietly photograph the game's own camera. `installCaptureCamera` is read here,
19
+ * above all three, so `?look=`/`--look` frames what it was asked for; with nothing installed this is
20
+ * exactly `resolveRuntimeCameraConfig` and every game behaves as before.
21
+ *
22
+ * @capability capture render a framed capture camera over a game that owns its camera
23
+ * @internal
24
+ */
25
+ export function resolveCaptureCameraConfig(config, override) {
26
+ return resolveRuntimeCameraConfig(config, readCaptureCamera() ?? override);
27
+ }
12
28
  /**
13
29
  * Resolves which rig mounts from a `GameCameraConfig`. Precedence, most to least
14
30
  * specific: an explicit `rig` field always wins; then `perspective: "first"`
@@ -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, useState } 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,105 @@ 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
+ /** The map roles a detail material samples, in the order the loader starts them. */
53
+ function scanRoles(maps) {
54
+ return [
55
+ ["color", maps.color],
56
+ ["normal", maps.normal],
57
+ ...(maps.roughness === undefined ? [] : [["roughness", maps.roughness]]),
58
+ ...(maps.ao === undefined ? [] : [["ao", maps.ao]]),
59
+ ];
60
+ }
61
+ /**
62
+ * The same ground as {@link TexturedTerrainGround} for a scan the game pulled rather than shipped:
63
+ * the maps load straight through `THREE.TextureLoader`, so a missing one stays a warning instead of
64
+ * a suspense rejection that would take the view down, and the authored procedural ground renders
65
+ * until they answer.
66
+ */
67
+ function TolerantTerrainGround({ detail, layout, layerMaps, fallback, }) {
68
+ const maps = detail.material.maps;
69
+ const roles = scanRoles(maps);
70
+ const key = roles.map(([role, url]) => `${role}:${url}`).join("|");
71
+ const [loaded, setLoaded] = useState({ key });
72
+ useEffect(() => {
73
+ let active = true;
74
+ const textures = {};
75
+ let pending = roles.length;
76
+ const loader = new THREE.TextureLoader();
77
+ for (const [role, url] of roles) {
78
+ loader.load(url, (texture) => {
79
+ if (!active) {
80
+ texture.dispose();
81
+ return;
82
+ }
83
+ texture.colorSpace = role === "color" ? THREE.SRGBColorSpace : THREE.NoColorSpace;
84
+ texture.wrapS = THREE.RepeatWrapping;
85
+ texture.wrapT = THREE.RepeatWrapping;
86
+ textures[role] = texture;
87
+ pending -= 1;
88
+ if (pending === 0)
89
+ setLoaded({ key, textures: textures });
90
+ }, undefined, () => {
91
+ if (!active)
92
+ return;
93
+ active = false;
94
+ console.warn(`[jgengine] terrain detail maps did not load (${url}) — keeping the procedural ground. Pull the game's asset packs (\`bun run provision\`) to restore them.`);
95
+ setLoaded({ key });
96
+ });
97
+ }
98
+ return () => {
99
+ active = false;
100
+ for (const texture of Object.values(textures))
101
+ texture?.dispose();
102
+ };
103
+ }, [key]);
104
+ const quality = useTerrainQuality();
105
+ const material = useMemo(() => loaded.key !== key || loaded.textures === undefined
106
+ ? undefined
107
+ : createTerrainDetailMaterial(detail, loaded.textures, { paint: layout.paint, quality, ...(layerMaps === undefined ? {} : { layerMaps }) }).material, [detail, loaded, key, layout.paint, quality, layerMaps]);
108
+ useEffect(() => () => material?.dispose(), [material]);
109
+ if (material === undefined)
110
+ return _jsx(_Fragment, { children: fallback });
111
+ return (_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 }));
112
+ }
113
+ /**
114
+ * Scanned color maps for up to {@link MAX_TERRAIN_LAYER_MAPS} painted terrain layers, loaded through
115
+ * the shared recoverable texture path and tiled at each layer's own authored metres. The URL is
116
+ * authored data (`TerrainMaterialLayer.materialUrl`), resolved by the editor from a library name, so
117
+ * the renderer never reaches into the asset pipeline.
118
+ */
119
+ function useTerrainLayerMaps(paint) {
120
+ const urls = useMemo(() => Array.from({ length: MAX_TERRAIN_LAYER_MAPS }, (_, index) => {
121
+ const url = paint?.slots[index + 1]?.materialUrl;
122
+ return url === undefined || url.length === 0 ? NO_LAYER_MAP : url;
123
+ }), [paint]);
124
+ const textures = useRecoverableTexture({ layer1: urls[0], layer2: urls[1], layer3: urls[2], layer4: urls[3] });
125
+ useEffect(() => {
126
+ for (const texture of Object.values(textures)) {
127
+ texture.colorSpace = THREE.SRGBColorSpace;
128
+ texture.wrapS = THREE.RepeatWrapping;
129
+ texture.wrapT = THREE.RepeatWrapping;
130
+ texture.needsUpdate = true;
131
+ }
132
+ }, [textures]);
133
+ const slots = paint?.slots;
134
+ return useMemo(() => {
135
+ if (slots === undefined)
136
+ return undefined;
137
+ return Array.from({ length: MAX_TERRAIN_LAYER_MAPS }, (_, index) => {
138
+ const slot = slots[index + 1];
139
+ if (slot?.materialUrl === undefined)
140
+ return undefined;
141
+ const texture = textures[`layer${index + 1}`];
142
+ return texture === undefined ? undefined : { color: texture, tileMetres: slot.tileMetres };
143
+ });
144
+ }, [slots, textures]);
145
+ }
48
146
  function TerrainGround({ terrain, field, center, sculpt, }) {
49
147
  const detailEnabled = terrain.detail !== false;
50
148
  const paint = useMemo(() => {
@@ -79,14 +177,19 @@ function TerrainGround({ terrain, field, center, sculpt, }) {
79
177
  }, [terrain.baseHeight, terrain.height]);
80
178
  const resolvedDetail = useMemo(() => (terrain.detail === false ? undefined : resolveTerrainDetail(terrain.detail, terrain.waterLevel)), [terrain.detail, terrain.waterLevel]);
81
179
  const quality = useTerrainQuality();
82
- const proceduralMaterial = useMemo(() => resolvedDetail === undefined || resolvedDetail.material !== undefined
180
+ const layerMaps = useTerrainLayerMaps(paint);
181
+ const proceduralMaterial = useMemo(() => resolvedDetail === undefined || (resolvedDetail.material !== undefined && resolvedDetail.material.missing === "fail")
83
182
  ? undefined
84
- : createTerrainDetailMaterial(resolvedDetail, undefined, { paint, quality }).material, [resolvedDetail, paint, quality]);
183
+ : createTerrainDetailMaterial(resolvedDetail, undefined, { paint, quality, ...(layerMaps === undefined ? {} : { layerMaps }) }).material, [resolvedDetail, paint, quality, layerMaps]);
85
184
  useEffect(() => () => proceduralMaterial?.dispose(), [proceduralMaterial]);
185
+ const proceduralGround = (_jsx(CarvedTerrain, { field: field, size: size, segments: terrain.segments, colors: colors, heightRange: heightRange, paletteAt: paletteAt, paintColorAt: paintColorAt, center: center, roughness: 0.94, surfaceMaterial: proceduralMaterial }));
86
186
  if (resolvedDetail?.material !== undefined) {
87
- return (_jsx(PresentationAssetBoundary, { children: _jsx(TexturedTerrainGround, { detail: resolvedDetail, layout: { field, size, segments: terrain.segments, colors, heightRange, paletteAt, paint, center } }) }));
187
+ const textured = (_jsx(TexturedTerrainGround, { detail: resolvedDetail, layout: { field, size, segments: terrain.segments, colors, heightRange, paletteAt, paint, center }, ...(layerMaps === undefined ? {} : { layerMaps }) }));
188
+ if (resolvedDetail.material.missing === "fail")
189
+ return _jsx(PresentationAssetBoundary, { children: textured });
190
+ return (_jsx(TolerantTerrainGround, { detail: resolvedDetail, layout: { field, size, segments: terrain.segments, colors, heightRange, paletteAt, paint, center }, ...(layerMaps === undefined ? {} : { layerMaps }), fallback: proceduralGround }));
88
191
  }
89
- 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 }));
192
+ return proceduralGround;
90
193
  }
91
194
  function areaCenter(area) {
92
195
  return area.position ?? [0, 0];
@@ -100,7 +203,7 @@ function Vegetation({ grass, field, groundColor, }) {
100
203
  const wind = useMemo(() => ({ strength: grass.windStrength }), [grass.windStrength]);
101
204
  const colorBase = grass.colors[0];
102
205
  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 }) }));
206
+ 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
207
  }
105
208
  /** Terrain color under a grass patch center — so blade roots blend into the ground they stand on. */
106
209
  function vegetationGroundColor(terrain, field, grass, sculpt) {
@@ -142,19 +245,24 @@ function DynamicWater({ ocean }) {
142
245
  const groupRef = useRef(null);
143
246
  const [x, z] = ocean.position ?? [0, 0];
144
247
  const config = useOceanConfig(ocean);
248
+ 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
249
  useFrame(() => {
146
250
  if (groupRef.current !== null)
147
251
  groupRef.current.position.y = ocean.levelAt(ctx.time.now());
148
252
  });
149
- return (_jsx("group", { ref: groupRef, position: [0, ocean.level, 0], children: _jsx(Ocean, { position: [x, 0, z], config: config }) }));
253
+ return (_jsx("group", { ref: groupRef, position: [0, ocean.level, 0], children: _jsx(Ocean, { position: [x, 0, z], config: config, depthAt: depthAt }) }));
150
254
  }
151
255
  function Water({ ocean }) {
256
+ const ctx = useGameContext();
152
257
  const [x, z] = ocean.position ?? [0, 0];
153
258
  const config = useOceanConfig(ocean);
259
+ // The ocean descriptor has no depth field: sample the live ground so the sheet shades
260
+ // shallow→deep and the shoreline foam band lands where the bed meets the surface.
261
+ const depthAt = useCallback((lx, lz) => ocean.level - ctx.world.ground.sampleHeight(x + lx, z + lz), [ctx.world.ground, ocean.level, x, z]);
154
262
  if (ocean.levelAt !== undefined) {
155
263
  return _jsx(DynamicWater, { ocean: ocean });
156
264
  }
157
- return _jsx(Ocean, { position: [x, ocean.level, z], config: config });
265
+ return _jsx(Ocean, { position: [x, ocean.level, z], config: config, depthAt: depthAt });
158
266
  }
159
267
  function Structures({ structures, field }) {
160
268
  const placements = useMemo(() => resolveStructureBuildings(structures).map((building) => ({
@@ -1,3 +1,10 @@
1
1
  import type * as THREE from "three";
2
- /** Reuse the beauty pass's shadow maps while drawing a scene prepass. @internal */
2
+ /**
3
+ * Draw a prepass with the world matrices the beauty pass already built. A postfx prepass renders the
4
+ * same scene a few milliseconds later in the same frame, so three's `updateMatrixWorld` walk there
5
+ * recomposes a graph nothing has moved — skipping it removes one full scene-graph walk per frame.
6
+ * @internal
7
+ */
8
+ export declare function renderWithCurrentMatrices<T>(scene: THREE.Object3D, render: () => T): T;
9
+ /** Reuse the beauty pass's shadow maps and world matrices while drawing a scene prepass. @internal */
3
10
  export declare function renderShadowlessPrepass<T>(renderer: Pick<THREE.WebGLRenderer, "shadowMap">, scene: THREE.Object3D, hiddenOverlays: THREE.Object3D[], render: () => T): T;
@@ -1,5 +1,21 @@
1
1
  import { hidePostfxOverlays, restorePostfxOverlays } from "./postfxOverlay.js";
2
- /** Reuse the beauty pass's shadow maps while drawing a scene prepass. @internal */
2
+ /**
3
+ * Draw a prepass with the world matrices the beauty pass already built. A postfx prepass renders the
4
+ * same scene a few milliseconds later in the same frame, so three's `updateMatrixWorld` walk there
5
+ * recomposes a graph nothing has moved — skipping it removes one full scene-graph walk per frame.
6
+ * @internal
7
+ */
8
+ export function renderWithCurrentMatrices(scene, render) {
9
+ const autoUpdate = scene.matrixWorldAutoUpdate;
10
+ scene.matrixWorldAutoUpdate = false;
11
+ try {
12
+ return render();
13
+ }
14
+ finally {
15
+ scene.matrixWorldAutoUpdate = autoUpdate;
16
+ }
17
+ }
18
+ /** Reuse the beauty pass's shadow maps and world matrices while drawing a scene prepass. @internal */
3
19
  export function renderShadowlessPrepass(renderer, scene, hiddenOverlays, render) {
4
20
  const shadowMap = renderer.shadowMap;
5
21
  const autoUpdate = shadowMap.autoUpdate;
@@ -8,7 +24,7 @@ export function renderShadowlessPrepass(renderer, scene, hiddenOverlays, render)
8
24
  shadowMap.autoUpdate = false;
9
25
  shadowMap.needsUpdate = false;
10
26
  try {
11
- return render();
27
+ return renderWithCurrentMatrices(scene, render);
12
28
  }
13
29
  finally {
14
30
  shadowMap.autoUpdate = autoUpdate;
@@ -1,6 +1,7 @@
1
1
  import * as THREE from "three";
2
2
  import { FullScreenQuad, Pass } from "three-stdlib";
3
3
  import { isPostfxOverlay } from "./postfxOverlay.js";
4
+ import { renderWithCurrentMatrices } from "./shadowPrepass.js";
4
5
  const DEFAULT_OUTLINE_COLOR = "#101014";
5
6
  const DEFAULT_DEPTH_THRESHOLD = 0.08;
6
7
  const DEFAULT_NORMAL_THRESHOLD = 0.35;
@@ -197,7 +198,7 @@ export class StylizePass extends Pass {
197
198
  renderer.setRenderTarget(target);
198
199
  renderer.setClearColor(0x8080ff, 1);
199
200
  renderer.clear();
200
- renderer.render(scene, this.camera);
201
+ renderWithCurrentMatrices(scene, () => renderer.render(scene, this.camera));
201
202
  renderer.setClearColor(this.clearColor, previousClearAlpha);
202
203
  renderer.shadowMap.autoUpdate = previousShadowUpdate;
203
204
  scene.background = previousBackground;
@@ -1,9 +1,38 @@
1
1
  import * as THREE from "three";
2
2
  import { CSM } from "three/examples/jsm/csm/CSM.js";
3
3
  import type { DirectionalLightingConfig } from "@jgengine/core/game/playableGame";
4
+ import type { GraphicsProfile } from "@jgengine/core/settings/graphicsProfile";
4
5
  import type { DaylightModulation } from "./SceneLighting.js";
5
6
  /** @internal Default PCF penumbra in shadow-map texels for configured directional lights. */
6
7
  export declare const DEFAULT_SHADOW_RADIUS = 1.25;
8
+ /** @internal Reach in metres used when neither the game nor a graphics profile sets one. */
9
+ export declare const DEFAULT_SHADOW_DISTANCE = 200;
10
+ /**
11
+ * Shadow reach each tier's map resolution pays for. A cascade's texels stop describing a
12
+ * silhouette past roughly a tenth of a metre, so a smaller map covers less ground instead
13
+ * of covering the same ground coarsely.
14
+ * @internal
15
+ */
16
+ export declare const SHADOW_DISTANCE_BY_MAP_SIZE: Readonly<Record<number, number>>;
17
+ /**
18
+ * Cascaded-shadow reach from the graphics tier, clamped by that tier's draw distance.
19
+ * A game's own `shadowMaxFar` still wins.
20
+ * @internal
21
+ */
22
+ export declare function resolveShadowDistance(profile?: GraphicsProfile, authored?: number): number;
23
+ /**
24
+ * Frames between shadow-map refreshes for the cascade at `index`, nearest first. The nearest
25
+ * cascades move several texels per frame and refresh every frame; the far ones cover ground a
26
+ * slow camera barely crosses, so they pay the depth pass a half or a quarter as often.
27
+ * @internal
28
+ */
29
+ export declare function cascadeShadowCadence(index: number, cascades: number): number;
30
+ /**
31
+ * Set each cascade's shadow refresh policy for `frame`. A cascade that is not due keeps both
32
+ * its matrix and its map from the last refresh, so the pair stays consistent with itself.
33
+ * @internal
34
+ */
35
+ export declare function scheduleCascadeRefresh(lights: readonly THREE.DirectionalLight[], frame: number): void;
7
36
  /**
8
37
  * Cascaded shadow maps for one directional light — outdoor scenes keep shadows past a
9
38
  * single 40-unit ortho frustum. Uses three.js `CSM` (shader injection on standard materials).
@@ -13,8 +42,9 @@ export declare const DEFAULT_SHADOW_RADIUS = 1.25;
13
42
  *
14
43
  * @internal shell SceneLighting helper — games set `cascades` on directional lighting config.
15
44
  */
16
- export declare function CascadedShadows({ entry, modulation }: {
45
+ export declare function CascadedShadows({ entry, profile, modulation, }: {
17
46
  entry: DirectionalLightingConfig;
47
+ profile?: GraphicsProfile;
18
48
  modulation?: DaylightModulation;
19
49
  }): null;
20
50
  /** Structural slice of CSM so the patcher is testable without a renderer. */
@@ -4,6 +4,60 @@ import * as THREE from "three";
4
4
  import { CSM } from "three/examples/jsm/csm/CSM.js";
5
5
  /** @internal Default PCF penumbra in shadow-map texels for configured directional lights. */
6
6
  export const DEFAULT_SHADOW_RADIUS = 1.25;
7
+ /** @internal Reach in metres used when neither the game nor a graphics profile sets one. */
8
+ export const DEFAULT_SHADOW_DISTANCE = 200;
9
+ /**
10
+ * Shadow reach each tier's map resolution pays for. A cascade's texels stop describing a
11
+ * silhouette past roughly a tenth of a metre, so a smaller map covers less ground instead
12
+ * of covering the same ground coarsely.
13
+ * @internal
14
+ */
15
+ export const SHADOW_DISTANCE_BY_MAP_SIZE = {
16
+ 512: 100,
17
+ 1024: 150,
18
+ 2048: 200,
19
+ 4096: 200,
20
+ };
21
+ /**
22
+ * Cascaded-shadow reach from the graphics tier, clamped by that tier's draw distance.
23
+ * A game's own `shadowMaxFar` still wins.
24
+ * @internal
25
+ */
26
+ export function resolveShadowDistance(profile, authored) {
27
+ if (authored !== undefined)
28
+ return authored;
29
+ if (profile === undefined)
30
+ return DEFAULT_SHADOW_DISTANCE;
31
+ const reach = SHADOW_DISTANCE_BY_MAP_SIZE[profile.shadowMapSize] ?? DEFAULT_SHADOW_DISTANCE;
32
+ return Math.min(profile.drawDistance, reach);
33
+ }
34
+ /**
35
+ * Frames between shadow-map refreshes for the cascade at `index`, nearest first. The nearest
36
+ * cascades move several texels per frame and refresh every frame; the far ones cover ground a
37
+ * slow camera barely crosses, so they pay the depth pass a half or a quarter as often.
38
+ * @internal
39
+ */
40
+ export function cascadeShadowCadence(index, cascades) {
41
+ if (index < cascades - 2)
42
+ return 1;
43
+ if (cascades <= 2)
44
+ return index === 0 ? 1 : 2;
45
+ return 2 ** (index - (cascades - 3));
46
+ }
47
+ /**
48
+ * Set each cascade's shadow refresh policy for `frame`. A cascade that is not due keeps both
49
+ * its matrix and its map from the last refresh, so the pair stays consistent with itself.
50
+ * @internal
51
+ */
52
+ export function scheduleCascadeRefresh(lights, frame) {
53
+ for (let index = 0; index < lights.length; index += 1) {
54
+ const shadow = lights[index].shadow;
55
+ const cadence = cascadeShadowCadence(index, lights.length);
56
+ shadow.autoUpdate = cadence === 1;
57
+ if (cadence > 1)
58
+ shadow.needsUpdate = frame % cadence === 0;
59
+ }
60
+ }
7
61
  function isStandardMaterial(mat) {
8
62
  return mat instanceof THREE.MeshStandardMaterial || mat instanceof THREE.MeshPhysicalMaterial;
9
63
  }
@@ -16,11 +70,13 @@ function isStandardMaterial(mat) {
16
70
  *
17
71
  * @internal shell SceneLighting helper — games set `cascades` on directional lighting config.
18
72
  */
19
- export function CascadedShadows({ entry, modulation }) {
73
+ export function CascadedShadows({ entry, profile, modulation, }) {
20
74
  const camera = useThree((s) => s.camera);
21
75
  const scene = useThree((s) => s.scene);
22
76
  const csmRef = useRef(null);
77
+ const frameRef = useRef(0);
23
78
  const baseIntensity = entry.intensity ?? 1.3;
79
+ const maxFar = resolveShadowDistance(profile, entry.shadowMaxFar);
24
80
  useEffect(() => {
25
81
  const cascades = Math.max(2, Math.min(4, Math.floor(entry.cascades ?? 3)));
26
82
  const lightDirection = new THREE.Vector3(-entry.position[0], -entry.position[1], -entry.position[2]);
@@ -31,7 +87,7 @@ export function CascadedShadows({ entry, modulation }) {
31
87
  camera,
32
88
  parent: scene,
33
89
  cascades,
34
- maxFar: entry.shadowMaxFar ?? 200,
90
+ maxFar,
35
91
  mode: "practical",
36
92
  shadowMapSize: entry.shadowMapSize ?? 1024,
37
93
  shadowBias: entry.shadowBias ?? -0.0004,
@@ -65,7 +121,7 @@ export function CascadedShadows({ entry, modulation }) {
65
121
  entry.shadowBias,
66
122
  entry.shadowCameraSize,
67
123
  entry.shadowMapSize,
68
- entry.shadowMaxFar,
124
+ maxFar,
69
125
  entry.shadowRadius,
70
126
  ]);
71
127
  useFrame(() => {
@@ -81,6 +137,7 @@ export function CascadedShadows({ entry, modulation }) {
81
137
  }
82
138
  csm.camera = camera;
83
139
  csm.update();
140
+ scheduleCascadeRefresh(csm.lights, frameRef.current++);
84
141
  });
85
142
  return null;
86
143
  }
@@ -153,14 +210,23 @@ export function bindCsmMaterials(scene, csm) {
153
210
  const added = ({ child }) => attach(child);
154
211
  const removed = ({ child }) => detach(child);
155
212
  const prior = node.onBeforeRender;
213
+ // One reference compare per draw: a mesh whose material has not been swapped costs nothing
214
+ // more than the authored hook. Re-patching only on a change keeps the swap detection.
215
+ let lastMaterial;
216
+ const mesh = node;
156
217
  const before = function (...args) {
157
218
  prior.apply(this, args);
158
- patchMesh(this);
219
+ if (mesh.material === lastMaterial)
220
+ return;
221
+ lastMaterial = mesh.material;
222
+ patchMesh(mesh);
159
223
  };
160
224
  node.addEventListener("childadded", added);
161
225
  node.addEventListener("childremoved", removed);
162
- if (node.isMesh)
226
+ if (mesh.isMesh) {
227
+ lastMaterial = mesh.material;
163
228
  node.onBeforeRender = before;
229
+ }
164
230
  nodes.set(node, () => {
165
231
  node.removeEventListener("childadded", added);
166
232
  node.removeEventListener("childremoved", removed);
@@ -0,0 +1,20 @@
1
+ import type { ModelConfig } from "@jgengine/core/game/playableGame";
2
+ import { CharacterCrowdRegistry } from "./crowdRegistry.js";
3
+ /** Props for {@link CharacterCrowd}. */
4
+ export interface CharacterCrowdProps {
5
+ /** Character models the world can draw; only models with a `lod` policy join the crowd path. */
6
+ models: readonly ModelConfig[];
7
+ /** Where entity markers record the characters the crowd is drawing. */
8
+ registry: CharacterCrowdRegistry;
9
+ /** Baked samples per second for the crowd clips. Default 24. */
10
+ fps?: number;
11
+ }
12
+ /**
13
+ * Draws far characters through the GPU crowd path: one baked rig per character kind, one instanced
14
+ * draw per material, every member's clip, time offset and speed sampled in the vertex shader. The
15
+ * entity markers decide who is far enough (their `lod` tiers) and write poses into `registry`; this
16
+ * layer owns the bakes, the materials and the instance buffers. Mount it in the same canvas as the
17
+ * entity markers; unmount it to fall back to per-entity rigs.
18
+ * @capability character-crowd draw distant animated characters on the GPU instead of posing their skeletons
19
+ */
20
+ export declare function CharacterCrowd({ models, registry, fps }: CharacterCrowdProps): import("react").JSX.Element;