@jgengine/shell 0.34.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.
- package/CHANGELOG.md +28 -0
- package/dist/GamePlayerShell.js +2 -0
- package/dist/Shell3dPresentation.js +1 -1
- package/dist/audio/AudioComponents.js +7 -0
- package/dist/audio/audioEngine.d.ts +59 -0
- package/dist/audio/audioEngine.js +334 -15
- package/dist/audio/audioProbe.d.ts +10 -5
- package/dist/audio/audioProbe.js +6 -3
- package/dist/audio/occlusion.d.ts +11 -0
- package/dist/audio/occlusion.js +20 -0
- package/dist/play/index.d.ts +3 -0
- package/dist/play/index.js +3 -0
- package/dist/play/run.d.ts +27 -0
- package/dist/play/run.js +266 -0
- package/dist/play/script.d.ts +103 -0
- package/dist/play/script.js +52 -0
- package/dist/play/timeline.d.ts +43 -0
- package/dist/play/timeline.js +27 -0
- package/dist/pointer/pointerService.d.ts +2 -0
- package/dist/pointer/pointerService.js +4 -1
- package/dist/postfx/PostProcessing.d.ts +15 -1
- package/dist/postfx/PostProcessing.js +16 -6
- package/dist/postfx/postfxTierPolicy.d.ts +25 -0
- package/dist/postfx/postfxTierPolicy.js +17 -0
- package/dist/render/CascadedShadows.d.ts +4 -1
- package/dist/render/CascadedShadows.js +15 -7
- package/dist/render/CharacterCrowd.d.ts +11 -7
- package/dist/render/CharacterCrowd.js +103 -90
- package/dist/render/SceneLighting.js +14 -6
- package/dist/render/SceneWarmupGate.d.ts +6 -1
- package/dist/render/SceneWarmupGate.js +33 -7
- package/dist/render/SkinnedInstances.d.ts +16 -8
- package/dist/render/SkinnedInstances.js +215 -65
- package/dist/render/characterLod.d.ts +7 -0
- package/dist/render/characterLod.js +11 -0
- package/dist/render/crowdBatch.d.ts +45 -0
- package/dist/render/crowdBatch.js +68 -0
- package/dist/render/crowdRegistry.d.ts +55 -5
- package/dist/render/crowdRegistry.js +92 -9
- package/dist/render/shaderWarmup.d.ts +75 -18
- package/dist/render/shaderWarmup.js +221 -64
- package/dist/render/shadowCasters.d.ts +1 -0
- package/dist/render/shadowCasters.js +1 -1
- package/dist/render/shadowTiers.d.ts +36 -0
- package/dist/render/shadowTiers.js +50 -0
- package/dist/world/WorldScene.js +28 -11
- package/package.json +8 -4
- package/skills/jgengine/api.md +3 -3
- package/skills/jgengine-ui/api.md +79 -9
- package/skills/jgengine-ui/capabilities.md +7 -2
- package/skills/jgengine-world/api.md +18 -2
- package/skills/jgengine-world/capabilities.md +8 -0
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* layer owns the bakes, the materials and the instance buffers. Mount it in the same
|
|
17
|
-
* entity markers; unmount it to fall back to per-entity rigs.
|
|
18
|
-
* @capability character-crowd draw
|
|
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 {
|
|
8
|
-
import { CharacterCrowdRegistry,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 ||
|
|
51
|
+
if (baked === null || materials.length === 0)
|
|
58
52
|
return null;
|
|
59
|
-
const instanced = new THREE.InstancedMesh(baked.geometry, materials,
|
|
60
|
-
|
|
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
|
-
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
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(
|
|
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 ||
|
|
104
|
+
if (mesh === null || baked === null || placement === null)
|
|
95
105
|
return;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
* layer owns the bakes, the materials and the instance buffers. Mount it in the same
|
|
154
|
-
* entity markers; unmount it to fall back to per-entity rigs.
|
|
155
|
-
* @capability character-crowd draw
|
|
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
|
|
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 ${
|
|
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({
|
|
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
|
|
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:
|
|
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:
|
|
24
|
-
|
|
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
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
|
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. */
|