@jgengine/shell 0.18.1 → 0.19.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 (112) hide show
  1. package/CHANGELOG.md +158 -7
  2. package/dist/GamePlayerShell.js +20 -3
  3. package/dist/Shell3dPresentation.d.ts +3 -9
  4. package/dist/Shell3dPresentation.js +26 -6
  5. package/dist/ShellHudPresentation.d.ts +9 -1
  6. package/dist/ShellHudPresentation.js +21 -0
  7. package/dist/audio/AudioComponents.js +6 -2
  8. package/dist/audio/audioEngine.d.ts +8 -0
  9. package/dist/audio/audioEngine.js +65 -6
  10. package/dist/audio/audioWire.d.ts +3 -0
  11. package/dist/audio/audioWire.js +7 -1
  12. package/dist/audio/loopParams.d.ts +39 -0
  13. package/dist/audio/loopParams.js +43 -0
  14. package/dist/camera/GameCameraRig.js +1 -1
  15. package/dist/camera/GameFirstPersonCamera.d.ts +4 -2
  16. package/dist/camera/GameFirstPersonCamera.js +52 -15
  17. package/dist/camera/Viewports.d.ts +34 -0
  18. package/dist/camera/Viewports.js +123 -0
  19. package/dist/camera/cameraRigs.js +70 -62
  20. package/dist/camera/orbitCameraMath.js +2 -2
  21. package/dist/camera/rigMath.d.ts +82 -4
  22. package/dist/camera/rigMath.js +133 -15
  23. package/dist/defineGame.d.ts +9 -0
  24. package/dist/defineGame.js +20 -3
  25. package/dist/devtools/DevtoolsOverlay.js +2 -0
  26. package/dist/devtools/agentBridge.d.ts +6 -0
  27. package/dist/devtools/agentBridge.js +15 -1
  28. package/dist/diagnostics/FpsCounter.d.ts +17 -0
  29. package/dist/diagnostics/FpsCounter.js +32 -0
  30. package/dist/drivers/FrameDriver.js +13 -2
  31. package/dist/drivers/FrameRateLimiter.d.ts +9 -0
  32. package/dist/drivers/FrameRateLimiter.js +39 -0
  33. package/dist/drivers/HudOnlyDriver.js +1 -1
  34. package/dist/environment/DayNightSky.js +5 -5
  35. package/dist/environment/Daylight.d.ts +35 -1
  36. package/dist/environment/Daylight.js +98 -28
  37. package/dist/environment/VolumetricClouds.js +20 -2
  38. package/dist/environment/daylightCycle.d.ts +20 -0
  39. package/dist/environment/daylightCycle.js +59 -7
  40. package/dist/environment/sunShadowMath.d.ts +45 -0
  41. package/dist/environment/sunShadowMath.js +50 -0
  42. package/dist/input/gamepadPoll.d.ts +47 -0
  43. package/dist/input/gamepadPoll.js +142 -0
  44. package/dist/input/gamepadSource.d.ts +11 -1
  45. package/dist/input/gamepadSource.js +117 -71
  46. package/dist/input/mouseLook.js +2 -1
  47. package/dist/input/padHaptics.d.ts +17 -0
  48. package/dist/input/padHaptics.js +32 -0
  49. package/dist/input/pointerLock.d.ts +11 -0
  50. package/dist/input/pointerLock.js +19 -0
  51. package/dist/postfx/PostProcessing.d.ts +1 -1
  52. package/dist/postfx/PostProcessing.js +5 -1
  53. package/dist/postfx/stylizePass.d.ts +23 -0
  54. package/dist/postfx/stylizePass.js +216 -0
  55. package/dist/render/SceneModels.js +1 -1
  56. package/dist/render/SkinnedInstances.d.ts +91 -0
  57. package/dist/render/SkinnedInstances.js +249 -0
  58. package/dist/render/TextureFiltering.d.ts +16 -0
  59. package/dist/render/TextureFiltering.js +64 -0
  60. package/dist/render/displayColor.d.ts +33 -0
  61. package/dist/render/displayColor.js +138 -0
  62. package/dist/render/modelLoad.d.ts +29 -0
  63. package/dist/render/modelLoad.js +61 -1
  64. package/dist/render/resolveModel.js +4 -0
  65. package/dist/render/useFootIk.d.ts +53 -4
  66. package/dist/render/useFootIk.js +197 -70
  67. package/dist/render/useModelAnimation.d.ts +24 -0
  68. package/dist/render/useModelAnimation.js +61 -40
  69. package/dist/scene/AuthoredScene.d.ts +9 -0
  70. package/dist/scene/AuthoredScene.js +17 -1
  71. package/dist/scene/CityRenderer.js +6 -6
  72. package/dist/settings/QuickControls.js +1 -1
  73. package/dist/settings/SettingsMenu.js +1 -1
  74. package/dist/settings/appliedSettings.d.ts +9 -3
  75. package/dist/settings/appliedSettings.js +6 -5
  76. package/dist/settings/settingsController.d.ts +3 -0
  77. package/dist/settings/settingsController.js +47 -3
  78. package/dist/structures/BuildingKitBatch.js +26 -22
  79. package/dist/structures/GeneratedBuilding.d.ts +3 -15
  80. package/dist/structures/GeneratedBuilding.js +21 -15
  81. package/dist/structures/buildingSurface.d.ts +21 -0
  82. package/dist/structures/buildingSurface.js +134 -0
  83. package/dist/useShellMultiplayerSync.d.ts +1 -1
  84. package/dist/useShellMultiplayerSync.js +5 -3
  85. package/dist/world/WorldParticles.js +1 -1
  86. package/package.json +4 -4
  87. package/skills/ce-handoff/SKILL.md +1 -1
  88. package/skills/game-design/SKILL.md +2 -2
  89. package/skills/jgengine/SKILL.md +7 -5
  90. package/skills/jgengine/api.md +118 -89
  91. package/skills/jgengine/capabilities.md +28 -0
  92. package/skills/jgengine/recipes/minimal-game.md +3 -0
  93. package/skills/jgengine-ui/SKILL.md +13 -16
  94. package/skills/jgengine-ui/api.md +418 -317
  95. package/skills/jgengine-ui/capabilities.md +16 -0
  96. package/skills/jgengine-ui/reference-primitives.md +89 -0
  97. package/skills/jgengine-ui/reference.md +2 -81
  98. package/skills/jgengine-world/SKILL.md +15 -3
  99. package/skills/jgengine-world/api.md +873 -626
  100. package/skills/jgengine-world/capabilities.md +130 -6
  101. package/skills/jgengine-world/recipes/character-animation.md +39 -0
  102. package/skills/jgengine-world/recipes/controls.md +44 -0
  103. package/skills/jgengine-world/recipes/flight-feel.md +76 -0
  104. package/skills/jgengine-world/recipes/npc-senses.md +43 -0
  105. package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
  106. package/skills/jgengine-world/reference.md +31 -2
  107. package/skills/jgengine-world/visibility.md +112 -0
  108. package/skills/level-design/SKILL.md +1 -1
  109. package/dist/render/testFixtures/proceduralSpriteAtlas.d.ts +0 -15
  110. package/dist/render/testFixtures/proceduralSpriteAtlas.js +0 -30
  111. package/dist/render/testFixtures/proceduralTileLayer.d.ts +0 -13
  112. package/dist/render/testFixtures/proceduralTileLayer.js +0 -30
@@ -0,0 +1,23 @@
1
+ import * as THREE from "three";
2
+ import { Pass } from "three-stdlib";
3
+ import type { StylizeConfig } from "@jgengine/core/render/postProcessing";
4
+ /**
5
+ * Art-style post pass: ink outlines from a normal+depth prepass, cel luminance bands, and
6
+ * pixelation. Runs on the display-space frame after `OutputPass`. The prepass renders only
7
+ * when outlines are on, and reuses the frame's shadow maps instead of re-rendering them.
8
+ */
9
+ export declare class StylizePass extends Pass {
10
+ private readonly scene;
11
+ private readonly camera;
12
+ private readonly quad;
13
+ private readonly material;
14
+ private readonly normalTarget;
15
+ private readonly normalMaterial;
16
+ private readonly hidden;
17
+ private readonly clearColor;
18
+ constructor(scene: THREE.Scene, camera: THREE.Camera, config: StylizeConfig);
19
+ setSize(width: number, height: number): void;
20
+ render(renderer: THREE.WebGLRenderer, writeBuffer: THREE.WebGLRenderTarget, readBuffer: THREE.WebGLRenderTarget): void;
21
+ private renderPrepass;
22
+ dispose(): void;
23
+ }
@@ -0,0 +1,216 @@
1
+ import * as THREE from "three";
2
+ import { FullScreenQuad, Pass } from "three-stdlib";
3
+ import { isPostfxOverlay } from "./postfxOverlay.js";
4
+ const DEFAULT_OUTLINE_COLOR = "#101014";
5
+ const DEFAULT_DEPTH_THRESHOLD = 0.08;
6
+ const DEFAULT_NORMAL_THRESHOLD = 0.35;
7
+ const DEFAULT_FADE_DISTANCE = 160;
8
+ const fragmentShader = /* glsl */ `
9
+ #include <packing>
10
+ uniform sampler2D tDiffuse;
11
+ uniform sampler2D tNormal;
12
+ uniform sampler2D tDepth;
13
+ uniform vec2 uResolution;
14
+ uniform float uNear;
15
+ uniform float uFar;
16
+ uniform bool uOrtho;
17
+ uniform bool uOutline;
18
+ uniform vec3 uOutlineColor;
19
+ uniform float uOutlineOpacity;
20
+ uniform float uThickness;
21
+ uniform float uDepthThreshold;
22
+ uniform float uNormalThreshold;
23
+ uniform float uFade;
24
+ uniform float uBands;
25
+ uniform float uPixel;
26
+ varying vec2 vUv;
27
+
28
+ float viewDepth(vec2 uv) {
29
+ float d = texture2D(tDepth, uv).x;
30
+ return -(uOrtho ? orthographicDepthToViewZ(d, uNear, uFar) : perspectiveDepthToViewZ(d, uNear, uFar));
31
+ }
32
+
33
+ vec3 viewNormal(vec2 uv) {
34
+ return texture2D(tNormal, uv).xyz * 2.0 - 1.0;
35
+ }
36
+
37
+ // Ink a normal crease only on its nearer side, so a thin or tiny feature stays one line wide
38
+ // instead of stamping a plus-shaped blot onto the surface behind it.
39
+ float crease(vec3 nc, float dc, vec2 uv, float dn) {
40
+ return (1.0 - dot(nc, viewNormal(uv))) * step(dc, dn + dc * 0.02);
41
+ }
42
+
43
+ void main() {
44
+ vec2 uv = vUv;
45
+ if (uPixel > 1.0) {
46
+ vec2 block = uPixel / uResolution;
47
+ uv = (floor(uv / block) + 0.5) * block;
48
+ }
49
+ vec3 c = texture2D(tDiffuse, uv).rgb;
50
+
51
+ if (uBands > 0.5) {
52
+ float luma = dot(c, vec3(0.2126, 0.7152, 0.0722));
53
+ float x = min(luma * uBands, uBands - 0.001);
54
+ // Each band takes its centre brightness: the darkest band keeps colour instead of going black
55
+ // and the top band never clips a bright sky to flat white. A narrow soft step hides aliasing.
56
+ float q = (floor(x) + 0.5 + smoothstep(0.92, 1.0, fract(x))) / uBands;
57
+ c *= q / max(luma, 1e-4);
58
+ }
59
+
60
+ if (uOutline) {
61
+ vec2 px = uThickness / uResolution;
62
+ float dc = viewDepth(uv);
63
+ float dl = viewDepth(uv - vec2(px.x, 0.0));
64
+ float dr = viewDepth(uv + vec2(px.x, 0.0));
65
+ float dd = viewDepth(uv - vec2(0.0, px.y));
66
+ float du = viewDepth(uv + vec2(0.0, px.y));
67
+ // Laplacian is zero across flat planes at any angle, so only real depth breaks draw lines.
68
+ float lap = abs(dl + dr + dd + du - 4.0 * dc) / max(dc, 1e-3);
69
+ float nearest = min(dc, min(min(dl, dr), min(dd, du)));
70
+ // Same nearer-side rule as creases: the background around a silhouette stays clean.
71
+ float depthEdge = smoothstep(uDepthThreshold, uDepthThreshold * 2.0, lap) * step(dc, nearest + dc * 0.03);
72
+
73
+ vec3 nc = viewNormal(uv);
74
+ float nd = max(
75
+ max(crease(nc, dc, uv - vec2(px.x, 0.0), dl), crease(nc, dc, uv + vec2(px.x, 0.0), dr)),
76
+ max(crease(nc, dc, uv - vec2(0.0, px.y), dd), crease(nc, dc, uv + vec2(0.0, px.y), du))
77
+ );
78
+ float normalEdge = smoothstep(uNormalThreshold, uNormalThreshold + 0.25, nd);
79
+
80
+ float fade = 1.0 - smoothstep(uFade * 0.5, uFade, nearest);
81
+ float edge = max(depthEdge, normalEdge) * fade * uOutlineOpacity;
82
+ c = mix(c, uOutlineColor, clamp(edge, 0.0, 1.0));
83
+ }
84
+
85
+ gl_FragColor = vec4(c, 1.0);
86
+ }
87
+ `;
88
+ const vertexShader = /* glsl */ `
89
+ varying vec2 vUv;
90
+ void main() {
91
+ vUv = uv;
92
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
93
+ }
94
+ `;
95
+ function skipsDepthPrepass(material) {
96
+ const list = Array.isArray(material) ? material : [material];
97
+ // Shader materials place vertices in their own code, which the override material cannot reproduce.
98
+ return list.some((m) => m.transparent || m.alphaTest > 0 || !m.depthWrite || !m.colorWrite || m.isShaderMaterial === true);
99
+ }
100
+ /**
101
+ * Hide what the outline prepass must not see: marked overlays, points/lines/sprites, custom
102
+ * shader meshes, and transparent or alpha-cut meshes (foliage cards would otherwise outline as
103
+ * solid quads).
104
+ */
105
+ function hideForPrepass(scene, out) {
106
+ out.length = 0;
107
+ scene.traverse((object) => {
108
+ if (!object.visible)
109
+ return;
110
+ const drawable = object;
111
+ const hide = isPostfxOverlay(object) ||
112
+ object.isPoints === true ||
113
+ object.isLine === true ||
114
+ object.isSprite === true ||
115
+ (drawable.isMesh === true && skipsDepthPrepass(drawable.material));
116
+ if (!hide)
117
+ return;
118
+ object.visible = false;
119
+ out.push(object);
120
+ });
121
+ }
122
+ /**
123
+ * Art-style post pass: ink outlines from a normal+depth prepass, cel luminance bands, and
124
+ * pixelation. Runs on the display-space frame after `OutputPass`. The prepass renders only
125
+ * when outlines are on, and reuses the frame's shadow maps instead of re-rendering them.
126
+ */
127
+ export class StylizePass extends Pass {
128
+ scene;
129
+ camera;
130
+ quad;
131
+ material;
132
+ normalTarget;
133
+ normalMaterial = new THREE.MeshNormalMaterial();
134
+ hidden = [];
135
+ clearColor = new THREE.Color();
136
+ constructor(scene, camera, config) {
137
+ super();
138
+ this.scene = scene;
139
+ this.camera = camera;
140
+ const outline = config.outline === undefined || config.outline === false ? null : config.outline;
141
+ this.normalTarget = outline === null
142
+ ? null
143
+ : new THREE.WebGLRenderTarget(1, 1, { depthTexture: new THREE.DepthTexture(1, 1), type: THREE.UnsignedByteType });
144
+ this.material = new THREE.ShaderMaterial({
145
+ uniforms: {
146
+ tDiffuse: { value: null },
147
+ tNormal: { value: this.normalTarget?.texture ?? null },
148
+ tDepth: { value: this.normalTarget?.depthTexture ?? null },
149
+ uResolution: { value: new THREE.Vector2(1, 1) },
150
+ uNear: { value: 0.1 },
151
+ uFar: { value: 1000 },
152
+ uOrtho: { value: false },
153
+ uOutline: { value: outline !== null },
154
+ uOutlineColor: { value: new THREE.Color(outline?.color ?? DEFAULT_OUTLINE_COLOR) },
155
+ uOutlineOpacity: { value: outline?.opacity ?? 1 },
156
+ uThickness: { value: Math.max(0.5, outline?.thickness ?? 1) },
157
+ uDepthThreshold: { value: outline?.depthThreshold ?? DEFAULT_DEPTH_THRESHOLD },
158
+ uNormalThreshold: { value: outline?.normalThreshold ?? DEFAULT_NORMAL_THRESHOLD },
159
+ uFade: { value: outline?.fadeDistance ?? DEFAULT_FADE_DISTANCE },
160
+ uBands: { value: Math.max(0, Math.floor(config.bands ?? 0)) },
161
+ uPixel: { value: Math.max(0, config.pixelSize ?? 0) },
162
+ },
163
+ vertexShader,
164
+ fragmentShader,
165
+ depthTest: false,
166
+ depthWrite: false,
167
+ });
168
+ this.quad = new FullScreenQuad(this.material);
169
+ }
170
+ setSize(width, height) {
171
+ this.material.uniforms.uResolution.value.set(width, height);
172
+ this.normalTarget?.setSize(width, height);
173
+ }
174
+ render(renderer, writeBuffer, readBuffer) {
175
+ const uniforms = this.material.uniforms;
176
+ if (this.normalTarget !== null)
177
+ this.renderPrepass(renderer, this.normalTarget);
178
+ const camera = this.camera;
179
+ uniforms.uNear.value = camera.near;
180
+ uniforms.uFar.value = camera.far;
181
+ uniforms.uOrtho.value = camera.isOrthographicCamera === true;
182
+ uniforms.tDiffuse.value = readBuffer.texture;
183
+ renderer.setRenderTarget(this.renderToScreen ? null : writeBuffer);
184
+ this.quad.render(renderer);
185
+ }
186
+ renderPrepass(renderer, target) {
187
+ const scene = this.scene;
188
+ const previousOverride = scene.overrideMaterial;
189
+ const previousBackground = scene.background;
190
+ const previousShadowUpdate = renderer.shadowMap.autoUpdate;
191
+ const previousClearAlpha = renderer.getClearAlpha();
192
+ renderer.getClearColor(this.clearColor);
193
+ hideForPrepass(scene, this.hidden);
194
+ scene.overrideMaterial = this.normalMaterial;
195
+ scene.background = null;
196
+ renderer.shadowMap.autoUpdate = false;
197
+ renderer.setRenderTarget(target);
198
+ renderer.setClearColor(0x8080ff, 1);
199
+ renderer.clear();
200
+ renderer.render(scene, this.camera);
201
+ renderer.setClearColor(this.clearColor, previousClearAlpha);
202
+ renderer.shadowMap.autoUpdate = previousShadowUpdate;
203
+ scene.background = previousBackground;
204
+ scene.overrideMaterial = previousOverride;
205
+ for (const object of this.hidden)
206
+ object.visible = true;
207
+ this.hidden.length = 0;
208
+ }
209
+ dispose() {
210
+ this.material.dispose();
211
+ this.normalMaterial.dispose();
212
+ this.quad.dispose();
213
+ this.normalTarget?.depthTexture?.dispose();
214
+ this.normalTarget?.dispose();
215
+ }
216
+ }
@@ -218,7 +218,7 @@ export function EntityModel({ model, instanceId, measure, }) {
218
218
  reportMeasuredCollisionMesh(ctx, measureTarget, measureKey, triangles);
219
219
  }, [ctx, scene, scale, positionX, positionY, positionZ, measureTarget, measureKey]);
220
220
  useModelAnimation(scene, gltf.animations, model.animation, instanceId);
221
- useFootIk(scene, model.ik, ctx, instanceId);
221
+ useFootIk(scene, model.ik, ctx, instanceId, baseY);
222
222
  const paintCanvasRef = useRef(null);
223
223
  const paintDrawnCountRef = useRef(0);
224
224
  const paintVersionRef = useRef(-1);
@@ -0,0 +1,91 @@
1
+ import * as THREE from "three";
2
+ import { type BoneTextureLayout } from "@jgengine/core/anim/boneTexture";
3
+ /** A rig to bake: a loaded scene with skinned meshes and its clips. */
4
+ export interface SkinnedCrowdSource {
5
+ scene: THREE.Object3D;
6
+ animations: readonly THREE.AnimationClip[];
7
+ }
8
+ /** Options for {@link bakeSkinnedCrowd}. */
9
+ export interface BakeSkinnedCrowdOptions {
10
+ /** Clip names to bake, in order; default every clip on the rig. */
11
+ clips?: readonly string[];
12
+ /** Baked samples per second. Default 30. */
13
+ fps?: number;
14
+ /** Clips that hold their last pose instead of looping. */
15
+ once?: readonly string[];
16
+ maxTextureSize?: number;
17
+ }
18
+ /** A rig baked for instancing: one merged geometry, the bone-matrix texture and its layout. */
19
+ export interface BakedSkinnedCrowd {
20
+ geometry: THREE.BufferGeometry;
21
+ /** One material per merged mesh, in geometry-group order, not yet patched. */
22
+ materials: THREE.Material[];
23
+ texture: THREE.DataTexture;
24
+ layout: BoneTextureLayout;
25
+ /** Per clip: whether it loops. Same order as `layout.clips`. */
26
+ loops: boolean[];
27
+ clipIndex: ReadonlyMap<string, number>;
28
+ dispose(): void;
29
+ }
30
+ /**
31
+ * Bakes a rig for {@link SkinnedInstances}: merges its skinned meshes into one geometry in bind
32
+ * space, samples every requested clip at `fps`, and writes each bone's model-space skinning matrix
33
+ * into a float texture laid out by `planBoneTexture`. Meshes with several materials keep only the
34
+ * first. Exported for tests and custom crowd renderers.
35
+ */
36
+ export declare function bakeSkinnedCrowd(source: SkinnedCrowdSource, options?: BakeSkinnedCrowdOptions): BakedSkinnedCrowd;
37
+ /** Uniforms shared by every material of one crowd; `crowdTime` advances once per frame. */
38
+ export interface CrowdUniforms {
39
+ crowdBones: {
40
+ value: THREE.DataTexture;
41
+ };
42
+ crowdTime: {
43
+ value: number;
44
+ };
45
+ crowdClips: {
46
+ value: THREE.Vector4[];
47
+ };
48
+ }
49
+ /** Builds the uniforms a crowd material reads: the texture and per-clip start, frames, fps and loop. */
50
+ export declare function createCrowdUniforms(baked: BakedSkinnedCrowd): CrowdUniforms;
51
+ /**
52
+ * Patches a material so each instance skins itself from the crowd texture: per-instance `crowdPlay`
53
+ * is (clip index, time offset, speed). Works on any built-in material, the depth material for
54
+ * shadows included.
55
+ */
56
+ export declare function patchCrowdMaterial<T extends THREE.Material>(material: T, uniforms: CrowdUniforms): T;
57
+ /** One crowd member: where it stands and what it plays. */
58
+ export interface CrowdInstance {
59
+ position: readonly [number, number, number];
60
+ rotationY?: number;
61
+ scale?: number;
62
+ /** A baked clip name. */
63
+ clip: string;
64
+ /** Seconds into the clip at crowd time zero, so members do not move in lockstep. */
65
+ timeOffset?: number;
66
+ /** Playback rate. Default 1. */
67
+ speed?: number;
68
+ }
69
+ /** Props for {@link SkinnedInstances}. */
70
+ export interface SkinnedInstancesProps {
71
+ url: string;
72
+ instances: readonly CrowdInstance[];
73
+ /** Clip names to bake; default every clip on the rig. */
74
+ clips?: readonly string[];
75
+ /** Clips that hold their last pose instead of looping. */
76
+ once?: readonly string[];
77
+ fps?: number;
78
+ /** Uniform scale applied to every member, e.g. to normalize a rig to a height. */
79
+ modelScale?: number;
80
+ castShadow?: boolean;
81
+ receiveShadow?: boolean;
82
+ /** Multiplies crowd time; `0` freezes every member. Default 1. */
83
+ timeScale?: number;
84
+ }
85
+ /**
86
+ * Renders many animated copies of one rigged model in a single instanced draw per material: the
87
+ * rig's clips are baked to a bone-matrix texture once, and each instance samples its own clip, time
88
+ * offset and speed on the GPU. For crowds, spectators and distant NPCs; a character that needs
89
+ * blending, IK or attachments stays an entity model.
90
+ */
91
+ export declare function SkinnedInstances({ url, instances, clips, once, fps, modelScale, castShadow, receiveShadow, timeScale, }: SkinnedInstancesProps): import("react").JSX.Element;
@@ -0,0 +1,249 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useFrame, useLoader } from "@react-three/fiber";
3
+ import { useEffect, useLayoutEffect, useMemo } from "react";
4
+ import * as THREE from "three";
5
+ import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
6
+ import { clone as cloneSkinned } from "three/examples/jsm/utils/SkeletonUtils.js";
7
+ import { planBoneTexture } from "@jgengine/core/anim/boneTexture";
8
+ import { sharedGltfLoader } from "./modelLoad.js";
9
+ function slotFor(slots, bone, inverse) {
10
+ const found = slots.findIndex((slot) => slot.bone === bone && slot.inverse.equals(inverse));
11
+ if (found >= 0)
12
+ return found;
13
+ slots.push({ bone, inverse: inverse.clone() });
14
+ return slots.length - 1;
15
+ }
16
+ function firstMaterial(material) {
17
+ return Array.isArray(material) ? material[0] : material;
18
+ }
19
+ /**
20
+ * Bakes a rig for {@link SkinnedInstances}: merges its skinned meshes into one geometry in bind
21
+ * space, samples every requested clip at `fps`, and writes each bone's model-space skinning matrix
22
+ * into a float texture laid out by `planBoneTexture`. Meshes with several materials keep only the
23
+ * first. Exported for tests and custom crowd renderers.
24
+ */
25
+ export function bakeSkinnedCrowd(source, options = {}) {
26
+ const root = cloneSkinned(source.scene);
27
+ root.position.set(0, 0, 0);
28
+ root.quaternion.identity();
29
+ root.scale.set(1, 1, 1);
30
+ root.updateMatrixWorld(true);
31
+ const meshes = [];
32
+ root.traverse((object) => {
33
+ if (object.isSkinnedMesh === true)
34
+ meshes.push(object);
35
+ });
36
+ if (meshes.length === 0)
37
+ throw new Error("bakeSkinnedCrowd: the source has no skinned mesh");
38
+ const slots = [];
39
+ const geometries = [];
40
+ const materials = [];
41
+ for (const mesh of meshes) {
42
+ const source = mesh.geometry;
43
+ const geometry = new THREE.BufferGeometry();
44
+ geometry.setAttribute("position", source.getAttribute("position").clone());
45
+ if (source.getAttribute("normal") !== undefined)
46
+ geometry.setAttribute("normal", source.getAttribute("normal").clone());
47
+ else
48
+ geometry.setAttribute("normal", new THREE.Float32BufferAttribute(new Float32Array(source.getAttribute("position").count * 3), 3));
49
+ const uv = source.getAttribute("uv");
50
+ geometry.setAttribute("uv", uv !== undefined ? uv.clone() : new THREE.Float32BufferAttribute(new Float32Array(source.getAttribute("position").count * 2), 2));
51
+ if (source.index !== null)
52
+ geometry.setIndex(source.index.clone());
53
+ geometry.applyMatrix4(mesh.bindMatrix);
54
+ const skinIndex = source.getAttribute("skinIndex");
55
+ const skinWeight = source.getAttribute("skinWeight");
56
+ const remapped = new Float32Array(skinIndex.count * 4);
57
+ const weights = new Float32Array(skinWeight.count * 4);
58
+ const bySkeletonIndex = mesh.skeleton.bones.map((bone, i) => slotFor(slots, bone, mesh.skeleton.boneInverses[i]));
59
+ for (let v = 0; v < skinIndex.count; v += 1) {
60
+ for (let c = 0; c < 4; c += 1) {
61
+ remapped[v * 4 + c] = bySkeletonIndex[skinIndex.getComponent(v, c)] ?? 0;
62
+ weights[v * 4 + c] = skinWeight.getComponent(v, c);
63
+ }
64
+ }
65
+ geometry.setAttribute("skinIndex", new THREE.Float32BufferAttribute(remapped, 4));
66
+ geometry.setAttribute("skinWeight", new THREE.Float32BufferAttribute(weights, 4));
67
+ geometries.push(geometry);
68
+ materials.push(firstMaterial(mesh.material));
69
+ }
70
+ const geometry = mergeGeometries(geometries, true);
71
+ for (const part of geometries)
72
+ part.dispose();
73
+ if (geometry === null)
74
+ throw new Error("bakeSkinnedCrowd: could not merge the rig's meshes");
75
+ geometry.computeBoundingBox();
76
+ geometry.computeBoundingSphere();
77
+ const names = options.clips ?? source.animations.map((clip) => clip.name);
78
+ const clips = names.map((name) => {
79
+ const clip = THREE.AnimationClip.findByName(source.animations, name);
80
+ if (clip === null)
81
+ throw new Error(`bakeSkinnedCrowd: no clip named "${name}"`);
82
+ return clip;
83
+ });
84
+ const layout = planBoneTexture({
85
+ boneCount: slots.length,
86
+ clips: clips.map((clip) => ({ name: clip.name, duration: clip.duration })),
87
+ fps: options.fps,
88
+ maxTextureSize: options.maxTextureSize,
89
+ });
90
+ const data = new Float32Array(layout.width * layout.height * 4);
91
+ const mixer = new THREE.AnimationMixer(root);
92
+ const rootInverse = root.matrixWorld.clone().invert();
93
+ const matrix = new THREE.Matrix4();
94
+ clips.forEach((clip, clipIndex) => {
95
+ const baked = layout.clips[clipIndex];
96
+ const action = mixer.clipAction(clip);
97
+ action.play();
98
+ for (let frame = 0; frame < baked.frameCount; frame += 1) {
99
+ action.time = Math.min(frame / layout.fps, clip.duration);
100
+ mixer.update(0);
101
+ root.updateMatrixWorld(true);
102
+ const rowOffset = (baked.startFrame + frame) * layout.width * 4;
103
+ slots.forEach((slot, s) => {
104
+ matrix.multiplyMatrices(rootInverse, slot.bone.matrixWorld).multiply(slot.inverse);
105
+ data.set(matrix.elements, rowOffset + s * 16);
106
+ });
107
+ }
108
+ action.stop();
109
+ });
110
+ mixer.uncacheRoot(root);
111
+ const texture = new THREE.DataTexture(data, layout.width, layout.height, THREE.RGBAFormat, THREE.FloatType);
112
+ texture.minFilter = THREE.NearestFilter;
113
+ texture.magFilter = THREE.NearestFilter;
114
+ texture.generateMipmaps = false;
115
+ texture.needsUpdate = true;
116
+ const once = new Set(options.once ?? []);
117
+ return {
118
+ geometry,
119
+ materials,
120
+ texture,
121
+ layout,
122
+ loops: layout.clips.map((clip) => !once.has(clip.name)),
123
+ clipIndex: new Map(layout.clips.map((clip, index) => [clip.name, index])),
124
+ dispose() {
125
+ geometry.dispose();
126
+ texture.dispose();
127
+ },
128
+ };
129
+ }
130
+ /** Builds the uniforms a crowd material reads: the texture and per-clip start, frames, fps and loop. */
131
+ export function createCrowdUniforms(baked) {
132
+ return {
133
+ crowdBones: { value: baked.texture },
134
+ crowdTime: { value: 0 },
135
+ crowdClips: {
136
+ value: baked.layout.clips.map((clip, index) => new THREE.Vector4(clip.startFrame, clip.frameCount, baked.layout.fps, baked.loops[index] ? 1 : 0)),
137
+ },
138
+ };
139
+ }
140
+ const CROWD_HEADER = /* glsl */ `
141
+ uniform highp sampler2D crowdBones;
142
+ uniform float crowdTime;
143
+ uniform vec4 crowdClips[CROWD_CLIP_COUNT];
144
+ attribute vec4 skinIndex;
145
+ attribute vec4 skinWeight;
146
+ attribute vec3 crowdPlay;
147
+ mat4 crowdMatrix;
148
+ bool crowdReady = false;
149
+ mat4 crowdBone(float slot, float row) {
150
+ int x = int(slot + 0.5) * 4;
151
+ int y = int(row + 0.5);
152
+ return mat4(
153
+ texelFetch(crowdBones, ivec2(x, y), 0),
154
+ texelFetch(crowdBones, ivec2(x + 1, y), 0),
155
+ texelFetch(crowdBones, ivec2(x + 2, y), 0),
156
+ texelFetch(crowdBones, ivec2(x + 3, y), 0));
157
+ }
158
+ mat4 crowdSkin() {
159
+ if (crowdReady) return crowdMatrix;
160
+ vec4 clip = crowdClips[int(crowdPlay.x + 0.5)];
161
+ float span = clip.y - 1.0;
162
+ float raw = (crowdTime * crowdPlay.z + crowdPlay.y) * clip.z;
163
+ float frame = clip.w > 0.5 ? mod(raw, span) : clamp(raw, 0.0, span);
164
+ float a = floor(frame);
165
+ float t = frame - a;
166
+ float rowA = clip.x + a;
167
+ float rowB = clip.x + min(a + 1.0, span);
168
+ crowdMatrix = mat4(0.0);
169
+ for (int i = 0; i < 4; i++) {
170
+ float w = skinWeight[i];
171
+ if (w > 0.0) crowdMatrix += w * ((1.0 - t) * crowdBone(skinIndex[i], rowA) + t * crowdBone(skinIndex[i], rowB));
172
+ }
173
+ crowdReady = true;
174
+ return crowdMatrix;
175
+ }
176
+ `;
177
+ /**
178
+ * Patches a material so each instance skins itself from the crowd texture: per-instance `crowdPlay`
179
+ * is (clip index, time offset, speed). Works on any built-in material, the depth material for
180
+ * shadows included.
181
+ */
182
+ export function patchCrowdMaterial(material, uniforms) {
183
+ const clipCount = Math.max(1, uniforms.crowdClips.value.length);
184
+ material.onBeforeCompile = (shader) => {
185
+ Object.assign(shader.uniforms, uniforms);
186
+ shader.vertexShader = shader.vertexShader
187
+ .replace("#include <common>", `#include <common>\n#define CROWD_CLIP_COUNT ${clipCount}\n${CROWD_HEADER}`)
188
+ .replace("#include <beginnormal_vertex>", "#include <beginnormal_vertex>\nobjectNormal = mat3(crowdSkin()) * objectNormal;")
189
+ .replace("#include <begin_vertex>", "#include <begin_vertex>\ntransformed = (crowdSkin() * vec4(transformed, 1.0)).xyz;");
190
+ };
191
+ material.customProgramCacheKey = () => `jg-crowd-${clipCount}`;
192
+ return material;
193
+ }
194
+ const scratchMatrix = new THREE.Matrix4();
195
+ const scratchQuaternion = new THREE.Quaternion();
196
+ const scratchPosition = new THREE.Vector3();
197
+ const scratchScale = new THREE.Vector3();
198
+ /**
199
+ * Renders many animated copies of one rigged model in a single instanced draw per material: the
200
+ * rig's clips are baked to a bone-matrix texture once, and each instance samples its own clip, time
201
+ * offset and speed on the GPU. For crowds, spectators and distant NPCs; a character that needs
202
+ * blending, IK or attachments stays an entity model.
203
+ */
204
+ export function SkinnedInstances({ url, instances, clips, once, fps, modelScale = 1, castShadow = true, receiveShadow = true, timeScale = 1, }) {
205
+ const gltf = useLoader(sharedGltfLoader, url);
206
+ const clipKey = clips?.join("\u0000");
207
+ const onceKey = once?.join("\u0000");
208
+ const baked = useMemo(() => bakeSkinnedCrowd({ scene: gltf.scene, animations: gltf.animations }, { clips, once, fps }), [gltf, clipKey, onceKey, fps]);
209
+ useEffect(() => () => baked.dispose(), [baked]);
210
+ const uniforms = useMemo(() => createCrowdUniforms(baked), [baked]);
211
+ const materials = useMemo(() => baked.materials.map((material) => patchCrowdMaterial(material.clone(), uniforms)), [baked, uniforms]);
212
+ const depthMaterial = useMemo(() => patchCrowdMaterial(new THREE.MeshDepthMaterial({ depthPacking: THREE.RGBADepthPacking }), uniforms), [uniforms]);
213
+ useEffect(() => () => {
214
+ for (const material of materials)
215
+ material.dispose();
216
+ depthMaterial.dispose();
217
+ }, [materials, depthMaterial]);
218
+ const capacity = Math.max(1, instances.length);
219
+ const mesh = useMemo(() => {
220
+ const instanced = new THREE.InstancedMesh(baked.geometry, materials, capacity);
221
+ instanced.customDepthMaterial = depthMaterial;
222
+ instanced.geometry.setAttribute("crowdPlay", new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3), 3));
223
+ return instanced;
224
+ }, [baked, materials, depthMaterial, capacity]);
225
+ useEffect(() => () => {
226
+ mesh.dispose();
227
+ }, [mesh]);
228
+ useLayoutEffect(() => {
229
+ const play = mesh.geometry.getAttribute("crowdPlay");
230
+ instances.forEach((instance, i) => {
231
+ const scale = (instance.scale ?? 1) * modelScale;
232
+ scratchQuaternion.setFromAxisAngle(THREE.Object3D.DEFAULT_UP, instance.rotationY ?? 0);
233
+ scratchMatrix.compose(scratchPosition.set(instance.position[0], instance.position[1], instance.position[2]), scratchQuaternion, scratchScale.set(scale, scale, scale));
234
+ mesh.setMatrixAt(i, scratchMatrix);
235
+ play.setXYZ(i, baked.clipIndex.get(instance.clip) ?? 0, instance.timeOffset ?? 0, instance.speed ?? 1);
236
+ });
237
+ mesh.count = instances.length;
238
+ mesh.instanceMatrix.needsUpdate = true;
239
+ play.needsUpdate = true;
240
+ mesh.computeBoundingSphere();
241
+ // Poses reach past the bind-pose bounds (arms out, a lunge), so pad the cull sphere.
242
+ if (mesh.boundingSphere !== null)
243
+ mesh.boundingSphere.radius += (baked.geometry.boundingSphere?.radius ?? 0) * modelScale;
244
+ }, [mesh, instances, baked, modelScale]);
245
+ useFrame((_three, delta) => {
246
+ uniforms.crowdTime.value += delta * timeScale;
247
+ });
248
+ return _jsx("primitive", { object: mesh, castShadow: castShadow, receiveShadow: receiveShadow });
249
+ }
@@ -0,0 +1,16 @@
1
+ import * as THREE from "three";
2
+ /**
3
+ * Retune already-loaded textures that still carry the old default anisotropy. Textures a
4
+ * system set on purpose (pixel-art sprites pin 1) keep their value.
5
+ * @internal
6
+ */
7
+ export declare function retuneTextureAnisotropy(root: THREE.Object3D, from: number, to: number): number;
8
+ /**
9
+ * Applies the player's texture-filtering choice. Sets three's construction-time default so
10
+ * every texture loaded afterwards (GLTF, texture loaders) samples anisotropically, and walks the
11
+ * scene once per change for textures loaded before. No per-frame work.
12
+ * @internal shell-internal; driven by `GraphicsProfile.anisotropy`.
13
+ */
14
+ export declare function TextureFiltering({ anisotropy }: {
15
+ anisotropy: number;
16
+ }): null;
@@ -0,0 +1,64 @@
1
+ import { useThree } from "@react-three/fiber";
2
+ import { useLayoutEffect } from "react";
3
+ import * as THREE from "three";
4
+ const TEXTURE_SLOTS = [
5
+ "map",
6
+ "normalMap",
7
+ "roughnessMap",
8
+ "metalnessMap",
9
+ "aoMap",
10
+ "emissiveMap",
11
+ "bumpMap",
12
+ "alphaMap",
13
+ "displacementMap",
14
+ ];
15
+ /**
16
+ * Retune already-loaded textures that still carry the old default anisotropy. Textures a
17
+ * system set on purpose (pixel-art sprites pin 1) keep their value.
18
+ * @internal
19
+ */
20
+ export function retuneTextureAnisotropy(root, from, to) {
21
+ const seen = new Set();
22
+ root.traverse((node) => {
23
+ const mesh = node;
24
+ if (mesh.material === undefined)
25
+ return;
26
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
27
+ for (const material of materials) {
28
+ const slots = material;
29
+ for (const slot of TEXTURE_SLOTS) {
30
+ const texture = slots[slot];
31
+ if (texture === null || texture === undefined || seen.has(texture))
32
+ continue;
33
+ seen.add(texture);
34
+ if (texture.anisotropy !== from)
35
+ continue;
36
+ texture.anisotropy = to;
37
+ texture.needsUpdate = true;
38
+ }
39
+ }
40
+ });
41
+ return seen.size;
42
+ }
43
+ /**
44
+ * Applies the player's texture-filtering choice. Sets three's construction-time default so
45
+ * every texture loaded afterwards (GLTF, texture loaders) samples anisotropically, and walks the
46
+ * scene once per change for textures loaded before. No per-frame work.
47
+ * @internal shell-internal; driven by `GraphicsProfile.anisotropy`.
48
+ */
49
+ export function TextureFiltering({ anisotropy }) {
50
+ const gl = useThree((state) => state.gl);
51
+ const scene = useThree((state) => state.scene);
52
+ useLayoutEffect(() => {
53
+ const target = Math.max(1, Math.min(anisotropy, gl.capabilities.getMaxAnisotropy()));
54
+ const previous = THREE.Texture.DEFAULT_ANISOTROPY;
55
+ if (previous === target)
56
+ return;
57
+ THREE.Texture.DEFAULT_ANISOTROPY = target;
58
+ retuneTextureAnisotropy(scene, previous, target);
59
+ return () => {
60
+ THREE.Texture.DEFAULT_ANISOTROPY = previous;
61
+ };
62
+ }, [gl, scene, anisotropy]);
63
+ return null;
64
+ }