@jgengine/shell 0.26.0 → 0.28.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 +174 -0
- package/dist/GamePlayerShell.d.ts +8 -0
- package/dist/GamePlayerShell.js +15 -1
- package/dist/GameUiPreview.js +3 -1
- package/dist/Shell3dPresentation.js +6 -4
- package/dist/ShellChrome.js +3 -1
- package/dist/camera/GameCameraRig.js +2 -2
- package/dist/camera/GameFirstPersonCamera.js +39 -2
- package/dist/camera/cameraCollision.d.ts +66 -1
- package/dist/camera/cameraCollision.js +72 -1
- package/dist/camera/index.d.ts +1 -1
- package/dist/camera/index.js +1 -1
- package/dist/camera/rigResolve.d.ts +13 -0
- package/dist/camera/rigResolve.js +16 -0
- package/dist/environment/EnvironmentScene.js +118 -10
- package/dist/postfx/shadowPrepass.d.ts +8 -1
- package/dist/postfx/shadowPrepass.js +18 -2
- package/dist/postfx/stylizePass.js +2 -1
- package/dist/render/CascadedShadows.d.ts +31 -1
- package/dist/render/CascadedShadows.js +71 -5
- package/dist/render/CharacterCrowd.d.ts +20 -0
- package/dist/render/CharacterCrowd.js +176 -0
- package/dist/render/SceneLighting.js +27 -3
- package/dist/render/SceneWarmupGate.d.ts +20 -0
- package/dist/render/SceneWarmupGate.js +48 -0
- package/dist/render/characterLod.d.ts +62 -0
- package/dist/render/characterLod.js +165 -0
- package/dist/render/crowdRegistry.d.ts +48 -0
- package/dist/render/crowdRegistry.js +97 -0
- package/dist/render/decalGeometry.d.ts +26 -0
- package/dist/render/decalGeometry.js +54 -0
- package/dist/render/decalTexture.d.ts +13 -0
- package/dist/render/decalTexture.js +114 -0
- package/dist/render/materialAsset.js +2 -1
- package/dist/render/modelLoad.js +3 -0
- package/dist/render/modelMeshMerge.d.ts +49 -0
- package/dist/render/modelMeshMerge.js +269 -0
- package/dist/render/modelWind.d.ts +28 -0
- package/dist/render/modelWind.js +103 -0
- package/dist/render/shaderWarmup.d.ts +69 -0
- package/dist/render/shaderWarmup.js +120 -0
- package/dist/render/shadowCasters.d.ts +28 -0
- package/dist/render/shadowCasters.js +95 -0
- package/dist/render/staticTransforms.d.ts +20 -0
- package/dist/render/staticTransforms.js +32 -0
- package/dist/render/useFootIk.d.ts +11 -3
- package/dist/render/useFootIk.js +35 -7
- package/dist/render/useModelAnimation.d.ts +16 -2
- package/dist/render/useModelAnimation.js +73 -4
- package/dist/render/useModelInstance.d.ts +1 -1
- package/dist/render/useModelInstance.js +26 -1
- package/dist/render/useRagdoll.d.ts +32 -0
- package/dist/render/useRagdoll.js +105 -0
- package/dist/scatter/scatterModels.js +2 -0
- package/dist/scene/DecalRenderer.d.ts +2 -0
- package/dist/scene/DecalRenderer.js +74 -0
- package/dist/scene/GrassFieldRenderer.js +3 -2
- package/dist/scene/builtinSceneKindRenderers.js +2 -0
- package/dist/settings/SettingsMenu.d.ts +1 -1
- package/dist/settings/SettingsMenu.js +10 -79
- package/dist/settings/settingsController.js +3 -0
- package/dist/terrain/GrassCover.d.ts +21 -0
- package/dist/terrain/GrassCover.js +84 -0
- package/dist/terrain/grassCoverGeometry.d.ts +40 -0
- package/dist/terrain/grassCoverGeometry.js +112 -0
- package/dist/terrain/terrainDetailMaterial.d.ts +12 -0
- package/dist/terrain/terrainDetailMaterial.js +54 -1
- package/dist/terrain/terrainSurfaces.d.ts +6 -0
- package/dist/terrain/terrainSurfaces.js +10 -0
- package/dist/world/StaticObjectModels.d.ts +13 -0
- package/dist/world/StaticObjectModels.js +28 -3
- package/dist/world/WorldParticles.d.ts +2 -1
- package/dist/world/WorldParticles.js +29 -10
- package/dist/world/WorldScene.js +52 -14
- package/dist/world/entityPose.d.ts +17 -0
- package/dist/world/entityPose.js +42 -0
- package/dist/world/staticObjectBatches.d.ts +33 -5
- package/dist/world/staticObjectBatches.js +40 -6
- package/package.json +4 -4
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-ui/SKILL.md +1 -0
- package/skills/jgengine-ui/api.md +231 -9
- package/skills/jgengine-ui/capabilities.md +107 -1
- package/skills/jgengine-ui/reference-primitives.md +2 -0
- package/skills/jgengine-ui/reference.md +8 -0
- package/skills/jgengine-world/SKILL.md +8 -0
- package/skills/jgengine-world/api.md +243 -11
- package/skills/jgengine-world/capabilities.md +61 -1
- package/skills/jgengine-world/recipes/character-animation.md +25 -2
- package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
- package/skills/jgengine-world/reference.md +1 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { useFrame } from "@react-three/fiber";
|
|
3
|
+
/** Shared gust clock every foliage material reads; advanced once per frame by `WindClock`. */
|
|
4
|
+
export const windClock = { value: 0 };
|
|
5
|
+
/** Shared gust direction (XZ); a model may override it with its own authored direction. */
|
|
6
|
+
export const windDirection = new THREE.Vector2(1, 0.35).normalize();
|
|
7
|
+
const WIND_KEY = "jgWind";
|
|
8
|
+
/** Fully-defaulted sway settings for a model. @internal */
|
|
9
|
+
export function resolveModelWind(wind, fallbackHeight) {
|
|
10
|
+
if (wind === false || wind === undefined)
|
|
11
|
+
return null;
|
|
12
|
+
const strength = wind.strength ?? 0.35;
|
|
13
|
+
if (!Number.isFinite(strength) || strength < 0)
|
|
14
|
+
throw new Error("Model wind strength must be finite and non-negative");
|
|
15
|
+
const speed = wind.speed ?? 1;
|
|
16
|
+
if (!Number.isFinite(speed) || speed <= 0)
|
|
17
|
+
throw new Error("Model wind speed must be finite and positive");
|
|
18
|
+
const height = wind.height ?? fallbackHeight;
|
|
19
|
+
if (!Number.isFinite(height) || height <= 0)
|
|
20
|
+
throw new Error("Model wind height must be finite and positive");
|
|
21
|
+
const direction = new THREE.Vector2(wind.direction?.[0] ?? windDirection.x, wind.direction?.[1] ?? windDirection.y);
|
|
22
|
+
if (direction.lengthSq() === 0)
|
|
23
|
+
direction.set(1, 0);
|
|
24
|
+
direction.normalize();
|
|
25
|
+
return { strength, speed, height, direction };
|
|
26
|
+
}
|
|
27
|
+
const VERTEX_PARS = /* glsl */ `
|
|
28
|
+
uniform float uJgWindTime;
|
|
29
|
+
uniform vec2 uJgWindDir;
|
|
30
|
+
uniform float uJgWindStrength;
|
|
31
|
+
uniform float uJgWindSpeed;
|
|
32
|
+
uniform float uJgWindHeight;`;
|
|
33
|
+
// Height-ramped sway in object space, phased by the instance's world position so neighbouring
|
|
34
|
+
// plants move on different gusts. Trunks (low vertices) barely move; fronds ride the full travel.
|
|
35
|
+
const VERTEX_SWAY = /* glsl */ `#include <begin_vertex>
|
|
36
|
+
{
|
|
37
|
+
float jgWindT = clamp(position.y / max(uJgWindHeight, 0.001), 0.0, 1.0);
|
|
38
|
+
float jgWindRamp = jgWindT * jgWindT;
|
|
39
|
+
#ifdef USE_INSTANCING
|
|
40
|
+
vec3 jgWindWorld = (instanceMatrix * vec4(transformed, 1.0)).xyz;
|
|
41
|
+
#else
|
|
42
|
+
vec3 jgWindWorld = (modelMatrix * vec4(transformed, 1.0)).xyz;
|
|
43
|
+
#endif
|
|
44
|
+
float jgWindPhase = dot(jgWindWorld.xz, vec2(0.17, 0.13)) + uJgWindTime * uJgWindSpeed;
|
|
45
|
+
float jgWindGust = sin(jgWindPhase) * 0.6 + sin(jgWindPhase * 0.47 + 2.3) * 0.4;
|
|
46
|
+
float jgWindFlutter = sin(uJgWindTime * 6.3 + jgWindWorld.x * 0.9 + jgWindWorld.z * 1.3) * 0.18;
|
|
47
|
+
vec2 jgWindPush = uJgWindDir * (jgWindGust + jgWindFlutter) * uJgWindStrength * jgWindRamp;
|
|
48
|
+
transformed.x += jgWindPush.x;
|
|
49
|
+
transformed.z += jgWindPush.y;
|
|
50
|
+
}`;
|
|
51
|
+
/**
|
|
52
|
+
* Compiles height-ramped wind sway into a standard material's vertex shader. Idempotent per
|
|
53
|
+
* material; the clock and direction are shared module uniforms, so the whole scene advances with
|
|
54
|
+
* one write per frame and no per-material bookkeeping.
|
|
55
|
+
* @capability model-wind sway a foliage model in the shared gust field
|
|
56
|
+
*/
|
|
57
|
+
export function applyModelWind(material, wind) {
|
|
58
|
+
if (material.userData[WIND_KEY] !== undefined)
|
|
59
|
+
return material;
|
|
60
|
+
const uniforms = {
|
|
61
|
+
uJgWindTime: windClock,
|
|
62
|
+
uJgWindDir: { value: wind.direction.clone() },
|
|
63
|
+
uJgWindStrength: { value: wind.strength },
|
|
64
|
+
uJgWindSpeed: { value: wind.speed },
|
|
65
|
+
uJgWindHeight: { value: wind.height },
|
|
66
|
+
};
|
|
67
|
+
material.userData[WIND_KEY] = uniforms;
|
|
68
|
+
const previousCompile = material.onBeforeCompile;
|
|
69
|
+
const previousKey = material.customProgramCacheKey;
|
|
70
|
+
material.onBeforeCompile = (shader, renderer) => {
|
|
71
|
+
previousCompile.call(material, shader, renderer);
|
|
72
|
+
Object.assign(shader.uniforms, uniforms);
|
|
73
|
+
shader.vertexShader = shader.vertexShader
|
|
74
|
+
.replace("#include <common>", `#include <common>\n${VERTEX_PARS}`)
|
|
75
|
+
.replace("#include <begin_vertex>", VERTEX_SWAY);
|
|
76
|
+
};
|
|
77
|
+
material.customProgramCacheKey = () => `${previousKey.call(material)}|jg-wind:v1`;
|
|
78
|
+
material.needsUpdate = true;
|
|
79
|
+
return material;
|
|
80
|
+
}
|
|
81
|
+
/** Applies wind to every standard material under a cloned model. @internal */
|
|
82
|
+
export function applyModelWindToScene(root, wind, fallbackHeight = 4) {
|
|
83
|
+
const resolved = resolveModelWind(wind, fallbackHeight);
|
|
84
|
+
if (resolved === null)
|
|
85
|
+
return;
|
|
86
|
+
root.traverse((node) => {
|
|
87
|
+
const mesh = node;
|
|
88
|
+
if (!mesh.isMesh)
|
|
89
|
+
return;
|
|
90
|
+
const list = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
|
91
|
+
for (const material of list) {
|
|
92
|
+
if (material.isMeshStandardMaterial === true)
|
|
93
|
+
applyModelWind(material, resolved);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/** Advances the shared gust clock once per frame; mount one per 3D world. */
|
|
98
|
+
export function WindClock() {
|
|
99
|
+
useFrame(({ clock }) => {
|
|
100
|
+
windClock.value = clock.getElapsedTime();
|
|
101
|
+
});
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Camera, Object3D } from "three";
|
|
2
|
+
/**
|
|
3
|
+
* Where the load-time shader warmup is: waiting for the scene to stop growing, compiling a
|
|
4
|
+
* round, finished, or skipped because the renderer refused. The loading screen renders it.
|
|
5
|
+
*/
|
|
6
|
+
export interface ShaderWarmupState {
|
|
7
|
+
phase: "waiting" | "compiling" | "ready" | "failed";
|
|
8
|
+
/** Compile rounds finished. One round covers one burst of streamed models. */
|
|
9
|
+
rounds: number;
|
|
10
|
+
/** Shader programs the renderer has initialised, as of the last round. */
|
|
11
|
+
programs: number;
|
|
12
|
+
elapsedMs: number;
|
|
13
|
+
}
|
|
14
|
+
/** The renderer surface the warmup needs; a real `WebGLRenderer` satisfies it. */
|
|
15
|
+
export interface ShaderWarmupRenderer {
|
|
16
|
+
/** Links every mounted material's program; returns the materials it prepared. */
|
|
17
|
+
compile(scene: Object3D, camera: Camera): unknown;
|
|
18
|
+
/** Renderer program list; each entry answers `isReady()` once the driver finishes its link. */
|
|
19
|
+
readonly info?: {
|
|
20
|
+
readonly programs?: readonly unknown[] | null;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** Defaults sized to cover an initial burst of streamed models without holding the screen. */
|
|
24
|
+
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;
|
|
33
|
+
};
|
|
34
|
+
/** Options for {@link createShaderWarmup}; the clock, sleep and loader signal are injectable for tests. */
|
|
35
|
+
export interface ShaderWarmupOptions {
|
|
36
|
+
renderer: ShaderWarmupRenderer;
|
|
37
|
+
scene: Object3D;
|
|
38
|
+
camera: Camera;
|
|
39
|
+
quietMs?: number;
|
|
40
|
+
budgetMs?: number;
|
|
41
|
+
streamWaitMs?: number;
|
|
42
|
+
/** Called on every state change, so the loading screen can show the phase. */
|
|
43
|
+
onState?: (state: ShaderWarmupState) => void;
|
|
44
|
+
now?: () => number;
|
|
45
|
+
sleep?: (ms: number) => Promise<void>;
|
|
46
|
+
/** Whether the shared model loader has been quiet long enough. */
|
|
47
|
+
loaderQuiet?: (quietMs: number) => boolean;
|
|
48
|
+
/** Changes whenever the shared loader starts a new burst of model loads. */
|
|
49
|
+
loaderStarts?: () => number;
|
|
50
|
+
}
|
|
51
|
+
/** A warmup in flight: its promise, its latest state, and a way to stop it. */
|
|
52
|
+
export interface ShaderWarmup {
|
|
53
|
+
/** Resolves when warm, capped or failed; never rejects. */
|
|
54
|
+
done: Promise<ShaderWarmupState>;
|
|
55
|
+
state(): ShaderWarmupState;
|
|
56
|
+
/** Stop after the round in flight and stop waiting; used when the view unmounts. */
|
|
57
|
+
cancel(): void;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
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.
|
|
62
|
+
*
|
|
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.
|
|
67
|
+
* @internal
|
|
68
|
+
*/
|
|
69
|
+
export declare function createShaderWarmup(options: ShaderWarmupOptions): ShaderWarmup;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { modelLoadIdleMs, modelLoadStarts } from "./modelLoad.js";
|
|
2
|
+
function programReady(program) {
|
|
3
|
+
try {
|
|
4
|
+
return program.isReady?.() !== false;
|
|
5
|
+
}
|
|
6
|
+
catch {
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** Defaults sized to cover an initial burst of streamed models without holding the screen. */
|
|
11
|
+
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,
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
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.
|
|
24
|
+
*
|
|
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.
|
|
29
|
+
* @internal
|
|
30
|
+
*/
|
|
31
|
+
export function createShaderWarmup(options) {
|
|
32
|
+
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;
|
|
35
|
+
const now = options.now ?? (() => performance.now());
|
|
36
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
37
|
+
const loaderQuiet = options.loaderQuiet ?? ((quiet) => modelLoadIdleMs() >= quiet);
|
|
38
|
+
const loaderStarts = options.loaderStarts ?? modelLoadStarts;
|
|
39
|
+
const startedAt = now();
|
|
40
|
+
let state = { phase: "waiting", rounds: 0, programs: 0, elapsedMs: 0 };
|
|
41
|
+
let cancelled = false;
|
|
42
|
+
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);
|
|
46
|
+
};
|
|
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;
|
|
52
|
+
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);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return "error";
|
|
60
|
+
}
|
|
61
|
+
for (;;) {
|
|
62
|
+
if (cancelled)
|
|
63
|
+
return "timeout";
|
|
64
|
+
const programs = options.renderer.info?.programs?.slice(before) ?? [];
|
|
65
|
+
if (programs.every(programReady))
|
|
66
|
+
return "compiled";
|
|
67
|
+
if (now() >= deadline)
|
|
68
|
+
return "timeout";
|
|
69
|
+
await sleep(10);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
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;
|
|
83
|
+
}
|
|
84
|
+
return loaderQuiet(quietMs);
|
|
85
|
+
};
|
|
86
|
+
const done = (async () => {
|
|
87
|
+
publish({ phase: "compiling" });
|
|
88
|
+
for (;;) {
|
|
89
|
+
if (cancelled)
|
|
90
|
+
break;
|
|
91
|
+
const starts = loaderStarts();
|
|
92
|
+
const outcome = await compileRound();
|
|
93
|
+
if (outcome === "error") {
|
|
94
|
+
publish({ phase: "failed" });
|
|
95
|
+
return state;
|
|
96
|
+
}
|
|
97
|
+
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)))
|
|
102
|
+
break;
|
|
103
|
+
const streamed = loaderStarts() !== starts;
|
|
104
|
+
const linked = programs() > settled;
|
|
105
|
+
if (cancelled || (!streamed && !linked))
|
|
106
|
+
break;
|
|
107
|
+
if (now() - startedAt >= budgetMs)
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
publish({ phase: "ready" });
|
|
111
|
+
return state;
|
|
112
|
+
})();
|
|
113
|
+
return {
|
|
114
|
+
done,
|
|
115
|
+
state: () => state,
|
|
116
|
+
cancel: () => {
|
|
117
|
+
cancelled = true;
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
/**
|
|
3
|
+
* World-space bounding-sphere radius a mesh needs before its shadow is worth a depth
|
|
4
|
+
* draw. Below this the silhouette is a couple of shadow-map texels wide at every
|
|
5
|
+
* cascade that reaches it, so the pass spends a draw for a smudge.
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
export declare const MIN_SHADOW_CASTER_RADIUS = 0.5;
|
|
9
|
+
/** A live caster cull: what it is keeping, and how to put the authored flags back. @internal */
|
|
10
|
+
export interface ShadowCasterCull {
|
|
11
|
+
/** Meshes still casting in the tree, and how many were cleared for being under the radius. */
|
|
12
|
+
stats(): {
|
|
13
|
+
casters: number;
|
|
14
|
+
skipped: number;
|
|
15
|
+
};
|
|
16
|
+
restore(): void;
|
|
17
|
+
}
|
|
18
|
+
/** Bounding-sphere radius of what a mesh draws, in world units; an instance batch counts as its whole spread. @internal */
|
|
19
|
+
export declare function shadowCasterRadius(mesh: THREE.Mesh): number | null;
|
|
20
|
+
/** True when a mesh's shadow is smaller than the map can resolve, so the depth pass should skip it. @internal */
|
|
21
|
+
export declare function isUnderShadowCasterSize(mesh: THREE.Mesh, minRadius?: number): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Clear `castShadow` on attached meshes under `minRadius` as nodes stream in, and put the
|
|
24
|
+
* authored value back when they leave. Only clears: a mesh the game keeps out of the pass
|
|
25
|
+
* is never turned back on.
|
|
26
|
+
* @internal
|
|
27
|
+
*/
|
|
28
|
+
export declare function cullSmallShadowCasters(root: THREE.Object3D, minRadius?: number): ShadowCasterCull;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
/**
|
|
3
|
+
* World-space bounding-sphere radius a mesh needs before its shadow is worth a depth
|
|
4
|
+
* draw. Below this the silhouette is a couple of shadow-map texels wide at every
|
|
5
|
+
* cascade that reaches it, so the pass spends a draw for a smudge.
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
export const MIN_SHADOW_CASTER_RADIUS = 0.5;
|
|
9
|
+
function maxAxisScale(node) {
|
|
10
|
+
const e = node.matrixWorld.elements;
|
|
11
|
+
return Math.max(Math.hypot(e[0], e[1], e[2]), Math.hypot(e[4], e[5], e[6]), Math.hypot(e[8], e[9], e[10]));
|
|
12
|
+
}
|
|
13
|
+
/** Bounding-sphere radius of what a mesh draws, in world units; an instance batch counts as its whole spread. @internal */
|
|
14
|
+
export function shadowCasterRadius(mesh) {
|
|
15
|
+
if (mesh.isInstancedMesh === true) {
|
|
16
|
+
const batch = mesh;
|
|
17
|
+
if (batch.boundingSphere === null)
|
|
18
|
+
batch.computeBoundingSphere();
|
|
19
|
+
return batch.boundingSphere === null ? null : batch.boundingSphere.radius * maxAxisScale(batch);
|
|
20
|
+
}
|
|
21
|
+
const geometry = mesh.geometry;
|
|
22
|
+
if (geometry === undefined || geometry === null)
|
|
23
|
+
return null;
|
|
24
|
+
if (geometry.boundingSphere === null)
|
|
25
|
+
geometry.computeBoundingSphere();
|
|
26
|
+
const sphere = geometry.boundingSphere;
|
|
27
|
+
return sphere === null ? null : sphere.radius * maxAxisScale(mesh);
|
|
28
|
+
}
|
|
29
|
+
/** True when a mesh's shadow is smaller than the map can resolve, so the depth pass should skip it. @internal */
|
|
30
|
+
export function isUnderShadowCasterSize(mesh, minRadius = MIN_SHADOW_CASTER_RADIUS) {
|
|
31
|
+
const radius = shadowCasterRadius(mesh);
|
|
32
|
+
return radius !== null && radius < minRadius;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Clear `castShadow` on attached meshes under `minRadius` as nodes stream in, and put the
|
|
36
|
+
* authored value back when they leave. Only clears: a mesh the game keeps out of the pass
|
|
37
|
+
* is never turned back on.
|
|
38
|
+
* @internal
|
|
39
|
+
*/
|
|
40
|
+
export function cullSmallShadowCasters(root, minRadius = MIN_SHADOW_CASTER_RADIUS) {
|
|
41
|
+
const cleared = new Map();
|
|
42
|
+
const listeners = new Map();
|
|
43
|
+
const apply = (node) => {
|
|
44
|
+
const mesh = node;
|
|
45
|
+
if (mesh.isMesh !== true || cleared.has(mesh) || mesh.castShadow !== true)
|
|
46
|
+
return;
|
|
47
|
+
node.updateWorldMatrix(true, false);
|
|
48
|
+
if (!isUnderShadowCasterSize(mesh, minRadius))
|
|
49
|
+
return;
|
|
50
|
+
cleared.set(mesh, mesh.castShadow);
|
|
51
|
+
mesh.castShadow = false;
|
|
52
|
+
};
|
|
53
|
+
const restoreTree = (node) => {
|
|
54
|
+
const mesh = node;
|
|
55
|
+
const authored = cleared.get(mesh);
|
|
56
|
+
if (authored !== undefined) {
|
|
57
|
+
mesh.castShadow = authored;
|
|
58
|
+
cleared.delete(mesh);
|
|
59
|
+
}
|
|
60
|
+
for (const child of node.children)
|
|
61
|
+
restoreTree(child);
|
|
62
|
+
};
|
|
63
|
+
const attach = (node) => {
|
|
64
|
+
if (listeners.has(node))
|
|
65
|
+
return;
|
|
66
|
+
apply(node);
|
|
67
|
+
const added = ({ child }) => attach(child);
|
|
68
|
+
const removed = ({ child }) => restoreTree(child);
|
|
69
|
+
node.addEventListener("childadded", added);
|
|
70
|
+
node.addEventListener("childremoved", removed);
|
|
71
|
+
listeners.set(node, () => {
|
|
72
|
+
node.removeEventListener("childadded", added);
|
|
73
|
+
node.removeEventListener("childremoved", removed);
|
|
74
|
+
});
|
|
75
|
+
for (const child of node.children)
|
|
76
|
+
attach(child);
|
|
77
|
+
};
|
|
78
|
+
attach(root);
|
|
79
|
+
return {
|
|
80
|
+
stats() {
|
|
81
|
+
let casters = 0;
|
|
82
|
+
root.traverse((node) => {
|
|
83
|
+
if (node.isMesh === true && node.castShadow)
|
|
84
|
+
casters += 1;
|
|
85
|
+
});
|
|
86
|
+
return { casters, skipped: cleared.size };
|
|
87
|
+
},
|
|
88
|
+
restore() {
|
|
89
|
+
for (const off of listeners.values())
|
|
90
|
+
off();
|
|
91
|
+
listeners.clear();
|
|
92
|
+
restoreTree(root);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
/**
|
|
3
|
+
* Bake a subtree's world matrices from its current pose and take it out of three's per-frame
|
|
4
|
+
* `updateMatrixWorld` pass. Three recomposes every node that keeps `matrixAutoUpdate` and then forces
|
|
5
|
+
* its whole subtree to rebuild the world matrix, so authored scenery — terrain, batched props,
|
|
6
|
+
* scatter, instanced ground — pays that cost every frame for a pose it never changes (#2038).
|
|
7
|
+
*
|
|
8
|
+
* Freeze a subtree only when its pose is final and its ancestors stay put: a frozen node ignores a
|
|
9
|
+
* moving parent, and a frozen light or camera stops following the scene. Actors, their bones and
|
|
10
|
+
* anything a game writes per frame keep auto-updating; a frozen seam that moves on a rare event
|
|
11
|
+
* (a door, a lift) thaws, moves, then freezes again.
|
|
12
|
+
* @capability static-transforms
|
|
13
|
+
*/
|
|
14
|
+
export declare function freezeStaticTransforms(root: THREE.Object3D): void;
|
|
15
|
+
/**
|
|
16
|
+
* Put a subtree back on the per-frame matrix pass after {@link freezeStaticTransforms} — for the
|
|
17
|
+
* authored seam that moves on an event rather than every frame.
|
|
18
|
+
* @capability static-transforms
|
|
19
|
+
*/
|
|
20
|
+
export declare function thawStaticTransforms(root: THREE.Object3D): void;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
/**
|
|
3
|
+
* Bake a subtree's world matrices from its current pose and take it out of three's per-frame
|
|
4
|
+
* `updateMatrixWorld` pass. Three recomposes every node that keeps `matrixAutoUpdate` and then forces
|
|
5
|
+
* its whole subtree to rebuild the world matrix, so authored scenery — terrain, batched props,
|
|
6
|
+
* scatter, instanced ground — pays that cost every frame for a pose it never changes (#2038).
|
|
7
|
+
*
|
|
8
|
+
* Freeze a subtree only when its pose is final and its ancestors stay put: a frozen node ignores a
|
|
9
|
+
* moving parent, and a frozen light or camera stops following the scene. Actors, their bones and
|
|
10
|
+
* anything a game writes per frame keep auto-updating; a frozen seam that moves on a rare event
|
|
11
|
+
* (a door, a lift) thaws, moves, then freezes again.
|
|
12
|
+
* @capability static-transforms
|
|
13
|
+
*/
|
|
14
|
+
export function freezeStaticTransforms(root) {
|
|
15
|
+
root.updateMatrixWorld(true);
|
|
16
|
+
root.traverse((node) => {
|
|
17
|
+
node.matrixAutoUpdate = false;
|
|
18
|
+
node.matrixWorldAutoUpdate = false;
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Put a subtree back on the per-frame matrix pass after {@link freezeStaticTransforms} — for the
|
|
23
|
+
* authored seam that moves on an event rather than every frame.
|
|
24
|
+
* @capability static-transforms
|
|
25
|
+
*/
|
|
26
|
+
export function thawStaticTransforms(root) {
|
|
27
|
+
root.traverse((node) => {
|
|
28
|
+
node.matrixAutoUpdate = true;
|
|
29
|
+
node.matrixWorldAutoUpdate = true;
|
|
30
|
+
node.matrixWorldNeedsUpdate = true;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -27,6 +27,13 @@ export interface FootIkRig {
|
|
|
27
27
|
legs: IkLeg[];
|
|
28
28
|
pelvis: THREE.Object3D | null;
|
|
29
29
|
head: THREE.Object3D | null;
|
|
30
|
+
/** Yaw and pitch limits of the look-at solve, in radians; absent means unconstrained. */
|
|
31
|
+
lookAtLimits: {
|
|
32
|
+
maxYaw?: number;
|
|
33
|
+
maxPitch?: number;
|
|
34
|
+
};
|
|
35
|
+
/** How much of the solved look-at turn reaches the bone, `0..1`. */
|
|
36
|
+
lookAtWeight: number;
|
|
30
37
|
maxAdjust: number;
|
|
31
38
|
alignToGround: number;
|
|
32
39
|
/** Pose before and after last frame's correction, so a bone no clip animates never accumulates it. */
|
|
@@ -45,10 +52,11 @@ export declare function resolveFootIkRig(scene: THREE.Object3D, config: FootIkCo
|
|
|
45
52
|
/**
|
|
46
53
|
* Applies one frame of foot IK after the animation mixer: probes the ground under each foot,
|
|
47
54
|
* resolves targets with `placeFeet`, lowers the pelvis, solves each leg with `solveTwoBone` bending
|
|
48
|
-
* toward the animated knee, and tilts planted feet to the ground.
|
|
49
|
-
*
|
|
55
|
+
* toward the animated knee, and tilts planted feet to the ground. Turns the look-at bone toward
|
|
56
|
+
* `lookAtTarget` when the rig has one. Returns whether the feet are grounded; `state` carries the
|
|
57
|
+
* faded weight between frames. Exported for tests and custom hosts.
|
|
50
58
|
*/
|
|
51
|
-
export declare function applyFootIk(rig: FootIkRig, originY: number, probe: GroundProbe, state: FootIkState, delta: number,
|
|
59
|
+
export declare function applyFootIk(rig: FootIkRig, originY: number, probe: GroundProbe, state: FootIkState, delta: number, lookAtTarget?: readonly [number, number, number]): boolean;
|
|
52
60
|
/**
|
|
53
61
|
* Runs foot IK after the model's animation mixer for `ModelConfig.ik`. Ground probes hit terrain and
|
|
54
62
|
* blocking physical objects, never the model's own entity. `groundOffset` is the model-space height
|
package/dist/render/useFootIk.js
CHANGED
|
@@ -134,6 +134,8 @@ export function resolveFootIkRig(scene, config) {
|
|
|
134
134
|
legs,
|
|
135
135
|
pelvis,
|
|
136
136
|
head,
|
|
137
|
+
lookAtLimits: { ...(options.lookAt?.maxYaw === undefined ? {} : { maxYaw: options.lookAt.maxYaw }), ...(options.lookAt?.maxPitch === undefined ? {} : { maxPitch: options.lookAt.maxPitch }) },
|
|
138
|
+
lookAtWeight: Math.max(0, Math.min(1, options.lookAt?.weight ?? 1)),
|
|
137
139
|
maxAdjust: options.maxAdjust ?? DEFAULT_MAX_ADJUST,
|
|
138
140
|
alignToGround: Math.max(0, Math.min(1, options.alignToGround ?? 1)),
|
|
139
141
|
poses: [...bones].map((bone) => ({
|
|
@@ -175,10 +177,11 @@ function restoreFootIkPose(rig) {
|
|
|
175
177
|
/**
|
|
176
178
|
* Applies one frame of foot IK after the animation mixer: probes the ground under each foot,
|
|
177
179
|
* resolves targets with `placeFeet`, lowers the pelvis, solves each leg with `solveTwoBone` bending
|
|
178
|
-
* toward the animated knee, and tilts planted feet to the ground.
|
|
179
|
-
*
|
|
180
|
+
* toward the animated knee, and tilts planted feet to the ground. Turns the look-at bone toward
|
|
181
|
+
* `lookAtTarget` when the rig has one. Returns whether the feet are grounded; `state` carries the
|
|
182
|
+
* faded weight between frames. Exported for tests and custom hosts.
|
|
180
183
|
*/
|
|
181
|
-
export function applyFootIk(rig, originY, probe, state, delta,
|
|
184
|
+
export function applyFootIk(rig, originY, probe, state, delta, lookAtTarget) {
|
|
182
185
|
restoreFootIkPose(rig);
|
|
183
186
|
for (const record of rig.poses) {
|
|
184
187
|
record.animated.q.copy(record.bone.quaternion);
|
|
@@ -235,11 +238,12 @@ export function applyFootIk(rig, originY, probe, state, delta, cameraTarget) {
|
|
|
235
238
|
writeWorldQuaternion(leg.tip, current.slerp(tilt.multiply(current), planted * weight * rig.alignToGround));
|
|
236
239
|
}
|
|
237
240
|
}
|
|
238
|
-
if (rig.head !== null &&
|
|
241
|
+
if (rig.head !== null && lookAtTarget !== undefined) {
|
|
239
242
|
const from = rig.head.getWorldPosition(scratch.a);
|
|
240
|
-
const q = lookAt({ from: [from.x, from.y, from.z], target:
|
|
243
|
+
const q = lookAt({ from: [from.x, from.y, from.z], target: lookAtTarget, up: [0, 1, 0], ...rig.lookAtLimits });
|
|
241
244
|
const current = rig.head.getWorldQuaternion(scratch.q2);
|
|
242
|
-
|
|
245
|
+
const reach = Math.max(weight, rig.legs.length === 0 ? 1 : 0) * rig.lookAtWeight;
|
|
246
|
+
writeWorldQuaternion(rig.head, current.slerp(scratch.q.set(q[0], q[1], q[2], q[3]), reach));
|
|
243
247
|
}
|
|
244
248
|
for (const record of rig.poses) {
|
|
245
249
|
record.written.q.copy(record.bone.quaternion);
|
|
@@ -256,8 +260,11 @@ export function useFootIk(scene, config, ctx, instanceId, groundOffset = 0) {
|
|
|
256
260
|
const camera = useThree((three) => three.camera);
|
|
257
261
|
const rig = useMemo(() => (config === undefined ? null : resolveFootIkRig(scene, config)), [scene, config]);
|
|
258
262
|
const state = useRef({ weight: 0, pelvis: 0 });
|
|
263
|
+
const lookAtConfig = config === undefined || config === "auto" ? undefined : config.lookAt;
|
|
264
|
+
const target = useRef(null);
|
|
259
265
|
useLayoutEffect(() => {
|
|
260
266
|
state.current = { weight: 0, pelvis: 0 };
|
|
267
|
+
target.current = null;
|
|
261
268
|
return () => {
|
|
262
269
|
if (rig !== null) {
|
|
263
270
|
restoreFootIkPose(rig);
|
|
@@ -271,11 +278,32 @@ export function useFootIk(scene, config, ctx, instanceId, groundOffset = 0) {
|
|
|
271
278
|
const parent = scene.parent;
|
|
272
279
|
const originY = parent === null ? scene.position.y : parent.localToWorld(scratch.a.set(0, groundOffset, 0)).y;
|
|
273
280
|
const exclude = [instanceId];
|
|
281
|
+
const aim = resolveLookAtTarget(lookAtConfig?.target, ctx, target.current, lookAtConfig?.entityEyeHeight ?? DEFAULT_EYE_HEIGHT);
|
|
282
|
+
target.current = aim;
|
|
274
283
|
applyFootIk(rig, originY, (input) => ctx.scene.raycast({
|
|
275
284
|
...input,
|
|
276
285
|
excludeInstanceIds: exclude,
|
|
277
286
|
filter: { entities: false, objects: true, walls: false, terrain: true },
|
|
278
287
|
accept: (hit) => hit.blocks && hit.purpose === "physical",
|
|
279
|
-
}), state.current, delta,
|
|
288
|
+
}), state.current, delta, aim === null ? camera.getWorldPosition(scratch.b).toArray() : [aim.x, aim.y, aim.z]);
|
|
280
289
|
});
|
|
281
290
|
}
|
|
291
|
+
const DEFAULT_EYE_HEIGHT = 1.5;
|
|
292
|
+
/**
|
|
293
|
+
* Where a look-at bone should aim this frame, or `null` when it aims at the camera. An entity
|
|
294
|
+
* target that is not spawned yet reuses `previous` rather than snapping back to the camera.
|
|
295
|
+
*/
|
|
296
|
+
function resolveLookAtTarget(spec, ctx, previous, eyeHeight) {
|
|
297
|
+
if (spec === undefined || spec === "camera")
|
|
298
|
+
return null;
|
|
299
|
+
if ("point" in spec) {
|
|
300
|
+
previous?.set(spec.point[0], spec.point[1], spec.point[2]);
|
|
301
|
+
return previous ?? new THREE.Vector3(spec.point[0], spec.point[1], spec.point[2]);
|
|
302
|
+
}
|
|
303
|
+
const entity = ctx.scene.entity.get(spec.entity);
|
|
304
|
+
if (entity === null || entity === undefined)
|
|
305
|
+
return previous;
|
|
306
|
+
const position = entity.position;
|
|
307
|
+
previous?.set(position[0], position[1] + eyeHeight, position[2]);
|
|
308
|
+
return previous ?? new THREE.Vector3(position[0], position[1] + eyeHeight, position[2]);
|
|
309
|
+
}
|
|
@@ -13,14 +13,28 @@ export interface ModelAnimationDiagnostic {
|
|
|
13
13
|
* @capability model-animation-diagnostics inspect imported clip mappings, layer masks and root-track compatibility
|
|
14
14
|
*/
|
|
15
15
|
export declare function diagnoseModelAnimation(scene: THREE.Object3D, animation: ModelAnimationConfig, clips: readonly THREE.AnimationClip[]): readonly ModelAnimationDiagnostic[];
|
|
16
|
+
/**
|
|
17
|
+
* One visual sample's measured motion, used to derive the directional locomotion parameters.
|
|
18
|
+
* `dx`/`dz` are world horizontal displacement since the previous sample, `dyaw` the yaw change in
|
|
19
|
+
* radians and `seconds` the time the sample covers.
|
|
20
|
+
*/
|
|
21
|
+
export interface ModelMotionStep {
|
|
22
|
+
dx: number;
|
|
23
|
+
dz: number;
|
|
24
|
+
dyaw: number;
|
|
25
|
+
seconds: number;
|
|
26
|
+
}
|
|
16
27
|
/**
|
|
17
28
|
* Refreshes a caller-owned graph parameter dictionary from authored extras and known physical movement.
|
|
18
29
|
* Grounded, verticalSpeed and crouched use the shared motor's resolved state when available; custom movers
|
|
19
|
-
* retain their authored values. Speed always uses the supplied measured ground speed.
|
|
30
|
+
* retain their authored values. Speed always uses the supplied measured ground speed. Passing `step`
|
|
31
|
+
* derives the directional parameters from the character's own motion: `direction` is the motion's share
|
|
32
|
+
* along the character's right axis, `facing` its share along the facing axis, and `turnRate` its yaw
|
|
33
|
+
* change per second. Derived values override conflicting authored extras. Does not write game state.
|
|
20
34
|
*
|
|
21
35
|
* @capability animation-motion-params read resolved player movement into reusable animation graph parameters
|
|
22
36
|
*/
|
|
23
|
-
export declare function readModelAnimationParams(ctx: GameContext, instanceId: string, speed: number, out: Record<string, AnimParamValue
|
|
37
|
+
export declare function readModelAnimationParams(ctx: GameContext, instanceId: string, speed: number, out: Record<string, AnimParamValue>, step?: ModelMotionStep): Record<string, AnimParamValue>;
|
|
24
38
|
/**
|
|
25
39
|
* For a frame where a `rootMotion` state is current: pins the root bone's horizontal translation to
|
|
26
40
|
* its bind pose, so the clip plays in place, and returns that step's root travel as a world-space
|