@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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolveAnimationConfig } from "@jgengine/core/game/clipRoles";
|
|
2
|
-
/** Below this ground speed a crowd character idles
|
|
2
|
+
/** Below this ground speed a crowd character idles when its model declares no walk speed. */
|
|
3
3
|
const WALK_CLIP_SPEED = 0.25;
|
|
4
4
|
const RUN_CLIP_SPEED = 2.6;
|
|
5
5
|
/**
|
|
@@ -10,18 +10,34 @@ const RUN_CLIP_SPEED = 2.6;
|
|
|
10
10
|
export function crowdKindOf(model) {
|
|
11
11
|
return `${model.url}\u0000${model.targetHeight ?? 0}\u0000${model.scale ?? 1}\u0000${JSON.stringify(model.material ?? null)}`;
|
|
12
12
|
}
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Whether a model can leave its own rig for the GPU crowd path: one whose drawn result a batch can
|
|
15
|
+
* reproduce. A rigged, clip-played, plain model qualifies, and so does a model with no animation at
|
|
16
|
+
* all (a static crowd character) — it has no playback to lose. A model that needs per-entity
|
|
17
|
+
* materials, wind, hidden nodes, IK, attachments or a part composition keeps its own rig.
|
|
18
|
+
*/
|
|
14
19
|
export function crowdEligible(model) {
|
|
15
20
|
if (model === undefined || model.lod === false)
|
|
16
21
|
return false;
|
|
17
22
|
if (model.ik !== undefined || model.attachments !== undefined || model.parts !== undefined)
|
|
18
23
|
return false;
|
|
19
|
-
|
|
24
|
+
if (model.partMotion !== undefined)
|
|
25
|
+
return false;
|
|
26
|
+
if (model.wind !== undefined && model.wind !== false)
|
|
27
|
+
return false;
|
|
28
|
+
if (model.surfaceDetail === false)
|
|
29
|
+
return false;
|
|
30
|
+
if ((model.materialAssignments?.length ?? 0) > 0)
|
|
31
|
+
return false;
|
|
32
|
+
if (model.hiddenNodes !== undefined && !(Array.isArray(model.hiddenNodes) && model.hiddenNodes.length === 0))
|
|
33
|
+
return false;
|
|
34
|
+
return true;
|
|
20
35
|
}
|
|
21
36
|
/**
|
|
22
37
|
* The clips a crowd batch bakes for a model: its resolved idle, walk and run, in that order. Empty
|
|
23
|
-
* when the
|
|
24
|
-
* rig instead of drawing a frozen
|
|
38
|
+
* when the model plays no clips at all (a static character) or when the rig's clip names do not
|
|
39
|
+
* resolve to locomotion roles, which leaves the character on its own rig instead of drawing a frozen
|
|
40
|
+
* crowd copy.
|
|
25
41
|
*/
|
|
26
42
|
export function crowdClipsOf(model, clipNames) {
|
|
27
43
|
const animation = resolveAnimationConfig(model.animation, clipNames);
|
|
@@ -33,13 +49,29 @@ export function crowdClipsOf(model, clipNames) {
|
|
|
33
49
|
}
|
|
34
50
|
return clips;
|
|
35
51
|
}
|
|
36
|
-
/**
|
|
37
|
-
|
|
52
|
+
/**
|
|
53
|
+
* The ground speeds the model's own locomotion graph switches clips at, so a batched character walks
|
|
54
|
+
* and idles at the same speed its own rig would. A model that declares none keeps the engine's
|
|
55
|
+
* defaults.
|
|
56
|
+
*/
|
|
57
|
+
export function crowdSpeedBandsOf(model, clipNames) {
|
|
58
|
+
const states = resolveAnimationConfig(model.animation, clipNames)?.states;
|
|
59
|
+
const band = (value, fallback) => value !== undefined && Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
60
|
+
const walk = band(states?.walkSpeed, WALK_CLIP_SPEED);
|
|
61
|
+
return { walk, run: Math.max(band(states?.runSpeed, RUN_CLIP_SPEED), walk) };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The baked clip a crowd member at `speed` plays; clips are `[idle, walk, run]` as {@link crowdClipsOf}
|
|
65
|
+
* orders them. `bands` is the model's own walk/run speed.
|
|
66
|
+
*/
|
|
67
|
+
export function crowdClipForSpeed(clips, speed, bands) {
|
|
38
68
|
if (clips.length === 0)
|
|
39
69
|
return undefined;
|
|
40
|
-
|
|
70
|
+
const walk = bands?.walk ?? WALK_CLIP_SPEED;
|
|
71
|
+
const run = bands?.run ?? RUN_CLIP_SPEED;
|
|
72
|
+
if (speed >= run)
|
|
41
73
|
return clips[2] ?? clips[1] ?? clips[0];
|
|
42
|
-
if (speed >=
|
|
74
|
+
if (speed >= walk)
|
|
43
75
|
return clips[1] ?? clips[0];
|
|
44
76
|
return clips[0];
|
|
45
77
|
}
|
|
@@ -60,9 +92,55 @@ export function crowdKindReady(kind) {
|
|
|
60
92
|
* The characters the crowd layer is currently drawing, written by their entity markers each frame.
|
|
61
93
|
* It holds poses rather than React state: 71 pedestrians writing matrices must not re-render the
|
|
62
94
|
* world, and the crowd batch reads the same entries back to fill its instance buffers.
|
|
95
|
+
*
|
|
96
|
+
* A marker asks {@link claim} for a slot; the batch declares how many it can draw, so a full batch
|
|
97
|
+
* leaves the rest of its kind on their own rigs. The registry keeps drawn ids in claim order, which
|
|
98
|
+
* is the instance order the batch writes, so a character leaving compacts the rest instead of
|
|
99
|
+
* leaving a stale matrix behind.
|
|
63
100
|
*/
|
|
64
101
|
export class CharacterCrowdRegistry {
|
|
65
102
|
members = new Map();
|
|
103
|
+
capacities = new Map();
|
|
104
|
+
geometries = new Map();
|
|
105
|
+
/** Declare how many characters one kind's batch can draw; `undefined` clears the batch. */
|
|
106
|
+
setCapacity(kind, capacity) {
|
|
107
|
+
if (capacity === undefined)
|
|
108
|
+
this.capacities.delete(kind);
|
|
109
|
+
else
|
|
110
|
+
this.capacities.set(kind, Math.max(0, Math.floor(capacity)));
|
|
111
|
+
}
|
|
112
|
+
/** How many characters one kind's batch can draw; zero until its batch mounts. */
|
|
113
|
+
capacity(kind) {
|
|
114
|
+
return this.capacities.get(kind) ?? 0;
|
|
115
|
+
}
|
|
116
|
+
/** Record the geometry and placement a batch draws its kind with, for picking. */
|
|
117
|
+
setGeometry(kind, geometry) {
|
|
118
|
+
if (geometry === undefined)
|
|
119
|
+
this.geometries.delete(kind);
|
|
120
|
+
else
|
|
121
|
+
this.geometries.set(kind, geometry);
|
|
122
|
+
}
|
|
123
|
+
/** The drawn geometry of one kind, once its batch is ready. */
|
|
124
|
+
geometry(kind) {
|
|
125
|
+
return this.geometries.get(kind);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Hand a character to the batch. Returns false when the batch is full, and the caller then keeps
|
|
129
|
+
* that character on its own rig. An existing member always updates.
|
|
130
|
+
*/
|
|
131
|
+
claim(id, kind, member) {
|
|
132
|
+
const existing = this.members.get(id);
|
|
133
|
+
if (existing !== undefined && existing.kind === kind) {
|
|
134
|
+
Object.assign(existing.member, member);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
if (existing === undefined && this.count(kind) >= this.capacity(kind))
|
|
138
|
+
return false;
|
|
139
|
+
if (existing !== undefined)
|
|
140
|
+
this.members.delete(id);
|
|
141
|
+
this.members.set(id, { kind, member: { ...member } });
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
66
144
|
/** Record the pose an entity is showing as a crowd instance this frame. */
|
|
67
145
|
set(id, kind, member) {
|
|
68
146
|
const existing = this.members.get(id);
|
|
@@ -76,6 +154,10 @@ export class CharacterCrowdRegistry {
|
|
|
76
154
|
delete(id) {
|
|
77
155
|
this.members.delete(id);
|
|
78
156
|
}
|
|
157
|
+
/** The kind this character is batched as, or `undefined` when it draws its own rig. */
|
|
158
|
+
kindOf(id) {
|
|
159
|
+
return this.members.get(id)?.kind;
|
|
160
|
+
}
|
|
79
161
|
/** How many characters this kind is drawing; zero means the batch can skip its work. */
|
|
80
162
|
count(kind) {
|
|
81
163
|
let count = 0;
|
|
@@ -93,5 +175,6 @@ export class CharacterCrowdRegistry {
|
|
|
93
175
|
/** Drop every entry; called when the crowd layer unmounts. */
|
|
94
176
|
clear() {
|
|
95
177
|
this.members.clear();
|
|
178
|
+
this.geometries.clear();
|
|
96
179
|
}
|
|
97
180
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Camera, type Object3D } from "three";
|
|
2
2
|
/**
|
|
3
3
|
* Where the load-time shader warmup is: waiting for the scene to stop growing, compiling a
|
|
4
4
|
* round, finished, or skipped because the renderer refused. The loading screen renders it.
|
|
@@ -9,6 +9,10 @@ export interface ShaderWarmupState {
|
|
|
9
9
|
rounds: number;
|
|
10
10
|
/** Shader programs the renderer has initialised, as of the last round. */
|
|
11
11
|
programs: number;
|
|
12
|
+
/** Drawables the first frame can reach; these are compiled before the first frame is shown. */
|
|
13
|
+
planned: number;
|
|
14
|
+
/** Drawables the first frame cannot reach; compiled in the background after the reveal. */
|
|
15
|
+
deferred: number;
|
|
12
16
|
elapsedMs: number;
|
|
13
17
|
}
|
|
14
18
|
/** The renderer surface the warmup needs; a real `WebGLRenderer` satisfies it. */
|
|
@@ -19,17 +23,56 @@ export interface ShaderWarmupRenderer {
|
|
|
19
23
|
readonly info?: {
|
|
20
24
|
readonly programs?: readonly unknown[] | null;
|
|
21
25
|
};
|
|
26
|
+
/** The render target currently bound, if any; three builds different programs for a target. */
|
|
27
|
+
getRenderTarget?(): unknown;
|
|
28
|
+
/** Bind a render target, or `null` for the canvas. */
|
|
29
|
+
setRenderTarget?(target: unknown | null): void;
|
|
22
30
|
}
|
|
23
|
-
/**
|
|
31
|
+
/** Knobs for {@link planFirstFrame}; the defaults are what a normal load wants. @internal */
|
|
32
|
+
export interface FirstFramePlanOptions {
|
|
33
|
+
/** Bounding-sphere growth for the view test, so a prop just offscreen stays in the plan. */
|
|
34
|
+
viewMargin?: number;
|
|
35
|
+
}
|
|
36
|
+
/** Which drawables the first frame can reach, so the cover waits for those and no others. @internal */
|
|
37
|
+
export interface FirstFramePlan {
|
|
38
|
+
/** Drawables compiled before the first frame is shown. */
|
|
39
|
+
objects: ReadonlySet<Object3D>;
|
|
40
|
+
/** How many drawables the plan covers. */
|
|
41
|
+
reachable: number;
|
|
42
|
+
/** Drawables outside the first frame's reach, for the background compile. */
|
|
43
|
+
deferred: number;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The drawables the first frame can reach: shown, on a layer the camera renders, and either
|
|
47
|
+
* inside the view or a shadow caster, which the shadow pass draws without the camera seeing it.
|
|
48
|
+
*
|
|
49
|
+
* An object just outside the frame still counts, because a turned camera makes it visible before
|
|
50
|
+
* any background compile finishes; the view is grown by {@link FirstFramePlanOptions.viewMargin}.
|
|
51
|
+
* @internal
|
|
52
|
+
*/
|
|
53
|
+
export declare function planFirstFrame(scene: Object3D, camera: Camera, options?: FirstFramePlanOptions): FirstFramePlan;
|
|
54
|
+
/**
|
|
55
|
+
* Links the programs of the drawables the predicate keeps, and nothing else: three has no
|
|
56
|
+
* per-object compile, so the rest is lifted out of the graph for the length of the synchronous
|
|
57
|
+
* call and put back exactly where it was. Same objects, so the programs are the ones a draw
|
|
58
|
+
* would build, which a cloned stand-in scene cannot promise.
|
|
59
|
+
* @internal
|
|
60
|
+
*/
|
|
61
|
+
export declare function compileSubset(renderer: ShaderWarmupRenderer, scene: Object3D, camera: Camera, keep: (object: Object3D) => boolean): void;
|
|
62
|
+
/** Defaults sized to cover one load: the covered wait is the driver's link time, not a guess. */
|
|
24
63
|
export declare const SHADER_WARMUP_DEFAULTS: {
|
|
25
|
-
/** Loader quiet time
|
|
26
|
-
readonly quietMs:
|
|
27
|
-
/** Wall-clock cap on the
|
|
28
|
-
readonly
|
|
29
|
-
/** Floor for
|
|
30
|
-
readonly
|
|
31
|
-
/**
|
|
32
|
-
readonly
|
|
64
|
+
/** Loader and program quiet time before the first frame is shown. */
|
|
65
|
+
readonly quietMs: 250;
|
|
66
|
+
/** Wall-clock cap on the covered wait. The reveal happens at it either way. */
|
|
67
|
+
readonly planBudgetMs: 12000;
|
|
68
|
+
/** Floor for one wait, so a driver that never reports completion cannot stall load. */
|
|
69
|
+
readonly planFloorMs: 1500;
|
|
70
|
+
/** How often the covered loop looks for content that streamed in while it waited. */
|
|
71
|
+
readonly pollMs: 100;
|
|
72
|
+
/** How long the background compile keeps looking for content that landed after the reveal. */
|
|
73
|
+
readonly backgroundMs: 2000;
|
|
74
|
+
/** Gap between background rounds. A round only queues links; it does not wait on the driver. */
|
|
75
|
+
readonly backgroundEveryMs: 250;
|
|
33
76
|
};
|
|
34
77
|
/** Options for {@link createShaderWarmup}; the clock, sleep and loader signal are injectable for tests. */
|
|
35
78
|
export interface ShaderWarmupOptions {
|
|
@@ -37,8 +80,10 @@ export interface ShaderWarmupOptions {
|
|
|
37
80
|
scene: Object3D;
|
|
38
81
|
camera: Camera;
|
|
39
82
|
quietMs?: number;
|
|
40
|
-
|
|
41
|
-
|
|
83
|
+
planBudgetMs?: number;
|
|
84
|
+
pollMs?: number;
|
|
85
|
+
backgroundMs?: number;
|
|
86
|
+
backgroundEveryMs?: number;
|
|
42
87
|
/** Called on every state change, so the loading screen can show the phase. */
|
|
43
88
|
onState?: (state: ShaderWarmupState) => void;
|
|
44
89
|
now?: () => number;
|
|
@@ -47,6 +92,14 @@ export interface ShaderWarmupOptions {
|
|
|
47
92
|
loaderQuiet?: (quietMs: number) => boolean;
|
|
48
93
|
/** Changes whenever the shared loader starts a new burst of model loads. */
|
|
49
94
|
loaderStarts?: () => number;
|
|
95
|
+
/**
|
|
96
|
+
* Whether the frame that follows the cover draws the scene into a render target instead of the
|
|
97
|
+
* canvas, asked again on every round. three builds different programs for the two: a target
|
|
98
|
+
* renders linear with no tone map, the canvas renders in the output colour space with one. The
|
|
99
|
+
* warmup compiles against the one the frame will draw with, so it no longer links a set that
|
|
100
|
+
* nothing draws.
|
|
101
|
+
*/
|
|
102
|
+
sceneDrawsToTarget?: () => boolean;
|
|
50
103
|
}
|
|
51
104
|
/** A warmup in flight: its promise, its latest state, and a way to stop it. */
|
|
52
105
|
export interface ShaderWarmup {
|
|
@@ -57,13 +110,17 @@ export interface ShaderWarmup {
|
|
|
57
110
|
cancel(): void;
|
|
58
111
|
}
|
|
59
112
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
113
|
+
* Compile what the first frame can reach, show it, then finish the rest in the background.
|
|
114
|
+
*
|
|
115
|
+
* The canvas does not draw while this runs (the loading cover holds it), so the driver links the
|
|
116
|
+
* first frame's programs in parallel with the scene still mounting, and nothing waits on a link
|
|
117
|
+
* from inside a frame. The covered wait ends when a round finds no new program and the model
|
|
118
|
+
* loader has gone quiet, so a scene that streams for seconds no longer holds the cover for all of
|
|
119
|
+
* it. Everything the first frame cannot reach is compiled in background rounds afterwards: those
|
|
120
|
+
* objects are not on screen, so their links never land on a drawing frame.
|
|
62
121
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* still streaming looks like. Everything is bounded: the last round is raced against the
|
|
66
|
-
* budget, so a driver that never reports a finished program cannot hold the loading screen.
|
|
122
|
+
* Everything is bounded: the reveal is raced against the plan budget, so a driver that never
|
|
123
|
+
* reports a finished program cannot hold the loading screen.
|
|
67
124
|
* @internal
|
|
68
125
|
*/
|
|
69
126
|
export declare function createShaderWarmup(options: ShaderWarmupOptions): ShaderWarmup;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Frustum, Matrix4, Sphere, Vector3, WebGLRenderTarget } from "three";
|
|
1
2
|
import { modelLoadIdleMs, modelLoadStarts } from "./modelLoad.js";
|
|
2
3
|
function programReady(program) {
|
|
3
4
|
try {
|
|
@@ -7,107 +8,262 @@ function programReady(program) {
|
|
|
7
8
|
return true;
|
|
8
9
|
}
|
|
9
10
|
}
|
|
10
|
-
|
|
11
|
+
function isDrawable(object) {
|
|
12
|
+
const candidate = object;
|
|
13
|
+
if (candidate.material === undefined)
|
|
14
|
+
return false;
|
|
15
|
+
return candidate.isMesh === true || candidate.isPoints === true || candidate.isLine === true || candidate.isSprite === true;
|
|
16
|
+
}
|
|
17
|
+
const DEFAULT_VIEW_MARGIN = 1.4;
|
|
18
|
+
/**
|
|
19
|
+
* The drawables the first frame can reach: shown, on a layer the camera renders, and either
|
|
20
|
+
* inside the view or a shadow caster, which the shadow pass draws without the camera seeing it.
|
|
21
|
+
*
|
|
22
|
+
* An object just outside the frame still counts, because a turned camera makes it visible before
|
|
23
|
+
* any background compile finishes; the view is grown by {@link FirstFramePlanOptions.viewMargin}.
|
|
24
|
+
* @internal
|
|
25
|
+
*/
|
|
26
|
+
export function planFirstFrame(scene, camera, options = {}) {
|
|
27
|
+
const margin = options.viewMargin ?? DEFAULT_VIEW_MARGIN;
|
|
28
|
+
scene.updateMatrixWorld();
|
|
29
|
+
camera.updateMatrixWorld();
|
|
30
|
+
const frustum = new Frustum();
|
|
31
|
+
frustum.setFromProjectionMatrix(new Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse));
|
|
32
|
+
const objects = new Set();
|
|
33
|
+
let deferred = 0;
|
|
34
|
+
const visit = (object, shownByParent) => {
|
|
35
|
+
const shown = shownByParent && object.visible;
|
|
36
|
+
if (isDrawable(object)) {
|
|
37
|
+
if (shown && inReach(object, camera, frustum, margin))
|
|
38
|
+
objects.add(object);
|
|
39
|
+
else
|
|
40
|
+
deferred += 1;
|
|
41
|
+
}
|
|
42
|
+
for (const child of object.children)
|
|
43
|
+
visit(child, shown);
|
|
44
|
+
};
|
|
45
|
+
visit(scene, true);
|
|
46
|
+
return { objects, reachable: objects.size, deferred };
|
|
47
|
+
}
|
|
48
|
+
function inReach(object, camera, frustum, margin) {
|
|
49
|
+
if (!object.layers.test(camera.layers))
|
|
50
|
+
return false;
|
|
51
|
+
if (object.frustumCulled === false)
|
|
52
|
+
return true;
|
|
53
|
+
const geometry = object.geometry;
|
|
54
|
+
if (geometry !== undefined && geometry.boundingSphere === null)
|
|
55
|
+
geometry.computeBoundingSphere();
|
|
56
|
+
const sphere = geometry?.boundingSphere ?? null;
|
|
57
|
+
if (sphere === null)
|
|
58
|
+
return true;
|
|
59
|
+
// A caster outside the view is still drawn by the shadow pass, so its program is needed too.
|
|
60
|
+
if (object.castShadow === true)
|
|
61
|
+
return true;
|
|
62
|
+
const position = object.getWorldPosition(new Vector3());
|
|
63
|
+
const grown = new Sphere().copy(sphere).translate(position);
|
|
64
|
+
grown.radius = sphere.radius * margin;
|
|
65
|
+
return frustum.intersectsSphere(grown);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Links the programs of the drawables the predicate keeps, and nothing else: three has no
|
|
69
|
+
* per-object compile, so the rest is lifted out of the graph for the length of the synchronous
|
|
70
|
+
* call and put back exactly where it was. Same objects, so the programs are the ones a draw
|
|
71
|
+
* would build, which a cloned stand-in scene cannot promise.
|
|
72
|
+
* @internal
|
|
73
|
+
*/
|
|
74
|
+
export function compileSubset(renderer, scene, camera, keep) {
|
|
75
|
+
const lifted = [];
|
|
76
|
+
const walk = (object) => {
|
|
77
|
+
for (const child of [...object.children]) {
|
|
78
|
+
if (isDrawable(child) && !keep(child)) {
|
|
79
|
+
lifted.push({ parent: object, index: object.children.indexOf(child), object: child });
|
|
80
|
+
object.children.splice(object.children.indexOf(child), 1);
|
|
81
|
+
child.parent = null;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
walk(child);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
walk(scene);
|
|
88
|
+
try {
|
|
89
|
+
renderer.compile(scene, camera);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
for (const { parent, index, object } of lifted.sort((a, b) => a.index - b.index)) {
|
|
93
|
+
parent.children.splice(Math.min(index, parent.children.length), 0, object);
|
|
94
|
+
object.parent = parent;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Defaults sized to cover one load: the covered wait is the driver's link time, not a guess. */
|
|
11
99
|
export const SHADER_WARMUP_DEFAULTS = {
|
|
12
|
-
/** Loader quiet time
|
|
13
|
-
quietMs:
|
|
14
|
-
/** Wall-clock cap on the
|
|
15
|
-
|
|
16
|
-
/** Floor for
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
|
|
100
|
+
/** Loader and program quiet time before the first frame is shown. */
|
|
101
|
+
quietMs: 250,
|
|
102
|
+
/** Wall-clock cap on the covered wait. The reveal happens at it either way. */
|
|
103
|
+
planBudgetMs: 12_000,
|
|
104
|
+
/** Floor for one wait, so a driver that never reports completion cannot stall load. */
|
|
105
|
+
planFloorMs: 1_500,
|
|
106
|
+
/** How often the covered loop looks for content that streamed in while it waited. */
|
|
107
|
+
pollMs: 100,
|
|
108
|
+
/** How long the background compile keeps looking for content that landed after the reveal. */
|
|
109
|
+
backgroundMs: 2_000,
|
|
110
|
+
/** Gap between background rounds. A round only queues links; it does not wait on the driver. */
|
|
111
|
+
backgroundEveryMs: 250,
|
|
20
112
|
};
|
|
21
113
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
114
|
+
* Compile what the first frame can reach, show it, then finish the rest in the background.
|
|
115
|
+
*
|
|
116
|
+
* The canvas does not draw while this runs (the loading cover holds it), so the driver links the
|
|
117
|
+
* first frame's programs in parallel with the scene still mounting, and nothing waits on a link
|
|
118
|
+
* from inside a frame. The covered wait ends when a round finds no new program and the model
|
|
119
|
+
* loader has gone quiet, so a scene that streams for seconds no longer holds the cover for all of
|
|
120
|
+
* it. Everything the first frame cannot reach is compiled in background rounds afterwards: those
|
|
121
|
+
* objects are not on screen, so their links never land on a drawing frame.
|
|
24
122
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* still streaming looks like. Everything is bounded: the last round is raced against the
|
|
28
|
-
* budget, so a driver that never reports a finished program cannot hold the loading screen.
|
|
123
|
+
* Everything is bounded: the reveal is raced against the plan budget, so a driver that never
|
|
124
|
+
* reports a finished program cannot hold the loading screen.
|
|
29
125
|
* @internal
|
|
30
126
|
*/
|
|
31
127
|
export function createShaderWarmup(options) {
|
|
32
128
|
const quietMs = options.quietMs ?? SHADER_WARMUP_DEFAULTS.quietMs;
|
|
33
|
-
const
|
|
34
|
-
const
|
|
129
|
+
const planBudgetMs = options.planBudgetMs ?? SHADER_WARMUP_DEFAULTS.planBudgetMs;
|
|
130
|
+
const pollMs = options.pollMs ?? SHADER_WARMUP_DEFAULTS.pollMs;
|
|
131
|
+
const backgroundMs = options.backgroundMs ?? SHADER_WARMUP_DEFAULTS.backgroundMs;
|
|
132
|
+
const backgroundEveryMs = options.backgroundEveryMs ?? SHADER_WARMUP_DEFAULTS.backgroundEveryMs;
|
|
35
133
|
const now = options.now ?? (() => performance.now());
|
|
36
134
|
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
37
135
|
const loaderQuiet = options.loaderQuiet ?? ((quiet) => modelLoadIdleMs() >= quiet);
|
|
38
136
|
const loaderStarts = options.loaderStarts ?? modelLoadStarts;
|
|
39
137
|
const startedAt = now();
|
|
40
|
-
let state = { phase: "waiting", rounds: 0, programs: 0, elapsedMs: 0 };
|
|
138
|
+
let state = { phase: "waiting", rounds: 0, programs: 0, planned: 0, deferred: 0, elapsedMs: 0 };
|
|
41
139
|
let cancelled = false;
|
|
42
140
|
const programs = () => options.renderer.info?.programs?.length ?? 0;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
141
|
+
// Any target builds the same programs: three reads "rendering to a target" for the colour space
|
|
142
|
+
// and the tone map, not the target's size or format.
|
|
143
|
+
let scratchTarget = null;
|
|
144
|
+
const frameTarget = () => {
|
|
145
|
+
if (options.sceneDrawsToTarget?.() !== true)
|
|
146
|
+
return null;
|
|
147
|
+
scratchTarget ??= new WebGLRenderTarget(1, 1);
|
|
148
|
+
return scratchTarget;
|
|
149
|
+
};
|
|
150
|
+
const releaseScratch = () => {
|
|
151
|
+
scratchTarget?.dispose();
|
|
152
|
+
scratchTarget = null;
|
|
46
153
|
};
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
154
|
+
/** Compile with the frame's target bound, so the links are the programs that frame draws. */
|
|
155
|
+
const withFrameTarget = (compile) => {
|
|
156
|
+
const target = frameTarget();
|
|
157
|
+
const renderer = options.renderer;
|
|
158
|
+
if (target === null || renderer.setRenderTarget === undefined) {
|
|
159
|
+
compile();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const previous = renderer.getRenderTarget?.() ?? null;
|
|
163
|
+
renderer.setRenderTarget(target);
|
|
52
164
|
try {
|
|
53
|
-
|
|
54
|
-
// when a material regenerated its program mid-poll (ember-command does). Poll the public
|
|
55
|
-
// program list instead: same wait, no crash path.
|
|
56
|
-
options.renderer.compile(options.scene, options.camera);
|
|
165
|
+
compile();
|
|
57
166
|
}
|
|
58
|
-
|
|
59
|
-
|
|
167
|
+
finally {
|
|
168
|
+
renderer.setRenderTarget(previous);
|
|
60
169
|
}
|
|
170
|
+
};
|
|
171
|
+
const publish = (next) => {
|
|
172
|
+
state = { ...state, ...next, elapsedMs: Math.round(now() - startedAt) };
|
|
173
|
+
options.onState?.(state);
|
|
174
|
+
};
|
|
175
|
+
/** Wait for the programs a round created, polling readiness so the thread stays free to mount. */
|
|
176
|
+
const settle = async (from, deadline) => {
|
|
177
|
+
let polls = 0;
|
|
61
178
|
for (;;) {
|
|
62
179
|
if (cancelled)
|
|
63
|
-
return
|
|
64
|
-
const
|
|
65
|
-
if (
|
|
66
|
-
return
|
|
180
|
+
return false;
|
|
181
|
+
const created = options.renderer.info?.programs?.slice(from) ?? [];
|
|
182
|
+
if (created.every(programReady))
|
|
183
|
+
return true;
|
|
67
184
|
if (now() >= deadline)
|
|
68
|
-
return
|
|
185
|
+
return false;
|
|
186
|
+
if (polls++ % 20 === 0)
|
|
187
|
+
publish({ programs: programs() });
|
|
69
188
|
await sleep(10);
|
|
70
189
|
}
|
|
71
190
|
};
|
|
72
|
-
/**
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
while (!cancelled && now() < deadline) {
|
|
80
|
-
await sleep(100);
|
|
81
|
-
if (loaderQuiet(quietMs))
|
|
82
|
-
return true;
|
|
191
|
+
/** One covered round: plan the reachable set, queue its links, wait for them off the frame. */
|
|
192
|
+
const round = async (deadline) => {
|
|
193
|
+
const plan = planFirstFrame(options.scene, options.camera);
|
|
194
|
+
publish({ planned: plan.reachable, deferred: plan.deferred, programs: programs() });
|
|
195
|
+
const before = programs();
|
|
196
|
+
try {
|
|
197
|
+
withFrameTarget(() => compileSubset(options.renderer, options.scene, options.camera, (object) => plan.objects.has(object)));
|
|
83
198
|
}
|
|
84
|
-
|
|
199
|
+
catch {
|
|
200
|
+
return "error";
|
|
201
|
+
}
|
|
202
|
+
publish({ rounds: state.rounds + 1, programs: programs() });
|
|
203
|
+
const floor = now() + SHADER_WARMUP_DEFAULTS.planFloorMs;
|
|
204
|
+
await settle(before, Math.max(deadline, floor));
|
|
205
|
+
return "ok";
|
|
85
206
|
};
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
207
|
+
/** Queue links for content that has no program yet, without waiting on the driver. */
|
|
208
|
+
const queueBackground = () => {
|
|
209
|
+
try {
|
|
210
|
+
withFrameTarget(() => options.renderer.compile(options.scene, options.camera));
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
/* a background round is an optimisation; the first frame is already showing */
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
/** Compile later content in rounds that never block the cover or the frame in flight. */
|
|
217
|
+
const background = async () => {
|
|
218
|
+
const until = now() + backgroundMs;
|
|
219
|
+
let quietRounds = 0;
|
|
220
|
+
while (!cancelled && now() < until) {
|
|
221
|
+
await sleep(backgroundEveryMs);
|
|
89
222
|
if (cancelled)
|
|
90
223
|
break;
|
|
91
224
|
const starts = loaderStarts();
|
|
92
|
-
const
|
|
93
|
-
|
|
225
|
+
const before = programs();
|
|
226
|
+
queueBackground();
|
|
227
|
+
const grew = programs() > before || loaderStarts() !== starts;
|
|
228
|
+
quietRounds = grew ? 0 : quietRounds + 1;
|
|
229
|
+
publish({ programs: programs() });
|
|
230
|
+
if (quietRounds >= 2)
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
const covered = async () => {
|
|
235
|
+
publish({ phase: "compiling" });
|
|
236
|
+
const deadline = startedAt + planBudgetMs;
|
|
237
|
+
let quietSince = now();
|
|
238
|
+
let previous = programs();
|
|
239
|
+
while (!cancelled) {
|
|
240
|
+
if ((await round(deadline)) === "error") {
|
|
94
241
|
publish({ phase: "failed" });
|
|
95
|
-
return
|
|
242
|
+
return false;
|
|
96
243
|
}
|
|
97
244
|
const settled = programs();
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (
|
|
245
|
+
const grew = settled > previous;
|
|
246
|
+
previous = settled;
|
|
247
|
+
quietSince = grew ? now() : quietSince;
|
|
248
|
+
if (now() >= deadline)
|
|
102
249
|
break;
|
|
103
|
-
|
|
104
|
-
const linked = programs() > settled;
|
|
105
|
-
if (cancelled || (!streamed && !linked))
|
|
250
|
+
if (!grew && now() - quietSince >= quietMs && loaderQuiet(quietMs))
|
|
106
251
|
break;
|
|
107
|
-
|
|
252
|
+
await sleep(pollMs);
|
|
253
|
+
if (cancelled)
|
|
108
254
|
break;
|
|
109
255
|
}
|
|
110
|
-
|
|
256
|
+
// The reveal is the last draw boundary: everything the first frame shows has been settled
|
|
257
|
+
// above, so lifting the cover draws a scene with no program left to link mid-frame.
|
|
258
|
+
publish({ programs: programs(), phase: "ready" });
|
|
259
|
+
return true;
|
|
260
|
+
};
|
|
261
|
+
const done = (async () => {
|
|
262
|
+
if (!(await covered())) {
|
|
263
|
+
releaseScratch();
|
|
264
|
+
return state;
|
|
265
|
+
}
|
|
266
|
+
void background().finally(releaseScratch);
|
|
111
267
|
return state;
|
|
112
268
|
})();
|
|
113
269
|
return {
|
|
@@ -115,6 +271,7 @@ export function createShaderWarmup(options) {
|
|
|
115
271
|
state: () => state,
|
|
116
272
|
cancel: () => {
|
|
117
273
|
cancelled = true;
|
|
274
|
+
releaseScratch();
|
|
118
275
|
},
|
|
119
276
|
};
|
|
120
277
|
}
|
|
@@ -83,7 +83,7 @@ export function cullSmallShadowCasters(root, minRadius = MIN_SHADOW_CASTER_RADIU
|
|
|
83
83
|
if (node.isMesh === true && node.castShadow)
|
|
84
84
|
casters += 1;
|
|
85
85
|
});
|
|
86
|
-
return { casters, skipped: cleared.size };
|
|
86
|
+
return { casters, skipped: cleared.size, minRadius };
|
|
87
87
|
},
|
|
88
88
|
restore() {
|
|
89
89
|
for (const off of listeners.values())
|