@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.
Files changed (91) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/dist/GamePlayerShell.d.ts +8 -0
  3. package/dist/GamePlayerShell.js +15 -1
  4. package/dist/GameUiPreview.js +3 -1
  5. package/dist/Shell3dPresentation.js +6 -4
  6. package/dist/ShellChrome.js +3 -1
  7. package/dist/camera/GameCameraRig.js +2 -2
  8. package/dist/camera/GameFirstPersonCamera.js +39 -2
  9. package/dist/camera/cameraCollision.d.ts +66 -1
  10. package/dist/camera/cameraCollision.js +72 -1
  11. package/dist/camera/index.d.ts +1 -1
  12. package/dist/camera/index.js +1 -1
  13. package/dist/camera/rigResolve.d.ts +13 -0
  14. package/dist/camera/rigResolve.js +16 -0
  15. package/dist/environment/EnvironmentScene.js +118 -10
  16. package/dist/postfx/shadowPrepass.d.ts +8 -1
  17. package/dist/postfx/shadowPrepass.js +18 -2
  18. package/dist/postfx/stylizePass.js +2 -1
  19. package/dist/render/CascadedShadows.d.ts +31 -1
  20. package/dist/render/CascadedShadows.js +71 -5
  21. package/dist/render/CharacterCrowd.d.ts +20 -0
  22. package/dist/render/CharacterCrowd.js +176 -0
  23. package/dist/render/SceneLighting.js +27 -3
  24. package/dist/render/SceneWarmupGate.d.ts +20 -0
  25. package/dist/render/SceneWarmupGate.js +48 -0
  26. package/dist/render/characterLod.d.ts +62 -0
  27. package/dist/render/characterLod.js +165 -0
  28. package/dist/render/crowdRegistry.d.ts +48 -0
  29. package/dist/render/crowdRegistry.js +97 -0
  30. package/dist/render/decalGeometry.d.ts +26 -0
  31. package/dist/render/decalGeometry.js +54 -0
  32. package/dist/render/decalTexture.d.ts +13 -0
  33. package/dist/render/decalTexture.js +114 -0
  34. package/dist/render/materialAsset.js +2 -1
  35. package/dist/render/modelLoad.js +3 -0
  36. package/dist/render/modelMeshMerge.d.ts +49 -0
  37. package/dist/render/modelMeshMerge.js +269 -0
  38. package/dist/render/modelWind.d.ts +28 -0
  39. package/dist/render/modelWind.js +103 -0
  40. package/dist/render/shaderWarmup.d.ts +69 -0
  41. package/dist/render/shaderWarmup.js +120 -0
  42. package/dist/render/shadowCasters.d.ts +28 -0
  43. package/dist/render/shadowCasters.js +95 -0
  44. package/dist/render/staticTransforms.d.ts +20 -0
  45. package/dist/render/staticTransforms.js +32 -0
  46. package/dist/render/useFootIk.d.ts +11 -3
  47. package/dist/render/useFootIk.js +35 -7
  48. package/dist/render/useModelAnimation.d.ts +16 -2
  49. package/dist/render/useModelAnimation.js +73 -4
  50. package/dist/render/useModelInstance.d.ts +1 -1
  51. package/dist/render/useModelInstance.js +26 -1
  52. package/dist/render/useRagdoll.d.ts +32 -0
  53. package/dist/render/useRagdoll.js +105 -0
  54. package/dist/scatter/scatterModels.js +2 -0
  55. package/dist/scene/DecalRenderer.d.ts +2 -0
  56. package/dist/scene/DecalRenderer.js +74 -0
  57. package/dist/scene/GrassFieldRenderer.js +3 -2
  58. package/dist/scene/builtinSceneKindRenderers.js +2 -0
  59. package/dist/settings/SettingsMenu.d.ts +1 -1
  60. package/dist/settings/SettingsMenu.js +10 -79
  61. package/dist/settings/settingsController.js +3 -0
  62. package/dist/terrain/GrassCover.d.ts +21 -0
  63. package/dist/terrain/GrassCover.js +84 -0
  64. package/dist/terrain/grassCoverGeometry.d.ts +40 -0
  65. package/dist/terrain/grassCoverGeometry.js +112 -0
  66. package/dist/terrain/terrainDetailMaterial.d.ts +12 -0
  67. package/dist/terrain/terrainDetailMaterial.js +54 -1
  68. package/dist/terrain/terrainSurfaces.d.ts +6 -0
  69. package/dist/terrain/terrainSurfaces.js +10 -0
  70. package/dist/world/StaticObjectModels.d.ts +13 -0
  71. package/dist/world/StaticObjectModels.js +28 -3
  72. package/dist/world/WorldParticles.d.ts +2 -1
  73. package/dist/world/WorldParticles.js +29 -10
  74. package/dist/world/WorldScene.js +52 -14
  75. package/dist/world/entityPose.d.ts +17 -0
  76. package/dist/world/entityPose.js +42 -0
  77. package/dist/world/staticObjectBatches.d.ts +33 -5
  78. package/dist/world/staticObjectBatches.js +40 -6
  79. package/package.json +4 -4
  80. package/skills/jgengine/api.md +2 -2
  81. package/skills/jgengine-ui/SKILL.md +1 -0
  82. package/skills/jgengine-ui/api.md +231 -9
  83. package/skills/jgengine-ui/capabilities.md +107 -1
  84. package/skills/jgengine-ui/reference-primitives.md +2 -0
  85. package/skills/jgengine-ui/reference.md +8 -0
  86. package/skills/jgengine-world/SKILL.md +8 -0
  87. package/skills/jgengine-world/api.md +243 -11
  88. package/skills/jgengine-world/capabilities.md +61 -1
  89. package/skills/jgengine-world/recipes/character-animation.md +25 -2
  90. package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
  91. 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. Returns whether the feet are
49
- * grounded; `state` carries the faded weight between frames. Exported for tests and custom hosts.
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, cameraTarget?: readonly [number, number, number]): boolean;
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
@@ -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. Returns whether the feet are
179
- * grounded; `state` carries the faded weight between frames. Exported for tests and custom hosts.
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, cameraTarget) {
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 && cameraTarget !== undefined) {
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: cameraTarget, up: [0, 1, 0] });
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
- writeWorldQuaternion(rig.head, current.slerp(scratch.q.set(q[0], q[1], q[2], q[3]), Math.max(weight, rig.legs.length === 0 ? 1 : 0)));
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, rig.head === null ? undefined : camera.getWorldPosition(scratch.b).toArray());
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. Does not write game state.
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>): 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