@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.
- package/CHANGELOG.md +48 -0
- package/dist/GamePlayerShell.js +7 -0
- package/dist/Shell3dPresentation.js +5 -3
- package/dist/audio/AudioComponents.js +61 -51
- package/dist/audio/FeedbackHost.js +8 -4
- package/dist/audio/audioEngine.js +101 -24
- package/dist/audio/emitterTracking.d.ts +40 -0
- package/dist/audio/emitterTracking.js +153 -0
- package/dist/audio/eventFeedback.d.ts +15 -5
- package/dist/audio/eventFeedback.js +12 -7
- package/dist/audio/hapticsRoute.d.ts +28 -0
- package/dist/audio/hapticsRoute.js +59 -0
- package/dist/audio/loopParams.js +20 -10
- package/dist/devtools/FrameTelemetry.d.ts +1 -1
- package/dist/devtools/FrameTelemetry.js +4 -2
- package/dist/devtools/agentBridge.js +13 -0
- package/dist/devtools/frameSampler.js +4 -0
- package/dist/drivers/CanvasCoverFrameloop.d.ts +13 -0
- package/dist/drivers/CanvasCoverFrameloop.js +22 -0
- package/dist/drivers/FrameRateLimiter.d.ts +2 -1
- package/dist/drivers/FrameRateLimiter.js +6 -3
- package/dist/postfx/PostProcessing.d.ts +78 -2
- package/dist/postfx/PostProcessing.js +149 -76
- package/dist/postfx/postfxOverlay.d.ts +14 -4
- package/dist/postfx/postfxOverlay.js +90 -10
- package/dist/postfx/shadowPrepass.d.ts +6 -2
- package/dist/postfx/shadowPrepass.js +7 -3
- package/dist/render/characterLod.js +6 -0
- package/dist/render/useFootIk.js +54 -15
- package/dist/render/useModelAnimation.js +22 -5
- package/dist/settings/settingsController.d.ts +10 -1
- package/dist/settings/settingsController.js +81 -69
- package/dist/vision/RevealVision.d.ts +3 -1
- package/dist/vision/RevealVision.js +34 -15
- package/dist/world/WorldItems.js +11 -3
- package/package.json +4 -4
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-ui/api.md +45 -24
- package/skills/jgengine-ui/capabilities.md +8 -0
- package/skills/jgengine-ui/reference-primitives.md +2 -0
- 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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
19
|
-
* `out` (cleared first, no allocation on the hot path) so {@link restorePostfxOverlays} can
|
|
20
|
-
*
|
|
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
|
|
18
|
-
* `out` (cleared first, no allocation on the hot path) so {@link restorePostfxOverlays} can
|
|
19
|
-
*
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
/**
|
|
10
|
-
|
|
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
|
-
/**
|
|
19
|
-
|
|
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 */
|
package/dist/render/useFootIk.js
CHANGED
|
@@ -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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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:
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|