@jgengine/shell 0.27.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 (69) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/dist/GameUiPreview.js +3 -1
  3. package/dist/Shell3dPresentation.js +6 -4
  4. package/dist/ShellChrome.js +3 -1
  5. package/dist/camera/GameCameraRig.js +2 -2
  6. package/dist/camera/GameFirstPersonCamera.js +39 -2
  7. package/dist/camera/cameraCollision.d.ts +66 -1
  8. package/dist/camera/cameraCollision.js +72 -1
  9. package/dist/camera/index.d.ts +1 -1
  10. package/dist/camera/index.js +1 -1
  11. package/dist/camera/rigResolve.d.ts +13 -0
  12. package/dist/camera/rigResolve.js +16 -0
  13. package/dist/environment/EnvironmentScene.js +69 -4
  14. package/dist/postfx/shadowPrepass.d.ts +8 -1
  15. package/dist/postfx/shadowPrepass.js +18 -2
  16. package/dist/postfx/stylizePass.js +2 -1
  17. package/dist/render/CascadedShadows.d.ts +31 -1
  18. package/dist/render/CascadedShadows.js +71 -5
  19. package/dist/render/CharacterCrowd.d.ts +20 -0
  20. package/dist/render/CharacterCrowd.js +176 -0
  21. package/dist/render/SceneLighting.js +27 -3
  22. package/dist/render/SceneWarmupGate.d.ts +20 -0
  23. package/dist/render/SceneWarmupGate.js +48 -0
  24. package/dist/render/characterLod.d.ts +62 -0
  25. package/dist/render/characterLod.js +165 -0
  26. package/dist/render/crowdRegistry.d.ts +48 -0
  27. package/dist/render/crowdRegistry.js +97 -0
  28. package/dist/render/materialAsset.js +2 -1
  29. package/dist/render/modelLoad.js +3 -0
  30. package/dist/render/modelMeshMerge.d.ts +49 -0
  31. package/dist/render/modelMeshMerge.js +269 -0
  32. package/dist/render/shaderWarmup.d.ts +69 -0
  33. package/dist/render/shaderWarmup.js +120 -0
  34. package/dist/render/shadowCasters.d.ts +28 -0
  35. package/dist/render/shadowCasters.js +95 -0
  36. package/dist/render/staticTransforms.d.ts +20 -0
  37. package/dist/render/staticTransforms.js +32 -0
  38. package/dist/render/useFootIk.d.ts +11 -3
  39. package/dist/render/useFootIk.js +35 -7
  40. package/dist/render/useModelAnimation.d.ts +16 -2
  41. package/dist/render/useModelAnimation.js +73 -4
  42. package/dist/render/useModelInstance.d.ts +1 -1
  43. package/dist/render/useModelInstance.js +23 -0
  44. package/dist/render/useRagdoll.d.ts +32 -0
  45. package/dist/render/useRagdoll.js +105 -0
  46. package/dist/settings/SettingsMenu.d.ts +1 -1
  47. package/dist/settings/SettingsMenu.js +10 -79
  48. package/dist/settings/settingsController.js +3 -0
  49. package/dist/world/StaticObjectModels.d.ts +13 -0
  50. package/dist/world/StaticObjectModels.js +28 -3
  51. package/dist/world/WorldParticles.d.ts +2 -1
  52. package/dist/world/WorldParticles.js +29 -10
  53. package/dist/world/WorldScene.js +50 -13
  54. package/dist/world/entityPose.d.ts +17 -0
  55. package/dist/world/entityPose.js +42 -0
  56. package/dist/world/staticObjectBatches.d.ts +33 -5
  57. package/dist/world/staticObjectBatches.js +40 -6
  58. package/package.json +4 -4
  59. package/skills/jgengine/api.md +2 -2
  60. package/skills/jgengine-ui/SKILL.md +1 -0
  61. package/skills/jgengine-ui/api.md +200 -9
  62. package/skills/jgengine-ui/capabilities.md +103 -1
  63. package/skills/jgengine-ui/reference-primitives.md +2 -0
  64. package/skills/jgengine-ui/reference.md +8 -0
  65. package/skills/jgengine-world/SKILL.md +4 -0
  66. package/skills/jgengine-world/api.md +128 -6
  67. package/skills/jgengine-world/capabilities.md +41 -1
  68. package/skills/jgengine-world/recipes/character-animation.md +25 -2
  69. package/skills/jgengine-world/reference.md +1 -1
@@ -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 { useCallback, 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";
@@ -49,6 +49,67 @@ function TexturedTerrainGround({ detail, layout, layerMaps, }) {
49
49
  }
50
50
  /** 1×1 white PNG: binds a valid sampler for a painted layer that has no scanned map. */
51
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
+ }
52
113
  /**
53
114
  * Scanned color maps for up to {@link MAX_TERRAIN_LAYER_MAPS} painted terrain layers, loaded through
54
115
  * the shared recoverable texture path and tiled at each layer's own authored metres. The URL is
@@ -117,14 +178,18 @@ function TerrainGround({ terrain, field, center, sculpt, }) {
117
178
  const resolvedDetail = useMemo(() => (terrain.detail === false ? undefined : resolveTerrainDetail(terrain.detail, terrain.waterLevel)), [terrain.detail, terrain.waterLevel]);
118
179
  const quality = useTerrainQuality();
119
180
  const layerMaps = useTerrainLayerMaps(paint);
120
- const proceduralMaterial = useMemo(() => resolvedDetail === undefined || resolvedDetail.material !== undefined
181
+ const proceduralMaterial = useMemo(() => resolvedDetail === undefined || (resolvedDetail.material !== undefined && resolvedDetail.material.missing === "fail")
121
182
  ? undefined
122
183
  : createTerrainDetailMaterial(resolvedDetail, undefined, { paint, quality, ...(layerMaps === undefined ? {} : { layerMaps }) }).material, [resolvedDetail, paint, quality, layerMaps]);
123
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 }));
124
186
  if (resolvedDetail?.material !== undefined) {
125
- return (_jsx(PresentationAssetBoundary, { children: _jsx(TexturedTerrainGround, { detail: resolvedDetail, layout: { field, size, segments: terrain.segments, colors, heightRange, paletteAt, paint, center }, ...(layerMaps === undefined ? {} : { layerMaps }) }) }));
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 }));
126
191
  }
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 }));
192
+ return proceduralGround;
128
193
  }
129
194
  function areaCenter(area) {
130
195
  return area.position ?? [0, 0];
@@ -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;
@@ -0,0 +1,176 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useFrame, useLoader } from "@react-three/fiber";
3
+ import { Suspense, useEffect, useMemo, useRef } from "react";
4
+ import * as THREE from "three";
5
+ import { devtools } from "@jgengine/core/devtools/devtools";
6
+ import { warnOnce } from "@jgengine/core/devtools/warnOnce";
7
+ import { animationPhaseOf, characterLodProbe, clearCrowdKind, noteCharacterFrame, noteCrowdInstances, } from "./characterLod.js";
8
+ import { CharacterCrowdRegistry, crowdClipForSpeed, crowdClipsOf, crowdKindOf, setCrowdKindReady, } from "./crowdRegistry.js";
9
+ import { applyMaterialOverrideToMaterial } from "../materialOverride.js";
10
+ import { useRenderVisibility } from "../visibility/CullingProvider.js";
11
+ import { ModelFallbackBoundary } from "./SceneModels.js";
12
+ import { sharedGltfLoader } from "./modelLoad.js";
13
+ import { bakeSkinnedCrowd, createCrowdUniforms, patchCrowdMaterial, } from "./SkinnedInstances.js";
14
+ /** Instances one crowd batch can draw; a batch skips past this rather than allocate mid-frame. */
15
+ const CROWD_CAPACITY = 128;
16
+ const scratchMatrix = new THREE.Matrix4();
17
+ const scratchOffset = new THREE.Matrix4();
18
+ const scratchQuaternion = new THREE.Quaternion();
19
+ const scratchPosition = new THREE.Vector3();
20
+ const scratchScale = new THREE.Vector3();
21
+ /** A stable non-zero time offset for a member with no recorded pose, so a crowd is not in lockstep. */
22
+ function poseOffset(id) {
23
+ let hash = 0;
24
+ for (let index = 0; index < id.length; index += 1)
25
+ hash = (hash * 31 + id.charCodeAt(index)) | 0;
26
+ return (Math.abs(hash) % 1000) / 1000 + 0.05;
27
+ }
28
+ function CrowdBatch({ kind, model, registry, fps, }) {
29
+ const gltf = useLoader(sharedGltfLoader, model.url);
30
+ const clipNames = useMemo(() => gltf.animations.map((clip) => clip.name), [gltf]);
31
+ const clips = useMemo(() => crowdClipsOf(model, clipNames), [model, clipNames]);
32
+ const baked = useMemo(() => {
33
+ if (clips.length === 0)
34
+ return null;
35
+ try {
36
+ return bakeSkinnedCrowd({ scene: gltf.scene, animations: gltf.animations }, { clips, fps });
37
+ }
38
+ catch (error) {
39
+ // A model can carry clips without a skin (a node-transform prop animation). It keeps its own
40
+ // rig rather than entering the crowd path, which is what the readiness flag then reports.
41
+ warnOnce(`character-crowd:${model.url}`, `[jgengine] character crowd: "${model.url}" cannot be baked (no skinned mesh) and stays on its own rig`);
42
+ return null;
43
+ }
44
+ }, [gltf, clips, fps, model.url]);
45
+ useEffect(() => () => baked?.dispose(), [baked]);
46
+ const uniforms = useMemo(() => (baked === null ? null : createCrowdUniforms(baked)), [baked]);
47
+ const materials = useMemo(() => {
48
+ if (baked === null || uniforms === null)
49
+ return [];
50
+ return baked.materials.map((material) => {
51
+ const tinted = model.material === undefined ? material.clone() : applyMaterialOverrideToMaterial(material.clone(), model.material, false, undefined);
52
+ return patchCrowdMaterial(tinted, uniforms);
53
+ });
54
+ }, [baked, uniforms, model]);
55
+ const depthMaterial = useMemo(() => (uniforms === null ? null : patchCrowdMaterial(new THREE.MeshDepthMaterial({ depthPacking: THREE.RGBADepthPacking }), uniforms)), [uniforms]);
56
+ const mesh = useMemo(() => {
57
+ if (baked === null || depthMaterial === null || materials.length === 0)
58
+ return null;
59
+ const instanced = new THREE.InstancedMesh(baked.geometry, materials, CROWD_CAPACITY);
60
+ instanced.customDepthMaterial = depthMaterial;
61
+ instanced.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 0);
62
+ instanced.count = 0;
63
+ instanced.geometry.setAttribute("crowdPlay", new THREE.InstancedBufferAttribute(new Float32Array(CROWD_CAPACITY * 3), 3));
64
+ instanced.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
65
+ return instanced;
66
+ }, [baked, materials, depthMaterial]);
67
+ useEffect(() => () => {
68
+ for (const material of materials)
69
+ material.dispose();
70
+ depthMaterial?.dispose();
71
+ mesh?.dispose();
72
+ }, [materials, depthMaterial, mesh]);
73
+ useEffect(() => {
74
+ // A kind without a bakeable locomotion clip set keeps every character on its own rig rather than
75
+ // making them invisible past the crowd distance.
76
+ setCrowdKindReady(kind, mesh !== null && clips.length > 0);
77
+ return () => setCrowdKindReady(kind, false);
78
+ }, [kind, mesh, clips]);
79
+ /** The height and centering the full entity path gives this model, so a crowd copy stands in the same place. */
80
+ const placement = useMemo(() => {
81
+ if (baked === null)
82
+ return null;
83
+ const box = baked.geometry.boundingBox ?? (baked.geometry.computeBoundingBox(), baked.geometry.boundingBox);
84
+ const height = box.max.y - box.min.y;
85
+ const scale = (model.scale ?? 1) * (model.targetHeight !== undefined && height > 0 ? model.targetHeight / height : 1);
86
+ return {
87
+ scale,
88
+ offset: new THREE.Vector3(-scale * (box.min.x + box.max.x) / 2, (model.y ?? 0) - scale * box.min.y, -scale * (box.min.z + box.max.z) / 2),
89
+ };
90
+ }, [baked, model]);
91
+ const playback = useRef(new Map());
92
+ const visibleRef = useRenderVisibility();
93
+ useFrame((_state, delta) => {
94
+ if (mesh === null || baked === null || uniforms === null || placement === null)
95
+ return;
96
+ uniforms.crowdTime.value += delta;
97
+ const play = mesh.geometry.getAttribute("crowdPlay");
98
+ const active = playback.current;
99
+ let count = 0;
100
+ let minX = Infinity, minY = Infinity, minZ = Infinity, maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
101
+ registry.forEach(kind, (id, member) => {
102
+ if (count >= CROWD_CAPACITY)
103
+ return;
104
+ if (!visibleRef.current(id))
105
+ return;
106
+ const clipName = crowdClipForSpeed(clips, member.speed);
107
+ const clip = clipName === undefined ? 0 : baked.clipIndex.get(clipName) ?? 0;
108
+ let entry = active.get(id);
109
+ if (entry === undefined || entry.clip !== clip) {
110
+ // Hand the crowd the pose the rig was holding, so the swap continues the same gait instead
111
+ // of restarting the clip at a different phase.
112
+ const phase = animationPhaseOf(id);
113
+ entry = {
114
+ clip,
115
+ offset: phase !== undefined && phase.clip === clipName ? phase.time - uniforms.crowdTime.value : poseOffset(id),
116
+ };
117
+ active.set(id, entry);
118
+ }
119
+ scratchQuaternion.setFromAxisAngle(THREE.Object3D.DEFAULT_UP, member.rotationY);
120
+ scratchMatrix.compose(scratchPosition.set(member.x, member.y, member.z), scratchQuaternion, scratchScale.setScalar(placement.scale));
121
+ scratchMatrix.multiply(scratchOffset.makeTranslation(placement.offset.x, placement.offset.y, placement.offset.z));
122
+ mesh.setMatrixAt(count, scratchMatrix);
123
+ play.setXYZ(count, entry.clip, entry.offset, 1);
124
+ minX = Math.min(minX, member.x);
125
+ maxX = Math.max(maxX, member.x);
126
+ minY = Math.min(minY, member.y);
127
+ maxY = Math.max(maxY, member.y);
128
+ minZ = Math.min(minZ, member.z);
129
+ maxZ = Math.max(maxZ, member.z);
130
+ count += 1;
131
+ });
132
+ mesh.count = count;
133
+ mesh.instanceMatrix.needsUpdate = true;
134
+ play.needsUpdate = true;
135
+ if (count > 0) {
136
+ // One batch spans every drawn member, so its cull sphere has to follow them; three cannot cull
137
+ // an InstancedMesh per instance.
138
+ const sphere = mesh.boundingSphere ?? (mesh.boundingSphere = new THREE.Sphere());
139
+ sphere.center.set((minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2);
140
+ sphere.radius = Math.hypot(maxX - minX, maxY - minY, maxZ - minZ) / 2 + placement.scale * 1.2;
141
+ }
142
+ noteCrowdInstances(kind, count);
143
+ });
144
+ useEffect(() => () => clearCrowdKind(kind), [kind]);
145
+ if (mesh === null)
146
+ return null;
147
+ return _jsx("primitive", { object: mesh, castShadow: true, receiveShadow: true });
148
+ }
149
+ /**
150
+ * Draws far characters through the GPU crowd path: one baked rig per character kind, one instanced
151
+ * draw per material, every member's clip, time offset and speed sampled in the vertex shader. The
152
+ * entity markers decide who is far enough (their `lod` tiers) and write poses into `registry`; this
153
+ * layer owns the bakes, the materials and the instance buffers. Mount it in the same canvas as the
154
+ * entity markers; unmount it to fall back to per-entity rigs.
155
+ * @capability character-crowd draw distant animated characters on the GPU instead of posing their skeletons
156
+ */
157
+ export function CharacterCrowd({ models, registry, fps = 24 }) {
158
+ const kinds = useMemo(() => {
159
+ const unique = new Map();
160
+ for (const model of models) {
161
+ const kind = crowdKindOf(model);
162
+ if (!unique.has(kind))
163
+ unique.set(kind, model);
164
+ }
165
+ return [...unique.entries()];
166
+ }, [models]);
167
+ useEffect(() => {
168
+ const dispose = devtools.probes.registerSystem("render/characters", characterLodProbe());
169
+ return () => dispose();
170
+ }, []);
171
+ useFrame(() => {
172
+ noteCharacterFrame();
173
+ });
174
+ useEffect(() => () => registry.clear(), [registry]);
175
+ return (_jsx(_Fragment, { children: kinds.map(([kind, model]) => (_jsx(ModelFallbackBoundary, { fallback: null, url: model.url, seam: "entity", children: _jsx(Suspense, { fallback: null, children: _jsx(CrowdBatch, { kind: kind, model: model, registry: registry, fps: fps }) }) }, kind))) }));
176
+ }
@@ -1,7 +1,9 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useLayoutEffect, useRef } from "react";
3
- import { useFrame } from "@react-three/fiber";
2
+ import { useEffect, useLayoutEffect, useRef } from "react";
3
+ import { useFrame, useThree } from "@react-three/fiber";
4
+ import { devtools } from "@jgengine/core/devtools/devtools";
4
5
  import { CascadedShadows, DEFAULT_SHADOW_RADIUS } from "./CascadedShadows.js";
6
+ import { cullSmallShadowCasters, MIN_SHADOW_CASTER_RADIUS } from "./shadowCasters.js";
5
7
  const DEFAULT_BACKDROP_FOG_COLOR = "#1a1c22";
6
8
  /** Default cap for combined dynamic point and spot lights. */
7
9
  export const POINT_LIGHT_BUDGET = 8;
@@ -30,7 +32,7 @@ function usesCascades(entry) {
30
32
  function DirectionalShadowLight({ entry, profile, modulation }) {
31
33
  const resolved = profile === undefined ? entry : { ...entry, shadowMapSize: entry.shadowMapSize ?? profile.shadowMapSize, cascades: entry.cascades ?? profile.cascades };
32
34
  if (usesCascades(resolved)) {
33
- return _jsx(CascadedShadows, { entry: resolved, modulation: modulation });
35
+ return _jsx(CascadedShadows, { entry: resolved, profile: profile, modulation: modulation });
34
36
  }
35
37
  return _jsx(SingleShadowLight, { entry: resolved, modulation: modulation });
36
38
  }
@@ -71,6 +73,28 @@ function SingleShadowLight({ entry, modulation }) {
71
73
  }
72
74
  export function ConfiguredLighting({ lighting, profile, modulation, }) {
73
75
  const dynamic = resolveDynamicLights(lighting);
76
+ const scene = useThree((s) => s.scene);
77
+ const shadowsCast = (lighting.directional ?? []).some((entry) => entry.castShadow === true);
78
+ const cullRef = useRef(null);
79
+ useEffect(() => {
80
+ if (!shadowsCast)
81
+ return;
82
+ const cull = cullSmallShadowCasters(scene);
83
+ cullRef.current = cull;
84
+ return () => {
85
+ cull.restore();
86
+ cullRef.current = null;
87
+ };
88
+ }, [scene, shadowsCast]);
89
+ useEffect(() => devtools.probes.registerSystem("render/shadows", {
90
+ counters: () => cullRef.current?.stats() ?? { casters: 0, skipped: 0 },
91
+ state: () => {
92
+ const stats = cullRef.current?.stats();
93
+ return stats === undefined
94
+ ? "no shadow-casting directional light"
95
+ : `${stats.casters} casters · ${stats.skipped} under ${MIN_SHADOW_CASTER_RADIUS} m skipped`;
96
+ },
97
+ }), []);
74
98
  const ambientRef = useRef(null);
75
99
  const hemisphereRef = useRef(null);
76
100
  const baseAmbient = lighting.ambient?.intensity ?? 0.55;
@@ -0,0 +1,20 @@
1
+ import { type ShaderWarmupState } from "./shaderWarmup.js";
2
+ /** Warmup state before the gate has run, so the loading screen covers the very first frame. @internal */
3
+ export declare const SCENE_WARMUP_PENDING: ShaderWarmupState;
4
+ /**
5
+ * Compiles the mounted scene's shaders behind the loading screen and reports progress to it.
6
+ * Mounted inside the canvas, after the world, so the first round sees the world's materials.
7
+ * @internal
8
+ */
9
+ export declare function SceneWarmupGate({ onState }: {
10
+ onState: (state: ShaderWarmupState) => void;
11
+ }): null;
12
+ /**
13
+ * Opaque cover over the canvas while the scene warms: the first frames of a scene compile its
14
+ * materials, and a player should not see those frames. Game UI and menus mount after this and
15
+ * paint over it, so a title screen stays usable.
16
+ * @internal
17
+ */
18
+ export declare function SceneWarmupCover({ state }: {
19
+ state: ShaderWarmupState;
20
+ }): import("react").JSX.Element | null;