@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
@@ -1,5 +1,5 @@
1
1
  import { resolveAnimationConfig } from "@jgengine/core/game/clipRoles";
2
- /** Below this ground speed a crowd character idles; a walk until the run speed. */
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
- /** Whether a model can leave its own rig for the GPU crowd path: a rigged, clip-played, plain model. */
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
- return model.animation !== undefined && model.animation !== "none";
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 rig's clip names do not resolve to locomotion roles, which leaves the character on its own
24
- * rig instead of drawing a frozen crowd copy.
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
- /** The baked clip a crowd member at `speed` plays; clips are `[idle, walk, run]` as {@link crowdClipsOf} orders them. */
37
- export function crowdClipForSpeed(clips, speed) {
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
- if (speed >= RUN_CLIP_SPEED)
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 >= WALK_CLIP_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 { Camera, Object3D } from "three";
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
- /** Defaults sized to cover an initial burst of streamed models without holding the screen. */
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 after which a round is the last one. */
26
- readonly quietMs: 400;
27
- /** Wall-clock cap on the whole warmup. The last round is cut off at it. */
28
- readonly budgetMs: 5000;
29
- /** Floor for a single round, so a driver that never reports completion cannot stall load. */
30
- readonly roundFloorMs: 1500;
31
- /** A scene still streaming after this long gets one last round and is revealed anyway. */
32
- readonly streamWaitMs: 2000;
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
- budgetMs?: number;
41
- streamWaitMs?: number;
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
- * Warm the scene's shaders through the renderer's async compile, one round per burst of
61
- * streamed models, so materials first seen during load never compile on a visible frame.
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
- * A round is `compileAsync` over the mounted scene; the round after it runs only when the
64
- * model loader started a new burst or the program count grew, which is what a scene that is
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
- /** Defaults sized to cover an initial burst of streamed models without holding the screen. */
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 after which a round is the last one. */
13
- quietMs: 400,
14
- /** Wall-clock cap on the whole warmup. The last round is cut off at it. */
15
- budgetMs: 5_000,
16
- /** Floor for a single round, so a driver that never reports completion cannot stall load. */
17
- roundFloorMs: 1_500,
18
- /** A scene still streaming after this long gets one last round and is revealed anyway. */
19
- streamWaitMs: 2_000,
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
- * Warm the scene's shaders through the renderer's async compile, one round per burst of
23
- * streamed models, so materials first seen during load never compile on a visible frame.
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
- * A round is `compileAsync` over the mounted scene; the round after it runs only when the
26
- * model loader started a new burst or the program count grew, which is what a scene that is
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 budgetMs = options.budgetMs ?? SHADER_WARMUP_DEFAULTS.budgetMs;
34
- const streamWaitMs = options.streamWaitMs ?? SHADER_WARMUP_DEFAULTS.streamWaitMs;
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
- const publish = (next) => {
44
- state = { ...state, ...next, elapsedMs: Math.round(now() - startedAt) };
45
- options.onState?.(state);
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
- const compileRound = async () => {
48
- const remaining = budgetMs - (now() - startedAt);
49
- const allowance = Math.max(SHADER_WARMUP_DEFAULTS.roundFloorMs, remaining);
50
- const deadline = now() + allowance;
51
- const before = options.renderer.info?.programs?.length ?? 0;
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
- // `compileAsync` reports readiness through three's private program property, which throws
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
- catch {
59
- return "error";
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 "timeout";
64
- const programs = options.renderer.info?.programs?.slice(before) ?? [];
65
- if (programs.every(programReady))
66
- return "compiled";
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 "timeout";
185
+ return false;
186
+ if (polls++ % 20 === 0)
187
+ publish({ programs: programs() });
69
188
  await sleep(10);
70
189
  }
71
190
  };
72
- /** Wait (bounded) for the loader to go quiet, so a round is the last one when nothing is left. */
73
- const waitLoaderQuiet = async (limitMs) => {
74
- const until = startedAt + budgetMs;
75
- const limit = Math.min(limitMs, until - now());
76
- if (loaderQuiet(quietMs))
77
- return true;
78
- const deadline = now() + Math.max(0, limit);
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
- return loaderQuiet(quietMs);
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
- const done = (async () => {
87
- publish({ phase: "compiling" });
88
- for (;;) {
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 outcome = await compileRound();
93
- if (outcome === "error") {
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 state;
242
+ return false;
96
243
  }
97
244
  const settled = programs();
98
- publish({ rounds: state.rounds + (outcome === "compiled" ? 1 : 0), programs: settled });
99
- if (outcome === "timeout" || cancelled || now() - startedAt >= budgetMs)
100
- break;
101
- if (!(await waitLoaderQuiet(streamWaitMs)))
245
+ const grew = settled > previous;
246
+ previous = settled;
247
+ quietSince = grew ? now() : quietSince;
248
+ if (now() >= deadline)
102
249
  break;
103
- const streamed = loaderStarts() !== starts;
104
- const linked = programs() > settled;
105
- if (cancelled || (!streamed && !linked))
250
+ if (!grew && now() - quietSince >= quietMs && loaderQuiet(quietMs))
106
251
  break;
107
- if (now() - startedAt >= budgetMs)
252
+ await sleep(pollMs);
253
+ if (cancelled)
108
254
  break;
109
255
  }
110
- publish({ phase: "ready" });
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
  }
@@ -12,6 +12,7 @@ export interface ShadowCasterCull {
12
12
  stats(): {
13
13
  casters: number;
14
14
  skipped: number;
15
+ minRadius: number;
15
16
  };
16
17
  restore(): void;
17
18
  }
@@ -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())