@jgengine/shell 0.32.0 → 0.33.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 (41) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/GamePlayerShell.js +7 -0
  3. package/dist/Shell3dPresentation.js +5 -3
  4. package/dist/audio/AudioComponents.js +61 -51
  5. package/dist/audio/FeedbackHost.js +8 -4
  6. package/dist/audio/audioEngine.js +101 -24
  7. package/dist/audio/emitterTracking.d.ts +40 -0
  8. package/dist/audio/emitterTracking.js +153 -0
  9. package/dist/audio/eventFeedback.d.ts +15 -5
  10. package/dist/audio/eventFeedback.js +12 -7
  11. package/dist/audio/hapticsRoute.d.ts +28 -0
  12. package/dist/audio/hapticsRoute.js +59 -0
  13. package/dist/audio/loopParams.js +20 -10
  14. package/dist/devtools/FrameTelemetry.d.ts +1 -1
  15. package/dist/devtools/FrameTelemetry.js +4 -2
  16. package/dist/devtools/agentBridge.js +13 -0
  17. package/dist/devtools/frameSampler.js +4 -0
  18. package/dist/drivers/CanvasCoverFrameloop.d.ts +13 -0
  19. package/dist/drivers/CanvasCoverFrameloop.js +22 -0
  20. package/dist/drivers/FrameRateLimiter.d.ts +2 -1
  21. package/dist/drivers/FrameRateLimiter.js +6 -3
  22. package/dist/postfx/PostProcessing.d.ts +78 -2
  23. package/dist/postfx/PostProcessing.js +149 -76
  24. package/dist/postfx/postfxOverlay.d.ts +14 -4
  25. package/dist/postfx/postfxOverlay.js +90 -10
  26. package/dist/postfx/shadowPrepass.d.ts +6 -2
  27. package/dist/postfx/shadowPrepass.js +7 -3
  28. package/dist/render/characterLod.js +6 -0
  29. package/dist/render/useFootIk.js +54 -15
  30. package/dist/render/useModelAnimation.js +22 -5
  31. package/dist/settings/settingsController.d.ts +10 -1
  32. package/dist/settings/settingsController.js +81 -69
  33. package/dist/vision/RevealVision.d.ts +3 -1
  34. package/dist/vision/RevealVision.js +34 -15
  35. package/dist/world/WorldItems.js +11 -3
  36. package/package.json +4 -4
  37. package/skills/jgengine/api.md +2 -2
  38. package/skills/jgengine-ui/api.md +45 -24
  39. package/skills/jgengine-ui/capabilities.md +8 -0
  40. package/skills/jgengine-ui/reference-primitives.md +2 -0
  41. package/skills/jgengine-world/api.md +4 -4
@@ -6,23 +6,39 @@ import { OutputPass } from "three/examples/jsm/postprocessing/OutputPass.js";
6
6
  import { GTAOPass } from "three/examples/jsm/postprocessing/GTAOPass.js";
7
7
  import { BokehPass } from "three/examples/jsm/postprocessing/BokehPass.js";
8
8
  import { SMAAPass } from "three/examples/jsm/postprocessing/SMAAPass.js";
9
- import { DEFAULT_AO } from "@jgengine/core/render/postProcessing";
10
- import { adaptExposure, DEFAULT_AUTO_EXPOSURE, exposureForKey, keyLuminanceFromSamples } from "@jgengine/core/render/colorPipeline";
9
+ import { DEFAULT_AO, } from "@jgengine/core/render/postProcessing";
10
+ import { adaptExposure, DEFAULT_AUTO_EXPOSURE, exposureForKey, keyLuminanceFromSamples, } from "@jgengine/core/render/colorPipeline";
11
11
  import { ExposureMeterPass } from "./autoExposure.js";
12
12
  import { createGradePass } from "./gradeShader.js";
13
+ import { releasePostfxOverlayTracking } from "./postfxOverlay.js";
13
14
  import { StylizePass } from "./stylizePass.js";
14
15
  import { renderShadowlessPrepass } from "./shadowPrepass.js";
15
- /**
16
- * GTAO renders the scene with an `overrideMaterial` normal/depth prepass, which stamps
17
- * transparent overlay quads (additive VFX sprites, telegraphs, debug gizmos) into the AO
18
- * buffers as opaque squares — the AO composite then multiplies them toward black. Hide
19
- * marked overlay subtrees for the whole pass: the beauty buffer it composites over was
20
- * already rendered with them in.
21
- */
16
+ /** Point three's GTAO pass at the prepass that already hid its points and lines. @internal */
17
+ export function skipGtaoVisibilityWalk(pass) {
18
+ const hooks = pass;
19
+ if (typeof hooks._overrideVisibility !== "function" || typeof hooks._restoreVisibility !== "function")
20
+ return false;
21
+ const restore = hooks._restoreVisibility;
22
+ hooks._overrideVisibility = () => { };
23
+ hooks._restoreVisibility = () => {
24
+ // three's restore only touches its own cache, which stays empty while the walk is skipped.
25
+ restore.call(pass);
26
+ };
27
+ return true;
28
+ }
22
29
  class OverlayAwareGTAOPass extends GTAOPass {
23
30
  hiddenOverlays = [];
31
+ constructor(scene, camera, width, height) {
32
+ super(scene, camera, width, height);
33
+ skipGtaoVisibilityWalk(this);
34
+ }
24
35
  render(renderer, writeBuffer, readBuffer, deltaTime, maskActive) {
25
- renderShadowlessPrepass(renderer, this.scene, this.hiddenOverlays, () => super.render(renderer, writeBuffer, readBuffer, deltaTime, maskActive));
36
+ const rendersGbuffer = this._renderGBuffer !== false;
37
+ if (!rendersGbuffer) {
38
+ super.render(renderer, writeBuffer, readBuffer, deltaTime, maskActive);
39
+ return;
40
+ }
41
+ renderShadowlessPrepass(renderer, this.scene, this.hiddenOverlays, () => super.render(renderer, writeBuffer, readBuffer, deltaTime, maskActive), true);
26
42
  }
27
43
  }
28
44
  /** DOF's depth prepass has the same `overrideMaterial` overlay-stamping problem as GTAO. */
@@ -65,6 +81,125 @@ export function resolveAoStage(ao, tierAllowsAo) {
65
81
  return null;
66
82
  return ao ?? DEFAULT_AO;
67
83
  }
84
+ /** The stages one chain asks for, before any pass is built. Pure data. @internal */
85
+ export function planPostStages(config, quality = "high", stages) {
86
+ const aa = config.aa === undefined ? "smaa" : config.aa;
87
+ const tierStages = stages ?? { ao: quality === "high", bloom: true, dof: quality === "high", smaa: true };
88
+ const ao = resolveAoStage(config.ao, tierStages.ao);
89
+ const authoredDof = tierStages.dof && config.dof !== undefined && config.dof !== false ? config.dof : null;
90
+ const bloom = tierStages.bloom && config.bloom !== false ? (config.bloom ?? {}) : null;
91
+ const smaa = tierStages.smaa && aa === "smaa";
92
+ return {
93
+ samples: aa === false ? 0 : 2,
94
+ ao,
95
+ aoSamples: quality === "ultra" ? 16 : quality === "high" ? 12 : 8,
96
+ aoGbuffer: smaa || aa === false ? "beauty" : "prepass",
97
+ bloom,
98
+ dof: authoredDof === null ? null : { ...authoredDof, aperture: authoredDof.aperture ?? (quality === "high" ? 0.00025 : 0.00012) },
99
+ smaa,
100
+ autoExposure: config.autoExposure === false ? null : { ...DEFAULT_AUTO_EXPOSURE, ...(config.autoExposure ?? {}) },
101
+ stylize: config.stylize !== undefined && config.stylize !== false ? config.stylize : null,
102
+ grade: config.grade !== false ? (config.grade ?? {}) : null,
103
+ exposure: config.exposure ?? 1,
104
+ };
105
+ }
106
+ /**
107
+ * The toggle stages a chain built from `config` can contain on its most permissive tier, with the
108
+ * player's own toggles ignored. A row for any other stage switches a pass the chain never builds, so
109
+ * nothing the player picks reaches the screen.
110
+ * @internal
111
+ */
112
+ export function availablePostfxStages(config) {
113
+ const plan = planPostStages(config, "high", { ao: true, bloom: true, dof: true, smaa: true });
114
+ const stages = [];
115
+ if (plan.ao !== null)
116
+ stages.push("ao");
117
+ if (plan.bloom !== null)
118
+ stages.push("bloom");
119
+ if (plan.dof !== null)
120
+ stages.push("dof");
121
+ if (plan.smaa)
122
+ stages.push("smaa");
123
+ return stages;
124
+ }
125
+ /**
126
+ * Build the composer for a plan. A stage the plan left out adds no pass, so it draws nothing and
127
+ * allocates no render target.
128
+ * @internal
129
+ */
130
+ export function buildPostGraph(gl, scene, camera, plan, width, height) {
131
+ const beautyDepth = plan.ao !== null && plan.aoGbuffer === "beauty" ? new THREE.DepthTexture(width, height) : null;
132
+ const target = new THREE.WebGLRenderTarget(width, height, {
133
+ type: THREE.HalfFloatType,
134
+ samples: beautyDepth === null ? plan.samples : 0,
135
+ ...(beautyDepth === null ? {} : { depthTexture: beautyDepth }),
136
+ });
137
+ const composer = new EffectComposer(gl, target);
138
+ const built = [];
139
+ composer.addPass(new RenderPass(scene, camera));
140
+ if (plan.ao !== null) {
141
+ const ao = new OverlayAwareGTAOPass(scene, camera, width, height);
142
+ if (beautyDepth !== null)
143
+ ao.setGBuffer(beautyDepth);
144
+ ao.blendIntensity = plan.ao.blend ?? 1;
145
+ ao.updateGtaoMaterial({
146
+ radius: plan.ao.radius ?? 1.8,
147
+ distanceExponent: 1,
148
+ thickness: plan.ao.distanceFalloff ?? 3.6,
149
+ scale: plan.ao.intensity ?? 2.4,
150
+ samples: plan.aoSamples,
151
+ });
152
+ composer.addPass(ao);
153
+ built.push("ao");
154
+ }
155
+ if (plan.bloom !== null) {
156
+ composer.addPass(new UnrealBloomPass(new THREE.Vector2(width, height), plan.bloom.strength ?? 0.32, plan.bloom.radius ?? 0.55, plan.bloom.threshold ?? 0.85));
157
+ built.push("bloom");
158
+ }
159
+ if (plan.dof !== null) {
160
+ const dof = new OverlayAwareBokehPass(scene, camera, {
161
+ focus: plan.dof.focus ?? 18,
162
+ aperture: plan.dof.aperture ?? 0.00025,
163
+ maxblur: plan.dof.maxBlur ?? 0.01,
164
+ });
165
+ composer.addPass(dof);
166
+ built.push("dof");
167
+ }
168
+ // SMAA must run before OutputPass (linear-sRGB). Default on for post chains.
169
+ if (plan.smaa) {
170
+ composer.addPass(new SMAAPass());
171
+ built.push("smaa");
172
+ }
173
+ // Clamped auto-exposure meters the pre-tone-map buffer, so the meter sits before OutputPass and
174
+ // hands the adaptation to the renderer's exposure — the one value the sky swatches also read.
175
+ const authoredExposure = plan.exposure;
176
+ if (plan.autoExposure !== null) {
177
+ const autoExposure = plan.autoExposure;
178
+ let current = authoredExposure;
179
+ let sampled = false;
180
+ composer.addPass(new ExposureMeterPass((samples, seconds) => {
181
+ const target = authoredExposure * exposureForKey(keyLuminanceFromSamples(samples), autoExposure);
182
+ // The first sample lands on its own target; fading in from the authored exposure would
183
+ // open every load on a visible exposure slideshow.
184
+ current = sampled ? adaptExposure(current, target, seconds, autoExposure.adaptationSeconds) : target;
185
+ sampled = true;
186
+ gl.toneMappingExposure = quantizeExposure(current);
187
+ }));
188
+ built.push("exposure");
189
+ }
190
+ composer.addPass(new OutputPass());
191
+ if (plan.stylize !== null) {
192
+ composer.addPass(new StylizePass(scene, camera, plan.stylize));
193
+ built.push("stylize");
194
+ }
195
+ let grade = null;
196
+ if (plan.grade !== null) {
197
+ grade = createGradePass(plan.grade);
198
+ composer.addPass(grade);
199
+ built.push("grade");
200
+ }
201
+ return { composer, grade, stages: built };
202
+ }
68
203
  /**
69
204
  * Mounts an `EffectComposer` inside the shell Canvas and takes over rendering
70
205
  * (priority-1 `useFrame`, which disables R3F auto-render) to run the configured
@@ -73,7 +208,8 @@ export function resolveAoStage(ao, tierAllowsAo) {
73
208
  *
74
209
  * `stages` (resolved from the player's graphics profile) controls expensive passes
75
210
  * independently. Medium keeps AO and DOF with reduced AO sampling; low can disable
76
- * them while retaining cheaper bloom, SMAA, tone mapping, and grade stages.
211
+ * them while retaining cheaper bloom, SMAA, tone mapping, and grade stages. A stage
212
+ * that is off adds no pass at all, so it costs no draw and allocates no target.
77
213
  */
78
214
  export function PostProcessing({ config, quality = "high", stages }) {
79
215
  const gl = useThree((s) => s.gl);
@@ -84,75 +220,12 @@ export function PostProcessing({ config, quality = "high", stages }) {
84
220
  useEffect(() => {
85
221
  const width = Math.max(1, size.width);
86
222
  const height = Math.max(1, size.height);
87
- const aa = config.aa === undefined ? "smaa" : config.aa;
88
- const msaaSamples = aa === false ? 0 : 2;
89
- const target = new THREE.WebGLRenderTarget(width, height, {
90
- type: THREE.HalfFloatType,
91
- samples: msaaSamples,
92
- });
93
- const composer = new EffectComposer(gl, target);
94
- composer.addPass(new RenderPass(scene, camera));
95
- const resolvedStages = stages ?? { ao: quality === "high", bloom: true, dof: quality === "high", smaa: true };
96
- const aoStage = resolveAoStage(config.ao, resolvedStages.ao);
97
- if (aoStage !== null) {
98
- const ao = new OverlayAwareGTAOPass(scene, camera, width, height);
99
- ao.blendIntensity = aoStage.blend ?? 1;
100
- ao.updateGtaoMaterial({
101
- radius: aoStage.radius ?? 1.8,
102
- distanceExponent: 1,
103
- thickness: aoStage.distanceFalloff ?? 3.6,
104
- scale: aoStage.intensity ?? 2.4,
105
- samples: quality === "ultra" ? 16 : quality === "high" ? 12 : 8,
106
- });
107
- composer.addPass(ao);
108
- }
109
- if (resolvedStages.bloom && config.bloom !== false) {
110
- const b = config.bloom ?? {};
111
- composer.addPass(new UnrealBloomPass(new THREE.Vector2(width, height), b.strength ?? 0.32, b.radius ?? 0.55, b.threshold ?? 0.85));
112
- }
113
- if (resolvedStages.dof && config.dof !== undefined && config.dof !== false) {
114
- const d = config.dof;
115
- const dof = new OverlayAwareBokehPass(scene, camera, {
116
- focus: d.focus ?? 18,
117
- aperture: d.aperture ?? (quality === "high" ? 0.00025 : 0.00012),
118
- maxblur: d.maxBlur ?? 0.01,
119
- });
120
- composer.addPass(dof);
121
- }
122
- // SMAA must run before OutputPass (linear-sRGB). Default on for post chains.
123
- if (resolvedStages.smaa && aa === "smaa") {
124
- composer.addPass(new SMAAPass());
125
- }
126
- // Clamped auto-exposure meters the pre-tone-map buffer, so the meter sits before OutputPass and
127
- // hands the adaptation to the renderer's exposure — the one value the sky swatches also read.
128
- const autoExposure = config.autoExposure === false ? null : { ...DEFAULT_AUTO_EXPOSURE, ...(config.autoExposure ?? {}) };
129
- const authoredExposure = config.exposure ?? 1;
130
- if (autoExposure !== null) {
131
- let current = authoredExposure;
132
- let sampled = false;
133
- composer.addPass(new ExposureMeterPass((samples, seconds) => {
134
- const target = authoredExposure * exposureForKey(keyLuminanceFromSamples(samples), autoExposure);
135
- // The first sample lands on its own target; fading in from the authored exposure would
136
- // open every load on a visible exposure slideshow.
137
- current = sampled ? adaptExposure(current, target, seconds, autoExposure.adaptationSeconds) : target;
138
- sampled = true;
139
- gl.toneMappingExposure = quantizeExposure(current);
140
- }));
141
- }
142
- composer.addPass(new OutputPass());
143
- if (config.stylize !== undefined && config.stylize !== false) {
144
- composer.addPass(new StylizePass(scene, camera, config.stylize));
145
- }
146
- let grade = null;
147
- if (config.grade !== false) {
148
- grade = createGradePass(config.grade ?? {});
149
- composer.addPass(grade);
150
- }
151
- const graph = { composer, grade };
223
+ const graph = buildPostGraph(gl, scene, camera, planPostStages(config, quality, stages), width, height);
152
224
  syncSize(graph, width, height, gl.getPixelRatio());
153
225
  builtRef.current = graph;
154
226
  return () => {
155
227
  disposeGraph(graph);
228
+ releasePostfxOverlayTracking(scene);
156
229
  builtRef.current = null;
157
230
  };
158
231
  }, [gl, scene, camera, config, quality, stages]);
@@ -15,10 +15,20 @@ export declare function isPostfxOverlay(object: {
15
15
  userData?: Record<string, unknown>;
16
16
  }): boolean;
17
17
  /**
18
- * Hide every currently visible marked object under `scene`, recording what was hidden into
19
- * `out` (cleared first, no allocation on the hot path) so {@link restorePostfxOverlays} can
20
- * undo exactly that set.
18
+ * Hide every currently visible object `scene`'s prepasses must skip, recording what was hidden into
19
+ * `out` (cleared first, no allocation on the hot path) so {@link restorePostfxOverlays} can undo
20
+ * exactly that set. `withUnmodelled` also hides points and lines, which three's GTAO pass excludes
21
+ * from its normal buffer but a depth prepass keeps.
22
+ *
23
+ * The set is cached and rescanned only when the scene graph changes: walking the whole scene per
24
+ * prepass cost 2.4 ms/frame in Drift Foundry and 0.6 ms/frame in Harbor Heat, and the graph changes
25
+ * when something mounts, not between frames.
21
26
  */
22
- export declare function hidePostfxOverlays(scene: THREE.Object3D, out: THREE.Object3D[]): void;
27
+ export declare function hidePostfxOverlays(scene: THREE.Object3D, out: THREE.Object3D[], withUnmodelled?: boolean): void;
23
28
  /** Restore visibility for the objects hidden by {@link hidePostfxOverlays} and clear the list. */
24
29
  export declare function restorePostfxOverlays(hidden: THREE.Object3D[]): void;
30
+ /**
31
+ * Drop the cached set and its listeners for a scene that is going away, so a torn-down world does
32
+ * not keep its node graph reachable through this module.
33
+ */
34
+ export declare function releasePostfxOverlayTracking(scene: THREE.Object3D): void;
@@ -13,19 +13,84 @@ export const POSTFX_OVERLAY_USERDATA = {
13
13
  export function isPostfxOverlay(object) {
14
14
  return object.userData?.jgPostfxOverlay === true;
15
15
  }
16
+ /** Whether a normal-material prepass must leave `object` out: three's AO pass cannot describe these. */
17
+ function isUnmodelledDrawable(object) {
18
+ const drawable = object;
19
+ return drawable.isPoints === true || drawable.isLine === true || drawable.isLine2 === true;
20
+ }
21
+ const tracks = new WeakMap();
22
+ function unwatchAll(track, node) {
23
+ track.listeners.get(node)?.();
24
+ track.listeners.delete(node);
25
+ for (const child of node.children)
26
+ unwatchAll(track, child);
27
+ }
28
+ function watch(track, node) {
29
+ if (track.listeners.has(node))
30
+ return;
31
+ const added = ({ child }) => {
32
+ track.revision += 1;
33
+ watch(track, child);
34
+ };
35
+ const removed = ({ child }) => {
36
+ track.revision += 1;
37
+ unwatchAll(track, child);
38
+ };
39
+ node.addEventListener("childadded", added);
40
+ node.addEventListener("childremoved", removed);
41
+ track.listeners.set(node, () => {
42
+ node.removeEventListener("childadded", added);
43
+ node.removeEventListener("childremoved", removed);
44
+ });
45
+ for (const child of node.children)
46
+ watch(track, child);
47
+ }
48
+ function trackFor(scene) {
49
+ const existing = tracks.get(scene);
50
+ if (existing !== undefined)
51
+ return existing;
52
+ const track = { revision: 0, scanned: -1, overlays: [], drawables: [], listeners: new Map() };
53
+ tracks.set(scene, track);
54
+ watch(track, scene);
55
+ return track;
56
+ }
57
+ function rescan(scene, track) {
58
+ track.overlays.length = 0;
59
+ track.drawables.length = 0;
60
+ scene.traverse((object) => {
61
+ if (isPostfxOverlay(object))
62
+ track.overlays.push(object);
63
+ else if (isUnmodelledDrawable(object))
64
+ track.drawables.push(object);
65
+ });
66
+ track.scanned = track.revision;
67
+ }
68
+ function hideAll(objects, out) {
69
+ for (const object of objects) {
70
+ if (!object.visible)
71
+ continue;
72
+ object.visible = false;
73
+ out.push(object);
74
+ }
75
+ }
16
76
  /**
17
- * Hide every currently visible marked object under `scene`, recording what was hidden into
18
- * `out` (cleared first, no allocation on the hot path) so {@link restorePostfxOverlays} can
19
- * undo exactly that set.
77
+ * Hide every currently visible object `scene`'s prepasses must skip, recording what was hidden into
78
+ * `out` (cleared first, no allocation on the hot path) so {@link restorePostfxOverlays} can undo
79
+ * exactly that set. `withUnmodelled` also hides points and lines, which three's GTAO pass excludes
80
+ * from its normal buffer but a depth prepass keeps.
81
+ *
82
+ * The set is cached and rescanned only when the scene graph changes: walking the whole scene per
83
+ * prepass cost 2.4 ms/frame in Drift Foundry and 0.6 ms/frame in Harbor Heat, and the graph changes
84
+ * when something mounts, not between frames.
20
85
  */
21
- export function hidePostfxOverlays(scene, out) {
86
+ export function hidePostfxOverlays(scene, out, withUnmodelled = false) {
22
87
  out.length = 0;
23
- scene.traverse((object) => {
24
- if (object.visible && isPostfxOverlay(object)) {
25
- object.visible = false;
26
- out.push(object);
27
- }
28
- });
88
+ const track = trackFor(scene);
89
+ if (track.scanned !== track.revision)
90
+ rescan(scene, track);
91
+ hideAll(track.overlays, out);
92
+ if (withUnmodelled)
93
+ hideAll(track.drawables, out);
29
94
  }
30
95
  /** Restore visibility for the objects hidden by {@link hidePostfxOverlays} and clear the list. */
31
96
  export function restorePostfxOverlays(hidden) {
@@ -33,3 +98,18 @@ export function restorePostfxOverlays(hidden) {
33
98
  object.visible = true;
34
99
  hidden.length = 0;
35
100
  }
101
+ /**
102
+ * Drop the cached set and its listeners for a scene that is going away, so a torn-down world does
103
+ * not keep its node graph reachable through this module.
104
+ */
105
+ export function releasePostfxOverlayTracking(scene) {
106
+ const track = tracks.get(scene);
107
+ if (track === undefined)
108
+ return;
109
+ tracks.delete(scene);
110
+ for (const off of track.listeners.values())
111
+ off();
112
+ track.listeners.clear();
113
+ track.overlays.length = 0;
114
+ track.drawables.length = 0;
115
+ }
@@ -6,5 +6,9 @@ import type * as THREE from "three";
6
6
  * @internal
7
7
  */
8
8
  export declare function renderWithCurrentMatrices<T>(scene: THREE.Object3D, render: () => T): T;
9
- /** Reuse the beauty pass's shadow maps and world matrices while drawing a scene prepass. @internal */
10
- export declare function renderShadowlessPrepass<T>(renderer: Pick<THREE.WebGLRenderer, "shadowMap">, scene: THREE.Object3D, hiddenOverlays: THREE.Object3D[], render: () => T): T;
9
+ /**
10
+ * Reuse the beauty pass's shadow maps and world matrices while drawing a scene prepass.
11
+ * `withUnmodelled` also hides points and lines, which GTAO's normal buffer excludes anyway.
12
+ * @internal
13
+ */
14
+ export declare function renderShadowlessPrepass<T>(renderer: Pick<THREE.WebGLRenderer, "shadowMap">, scene: THREE.Object3D, hiddenOverlays: THREE.Object3D[], render: () => T, withUnmodelled?: boolean): T;
@@ -15,12 +15,16 @@ export function renderWithCurrentMatrices(scene, render) {
15
15
  scene.matrixWorldAutoUpdate = autoUpdate;
16
16
  }
17
17
  }
18
- /** Reuse the beauty pass's shadow maps and world matrices while drawing a scene prepass. @internal */
19
- export function renderShadowlessPrepass(renderer, scene, hiddenOverlays, render) {
18
+ /**
19
+ * Reuse the beauty pass's shadow maps and world matrices while drawing a scene prepass.
20
+ * `withUnmodelled` also hides points and lines, which GTAO's normal buffer excludes anyway.
21
+ * @internal
22
+ */
23
+ export function renderShadowlessPrepass(renderer, scene, hiddenOverlays, render, withUnmodelled = false) {
20
24
  const shadowMap = renderer.shadowMap;
21
25
  const autoUpdate = shadowMap.autoUpdate;
22
26
  const needsUpdate = shadowMap.needsUpdate;
23
- hidePostfxOverlays(scene, hiddenOverlays);
27
+ hidePostfxOverlays(scene, hiddenOverlays, withUnmodelled);
24
28
  shadowMap.autoUpdate = false;
25
29
  shadowMap.needsUpdate = false;
26
30
  try {
@@ -94,6 +94,12 @@ export function noteCharacterPose(instanceId) {
94
94
  }
95
95
  /** Publish the clip and time a rig is posing at, so the crowd can continue the same pose at the swap. @internal */
96
96
  export function noteAnimationPhase(instanceId, clip, time) {
97
+ const published = phases.get(instanceId);
98
+ if (published !== undefined) {
99
+ published.clip = clip;
100
+ published.time = time;
101
+ return;
102
+ }
97
103
  phases.set(instanceId, { clip, time });
98
104
  }
99
105
  /** The clip phase a character was last posed at, or `undefined` when it never posed. @internal */
@@ -174,6 +174,14 @@ function restoreFootIkPose(rig) {
174
174
  record.bone.position.copy(record.animated.p);
175
175
  }
176
176
  }
177
+ /**
178
+ * Reused per frame by {@link applyFootIk}: the ground probes, their hits and the solver input are read
179
+ * synchronously inside one call, so a rig posing every frame allocates nothing.
180
+ */
181
+ const probeInput = { origin: [0, 0, 0], direction: [0, -1, 0], maxDistance: 0 };
182
+ const samplesScratch = [];
183
+ const normalsScratch = [];
184
+ const samplePool = [];
177
185
  /**
178
186
  * Applies one frame of foot IK after the animation mixer: probes the ground under each foot,
179
187
  * resolves targets with `placeFeet`, lowers the pelvis, solves each leg with `solveTwoBone` bending
@@ -187,8 +195,8 @@ export function applyFootIk(rig, originY, probe, state, delta, lookAtTarget) {
187
195
  record.animated.q.copy(record.bone.quaternion);
188
196
  record.animated.p.copy(record.bone.position);
189
197
  }
190
- const samples = [];
191
- const normals = [];
198
+ samplesScratch.length = 0;
199
+ normalsScratch.length = 0;
192
200
  let length = 0;
193
201
  let ankleHeight = 0;
194
202
  for (const leg of rig.legs) {
@@ -197,11 +205,19 @@ export function applyFootIk(rig, originY, probe, state, delta, lookAtTarget) {
197
205
  length = Math.max(length, legSize);
198
206
  ankleHeight = Math.max(ankleHeight, leg.ankleRatio * legSize);
199
207
  const from = Math.max(scratch.tip.y, originY) + legSize * 0.5;
200
- const hit = probe({ origin: [scratch.tip.x, from, scratch.tip.z], direction: [0, -1, 0], maxDistance: legSize * 2 });
201
- samples.push({ ankleY: scratch.tip.y, groundY: hit === null ? null : hit.point[1] });
202
- normals.push(hit === null ? null : hit.normal);
208
+ probeInput.origin[0] = scratch.tip.x;
209
+ probeInput.origin[1] = from;
210
+ probeInput.origin[2] = scratch.tip.z;
211
+ probeInput.maxDistance = legSize * 2;
212
+ const hit = probe(probeInput);
213
+ const index = samplesScratch.length;
214
+ const sample = samplePool[index] ?? (samplePool[index] = { ankleY: 0, groundY: null });
215
+ sample.ankleY = scratch.tip.y;
216
+ sample.groundY = hit === null ? null : hit.point[1];
217
+ samplesScratch.push(sample);
218
+ normalsScratch.push(hit === null ? null : hit.normal);
203
219
  }
204
- const placement = placeFeet({ feet: samples, originY, ankleHeight, maxAdjust: rig.maxAdjust * length, airborneGap: length * 0.3 });
220
+ const placement = placeFeet({ feet: samplesScratch, originY, ankleHeight, maxAdjust: rig.maxAdjust * length, airborneGap: length * 0.3 });
205
221
  const blend = Math.min(1, delta * (placement.grounded ? 12 : 6));
206
222
  state.weight += ((placement.grounded ? 1 : 0) - state.weight) * blend;
207
223
  state.pelvis += (placement.pelvisOffset - state.pelvis) * Math.min(1, delta * 15);
@@ -230,9 +246,9 @@ export function applyFootIk(rig, originY, probe, state, delta, lookAtTarget) {
230
246
  aimSegment(leg.root, root, mid, solved.mid);
231
247
  // Rotating the thigh also moved the tip; aim the shin from that updated pose.
232
248
  aimSegment(leg.mid, leg.mid.getWorldPosition(scratch.mid), leg.tip.getWorldPosition(scratch.tip), solved.tip);
233
- const normal = normals[i];
249
+ const normal = normalsScratch[i];
234
250
  if (normal !== null && normal !== undefined && rig.alignToGround > 0) {
235
- const planted = 1 - Math.min(1, Math.max(0, samples[i].ankleY - ankleHeight - samples[i].groundY) / Math.max(ankleHeight, length * 0.05));
251
+ const planted = 1 - Math.min(1, Math.max(0, samplesScratch[i].ankleY - ankleHeight - samplesScratch[i].groundY) / Math.max(ankleHeight, length * 0.05));
236
252
  const tilt = scratch.q.setFromUnitVectors(UP, scratch.b.set(normal[0], normal[1], normal[2]).normalize());
237
253
  const current = leg.tip.getWorldQuaternion(scratch.q2);
238
254
  writeWorldQuaternion(leg.tip, current.slerp(tilt.multiply(current), planted * weight * rig.alignToGround));
@@ -251,6 +267,8 @@ export function applyFootIk(rig, originY, probe, state, delta, lookAtTarget) {
251
267
  }
252
268
  return placement.grounded;
253
269
  }
270
+ /** Materials a foot probe may hit: blocking physical objects and terrain, never the model's own entity. */
271
+ const IK_FILTER = { entities: false, objects: true, walls: false, terrain: true };
254
272
  /**
255
273
  * Runs foot IK after the model's animation mixer for `ModelConfig.ik`. Ground probes hit terrain and
256
274
  * blocking physical objects, never the model's own entity. `groundOffset` is the model-space height
@@ -262,6 +280,16 @@ export function useFootIk(scene, config, ctx, instanceId, groundOffset = 0) {
262
280
  const state = useRef({ weight: 0, pelvis: 0 });
263
281
  const lookAtConfig = config === undefined || config === "auto" ? undefined : config.lookAt;
264
282
  const target = useRef(null);
283
+ // One probe input and one look-at triple per mount: a rig that poses every frame allocates nothing.
284
+ const probe = useMemo(() => ({
285
+ origin: [0, 0, 0],
286
+ direction: [0, -1, 0],
287
+ maxDistance: 0,
288
+ excludeInstanceIds: instanceId === undefined ? [] : [instanceId],
289
+ filter: IK_FILTER,
290
+ accept: (hit) => hit.blocks && hit.purpose === "physical",
291
+ }), [instanceId]);
292
+ const lookAtPoint = useMemo(() => [0, 0, 0], []);
265
293
  useLayoutEffect(() => {
266
294
  state.current = { weight: 0, pelvis: 0 };
267
295
  target.current = null;
@@ -277,15 +305,26 @@ export function useFootIk(scene, config, ctx, instanceId, groundOffset = 0) {
277
305
  return;
278
306
  const parent = scene.parent;
279
307
  const originY = parent === null ? scene.position.y : parent.localToWorld(scratch.a.set(0, groundOffset, 0)).y;
280
- const exclude = [instanceId];
281
308
  const aim = resolveLookAtTarget(lookAtConfig?.target, ctx, target.current, lookAtConfig?.entityEyeHeight ?? DEFAULT_EYE_HEIGHT);
282
309
  target.current = aim;
283
- applyFootIk(rig, originY, (input) => ctx.scene.raycast({
284
- ...input,
285
- excludeInstanceIds: exclude,
286
- filter: { entities: false, objects: true, walls: false, terrain: true },
287
- accept: (hit) => hit.blocks && hit.purpose === "physical",
288
- }), state.current, delta, aim === null ? camera.getWorldPosition(scratch.b).toArray() : [aim.x, aim.y, aim.z]);
310
+ if (aim === null) {
311
+ const world = camera.getWorldPosition(scratch.b);
312
+ lookAtPoint[0] = world.x;
313
+ lookAtPoint[1] = world.y;
314
+ lookAtPoint[2] = world.z;
315
+ }
316
+ else {
317
+ lookAtPoint[0] = aim.x;
318
+ lookAtPoint[1] = aim.y;
319
+ lookAtPoint[2] = aim.z;
320
+ }
321
+ applyFootIk(rig, originY, (input) => {
322
+ probe.origin[0] = input.origin[0];
323
+ probe.origin[1] = input.origin[1];
324
+ probe.origin[2] = input.origin[2];
325
+ probe.maxDistance = input.maxDistance;
326
+ return ctx.scene.raycast(probe);
327
+ }, state.current, delta, lookAtPoint);
289
328
  });
290
329
  }
291
330
  const DEFAULT_EYE_HEIGHT = 1.5;
@@ -187,13 +187,17 @@ function applyGraphClips(actions, clips) {
187
187
  }
188
188
  }
189
189
  /** The clip with the largest weight in one graph output: the pose the rig is mostly showing. */
190
- function dominantPhase(clips) {
190
+ function dominantPhase(clips, out) {
191
191
  let best;
192
192
  for (const entry of clips) {
193
193
  if (entry.weight > 0 && (best === undefined || entry.weight > best.weight))
194
194
  best = entry;
195
195
  }
196
- return best === undefined ? undefined : { clip: best.clip, time: best.time };
196
+ if (best === undefined)
197
+ return undefined;
198
+ out.clip = best.clip;
199
+ out.time = best.time;
200
+ return out;
197
201
  }
198
202
  /**
199
203
  * Binds a graph's clips to a rig exactly as `useModelAnimation` does (masked layers get filtered
@@ -282,6 +286,8 @@ function buildGraphPlayback(scene, mixer, graph, clips, rng) {
282
286
  lastYaw: null,
283
287
  smoothedSpeed: 0,
284
288
  params: {},
289
+ step: { dx: 0, dz: 0, dyaw: 0, seconds: 0 },
290
+ phase: { clip: "", time: 0 },
285
291
  };
286
292
  }
287
293
  /**
@@ -465,10 +471,21 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
465
471
  else {
466
472
  const instantSpeed = Math.hypot(dx, dz) / playbackDelta;
467
473
  playback.smoothedSpeed += (instantSpeed - playback.smoothedSpeed) * Math.min(1, playbackDelta * 12);
468
- step = { dx, dz, dyaw: playback.lastYaw === null ? 0 : shortestAngle(yaw - playback.lastYaw), seconds: playbackDelta };
474
+ const sample = playback.step;
475
+ sample.dx = dx;
476
+ sample.dz = dz;
477
+ sample.dyaw = playback.lastYaw === null ? 0 : shortestAngle(yaw - playback.lastYaw);
478
+ sample.seconds = playbackDelta;
479
+ step = sample;
469
480
  }
470
481
  }
471
- playback.lastPos = [x, entity.position[1], z];
482
+ if (playback.lastPos === null)
483
+ playback.lastPos = [x, entity.position[1], z];
484
+ else {
485
+ playback.lastPos[0] = x;
486
+ playback.lastPos[1] = entity.position[1];
487
+ playback.lastPos[2] = z;
488
+ }
472
489
  playback.lastYaw = yaw;
473
490
  readModelAnimationParams(ctx, instanceId, playback.smoothedSpeed, params, step);
474
491
  }
@@ -487,7 +504,7 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
487
504
  mixerRef.current.update(0);
488
505
  if (instanceId !== undefined) {
489
506
  noteCharacterPose(instanceId);
490
- const phase = dominantPhase(out.clips);
507
+ const phase = dominantPhase(out.clips, playback.phase);
491
508
  if (phase !== undefined)
492
509
  noteAnimationPhase(instanceId, phase.clip, phase.time);
493
510
  }