@jgengine/shell 0.33.0 → 0.35.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 (53) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/GamePlayerShell.js +2 -0
  3. package/dist/Shell3dPresentation.js +1 -1
  4. package/dist/audio/AudioComponents.js +7 -0
  5. package/dist/audio/audioEngine.d.ts +59 -0
  6. package/dist/audio/audioEngine.js +334 -15
  7. package/dist/audio/audioProbe.d.ts +10 -5
  8. package/dist/audio/audioProbe.js +6 -3
  9. package/dist/audio/occlusion.d.ts +11 -0
  10. package/dist/audio/occlusion.js +20 -0
  11. package/dist/play/index.d.ts +3 -0
  12. package/dist/play/index.js +3 -0
  13. package/dist/play/run.d.ts +27 -0
  14. package/dist/play/run.js +266 -0
  15. package/dist/play/script.d.ts +103 -0
  16. package/dist/play/script.js +52 -0
  17. package/dist/play/timeline.d.ts +43 -0
  18. package/dist/play/timeline.js +27 -0
  19. package/dist/pointer/pointerService.d.ts +2 -0
  20. package/dist/pointer/pointerService.js +4 -1
  21. package/dist/postfx/PostProcessing.d.ts +15 -1
  22. package/dist/postfx/PostProcessing.js +16 -6
  23. package/dist/postfx/postfxTierPolicy.d.ts +25 -0
  24. package/dist/postfx/postfxTierPolicy.js +17 -0
  25. package/dist/render/CascadedShadows.d.ts +4 -1
  26. package/dist/render/CascadedShadows.js +15 -7
  27. package/dist/render/CharacterCrowd.d.ts +11 -7
  28. package/dist/render/CharacterCrowd.js +103 -90
  29. package/dist/render/SceneLighting.js +14 -6
  30. package/dist/render/SceneWarmupGate.d.ts +6 -1
  31. package/dist/render/SceneWarmupGate.js +33 -7
  32. package/dist/render/SkinnedInstances.d.ts +16 -8
  33. package/dist/render/SkinnedInstances.js +215 -65
  34. package/dist/render/characterLod.d.ts +7 -0
  35. package/dist/render/characterLod.js +11 -0
  36. package/dist/render/crowdBatch.d.ts +45 -0
  37. package/dist/render/crowdBatch.js +68 -0
  38. package/dist/render/crowdRegistry.d.ts +55 -5
  39. package/dist/render/crowdRegistry.js +92 -9
  40. package/dist/render/shaderWarmup.d.ts +75 -18
  41. package/dist/render/shaderWarmup.js +221 -64
  42. package/dist/render/shadowCasters.d.ts +1 -0
  43. package/dist/render/shadowCasters.js +1 -1
  44. package/dist/render/shadowTiers.d.ts +36 -0
  45. package/dist/render/shadowTiers.js +50 -0
  46. package/dist/world/WorldScene.js +28 -11
  47. package/package.json +8 -4
  48. package/skills/jgengine/api.md +3 -3
  49. package/skills/jgengine-ui/api.md +79 -9
  50. package/skills/jgengine-ui/capabilities.md +7 -2
  51. package/skills/jgengine-world/api.md +21 -3
  52. package/skills/jgengine-world/capabilities.md +8 -0
  53. package/skills/jgengine-world/reference.md +1 -1
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The post chain's quality tiers. `high` and `ultra` render at native size; the tiers below pay for
3
+ * their cheaper stage set twice, by building fewer passes and by rendering the ones left smaller.
4
+ * `stages` mirrors `DEFAULT_GRAPHICS_PROFILES.postStages`, so a plan built without the player's
5
+ * toggles spends the same passes the profile names.
6
+ * @internal
7
+ */
8
+ export const POSTFX_TIER_POLICY = {
9
+ low: { resolutionScale: 0.7, aoSamples: 8, stages: { ao: false, bloom: true, dof: false, smaa: true } },
10
+ medium: { resolutionScale: 0.85, aoSamples: 8, stages: { ao: true, bloom: true, dof: true, smaa: true } },
11
+ high: { resolutionScale: 1, aoSamples: 12, stages: { ao: true, bloom: true, dof: true, smaa: true } },
12
+ ultra: { resolutionScale: 1, aoSamples: 16, stages: { ao: true, bloom: true, dof: true, smaa: true } },
13
+ };
14
+ /** A tier's post-chain budget; `high` is the reference every lower tier is measured against. @internal */
15
+ export function postfxTierPolicy(quality) {
16
+ return POSTFX_TIER_POLICY[quality];
17
+ }
@@ -30,9 +30,12 @@ export declare function cascadeShadowCadence(index: number, cascades: number): n
30
30
  /**
31
31
  * Set each cascade's shadow refresh policy for `frame`. A cascade that is not due keeps both
32
32
  * its matrix and its map from the last refresh, so the pair stays consistent with itself.
33
+ * `refreshScale` stretches the far cascades' interval further on a tier whose maps are cheap
34
+ * enough to lag; `1` is the cadence the cascade rig was measured with. The nearest cascade always
35
+ * refreshes every frame: it is the one a moving camera crosses fastest.
33
36
  * @internal
34
37
  */
35
- export declare function scheduleCascadeRefresh(lights: readonly THREE.DirectionalLight[], frame: number): void;
38
+ export declare function scheduleCascadeRefresh(lights: readonly THREE.DirectionalLight[], frame: number, refreshScale?: number): void;
36
39
  /**
37
40
  * Cascaded shadow maps for one directional light — outdoor scenes keep shadows past a
38
41
  * single 40-unit ortho frustum. Uses three.js `CSM` (shader injection on standard materials).
@@ -2,6 +2,7 @@ import { useFrame, useThree } from "@react-three/fiber";
2
2
  import { useEffect, useRef } from "react";
3
3
  import * as THREE from "three";
4
4
  import { CSM } from "three/examples/jsm/csm/CSM.js";
5
+ import { resolveCascadeCount, resolveShadowMapSize, shadowRefreshScale } from "./shadowTiers.js";
5
6
  /** @internal Default PCF penumbra in shadow-map texels for configured directional lights. */
6
7
  export const DEFAULT_SHADOW_RADIUS = 1.25;
7
8
  /** @internal Reach in metres used when neither the game nor a graphics profile sets one. */
@@ -47,12 +48,17 @@ export function cascadeShadowCadence(index, cascades) {
47
48
  /**
48
49
  * Set each cascade's shadow refresh policy for `frame`. A cascade that is not due keeps both
49
50
  * its matrix and its map from the last refresh, so the pair stays consistent with itself.
51
+ * `refreshScale` stretches the far cascades' interval further on a tier whose maps are cheap
52
+ * enough to lag; `1` is the cadence the cascade rig was measured with. The nearest cascade always
53
+ * refreshes every frame: it is the one a moving camera crosses fastest.
50
54
  * @internal
51
55
  */
52
- export function scheduleCascadeRefresh(lights, frame) {
56
+ export function scheduleCascadeRefresh(lights, frame, refreshScale = 1) {
57
+ const stretch = Math.max(1, Math.floor(refreshScale));
53
58
  for (let index = 0; index < lights.length; index += 1) {
54
59
  const shadow = lights[index].shadow;
55
- const cadence = cascadeShadowCadence(index, lights.length);
60
+ const base = cascadeShadowCadence(index, lights.length);
61
+ const cadence = base === 1 ? 1 : base * stretch;
56
62
  shadow.autoUpdate = cadence === 1;
57
63
  if (cadence > 1)
58
64
  shadow.needsUpdate = frame % cadence === 0;
@@ -77,8 +83,10 @@ export function CascadedShadows({ entry, profile, modulation, }) {
77
83
  const frameRef = useRef(0);
78
84
  const baseIntensity = entry.intensity ?? 1.3;
79
85
  const maxFar = resolveShadowDistance(profile, entry.shadowMaxFar);
86
+ const shadowMapSize = resolveShadowMapSize(profile, entry.shadowMapSize);
87
+ const cascades = Math.max(2, Math.min(4, Math.floor(resolveCascadeCount(profile, entry.cascades))));
88
+ const refreshScale = shadowRefreshScale(shadowMapSize);
80
89
  useEffect(() => {
81
- const cascades = Math.max(2, Math.min(4, Math.floor(entry.cascades ?? 3)));
82
90
  const lightDirection = new THREE.Vector3(-entry.position[0], -entry.position[1], -entry.position[2]);
83
91
  if (lightDirection.lengthSq() < 1e-8)
84
92
  lightDirection.set(-1, -1, -1);
@@ -89,7 +97,7 @@ export function CascadedShadows({ entry, profile, modulation, }) {
89
97
  cascades,
90
98
  maxFar,
91
99
  mode: "practical",
92
- shadowMapSize: entry.shadowMapSize ?? 1024,
100
+ shadowMapSize,
93
101
  shadowBias: entry.shadowBias ?? -0.0004,
94
102
  lightDirection,
95
103
  lightIntensity: entry.intensity ?? 1.3,
@@ -112,7 +120,7 @@ export function CascadedShadows({ entry, profile, modulation, }) {
112
120
  }, [
113
121
  camera,
114
122
  scene,
115
- entry.cascades,
123
+ cascades,
116
124
  entry.color,
117
125
  entry.intensity,
118
126
  entry.position[0],
@@ -120,7 +128,7 @@ export function CascadedShadows({ entry, profile, modulation, }) {
120
128
  entry.position[2],
121
129
  entry.shadowBias,
122
130
  entry.shadowCameraSize,
123
- entry.shadowMapSize,
131
+ shadowMapSize,
124
132
  maxFar,
125
133
  entry.shadowRadius,
126
134
  ]);
@@ -137,7 +145,7 @@ export function CascadedShadows({ entry, profile, modulation, }) {
137
145
  }
138
146
  csm.camera = camera;
139
147
  csm.update();
140
- scheduleCascadeRefresh(csm.lights, frameRef.current++);
148
+ scheduleCascadeRefresh(csm.lights, frameRef.current++, refreshScale);
141
149
  });
142
150
  return null;
143
151
  }
@@ -1,5 +1,7 @@
1
1
  import type { ModelConfig } from "@jgengine/core/game/playableGame";
2
2
  import { CharacterCrowdRegistry } from "./crowdRegistry.js";
3
+ /** Instance slot → entity id, so picking a batched character resolves like its own rig. @internal */
4
+ export declare const ENTITY_INSTANCES_KEY = "jgEntityInstances";
3
5
  /** Props for {@link CharacterCrowd}. */
4
6
  export interface CharacterCrowdProps {
5
7
  /** Character models the world can draw; only models with a `lod` policy join the crowd path. */
@@ -8,13 +10,15 @@ export interface CharacterCrowdProps {
8
10
  registry: CharacterCrowdRegistry;
9
11
  /** Baked samples per second for the crowd clips. Default 24. */
10
12
  fps?: number;
13
+ /** Called when a batched character is clicked, with the entity a click on its own rig would select. */
14
+ onPick?: (entityId: string) => void;
11
15
  }
12
16
  /**
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
17
+ * Draws characters through the GPU batch path: one baked rig per character kind, one instanced draw
18
+ * per material, every member's clip, time offset and speed sampled in the vertex shader. The entity
19
+ * markers decide who is batched (their `lod` tiers and the batch capacity) and write poses into
20
+ * `registry`; this layer owns the bakes, the materials and the instance buffers. Mount it in the same
21
+ * canvas as the entity markers; unmount it to fall back to per-entity rigs.
22
+ * @capability character-crowd draw animated or static characters as batched instanced draws instead of posing each skeleton
19
23
  */
20
- export declare function CharacterCrowd({ models, registry, fps }: CharacterCrowdProps): import("react").JSX.Element;
24
+ export declare function CharacterCrowd({ models, registry, fps, onPick }: CharacterCrowdProps): import("react").JSX.Element;
@@ -4,33 +4,27 @@ import { Suspense, useEffect, useMemo, useRef } from "react";
4
4
  import * as THREE from "three";
5
5
  import { devtools } from "@jgengine/core/devtools/devtools";
6
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";
7
+ import { characterLodPolicy, characterLodProbe, clearCrowdKind, noteCharacterFrame, noteCrowdInstances, } from "./characterLod.js";
8
+ import { CharacterCrowdRegistry, crowdClipsOf, crowdKindOf, crowdSpeedBandsOf, setCrowdKindReady, } from "./crowdRegistry.js";
9
9
  import { applyMaterialOverrideToMaterial } from "../materialOverride.js";
10
10
  import { useRenderVisibility } from "../visibility/CullingProvider.js";
11
+ import { useOptionalGameContext } from "@jgengine/react/provider";
12
+ import { POINTER_ENTITY_INSTANCES_KEY } from "../pointer/pointerService.js";
13
+ import { writeCrowdBatch } from "./crowdBatch.js";
11
14
  import { ModelFallbackBoundary } from "./SceneModels.js";
12
15
  import { sharedGltfLoader } from "./modelLoad.js";
16
+ import { modelPlacementTransform } from "./modelRender.js";
13
17
  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, }) {
18
+ function CrowdBatch({ kind, model, registry, fps, onPick, }) {
29
19
  const gltf = useLoader(sharedGltfLoader, model.url);
30
20
  const clipNames = useMemo(() => gltf.animations.map((clip) => clip.name), [gltf]);
31
21
  const clips = useMemo(() => crowdClipsOf(model, clipNames), [model, clipNames]);
22
+ const bands = useMemo(() => crowdSpeedBandsOf(model, clipNames), [model, clipNames]);
23
+ const capacity = useMemo(() => characterLodPolicy(model.lod === false ? undefined : model.lod).crowdCapacity, [model.lod]);
32
24
  const baked = useMemo(() => {
33
- if (clips.length === 0)
25
+ // A model that plays clips but resolves none of them to locomotion keeps its own rig: batching it
26
+ // would draw a frozen stance. A model with no clips at all is a static character, which batches.
27
+ if (clipNames.length > 0 && clips.length === 0)
34
28
  return null;
35
29
  try {
36
30
  return bakeSkinnedCrowd({ scene: gltf.scene, animations: gltf.animations }, { clips, fps });
@@ -38,123 +32,142 @@ function CrowdBatch({ kind, model, registry, fps, }) {
38
32
  catch (error) {
39
33
  // A model can carry clips without a skin (a node-transform prop animation). It keeps its own
40
34
  // 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`);
35
+ warnOnce(`character-crowd:${model.url}`, `[jgengine] character crowd: "${model.url}" cannot be baked and stays on its own rig`);
42
36
  return null;
43
37
  }
44
- }, [gltf, clips, fps, model.url]);
38
+ }, [gltf, clips, clipNames.length, fps, model.url]);
45
39
  useEffect(() => () => baked?.dispose(), [baked]);
46
- const uniforms = useMemo(() => (baked === null ? null : createCrowdUniforms(baked)), [baked]);
40
+ const uniforms = useMemo(() => (baked === null || !baked.animated ? null : createCrowdUniforms(baked)), [baked]);
47
41
  const materials = useMemo(() => {
48
- if (baked === null || uniforms === null)
42
+ if (baked === null)
49
43
  return [];
50
44
  return baked.materials.map((material) => {
51
45
  const tinted = model.material === undefined ? material.clone() : applyMaterialOverrideToMaterial(material.clone(), model.material, false, undefined);
52
- return patchCrowdMaterial(tinted, uniforms);
46
+ return uniforms === null ? tinted : patchCrowdMaterial(tinted, uniforms);
53
47
  });
54
48
  }, [baked, uniforms, model]);
55
49
  const depthMaterial = useMemo(() => (uniforms === null ? null : patchCrowdMaterial(new THREE.MeshDepthMaterial({ depthPacking: THREE.RGBADepthPacking }), uniforms)), [uniforms]);
56
50
  const mesh = useMemo(() => {
57
- if (baked === null || depthMaterial === null || materials.length === 0)
51
+ if (baked === null || materials.length === 0)
58
52
  return null;
59
- const instanced = new THREE.InstancedMesh(baked.geometry, materials, CROWD_CAPACITY);
60
- instanced.customDepthMaterial = depthMaterial;
53
+ const instanced = new THREE.InstancedMesh(baked.geometry, materials.length === 1 ? materials[0] : materials, capacity);
54
+ if (depthMaterial !== null)
55
+ instanced.customDepthMaterial = depthMaterial;
61
56
  instanced.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 0);
62
57
  instanced.count = 0;
63
- instanced.geometry.setAttribute("crowdPlay", new THREE.InstancedBufferAttribute(new Float32Array(CROWD_CAPACITY * 3), 3));
58
+ if (uniforms !== null)
59
+ instanced.geometry.setAttribute("crowdPlay", new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3), 3));
64
60
  instanced.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
65
61
  return instanced;
66
- }, [baked, materials, depthMaterial]);
62
+ }, [baked, materials, depthMaterial, uniforms, capacity]);
67
63
  useEffect(() => () => {
68
64
  for (const material of materials)
69
65
  material.dispose();
70
66
  depthMaterial?.dispose();
71
67
  mesh?.dispose();
72
68
  }, [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
69
  /** The height and centering the full entity path gives this model, so a crowd copy stands in the same place. */
80
70
  const placement = useMemo(() => {
81
71
  if (baked === null)
82
72
  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);
73
+ const next = modelPlacementTransform(new THREE.Group(), model, {
74
+ min: baked.bounds.min.toArray(),
75
+ max: baked.bounds.max.toArray(),
76
+ meshCount: 1,
77
+ });
86
78
  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),
79
+ scale: next.scale,
80
+ offset: new THREE.Vector3(next.position[0], next.position[1], next.position[2]),
89
81
  };
90
82
  }, [baked, model]);
83
+ useEffect(() => {
84
+ // A kind without a bakeable set keeps every character on its own rig rather than making them
85
+ // invisible past the crowd distance.
86
+ const ready = mesh !== null && placement !== null;
87
+ setCrowdKindReady(kind, ready);
88
+ registry.setCapacity(kind, ready ? capacity : 0);
89
+ if (ready && baked !== null && placement !== null) {
90
+ registry.setGeometry(kind, { geometry: baked.geometry, position: placement.offset.toArray(), scale: placement.scale });
91
+ }
92
+ return () => {
93
+ setCrowdKindReady(kind, false);
94
+ registry.setCapacity(kind, 0);
95
+ registry.setGeometry(kind, undefined);
96
+ };
97
+ }, [kind, mesh, placement, registry, capacity, baked]);
91
98
  const playback = useRef(new Map());
92
99
  const visibleRef = useRenderVisibility();
100
+ const ctx = useOptionalGameContext();
101
+ const drawnIds = useRef([]);
102
+ const drawnMembers = useRef([]);
93
103
  useFrame((_state, delta) => {
94
- if (mesh === null || baked === null || uniforms === null || placement === null)
104
+ if (mesh === null || baked === null || placement === null)
95
105
  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;
106
+ // A crowd on a game clock stops when the game stops; an unbound preview advances on real time.
107
+ if (uniforms !== null) {
108
+ const advanced = ctx?.time.advancedSeconds?.();
109
+ uniforms.crowdTime.value = advanced !== undefined && advanced > 0 ? advanced : uniforms.crowdTime.value + delta;
110
+ }
111
+ const batches = drawnMembers.current;
112
+ let visible = 0;
101
113
  registry.forEach(kind, (id, member) => {
102
- if (count >= CROWD_CAPACITY)
103
- return;
104
114
  if (!visibleRef.current(id))
105
115
  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);
116
+ const existing = batches[visible];
117
+ if (existing === undefined)
118
+ batches[visible] = { id, member };
119
+ else {
120
+ existing.id = id;
121
+ existing.member = member;
118
122
  }
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;
123
+ visible += 1;
131
124
  });
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
- }
125
+ batches.length = visible;
126
+ const count = writeCrowdBatch({
127
+ mesh,
128
+ play: uniforms === null ? null : mesh.geometry.getAttribute("crowdPlay"),
129
+ members: batches,
130
+ placement,
131
+ clips,
132
+ bands,
133
+ clipIndex: baked.clipIndex,
134
+ time: uniforms === null ? 0 : uniforms.crowdTime.value,
135
+ playback: playback.current,
136
+ ids: drawnIds.current,
137
+ });
138
+ mesh.userData[ENTITY_INSTANCES_KEY] = drawnIds.current;
142
139
  noteCrowdInstances(kind, count);
143
140
  });
144
141
  useEffect(() => () => clearCrowdKind(kind), [kind]);
142
+ // A member removed while its matrix is still in the buffer must not linger on screen.
143
+ useEffect(() => () => {
144
+ playback.current.clear();
145
+ }, []);
145
146
  if (mesh === null)
146
147
  return null;
147
- return _jsx("primitive", { object: mesh, castShadow: true, receiveShadow: true });
148
+ return (_jsx("primitive", { object: mesh, castShadow: true, receiveShadow: true, onPointerDown: onPick === undefined
149
+ ? undefined
150
+ : (event) => {
151
+ if (event.instanceId === undefined)
152
+ return;
153
+ const id = drawnIds.current[event.instanceId];
154
+ if (id === undefined)
155
+ return;
156
+ event.stopPropagation();
157
+ onPick(id);
158
+ } }));
148
159
  }
160
+ /** Instance slot → entity id, so picking a batched character resolves like its own rig. @internal */
161
+ export const ENTITY_INSTANCES_KEY = POINTER_ENTITY_INSTANCES_KEY;
149
162
  /**
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
163
+ * Draws characters through the GPU batch path: one baked rig per character kind, one instanced draw
164
+ * per material, every member's clip, time offset and speed sampled in the vertex shader. The entity
165
+ * markers decide who is batched (their `lod` tiers and the batch capacity) and write poses into
166
+ * `registry`; this layer owns the bakes, the materials and the instance buffers. Mount it in the same
167
+ * canvas as the entity markers; unmount it to fall back to per-entity rigs.
168
+ * @capability character-crowd draw animated or static characters as batched instanced draws instead of posing each skeleton
156
169
  */
157
- export function CharacterCrowd({ models, registry, fps = 24 }) {
170
+ export function CharacterCrowd({ models, registry, fps = 24, onPick }) {
158
171
  const kinds = useMemo(() => {
159
172
  const unique = new Map();
160
173
  for (const model of models) {
@@ -172,5 +185,5 @@ export function CharacterCrowd({ models, registry, fps = 24 }) {
172
185
  noteCharacterFrame();
173
186
  });
174
187
  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))) }));
188
+ 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, onPick: onPick }) }) }, kind))) }));
176
189
  }
@@ -4,6 +4,7 @@ import { useFrame, useThree } from "@react-three/fiber";
4
4
  import { devtools } from "@jgengine/core/devtools/devtools";
5
5
  import { CascadedShadows, DEFAULT_SHADOW_RADIUS } from "./CascadedShadows.js";
6
6
  import { cullSmallShadowCasters, MIN_SHADOW_CASTER_RADIUS } from "./shadowCasters.js";
7
+ import { minShadowCasterRadius, resolveCascadeCount, resolveShadowMapSize, SHADOW_CASTER_RADIUS_REFERENCE_MAP } from "./shadowTiers.js";
7
8
  const DEFAULT_BACKDROP_FOG_COLOR = "#1a1c22";
8
9
  /** Default cap for combined dynamic point and spot lights. */
9
10
  export const POINT_LIGHT_BUDGET = 8;
@@ -30,7 +31,13 @@ function usesCascades(entry) {
30
31
  return (entry.castShadow ?? false) && (entry.cascades ?? 1) > 1;
31
32
  }
32
33
  function DirectionalShadowLight({ entry, profile, modulation }) {
33
- const resolved = profile === undefined ? entry : { ...entry, shadowMapSize: entry.shadowMapSize ?? profile.shadowMapSize, cascades: entry.cascades ?? profile.cascades };
34
+ const resolved = profile === undefined
35
+ ? entry
36
+ : {
37
+ ...entry,
38
+ shadowMapSize: resolveShadowMapSize(profile, entry.shadowMapSize),
39
+ cascades: resolveCascadeCount(profile, entry.cascades),
40
+ };
34
41
  if (usesCascades(resolved)) {
35
42
  return _jsx(CascadedShadows, { entry: resolved, profile: profile, modulation: modulation });
36
43
  }
@@ -76,25 +83,26 @@ export function ConfiguredLighting({ lighting, profile, modulation, }) {
76
83
  const scene = useThree((s) => s.scene);
77
84
  const shadowsCast = (lighting.directional ?? []).some((entry) => entry.castShadow === true);
78
85
  const cullRef = useRef(null);
86
+ const casterRadius = minShadowCasterRadius(profile?.shadowMapSize ?? SHADOW_CASTER_RADIUS_REFERENCE_MAP, MIN_SHADOW_CASTER_RADIUS);
79
87
  useEffect(() => {
80
88
  if (!shadowsCast)
81
89
  return;
82
- const cull = cullSmallShadowCasters(scene);
90
+ const cull = cullSmallShadowCasters(scene, casterRadius);
83
91
  cullRef.current = cull;
84
92
  return () => {
85
93
  cull.restore();
86
94
  cullRef.current = null;
87
95
  };
88
- }, [scene, shadowsCast]);
96
+ }, [scene, shadowsCast, casterRadius]);
89
97
  useEffect(() => devtools.probes.registerSystem("render/shadows", {
90
- counters: () => cullRef.current?.stats() ?? { casters: 0, skipped: 0 },
98
+ counters: () => cullRef.current?.stats() ?? { casters: 0, skipped: 0, minRadius: casterRadius },
91
99
  state: () => {
92
100
  const stats = cullRef.current?.stats();
93
101
  return stats === undefined
94
102
  ? "no shadow-casting directional light"
95
- : `${stats.casters} casters · ${stats.skipped} under ${MIN_SHADOW_CASTER_RADIUS} m skipped`;
103
+ : `${stats.casters} casters · ${stats.skipped} under ${stats.minRadius} m skipped`;
96
104
  },
97
- }), []);
105
+ }), [casterRadius]);
98
106
  const ambientRef = useRef(null);
99
107
  const hemisphereRef = useRef(null);
100
108
  const baseAmbient = lighting.ambient?.intensity ?? 0.55;
@@ -6,9 +6,14 @@ export declare const SCENE_WARMUP_PENDING: ShaderWarmupState;
6
6
  * Mounted inside the canvas, after the world, so the first round sees the world's materials.
7
7
  * @internal
8
8
  */
9
- export declare function SceneWarmupGate({ onState }: {
9
+ export declare function SceneWarmupGate({ onState, sceneDrawsToTarget }: {
10
10
  onState: (state: ShaderWarmupState) => void;
11
+ sceneDrawsToTarget?: boolean;
11
12
  }): null;
13
+ /** What the loading cover says, so a wait that takes seconds reads as progress instead of a blank. @internal */
14
+ export declare function warmupLabel(state: ShaderWarmupState): string;
15
+ /** Whether the cover is still up: a failed compile releases the frame rather than hiding a broken scene. @internal */
16
+ export declare function warmupCovering(state: ShaderWarmupState): boolean;
12
17
  /**
13
18
  * Opaque cover over the canvas while the scene warms: the first frames of a scene compile its
14
19
  * materials, and a player should not see those frames. Game UI and menus mount after this and
@@ -1,24 +1,34 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useThree } from "@react-three/fiber";
3
3
  import { useEffect } from "react";
4
4
  import { devtools } from "@jgengine/core/devtools/devtools";
5
5
  import { createShaderWarmup } from "./shaderWarmup.js";
6
6
  /** Warmup state before the gate has run, so the loading screen covers the very first frame. @internal */
7
- export const SCENE_WARMUP_PENDING = { phase: "waiting", rounds: 0, programs: 0, elapsedMs: 0 };
7
+ export const SCENE_WARMUP_PENDING = { phase: "waiting", rounds: 0, programs: 0, planned: 0, deferred: 0, elapsedMs: 0 };
8
8
  /**
9
9
  * Compiles the mounted scene's shaders behind the loading screen and reports progress to it.
10
10
  * Mounted inside the canvas, after the world, so the first round sees the world's materials.
11
11
  * @internal
12
12
  */
13
- export function SceneWarmupGate({ onState }) {
13
+ export function SceneWarmupGate({ onState, sceneDrawsToTarget }) {
14
14
  const gl = useThree((state) => state.gl);
15
15
  const scene = useThree((state) => state.scene);
16
16
  const camera = useThree((state) => state.camera);
17
+ // With no answer from the canvas owner, a priority frame loop is taken for the post chain, which
18
+ // draws the scene into its own buffer. A scissored split-screen loop also takes priority and draws
19
+ // the canvas, so the composition passes the answer where it knows it.
20
+ const internal = useThree((state) => state.internal);
17
21
  useEffect(() => {
18
- const warmup = createShaderWarmup({ renderer: gl, scene, camera, onState });
22
+ const warmup = createShaderWarmup({
23
+ renderer: gl,
24
+ scene,
25
+ camera,
26
+ onState,
27
+ sceneDrawsToTarget: () => sceneDrawsToTarget ?? internal.priority > 0,
28
+ });
19
29
  const unregister = devtools.probes.register("sceneWarmup", () => {
20
30
  const state = warmup.state();
21
- return `${state.phase}, ${state.rounds} rounds, ${state.programs} programs, ${state.elapsedMs}ms`;
31
+ return `${state.phase}, ${state.rounds} rounds, ${state.programs} programs, ${state.planned} planned, ${state.deferred} deferred, ${state.elapsedMs}ms`;
22
32
  });
23
33
  return () => {
24
34
  warmup.cancel();
@@ -27,6 +37,17 @@ export function SceneWarmupGate({ onState }) {
27
37
  }, [gl, scene, camera, onState]);
28
38
  return null;
29
39
  }
40
+ /** What the loading cover says, so a wait that takes seconds reads as progress instead of a blank. @internal */
41
+ export function warmupLabel(state) {
42
+ if (state.phase === "compiling") {
43
+ return state.programs === 0 ? "Preparing scene." : `Preparing scene (${state.programs} shaders)`;
44
+ }
45
+ return "Preparing scene.";
46
+ }
47
+ /** Whether the cover is still up: a failed compile releases the frame rather than hiding a broken scene. @internal */
48
+ export function warmupCovering(state) {
49
+ return state.phase !== "ready" && state.phase !== "failed";
50
+ }
30
51
  /**
31
52
  * Opaque cover over the canvas while the scene warms: the first frames of a scene compile its
32
53
  * materials, and a player should not see those frames. Game UI and menus mount after this and
@@ -34,7 +55,7 @@ export function SceneWarmupGate({ onState }) {
34
55
  * @internal
35
56
  */
36
57
  export function SceneWarmupCover({ state }) {
37
- if (state.phase === "ready" || state.phase === "failed")
58
+ if (!warmupCovering(state))
38
59
  return null;
39
60
  return (_jsx("div", { "data-shell-code-loading": true, "data-shell-warmup": true, role: "status", "aria-live": "polite", style: {
40
61
  position: "absolute",
@@ -44,5 +65,10 @@ export function SceneWarmupCover({ state }) {
44
65
  background: "#0f172a",
45
66
  color: "#e2e8f0",
46
67
  fontFamily: "ui-sans-serif, system-ui, sans-serif",
47
- }, children: state.phase === "compiling" && state.programs > 0 ? `Preparing scene (${state.programs} shaders)` : "Preparing scene." }));
68
+ }, children: _jsxs("div", { style: { display: "grid", gap: 10, justifyItems: "center", fontSize: 14 }, children: [_jsx("span", { children: warmupLabel(state) }), _jsx("div", { "data-shell-warmup-bar": true, "aria-hidden": "true", style: { width: 160, height: 3, borderRadius: 2, background: "#1e293b", overflow: "hidden" }, children: _jsx("div", { style: {
69
+ width: state.phase === "compiling" ? "100%" : "20%",
70
+ height: "100%",
71
+ background: "#38bdf8",
72
+ opacity: state.phase === "compiling" ? 1 : 0.4,
73
+ } }) }), state.deferred > 0 ? (_jsx("span", { style: { fontSize: 11, color: "#94a3b8" }, children: `${state.deferred} more prepare after this` })) : null, state.phase === "compiling" && state.elapsedMs >= 1_000 ? (_jsx("span", { style: { fontSize: 11, color: "#64748b" }, children: `${(state.elapsedMs / 1000).toFixed(1)}s` })) : null] }) }));
48
74
  }
@@ -20,18 +20,26 @@ export interface BakedSkinnedCrowd {
20
20
  geometry: THREE.BufferGeometry;
21
21
  /** One material per merged mesh, in geometry-group order, not yet patched. */
22
22
  materials: THREE.Material[];
23
- texture: THREE.DataTexture;
24
- layout: BoneTextureLayout;
23
+ /** The bone-matrix texture, or `null` for a rig with no clips: a static character needs no sampling. */
24
+ texture: THREE.DataTexture | null;
25
+ layout: BoneTextureLayout | null;
25
26
  /** Per clip: whether it loops. Same order as `layout.clips`. */
26
27
  loops: boolean[];
27
28
  clipIndex: ReadonlyMap<string, number>;
29
+ /** Whether members play the baked clips; false for a static character, whose geometry never moves. */
30
+ animated: boolean;
31
+ /** Bind-pose bounds of every merged part, for placement that matches the per-entity path. */
32
+ bounds: THREE.Box3;
28
33
  dispose(): void;
29
34
  }
30
35
  /**
31
- * Bakes a rig for {@link SkinnedInstances}: merges its skinned meshes into one geometry in bind
32
- * space, samples every requested clip at `fps`, and writes each bone's model-space skinning matrix
33
- * into a float texture laid out by `planBoneTexture`. Meshes with several materials keep only the
34
- * first. Exported for tests and custom crowd renderers.
36
+ * Bakes a rig for {@link SkinnedInstances}: merges its meshes into one geometry per material, samples
37
+ * every requested clip at `fps`, and writes each slot's model-space matrix into a float texture laid
38
+ * out by `planBoneTexture`. A skinned mesh contributes its bones as slots; a rig-less part that a
39
+ * clip moves contributes that node, with its vertices weighted fully to it, so a node-transform
40
+ * character (a walking miniature with rotating limbs) batches through the same shader. A model with
41
+ * no clips at all bakes to `animated: false`: one merged geometry per material and no texture.
42
+ * Meshes with several materials keep only the first. Exported for tests and custom crowd renderers.
35
43
  */
36
44
  export declare function bakeSkinnedCrowd(source: SkinnedCrowdSource, options?: BakeSkinnedCrowdOptions): BakedSkinnedCrowd;
37
45
  /** Uniforms shared by every material of one crowd; `crowdTime` advances once per frame. */
@@ -59,8 +67,8 @@ export interface CrowdInstance {
59
67
  position: readonly [number, number, number];
60
68
  rotationY?: number;
61
69
  scale?: number;
62
- /** A baked clip name. */
63
- clip: string;
70
+ /** A baked clip name; a static rig draws bind pose whatever the member names. */
71
+ clip?: string;
64
72
  /** Seconds into the clip at crowd time zero, so members do not move in lockstep. */
65
73
  timeOffset?: number;
66
74
  /** Playback rate. Default 1. */