@jgengine/shell 0.18.0 → 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.
- package/CHANGELOG.md +265 -2
- package/dist/GamePlayerShell.js +27 -4
- package/dist/JoinGate.d.ts +17 -0
- package/dist/JoinGate.js +14 -0
- package/dist/Shell3dPresentation.d.ts +9 -8
- package/dist/Shell3dPresentation.js +57 -8
- package/dist/ShellChrome.d.ts +2 -0
- package/dist/ShellChrome.js +6 -0
- package/dist/ShellHudPresentation.d.ts +9 -1
- package/dist/ShellHudPresentation.js +21 -0
- package/dist/TerritoryOverlay.d.ts +21 -0
- package/dist/TerritoryOverlay.js +9 -0
- package/dist/audio/AudioComponents.js +13 -2
- package/dist/audio/audioEngine.d.ts +15 -1
- package/dist/audio/audioEngine.js +132 -11
- package/dist/audio/audioWire.d.ts +3 -0
- package/dist/audio/audioWire.js +7 -1
- package/dist/audio/loopParams.d.ts +39 -0
- package/dist/audio/loopParams.js +43 -0
- package/dist/camera/GameCameraRig.js +1 -1
- package/dist/camera/GameFirstPersonCamera.d.ts +4 -2
- package/dist/camera/GameFirstPersonCamera.js +52 -15
- package/dist/camera/Viewports.d.ts +34 -0
- package/dist/camera/Viewports.js +123 -0
- package/dist/camera/cameraRigs.js +77 -62
- package/dist/camera/orbitCameraMath.js +2 -2
- package/dist/camera/rigMath.d.ts +82 -4
- package/dist/camera/rigMath.js +133 -15
- package/dist/defineGame.d.ts +9 -0
- package/dist/defineGame.js +38 -6
- package/dist/devtools/DevtoolsOverlay.js +2 -0
- package/dist/devtools/KeysPanel.js +3 -3
- package/dist/devtools/agentBridge.d.ts +6 -0
- package/dist/devtools/agentBridge.js +15 -1
- package/dist/diagnostics/FpsCounter.d.ts +17 -0
- package/dist/diagnostics/FpsCounter.js +32 -0
- package/dist/drivers/FrameDriver.d.ts +5 -1
- package/dist/drivers/FrameDriver.js +74 -26
- package/dist/drivers/FrameRateLimiter.d.ts +9 -0
- package/dist/drivers/FrameRateLimiter.js +39 -0
- package/dist/drivers/HudOnlyDriver.js +10 -5
- package/dist/environment/DayNightSky.js +5 -5
- package/dist/environment/Daylight.d.ts +35 -1
- package/dist/environment/Daylight.js +98 -28
- package/dist/environment/EnvironmentScene.js +1 -1
- package/dist/environment/VolumetricClouds.js +20 -2
- package/dist/environment/daylightCycle.d.ts +20 -0
- package/dist/environment/daylightCycle.js +59 -7
- package/dist/environment/sunShadowMath.d.ts +45 -0
- package/dist/environment/sunShadowMath.js +50 -0
- package/dist/input/gamepadMerge.d.ts +10 -0
- package/dist/input/gamepadMerge.js +12 -0
- package/dist/input/gamepadPoll.d.ts +47 -0
- package/dist/input/gamepadPoll.js +142 -0
- package/dist/input/gamepadSource.d.ts +22 -0
- package/dist/input/gamepadSource.js +153 -0
- package/dist/input/mouseLook.js +2 -1
- package/dist/input/padHaptics.d.ts +17 -0
- package/dist/input/padHaptics.js +32 -0
- package/dist/input/pointerLock.d.ts +11 -0
- package/dist/input/pointerLock.js +19 -0
- package/dist/materialOverride.d.ts +4 -1
- package/dist/materialOverride.js +10 -1
- package/dist/postfx/PostProcessing.d.ts +11 -8
- package/dist/postfx/PostProcessing.js +38 -16
- package/dist/postfx/gradeShader.js +13 -0
- package/dist/postfx/stylizePass.d.ts +23 -0
- package/dist/postfx/stylizePass.js +216 -0
- package/dist/render/CascadedShadows.d.ts +11 -0
- package/dist/render/CascadedShadows.js +19 -8
- package/dist/render/EnvironmentLighting.d.ts +4 -1
- package/dist/render/EnvironmentLighting.js +45 -9
- package/dist/render/SceneLighting.d.ts +16 -2
- package/dist/render/SceneLighting.js +56 -6
- package/dist/render/SceneModels.js +45 -9
- package/dist/render/SkinnedInstances.d.ts +91 -0
- package/dist/render/SkinnedInstances.js +249 -0
- package/dist/render/TextureFiltering.d.ts +16 -0
- package/dist/render/TextureFiltering.js +64 -0
- package/dist/render/displayColor.d.ts +33 -0
- package/dist/render/displayColor.js +138 -0
- package/dist/render/modelLoad.d.ts +39 -0
- package/dist/render/modelLoad.js +89 -1
- package/dist/render/modelRender.d.ts +3 -0
- package/dist/render/modelRender.js +6 -2
- package/dist/render/resolveModel.js +4 -0
- package/dist/render/useFootIk.d.ts +58 -0
- package/dist/render/useFootIk.js +216 -0
- package/dist/render/useModelAnimation.d.ts +26 -0
- package/dist/render/useModelAnimation.js +184 -107
- package/dist/scene/AuthoredScene.d.ts +9 -0
- package/dist/scene/AuthoredScene.js +17 -1
- package/dist/scene/CityRenderer.js +666 -241
- package/dist/scene/cityWindowMaterial.d.ts +8 -6
- package/dist/scene/cityWindowMaterial.js +80 -21
- package/dist/settings/QuickControls.js +1 -1
- package/dist/settings/SettingsMenu.js +1 -1
- package/dist/settings/appliedSettings.d.ts +10 -2
- package/dist/settings/appliedSettings.js +9 -5
- package/dist/settings/settingsController.d.ts +3 -0
- package/dist/settings/settingsController.js +47 -3
- package/dist/structures/BuildingKitBatch.js +26 -22
- package/dist/structures/GeneratedBuilding.d.ts +3 -15
- package/dist/structures/GeneratedBuilding.js +21 -15
- package/dist/structures/buildingSurface.d.ts +21 -0
- package/dist/structures/buildingSurface.js +134 -0
- package/dist/terrain/terrainDetailMaterial.js +7 -3
- package/dist/useShellMultiplayerSync.d.ts +9 -2
- package/dist/useShellMultiplayerSync.js +30 -8
- package/dist/visibility/CullingProvider.d.ts +2 -1
- package/dist/visibility/CullingProvider.js +7 -2
- package/dist/world/GridWorldScene.js +4 -0
- package/dist/world/TileLayerRenderer.d.ts +9 -0
- package/dist/world/TileLayerRenderer.js +64 -0
- package/dist/world/WorldParticles.d.ts +31 -0
- package/dist/world/WorldParticles.js +128 -0
- package/dist/world/WorldScene.d.ts +2 -1
- package/dist/world/WorldScene.js +46 -4
- package/dist/world/entityPose.d.ts +4 -0
- package/dist/world/entityPose.js +9 -0
- package/dist/world/tileLayerInstances.d.ts +11 -0
- package/dist/world/tileLayerInstances.js +28 -0
- package/dist/worldSync.d.ts +5 -1
- package/dist/worldSync.js +25 -4
- package/package.json +4 -4
- package/skills/ce-handoff/SKILL.md +2 -2
- package/skills/game-design/SKILL.md +11 -1
- package/skills/jgengine/SKILL.md +9 -5
- package/skills/jgengine/api.md +205 -101
- package/skills/jgengine/capabilities.md +56 -0
- package/skills/jgengine/recipes/minimal-game.md +3 -0
- package/skills/jgengine-ui/SKILL.md +20 -15
- package/skills/jgengine-ui/api.md +538 -326
- package/skills/jgengine-ui/capabilities.md +37 -0
- package/skills/jgengine-ui/reference-primitives.md +89 -0
- package/skills/jgengine-ui/reference.md +2 -78
- package/skills/jgengine-world/SKILL.md +21 -3
- package/skills/jgengine-world/api.md +1172 -625
- package/skills/jgengine-world/capabilities.md +229 -3
- package/skills/jgengine-world/recipes/character-animation.md +39 -0
- package/skills/jgengine-world/recipes/controls.md +44 -0
- package/skills/jgengine-world/recipes/flight-feel.md +76 -0
- package/skills/jgengine-world/recipes/npc-senses.md +43 -0
- package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
- package/skills/jgengine-world/reference.md +31 -2
- package/skills/jgengine-world/visibility.md +112 -0
- package/skills/level-design/SKILL.md +1 -1
- package/skills/jgengine-ui/references/visual-scorecard.md +0 -73
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useThree } from "@react-three/fiber";
|
|
2
2
|
import { useEffect } from "react";
|
|
3
3
|
import * as THREE from "three";
|
|
4
|
+
import { EXRLoader } from "three/examples/jsm/loaders/EXRLoader.js";
|
|
5
|
+
import { RGBELoader } from "three/examples/jsm/loaders/RGBELoader.js";
|
|
4
6
|
/**
|
|
5
7
|
* Image-based lighting from a procedural sky/ground probe (not three's stock
|
|
6
8
|
* RoomEnvironment gray studio box). Metals and dielectrics pick up sky color and
|
|
@@ -10,27 +12,61 @@ import * as THREE from "three";
|
|
|
10
12
|
* Opt out of cinematic IBL with `look: "flat"` on the shell presentation.
|
|
11
13
|
* @internal shell-internal default lighting; games never import it.
|
|
12
14
|
*/
|
|
13
|
-
export function EnvironmentLighting({ intensity = 0.35, skyColor = "#87b5e0", groundColor = "#3d4a38", sunDirection, sunColor = "#fff2d6", }) {
|
|
15
|
+
export function EnvironmentLighting({ source, intensity = 0.35, skyColor = "#87b5e0", groundColor = "#3d4a38", sunDirection, sunColor = "#fff2d6", }) {
|
|
14
16
|
const gl = useThree((state) => state.gl);
|
|
15
17
|
const scene = useThree((state) => state.scene);
|
|
16
18
|
useEffect(() => {
|
|
17
19
|
const pmrem = new THREE.PMREMGenerator(gl);
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
let target;
|
|
21
|
+
let loadedTexture;
|
|
22
|
+
let cancelled = false;
|
|
23
|
+
const effective = source?.kind === "gradient" ? source : undefined;
|
|
24
|
+
const apply = (next, rotation) => {
|
|
25
|
+
if (cancelled) {
|
|
26
|
+
next.dispose();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
target = next;
|
|
30
|
+
scene.environment = next.texture;
|
|
31
|
+
if (rotation !== undefined)
|
|
32
|
+
scene.environmentRotation = new THREE.Euler(0, rotation, 0);
|
|
33
|
+
};
|
|
20
34
|
const previous = scene.environment;
|
|
21
|
-
|
|
35
|
+
const previousRotation = scene.environmentRotation;
|
|
36
|
+
if (source?.kind === "hdri") {
|
|
37
|
+
const Loader = /\.exr(?:$|\?)/i.test(source.url) ? EXRLoader : RGBELoader;
|
|
38
|
+
new Loader().loadAsync(source.url).then((texture) => {
|
|
39
|
+
loadedTexture = texture;
|
|
40
|
+
texture.mapping = THREE.EquirectangularReflectionMapping;
|
|
41
|
+
apply(pmrem.fromEquirectangular(texture), source.rotation);
|
|
42
|
+
}).catch(() => undefined);
|
|
43
|
+
}
|
|
44
|
+
else if (source?.kind === "cube") {
|
|
45
|
+
new THREE.CubeTextureLoader().loadAsync(source.urls).then((texture) => {
|
|
46
|
+
loadedTexture = texture;
|
|
47
|
+
apply(pmrem.fromCubemap(texture));
|
|
48
|
+
}).catch(() => undefined);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
const probe = buildSkyProbeScene({ skyColor: effective?.sky ?? skyColor, groundColor: effective?.ground ?? groundColor, sunDirection, sunColor: effective?.sun ?? sunColor });
|
|
52
|
+
apply(pmrem.fromScene(probe, 0.04));
|
|
53
|
+
disposeScene(probe);
|
|
54
|
+
}
|
|
22
55
|
const sceneWithIntensity = scene;
|
|
23
56
|
const previousIntensity = sceneWithIntensity.environmentIntensity;
|
|
24
|
-
sceneWithIntensity.environmentIntensity = intensity;
|
|
25
|
-
disposeScene(probe);
|
|
26
|
-
pmrem.dispose();
|
|
57
|
+
sceneWithIntensity.environmentIntensity = source?.intensity ?? intensity;
|
|
27
58
|
return () => {
|
|
28
|
-
|
|
59
|
+
cancelled = true;
|
|
60
|
+
pmrem.dispose();
|
|
61
|
+
target?.dispose();
|
|
62
|
+
loadedTexture?.dispose();
|
|
29
63
|
scene.environment = previous;
|
|
64
|
+
if (previousRotation !== undefined)
|
|
65
|
+
scene.environmentRotation = previousRotation;
|
|
30
66
|
if (previousIntensity !== undefined)
|
|
31
67
|
sceneWithIntensity.environmentIntensity = previousIntensity;
|
|
32
68
|
};
|
|
33
|
-
}, [gl, scene, intensity, skyColor, groundColor, sunColor, sunDirection?.[0], sunDirection?.[1], sunDirection?.[2]]);
|
|
69
|
+
}, [gl, scene, source, intensity, skyColor, groundColor, sunColor, sunDirection?.[0], sunDirection?.[1], sunDirection?.[2]]);
|
|
34
70
|
return null;
|
|
35
71
|
}
|
|
36
72
|
function buildSkyProbeScene(options) {
|
|
@@ -1,6 +1,20 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type { DirectionalLight } from "three";
|
|
2
|
+
import type { BackdropConfig, LightingConfig, PointLightingConfig } from "@jgengine/core/game/playableGame";
|
|
3
|
+
import type { GraphicsProfile } from "@jgengine/core/settings/graphicsProfile";
|
|
4
|
+
/** Default cap for combined dynamic point and spot lights. */
|
|
5
|
+
export declare const POINT_LIGHT_BUDGET = 8;
|
|
6
|
+
/** @internal Clamp point lights for the runtime budget. */
|
|
7
|
+
export declare function resolvePointLightBudget(entries: readonly PointLightingConfig[] | undefined): readonly PointLightingConfig[];
|
|
8
|
+
/**
|
|
9
|
+
* Refresh a directional light's shadow projection after its frustum bounds change.
|
|
10
|
+
* R3F pierced `shadow-camera-*` props write the bounds but never call
|
|
11
|
+
* `updateProjectionMatrix()`, so depth renders with the stale default ~10×10 box
|
|
12
|
+
* and `shadowCameraSize` is silently inert.
|
|
13
|
+
*/
|
|
14
|
+
export declare function syncShadowProjection(light: DirectionalLight): void;
|
|
15
|
+
export declare function ConfiguredLighting({ lighting, profile }: {
|
|
3
16
|
lighting: LightingConfig;
|
|
17
|
+
profile?: GraphicsProfile;
|
|
4
18
|
}): import("react").JSX.Element;
|
|
5
19
|
export declare function BackdropFog({ fog }: {
|
|
6
20
|
fog: BackdropConfig["fog"];
|
|
@@ -1,22 +1,72 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useLayoutEffect, useRef } from "react";
|
|
2
3
|
import { CascadedShadows } from "./CascadedShadows.js";
|
|
3
4
|
const DEFAULT_BACKDROP_FOG_COLOR = "#1a1c22";
|
|
5
|
+
/** Default cap for combined dynamic point and spot lights. */
|
|
6
|
+
export const POINT_LIGHT_BUDGET = 8;
|
|
7
|
+
/** @internal Clamp point lights for the runtime budget. */
|
|
8
|
+
export function resolvePointLightBudget(entries) {
|
|
9
|
+
if (entries === undefined || entries.length <= POINT_LIGHT_BUDGET)
|
|
10
|
+
return entries ?? [];
|
|
11
|
+
if (typeof console !== "undefined" && typeof console.warn === "function") {
|
|
12
|
+
console.warn(`[jgengine:lighting] ${entries.length} point lights requested — clamped to budget ${POINT_LIGHT_BUDGET}`);
|
|
13
|
+
}
|
|
14
|
+
return entries.slice(0, POINT_LIGHT_BUDGET);
|
|
15
|
+
}
|
|
16
|
+
function resolveDynamicLights(lighting) {
|
|
17
|
+
const point = lighting.point ?? [];
|
|
18
|
+
const spot = lighting.spot ?? [];
|
|
19
|
+
const budget = Math.max(0, Math.floor(lighting.maxDynamicLights ?? POINT_LIGHT_BUDGET));
|
|
20
|
+
if (point.length + spot.length <= budget)
|
|
21
|
+
return { point, spot };
|
|
22
|
+
if (typeof console !== "undefined" && typeof console.warn === "function")
|
|
23
|
+
console.warn(`[jgengine:lighting] ${point.length + spot.length} dynamic lights requested — clamped to budget ${budget}`);
|
|
24
|
+
return { point: point.slice(0, budget), spot: spot.slice(0, Math.max(0, budget - point.length)) };
|
|
25
|
+
}
|
|
4
26
|
function usesCascades(entry) {
|
|
5
27
|
return (entry.castShadow ?? false) && (entry.cascades ?? 1) > 1;
|
|
6
28
|
}
|
|
7
|
-
function DirectionalShadowLight({ entry }) {
|
|
8
|
-
|
|
9
|
-
|
|
29
|
+
function DirectionalShadowLight({ entry, profile }) {
|
|
30
|
+
const resolved = profile === undefined ? entry : { ...entry, shadowMapSize: entry.shadowMapSize ?? profile.shadowMapSize, cascades: entry.cascades ?? profile.cascades };
|
|
31
|
+
if (usesCascades(resolved)) {
|
|
32
|
+
return _jsx(CascadedShadows, { entry: resolved });
|
|
10
33
|
}
|
|
34
|
+
return _jsx(SingleShadowLight, { entry: resolved });
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Refresh a directional light's shadow projection after its frustum bounds change.
|
|
38
|
+
* R3F pierced `shadow-camera-*` props write the bounds but never call
|
|
39
|
+
* `updateProjectionMatrix()`, so depth renders with the stale default ~10×10 box
|
|
40
|
+
* and `shadowCameraSize` is silently inert.
|
|
41
|
+
*/
|
|
42
|
+
export function syncShadowProjection(light) {
|
|
43
|
+
light.shadow.camera.updateProjectionMatrix();
|
|
44
|
+
}
|
|
45
|
+
function SingleShadowLight({ entry }) {
|
|
46
|
+
const lightRef = useRef(null);
|
|
11
47
|
const size = entry.shadowCameraSize ?? 40;
|
|
12
|
-
|
|
48
|
+
const far = Math.max(200, size * 6);
|
|
49
|
+
const mapSize = entry.shadowMapSize ?? 1024;
|
|
50
|
+
useLayoutEffect(() => {
|
|
51
|
+
if (lightRef.current !== null)
|
|
52
|
+
syncShadowProjection(lightRef.current);
|
|
53
|
+
}, [size, far]);
|
|
54
|
+
useLayoutEffect(() => {
|
|
55
|
+
const light = lightRef.current;
|
|
56
|
+
if (light === null || light.shadow.map === null)
|
|
57
|
+
return;
|
|
58
|
+
light.shadow.map.dispose();
|
|
59
|
+
light.shadow.map = null;
|
|
60
|
+
}, [mapSize]);
|
|
61
|
+
return (_jsx("directionalLight", { ref: lightRef, position: [entry.position[0], entry.position[1], entry.position[2]], intensity: entry.intensity ?? 1.3, color: entry.color, castShadow: entry.castShadow ?? false, "shadow-mapSize-width": mapSize, "shadow-mapSize-height": mapSize, "shadow-camera-left": -size, "shadow-camera-right": size, "shadow-camera-top": size, "shadow-camera-bottom": -size, "shadow-camera-near": 0.5, "shadow-camera-far": far, "shadow-bias": entry.shadowBias ?? -0.0004, "shadow-normalBias": entry.shadowNormalBias ?? 0.02 }));
|
|
13
62
|
}
|
|
14
|
-
export function ConfiguredLighting({ lighting }) {
|
|
63
|
+
export function ConfiguredLighting({ lighting, profile }) {
|
|
64
|
+
const dynamic = resolveDynamicLights(lighting);
|
|
15
65
|
return (_jsxs(_Fragment, { children: [lighting.ambient !== undefined ? (_jsx("ambientLight", { color: lighting.ambient.color, intensity: lighting.ambient.intensity ?? 0.55 })) : null, lighting.hemisphere !== undefined ? (_jsx("hemisphereLight", { args: [
|
|
16
66
|
lighting.hemisphere.skyColor ?? "#bfe3ff",
|
|
17
67
|
lighting.hemisphere.groundColor ?? "#4c6b34",
|
|
18
68
|
lighting.hemisphere.intensity ?? 0.55,
|
|
19
|
-
] })) : null, (lighting.directional ?? []).map((entry, index) => (_jsx(DirectionalShadowLight, { entry: entry }, index)))] }));
|
|
69
|
+
] })) : null, (lighting.directional ?? []).map((entry, index) => (_jsx(DirectionalShadowLight, { entry: entry, profile: profile }, index))), dynamic.point.map((entry, index) => (_jsx("pointLight", { position: [entry.position[0], entry.position[1], entry.position[2]], intensity: entry.intensity ?? 1, color: entry.color, distance: entry.distance ?? 0, decay: entry.decay ?? 2, castShadow: entry.castShadow ?? false }, index))), dynamic.spot.map((entry, index) => (_jsx("spotLight", { position: [entry.position[0], entry.position[1], entry.position[2]], intensity: entry.intensity ?? 1, color: entry.color, distance: entry.distance ?? 0, decay: entry.decay ?? 2, angle: entry.angle ?? Math.PI / 6, penumbra: entry.penumbra ?? 0, castShadow: entry.castShadow ?? false, "target-position": [...(entry.target ?? [0, 0, 0])] }, index)))] }));
|
|
20
70
|
}
|
|
21
71
|
export function BackdropFog({ fog }) {
|
|
22
72
|
if (fog === undefined)
|
|
@@ -3,23 +3,51 @@ import { useFrame, useLoader } from "@react-three/fiber";
|
|
|
3
3
|
import { useTexture } from "@react-three/drei";
|
|
4
4
|
import { Component, Suspense, useEffect, useMemo, useRef } from "react";
|
|
5
5
|
import * as THREE from "three";
|
|
6
|
+
import { createSpriteClipPlayer } from "@jgengine/core/render/sprite2d";
|
|
6
7
|
import { reportFallbackSeam } from "@jgengine/core/devtools/fallbackSeams";
|
|
7
8
|
import { useOptionalGameContext } from "@jgengine/react/provider";
|
|
8
9
|
import { sharedGltfLoader } from "./modelLoad.js";
|
|
9
10
|
import { measureLocalBounds, reportMeasuredBounds } from "./measureBounds.js";
|
|
10
11
|
import { measureLocalCollisionTriangles, reportMeasuredCollisionMesh } from "./measureCollisionMesh.js";
|
|
11
12
|
import { useModelAnimation } from "./useModelAnimation.js";
|
|
13
|
+
import { useFootIk } from "./useFootIk.js";
|
|
12
14
|
import { PartMotionRig } from "./PartMotion.js";
|
|
13
15
|
import { applyMaterialOverride } from "../materialOverride.js";
|
|
14
16
|
import { applyPaintTextureToMaterials, cacheStandardMaterials, cloneModelScene, createPaintCanvas, disposeClonedMaterials, disposePaintCanvas, syncPaintCanvas, } from "./modelRender.js";
|
|
15
17
|
export function EntitySprite({ sprite }) {
|
|
16
|
-
const texture = useLoader(THREE.TextureLoader, sprite.url);
|
|
18
|
+
const texture = useLoader(THREE.TextureLoader, sprite.clip?.atlas.image ?? sprite.url);
|
|
19
|
+
const map = useMemo(() => texture.clone(), [texture]);
|
|
20
|
+
const player = useMemo(() => (sprite.clip === undefined ? null : createSpriteClipPlayer(sprite.clip.atlas)), [sprite.clip]);
|
|
21
|
+
const animation = sprite.clip?.animation;
|
|
17
22
|
useEffect(() => {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
map.colorSpace = THREE.SRGBColorSpace;
|
|
24
|
+
map.anisotropy = 4;
|
|
25
|
+
map.needsUpdate = true;
|
|
26
|
+
return () => map.dispose();
|
|
27
|
+
}, [map]);
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (player !== null && animation !== undefined)
|
|
30
|
+
player.play(animation);
|
|
31
|
+
}, [player, animation]);
|
|
32
|
+
useFrame((_state, delta) => {
|
|
33
|
+
if (player === null || sprite.clip === undefined)
|
|
34
|
+
return;
|
|
35
|
+
player.advance(delta);
|
|
36
|
+
const frame = player.frame();
|
|
37
|
+
if (frame === undefined)
|
|
38
|
+
return;
|
|
39
|
+
const [atlasWidth, atlasHeight] = sprite.clip.atlas.size;
|
|
40
|
+
map.repeat.set(frame.w / atlasWidth, frame.h / atlasHeight);
|
|
41
|
+
map.offset.set(frame.x / atlasWidth, 1 - (frame.y + frame.h) / atlasHeight);
|
|
42
|
+
map.needsUpdate = true;
|
|
43
|
+
});
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (player === null) {
|
|
46
|
+
map.repeat.set(1, 1);
|
|
47
|
+
map.offset.set(0, 0);
|
|
48
|
+
}
|
|
49
|
+
}, [map, player]);
|
|
50
|
+
return (_jsx("sprite", { "position-y": sprite.y, scale: [sprite.width, sprite.height, 1], children: _jsx("spriteMaterial", { map: map, transparent: true, alphaTest: 0.08, depthWrite: false }) }));
|
|
23
51
|
}
|
|
24
52
|
/**
|
|
25
53
|
* Catches a render throw from a model subtree and swaps in `fallback` — but never silently: the
|
|
@@ -102,8 +130,14 @@ function ModelMaterialMapsApplier({ scene, maps }) {
|
|
|
102
130
|
record.roughness = maps.roughness;
|
|
103
131
|
if (maps.ao !== undefined)
|
|
104
132
|
record.ao = maps.ao;
|
|
133
|
+
if (maps.metalness !== undefined)
|
|
134
|
+
record.metalness = maps.metalness;
|
|
135
|
+
if (maps.emissive !== undefined)
|
|
136
|
+
record.emissive = maps.emissive;
|
|
137
|
+
if (maps.height !== undefined)
|
|
138
|
+
record.height = maps.height;
|
|
105
139
|
return record;
|
|
106
|
-
}, [maps.color, maps.normal, maps.roughness, maps.ao]);
|
|
140
|
+
}, [maps.color, maps.normal, maps.roughness, maps.ao, maps.metalness, maps.emissive, maps.height]);
|
|
107
141
|
const textures = useTexture(entries);
|
|
108
142
|
useEffect(() => {
|
|
109
143
|
if (textures.color !== undefined)
|
|
@@ -121,12 +155,13 @@ export function EntityModel({ model, instanceId, measure, }) {
|
|
|
121
155
|
const material = model.material;
|
|
122
156
|
const baseY = model.y ?? 0;
|
|
123
157
|
const dims = model.dims;
|
|
158
|
+
const shadows = model.shadows;
|
|
124
159
|
const scene = useMemo(() => {
|
|
125
|
-
const cloned = cloneModelScene(gltf.scene);
|
|
160
|
+
const cloned = cloneModelScene(gltf.scene, { shadows });
|
|
126
161
|
if (material !== undefined)
|
|
127
162
|
applyMaterialOverride(cloned, material, { clone: false });
|
|
128
163
|
return cloned;
|
|
129
|
-
}, [gltf, material]);
|
|
164
|
+
}, [gltf, material, shadows]);
|
|
130
165
|
const measured = useMemo(() => {
|
|
131
166
|
if (model.targetHeight === undefined)
|
|
132
167
|
return null;
|
|
@@ -183,6 +218,7 @@ export function EntityModel({ model, instanceId, measure, }) {
|
|
|
183
218
|
reportMeasuredCollisionMesh(ctx, measureTarget, measureKey, triangles);
|
|
184
219
|
}, [ctx, scene, scale, positionX, positionY, positionZ, measureTarget, measureKey]);
|
|
185
220
|
useModelAnimation(scene, gltf.animations, model.animation, instanceId);
|
|
221
|
+
useFootIk(scene, model.ik, ctx, instanceId, baseY);
|
|
186
222
|
const paintCanvasRef = useRef(null);
|
|
187
223
|
const paintDrawnCountRef = useRef(0);
|
|
188
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;
|