@jgengine/shell 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +174 -0
- package/dist/GamePlayerShell.d.ts +8 -0
- package/dist/GamePlayerShell.js +15 -1
- package/dist/GameUiPreview.js +3 -1
- package/dist/Shell3dPresentation.js +6 -4
- package/dist/ShellChrome.js +3 -1
- package/dist/camera/GameCameraRig.js +2 -2
- package/dist/camera/GameFirstPersonCamera.js +39 -2
- package/dist/camera/cameraCollision.d.ts +66 -1
- package/dist/camera/cameraCollision.js +72 -1
- package/dist/camera/index.d.ts +1 -1
- package/dist/camera/index.js +1 -1
- package/dist/camera/rigResolve.d.ts +13 -0
- package/dist/camera/rigResolve.js +16 -0
- package/dist/environment/EnvironmentScene.js +118 -10
- package/dist/postfx/shadowPrepass.d.ts +8 -1
- package/dist/postfx/shadowPrepass.js +18 -2
- package/dist/postfx/stylizePass.js +2 -1
- package/dist/render/CascadedShadows.d.ts +31 -1
- package/dist/render/CascadedShadows.js +71 -5
- package/dist/render/CharacterCrowd.d.ts +20 -0
- package/dist/render/CharacterCrowd.js +176 -0
- package/dist/render/SceneLighting.js +27 -3
- package/dist/render/SceneWarmupGate.d.ts +20 -0
- package/dist/render/SceneWarmupGate.js +48 -0
- package/dist/render/characterLod.d.ts +62 -0
- package/dist/render/characterLod.js +165 -0
- package/dist/render/crowdRegistry.d.ts +48 -0
- package/dist/render/crowdRegistry.js +97 -0
- package/dist/render/decalGeometry.d.ts +26 -0
- package/dist/render/decalGeometry.js +54 -0
- package/dist/render/decalTexture.d.ts +13 -0
- package/dist/render/decalTexture.js +114 -0
- package/dist/render/materialAsset.js +2 -1
- package/dist/render/modelLoad.js +3 -0
- package/dist/render/modelMeshMerge.d.ts +49 -0
- package/dist/render/modelMeshMerge.js +269 -0
- package/dist/render/modelWind.d.ts +28 -0
- package/dist/render/modelWind.js +103 -0
- package/dist/render/shaderWarmup.d.ts +69 -0
- package/dist/render/shaderWarmup.js +120 -0
- package/dist/render/shadowCasters.d.ts +28 -0
- package/dist/render/shadowCasters.js +95 -0
- package/dist/render/staticTransforms.d.ts +20 -0
- package/dist/render/staticTransforms.js +32 -0
- package/dist/render/useFootIk.d.ts +11 -3
- package/dist/render/useFootIk.js +35 -7
- package/dist/render/useModelAnimation.d.ts +16 -2
- package/dist/render/useModelAnimation.js +73 -4
- package/dist/render/useModelInstance.d.ts +1 -1
- package/dist/render/useModelInstance.js +26 -1
- package/dist/render/useRagdoll.d.ts +32 -0
- package/dist/render/useRagdoll.js +105 -0
- package/dist/scatter/scatterModels.js +2 -0
- package/dist/scene/DecalRenderer.d.ts +2 -0
- package/dist/scene/DecalRenderer.js +74 -0
- package/dist/scene/GrassFieldRenderer.js +3 -2
- package/dist/scene/builtinSceneKindRenderers.js +2 -0
- package/dist/settings/SettingsMenu.d.ts +1 -1
- package/dist/settings/SettingsMenu.js +10 -79
- package/dist/settings/settingsController.js +3 -0
- package/dist/terrain/GrassCover.d.ts +21 -0
- package/dist/terrain/GrassCover.js +84 -0
- package/dist/terrain/grassCoverGeometry.d.ts +40 -0
- package/dist/terrain/grassCoverGeometry.js +112 -0
- package/dist/terrain/terrainDetailMaterial.d.ts +12 -0
- package/dist/terrain/terrainDetailMaterial.js +54 -1
- package/dist/terrain/terrainSurfaces.d.ts +6 -0
- package/dist/terrain/terrainSurfaces.js +10 -0
- package/dist/world/StaticObjectModels.d.ts +13 -0
- package/dist/world/StaticObjectModels.js +28 -3
- package/dist/world/WorldParticles.d.ts +2 -1
- package/dist/world/WorldParticles.js +29 -10
- package/dist/world/WorldScene.js +52 -14
- package/dist/world/entityPose.d.ts +17 -0
- package/dist/world/entityPose.js +42 -0
- package/dist/world/staticObjectBatches.d.ts +33 -5
- package/dist/world/staticObjectBatches.js +40 -6
- package/package.json +4 -4
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-ui/SKILL.md +1 -0
- package/skills/jgengine-ui/api.md +231 -9
- package/skills/jgengine-ui/capabilities.md +107 -1
- package/skills/jgengine-ui/reference-primitives.md +2 -0
- package/skills/jgengine-ui/reference.md +8 -0
- package/skills/jgengine-world/SKILL.md +8 -0
- package/skills/jgengine-world/api.md +243 -11
- package/skills/jgengine-world/capabilities.md +61 -1
- package/skills/jgengine-world/recipes/character-animation.md +25 -2
- package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
- package/skills/jgengine-world/reference.md +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Camera spring-arm occlusion filtering.
|
|
2
|
+
* Camera spring-arm occlusion filtering and camera clearance.
|
|
3
3
|
*
|
|
4
4
|
* The orbit rig raycasts target→camera each frame and pulls the boom in past
|
|
5
5
|
* occluders. Author decor mounted through the `WorldOverlay` presentation seam
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* decor group flags itself camera-transparent once (`jgCameraTransparent`) and
|
|
8
8
|
* every descendant inherits it — a child can opt back into blocking the camera
|
|
9
9
|
* with `jgCameraCollide`.
|
|
10
|
+
*
|
|
11
|
+
* `clearCameraBoom` is the collision half every rig (and a game-owned camera)
|
|
12
|
+
* shares: one query against the world's existing collision, one boom length that
|
|
13
|
+
* cannot leave the camera inside geometry.
|
|
10
14
|
*/
|
|
11
15
|
/** userData applied to the group wrapping author `WorldOverlay` decor so the spring-arm ignores it by default. */
|
|
12
16
|
export const CAMERA_TRANSPARENT_USERDATA = {
|
|
@@ -32,3 +36,70 @@ export function isCameraOccluderTransparent(object) {
|
|
|
32
36
|
}
|
|
33
37
|
return false;
|
|
34
38
|
}
|
|
39
|
+
function clampNumber(value, min, max) {
|
|
40
|
+
return value < min ? min : value > max ? max : value;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Pull a camera in along its boom so it never sits inside world collision.
|
|
44
|
+
* A blocking surface rejects the desired position: the camera moves to
|
|
45
|
+
* `clearance` before it, and onto the pivot when the surface is closer than that
|
|
46
|
+
* (the pivot is inside, so no camera position is clear). Pulling in is immediate
|
|
47
|
+
* — a camera must not spend a frame inside a trunk — while releasing eases back
|
|
48
|
+
* at `releaseRate`, which is what makes the boom read as a spring arm instead of
|
|
49
|
+
* a hard stop.
|
|
50
|
+
*
|
|
51
|
+
* @capability camera-clearance keep any camera out of world collision with a spring-arm pull-in
|
|
52
|
+
*/
|
|
53
|
+
export function clearCameraBoom(input) {
|
|
54
|
+
const { pivot, desired, query } = input;
|
|
55
|
+
const dx = desired.x - pivot.x;
|
|
56
|
+
const dy = desired.y - pivot.y;
|
|
57
|
+
const dz = desired.z - pivot.z;
|
|
58
|
+
const length = Math.hypot(dx, dy, dz);
|
|
59
|
+
if (!(length > 1e-6) || !Number.isFinite(length)) {
|
|
60
|
+
return { position: { ...desired }, arm: 0, pulled: false };
|
|
61
|
+
}
|
|
62
|
+
const clearance = Math.max(0, input.clearance ?? 0.3);
|
|
63
|
+
const direction = { x: dx / length, y: dy / length, z: dz / length };
|
|
64
|
+
const hit = query.firstHit(pivot, direction, length + clearance);
|
|
65
|
+
// A zero hit means the boom origin is itself inside collision, so the ray says nothing
|
|
66
|
+
// about the eye; moving to it would teleport the camera into whatever holds the origin.
|
|
67
|
+
if (hit !== null && hit <= 0) {
|
|
68
|
+
return { position: { ...desired }, arm: length, pulled: false };
|
|
69
|
+
}
|
|
70
|
+
const allowed = hit === null ? length : clampNumber(hit - clearance, 0, length);
|
|
71
|
+
let arm = allowed;
|
|
72
|
+
const previous = input.previousArm;
|
|
73
|
+
const dt = input.deltaSeconds ?? 0;
|
|
74
|
+
if (previous !== null && previous !== undefined && previous < allowed && dt > 0) {
|
|
75
|
+
const rate = Math.max(0, input.releaseRate ?? 8);
|
|
76
|
+
arm = Math.min(allowed, previous + (allowed - previous) * (1 - Math.exp(-rate * dt)));
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
position: { x: pivot.x + direction.x * arm, y: pivot.y + direction.y * arm, z: pivot.z + direction.z * arm },
|
|
80
|
+
arm,
|
|
81
|
+
pulled: hit !== null && arm < length - 1e-9,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* `CameraCollisionQuery` over the scene's existing collision query (`ctx.scene.raycast`),
|
|
86
|
+
* which already reads world solids, scene-object colliders and walls. Entities and
|
|
87
|
+
* terrain stay out by default: the followed body must not push its own camera, and
|
|
88
|
+
* looking down at the ground must not pull the boom.
|
|
89
|
+
*
|
|
90
|
+
* @capability camera-clearance keep any camera out of world collision with a spring-arm pull-in
|
|
91
|
+
*/
|
|
92
|
+
export function sceneCameraQuery(raycast, options = {}) {
|
|
93
|
+
const filter = { entities: options.entities ?? false, terrain: options.terrain ?? false };
|
|
94
|
+
return {
|
|
95
|
+
firstHit(origin, direction, maxDistance) {
|
|
96
|
+
const hit = raycast({
|
|
97
|
+
origin: [origin.x, origin.y, origin.z],
|
|
98
|
+
direction: [direction.x, direction.y, direction.z],
|
|
99
|
+
maxDistance,
|
|
100
|
+
filter,
|
|
101
|
+
});
|
|
102
|
+
return hit === null ? null : hit.distance;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
package/dist/camera/index.d.ts
CHANGED
|
@@ -8,5 +8,5 @@ export { CameraShakeContext, cameraShake, createCameraShakeChannel, defaultCamer
|
|
|
8
8
|
export { ControllerCameraShake, type ControllerCameraShakeProps } from "./ControllerCameraShake.js";
|
|
9
9
|
export { rtsPanKeysConflict } from "./rigMath.js";
|
|
10
10
|
export { GAME_SIM_FRAME_PRIORITY } from "./orbitCameraMath.js";
|
|
11
|
-
export { CAMERA_TRANSPARENT_USERDATA, isCameraOccluderTransparent, type CameraOccluder, } from "./cameraCollision.js";
|
|
11
|
+
export { CAMERA_TRANSPARENT_USERDATA, clearCameraBoom, isCameraOccluderTransparent, sceneCameraQuery, type CameraClearanceInput, type CameraClearanceResult, type CameraCollisionQuery, type CameraOccluder, type CameraPoint, } from "./cameraCollision.js";
|
|
12
12
|
export { registerFirstPersonMuzzle, type FirstPersonMuzzleReader } from "./customMuzzle.js";
|
package/dist/camera/index.js
CHANGED
|
@@ -8,5 +8,5 @@ export { CameraShakeContext, cameraShake, createCameraShakeChannel, defaultCamer
|
|
|
8
8
|
export { ControllerCameraShake } from "./ControllerCameraShake.js";
|
|
9
9
|
export { rtsPanKeysConflict } from "./rigMath.js";
|
|
10
10
|
export { GAME_SIM_FRAME_PRIORITY } from "./orbitCameraMath.js";
|
|
11
|
-
export { CAMERA_TRANSPARENT_USERDATA, isCameraOccluderTransparent, } from "./cameraCollision.js";
|
|
11
|
+
export { CAMERA_TRANSPARENT_USERDATA, clearCameraBoom, isCameraOccluderTransparent, sceneCameraQuery, } from "./cameraCollision.js";
|
|
12
12
|
export { registerFirstPersonMuzzle } from "./customMuzzle.js";
|
|
@@ -2,6 +2,19 @@ import type { CameraRigKind, GameCameraConfig } from "@jgengine/core/game/playab
|
|
|
2
2
|
import type { CameraRigOverride } from "@jgengine/core/runtime/cameraDirector";
|
|
3
3
|
/** @internal */
|
|
4
4
|
export declare function resolveRuntimeCameraConfig(config: GameCameraConfig | undefined, override: CameraRigOverride | null | undefined): GameCameraConfig | undefined;
|
|
5
|
+
/**
|
|
6
|
+
* The camera config that renders this frame, with the capture camera winning over everything.
|
|
7
|
+
*
|
|
8
|
+
* A game owns its camera three ways: the static `camera` block, a runtime `ctx.camera.setRig`
|
|
9
|
+
* selection, and a director cinematic. A capture harness can rewrite the first, but the other two
|
|
10
|
+
* run afterwards and quietly photograph the game's own camera. `installCaptureCamera` is read here,
|
|
11
|
+
* above all three, so `?look=`/`--look` frames what it was asked for; with nothing installed this is
|
|
12
|
+
* exactly `resolveRuntimeCameraConfig` and every game behaves as before.
|
|
13
|
+
*
|
|
14
|
+
* @capability capture render a framed capture camera over a game that owns its camera
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
export declare function resolveCaptureCameraConfig(config: GameCameraConfig | undefined, override: CameraRigOverride | null | undefined): GameCameraConfig | undefined;
|
|
5
18
|
/**
|
|
6
19
|
* Resolves which rig mounts from a `GameCameraConfig`. Precedence, most to least
|
|
7
20
|
* specific: an explicit `rig` field always wins; then `perspective: "first"`
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readCaptureCamera } from "@jgengine/core/world/captureCamera";
|
|
1
2
|
/** @internal */
|
|
2
3
|
export function resolveRuntimeCameraConfig(config, override) {
|
|
3
4
|
if (override === null || override === undefined)
|
|
@@ -9,6 +10,21 @@ export function resolveRuntimeCameraConfig(config, override) {
|
|
|
9
10
|
}
|
|
10
11
|
return merged;
|
|
11
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* The camera config that renders this frame, with the capture camera winning over everything.
|
|
15
|
+
*
|
|
16
|
+
* A game owns its camera three ways: the static `camera` block, a runtime `ctx.camera.setRig`
|
|
17
|
+
* selection, and a director cinematic. A capture harness can rewrite the first, but the other two
|
|
18
|
+
* run afterwards and quietly photograph the game's own camera. `installCaptureCamera` is read here,
|
|
19
|
+
* above all three, so `?look=`/`--look` frames what it was asked for; with nothing installed this is
|
|
20
|
+
* exactly `resolveRuntimeCameraConfig` and every game behaves as before.
|
|
21
|
+
*
|
|
22
|
+
* @capability capture render a framed capture camera over a game that owns its camera
|
|
23
|
+
* @internal
|
|
24
|
+
*/
|
|
25
|
+
export function resolveCaptureCameraConfig(config, override) {
|
|
26
|
+
return resolveRuntimeCameraConfig(config, readCaptureCamera() ?? override);
|
|
27
|
+
}
|
|
12
28
|
/**
|
|
13
29
|
* Resolves which rig mounts from a `GameCameraConfig`. Precedence, most to least
|
|
14
30
|
* specific: an explicit `rig` field always wins; then `perspective: "first"`
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useMemo, useRef } from "react";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { useFrame } from "@react-three/fiber";
|
|
4
4
|
import { PresentationAssetBoundary } from "../presentationRecovery.js";
|
|
5
5
|
import { PresentationRecoveredDraw, useRecoverableTexture } from "../presentationRecovery3d.js";
|
|
@@ -18,14 +18,16 @@ import { buildAuthoredRoadNetwork, RoadRibbons } from "./RoadRibbons.js";
|
|
|
18
18
|
import { terrainGroundColorSampler } from "./terrainGroundColor.js";
|
|
19
19
|
import { InstancedBuildings } from "../structures/GeneratedBuilding.js";
|
|
20
20
|
import { GrassField } from "../terrain/GrassField.js";
|
|
21
|
+
import { GrassCover } from "../terrain/GrassCover.js";
|
|
21
22
|
import { CarvedTerrain } from "../terrain/CarvedTerrain.js";
|
|
22
23
|
import { createTerrainDetailMaterial } from "../terrain/terrainDetailMaterial.js";
|
|
24
|
+
import { MAX_TERRAIN_LAYER_MAPS } from "../terrain/terrainDetailMaterial.js";
|
|
23
25
|
import { useTerrainQuality } from "../terrain/terrainQuality.js";
|
|
24
26
|
import { createTerrainPaintTextures, packTerrainPaintWeights } from "../terrain/terrainSurfaces.js";
|
|
25
27
|
import { Ocean } from "../water/Ocean.js";
|
|
26
28
|
import { RainField } from "../weather/RainField.js";
|
|
27
29
|
import { SnowField } from "../weather/SnowField.js";
|
|
28
|
-
function TexturedTerrainGround({ detail, layout, }) {
|
|
30
|
+
function TexturedTerrainGround({ detail, layout, layerMaps, }) {
|
|
29
31
|
const maps = detail.material.maps;
|
|
30
32
|
const textures = useRecoverableTexture({
|
|
31
33
|
color: maps.color,
|
|
@@ -42,9 +44,105 @@ function TexturedTerrainGround({ detail, layout, }) {
|
|
|
42
44
|
}
|
|
43
45
|
}, [textures]);
|
|
44
46
|
const quality = useTerrainQuality();
|
|
45
|
-
const material = useDisposable(() => createTerrainDetailMaterial(detail, textures, { paint: layout.paint, quality }).material, [detail, textures, layout.paint, quality]);
|
|
47
|
+
const material = useDisposable(() => createTerrainDetailMaterial(detail, textures, { paint: layout.paint, quality, ...(layerMaps === undefined ? {} : { layerMaps }) }).material, [detail, textures, layout.paint, quality, layerMaps]);
|
|
46
48
|
return (_jsxs(_Fragment, { children: [_jsx(PresentationRecoveredDraw, {}), _jsx(CarvedTerrain, { field: layout.field, size: layout.size, segments: layout.segments, colors: layout.colors, heightRange: layout.heightRange, paletteAt: layout.paletteAt, center: layout.center, roughness: 0.94, surfaceMaterial: material })] }));
|
|
47
49
|
}
|
|
50
|
+
/** 1×1 white PNG: binds a valid sampler for a painted layer that has no scanned map. */
|
|
51
|
+
const NO_LAYER_MAP = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
|
|
52
|
+
/** The map roles a detail material samples, in the order the loader starts them. */
|
|
53
|
+
function scanRoles(maps) {
|
|
54
|
+
return [
|
|
55
|
+
["color", maps.color],
|
|
56
|
+
["normal", maps.normal],
|
|
57
|
+
...(maps.roughness === undefined ? [] : [["roughness", maps.roughness]]),
|
|
58
|
+
...(maps.ao === undefined ? [] : [["ao", maps.ao]]),
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The same ground as {@link TexturedTerrainGround} for a scan the game pulled rather than shipped:
|
|
63
|
+
* the maps load straight through `THREE.TextureLoader`, so a missing one stays a warning instead of
|
|
64
|
+
* a suspense rejection that would take the view down, and the authored procedural ground renders
|
|
65
|
+
* until they answer.
|
|
66
|
+
*/
|
|
67
|
+
function TolerantTerrainGround({ detail, layout, layerMaps, fallback, }) {
|
|
68
|
+
const maps = detail.material.maps;
|
|
69
|
+
const roles = scanRoles(maps);
|
|
70
|
+
const key = roles.map(([role, url]) => `${role}:${url}`).join("|");
|
|
71
|
+
const [loaded, setLoaded] = useState({ key });
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
let active = true;
|
|
74
|
+
const textures = {};
|
|
75
|
+
let pending = roles.length;
|
|
76
|
+
const loader = new THREE.TextureLoader();
|
|
77
|
+
for (const [role, url] of roles) {
|
|
78
|
+
loader.load(url, (texture) => {
|
|
79
|
+
if (!active) {
|
|
80
|
+
texture.dispose();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
texture.colorSpace = role === "color" ? THREE.SRGBColorSpace : THREE.NoColorSpace;
|
|
84
|
+
texture.wrapS = THREE.RepeatWrapping;
|
|
85
|
+
texture.wrapT = THREE.RepeatWrapping;
|
|
86
|
+
textures[role] = texture;
|
|
87
|
+
pending -= 1;
|
|
88
|
+
if (pending === 0)
|
|
89
|
+
setLoaded({ key, textures: textures });
|
|
90
|
+
}, undefined, () => {
|
|
91
|
+
if (!active)
|
|
92
|
+
return;
|
|
93
|
+
active = false;
|
|
94
|
+
console.warn(`[jgengine] terrain detail maps did not load (${url}) — keeping the procedural ground. Pull the game's asset packs (\`bun run provision\`) to restore them.`);
|
|
95
|
+
setLoaded({ key });
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return () => {
|
|
99
|
+
active = false;
|
|
100
|
+
for (const texture of Object.values(textures))
|
|
101
|
+
texture?.dispose();
|
|
102
|
+
};
|
|
103
|
+
}, [key]);
|
|
104
|
+
const quality = useTerrainQuality();
|
|
105
|
+
const material = useMemo(() => loaded.key !== key || loaded.textures === undefined
|
|
106
|
+
? undefined
|
|
107
|
+
: createTerrainDetailMaterial(detail, loaded.textures, { paint: layout.paint, quality, ...(layerMaps === undefined ? {} : { layerMaps }) }).material, [detail, loaded, key, layout.paint, quality, layerMaps]);
|
|
108
|
+
useEffect(() => () => material?.dispose(), [material]);
|
|
109
|
+
if (material === undefined)
|
|
110
|
+
return _jsx(_Fragment, { children: fallback });
|
|
111
|
+
return (_jsx(CarvedTerrain, { field: layout.field, size: layout.size, segments: layout.segments, colors: layout.colors, heightRange: layout.heightRange, paletteAt: layout.paletteAt, center: layout.center, roughness: 0.94, surfaceMaterial: material }));
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Scanned color maps for up to {@link MAX_TERRAIN_LAYER_MAPS} painted terrain layers, loaded through
|
|
115
|
+
* the shared recoverable texture path and tiled at each layer's own authored metres. The URL is
|
|
116
|
+
* authored data (`TerrainMaterialLayer.materialUrl`), resolved by the editor from a library name, so
|
|
117
|
+
* the renderer never reaches into the asset pipeline.
|
|
118
|
+
*/
|
|
119
|
+
function useTerrainLayerMaps(paint) {
|
|
120
|
+
const urls = useMemo(() => Array.from({ length: MAX_TERRAIN_LAYER_MAPS }, (_, index) => {
|
|
121
|
+
const url = paint?.slots[index + 1]?.materialUrl;
|
|
122
|
+
return url === undefined || url.length === 0 ? NO_LAYER_MAP : url;
|
|
123
|
+
}), [paint]);
|
|
124
|
+
const textures = useRecoverableTexture({ layer1: urls[0], layer2: urls[1], layer3: urls[2], layer4: urls[3] });
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
for (const texture of Object.values(textures)) {
|
|
127
|
+
texture.colorSpace = THREE.SRGBColorSpace;
|
|
128
|
+
texture.wrapS = THREE.RepeatWrapping;
|
|
129
|
+
texture.wrapT = THREE.RepeatWrapping;
|
|
130
|
+
texture.needsUpdate = true;
|
|
131
|
+
}
|
|
132
|
+
}, [textures]);
|
|
133
|
+
const slots = paint?.slots;
|
|
134
|
+
return useMemo(() => {
|
|
135
|
+
if (slots === undefined)
|
|
136
|
+
return undefined;
|
|
137
|
+
return Array.from({ length: MAX_TERRAIN_LAYER_MAPS }, (_, index) => {
|
|
138
|
+
const slot = slots[index + 1];
|
|
139
|
+
if (slot?.materialUrl === undefined)
|
|
140
|
+
return undefined;
|
|
141
|
+
const texture = textures[`layer${index + 1}`];
|
|
142
|
+
return texture === undefined ? undefined : { color: texture, tileMetres: slot.tileMetres };
|
|
143
|
+
});
|
|
144
|
+
}, [slots, textures]);
|
|
145
|
+
}
|
|
48
146
|
function TerrainGround({ terrain, field, center, sculpt, }) {
|
|
49
147
|
const detailEnabled = terrain.detail !== false;
|
|
50
148
|
const paint = useMemo(() => {
|
|
@@ -79,14 +177,19 @@ function TerrainGround({ terrain, field, center, sculpt, }) {
|
|
|
79
177
|
}, [terrain.baseHeight, terrain.height]);
|
|
80
178
|
const resolvedDetail = useMemo(() => (terrain.detail === false ? undefined : resolveTerrainDetail(terrain.detail, terrain.waterLevel)), [terrain.detail, terrain.waterLevel]);
|
|
81
179
|
const quality = useTerrainQuality();
|
|
82
|
-
const
|
|
180
|
+
const layerMaps = useTerrainLayerMaps(paint);
|
|
181
|
+
const proceduralMaterial = useMemo(() => resolvedDetail === undefined || (resolvedDetail.material !== undefined && resolvedDetail.material.missing === "fail")
|
|
83
182
|
? undefined
|
|
84
|
-
: createTerrainDetailMaterial(resolvedDetail, undefined, { paint, quality }).material, [resolvedDetail, paint, quality]);
|
|
183
|
+
: createTerrainDetailMaterial(resolvedDetail, undefined, { paint, quality, ...(layerMaps === undefined ? {} : { layerMaps }) }).material, [resolvedDetail, paint, quality, layerMaps]);
|
|
85
184
|
useEffect(() => () => proceduralMaterial?.dispose(), [proceduralMaterial]);
|
|
185
|
+
const proceduralGround = (_jsx(CarvedTerrain, { field: field, size: size, segments: terrain.segments, colors: colors, heightRange: heightRange, paletteAt: paletteAt, paintColorAt: paintColorAt, center: center, roughness: 0.94, surfaceMaterial: proceduralMaterial }));
|
|
86
186
|
if (resolvedDetail?.material !== undefined) {
|
|
87
|
-
|
|
187
|
+
const textured = (_jsx(TexturedTerrainGround, { detail: resolvedDetail, layout: { field, size, segments: terrain.segments, colors, heightRange, paletteAt, paint, center }, ...(layerMaps === undefined ? {} : { layerMaps }) }));
|
|
188
|
+
if (resolvedDetail.material.missing === "fail")
|
|
189
|
+
return _jsx(PresentationAssetBoundary, { children: textured });
|
|
190
|
+
return (_jsx(TolerantTerrainGround, { detail: resolvedDetail, layout: { field, size, segments: terrain.segments, colors, heightRange, paletteAt, paint, center }, ...(layerMaps === undefined ? {} : { layerMaps }), fallback: proceduralGround }));
|
|
88
191
|
}
|
|
89
|
-
return
|
|
192
|
+
return proceduralGround;
|
|
90
193
|
}
|
|
91
194
|
function areaCenter(area) {
|
|
92
195
|
return area.position ?? [0, 0];
|
|
@@ -100,7 +203,7 @@ function Vegetation({ grass, field, groundColor, }) {
|
|
|
100
203
|
const wind = useMemo(() => ({ strength: grass.windStrength }), [grass.windStrength]);
|
|
101
204
|
const colorBase = grass.colors[0];
|
|
102
205
|
const colorTip = grass.colors[grass.colors.length - 1];
|
|
103
|
-
return (
|
|
206
|
+
return (_jsxs("group", { position: [cx, 0, cz], children: [_jsx(GrassCover, { area: area, seed: typeof grass.seed === "number" ? grass.seed : 1, colorBase: colorBase ?? "#3f7d2d", colorTip: colorTip ?? "#66a83f", strength: grass.groundCover ?? 0.9, edgeFeather: Math.min(8, Math.max(1.5, Math.min(grass.area.w, grass.area.d) * 0.12)), heightAt: heightAt, coverageAt: () => 1 }), _jsx(GrassField, { area: area, count: Math.max(1500, Math.min(250000, Math.ceil(grass.density * grass.area.w * grass.area.d))), density: grass.density, seed: grass.seed, bladeHeight: grass.bladeHeight, bladeWidth: grass.bladeWidth, bladeBend: grass.bladeBend, tuftRadius: grass.tuftRadius, colorVariation: grass.colorVariation, normalLift: grass.normalLift, heightAt: heightAt, colorBase: colorBase, colorTip: colorTip, colorGround: groundColor, wind: wind, edgeFeather: Math.min(8, Math.max(1.5, Math.min(grass.area.w, grass.area.d) * 0.12)), frustumCulled: true })] }));
|
|
104
207
|
}
|
|
105
208
|
/** Terrain color under a grass patch center — so blade roots blend into the ground they stand on. */
|
|
106
209
|
function vegetationGroundColor(terrain, field, grass, sculpt) {
|
|
@@ -142,19 +245,24 @@ function DynamicWater({ ocean }) {
|
|
|
142
245
|
const groupRef = useRef(null);
|
|
143
246
|
const [x, z] = ocean.position ?? [0, 0];
|
|
144
247
|
const config = useOceanConfig(ocean);
|
|
248
|
+
const depthAt = useCallback((lx, lz) => ocean.levelAt(ctx.time.now()) - ctx.world.ground.sampleHeight(x + lx, z + lz), [ctx.time.now, ctx.world.ground, ocean.levelAt, x, z]);
|
|
145
249
|
useFrame(() => {
|
|
146
250
|
if (groupRef.current !== null)
|
|
147
251
|
groupRef.current.position.y = ocean.levelAt(ctx.time.now());
|
|
148
252
|
});
|
|
149
|
-
return (_jsx("group", { ref: groupRef, position: [0, ocean.level, 0], children: _jsx(Ocean, { position: [x, 0, z], config: config }) }));
|
|
253
|
+
return (_jsx("group", { ref: groupRef, position: [0, ocean.level, 0], children: _jsx(Ocean, { position: [x, 0, z], config: config, depthAt: depthAt }) }));
|
|
150
254
|
}
|
|
151
255
|
function Water({ ocean }) {
|
|
256
|
+
const ctx = useGameContext();
|
|
152
257
|
const [x, z] = ocean.position ?? [0, 0];
|
|
153
258
|
const config = useOceanConfig(ocean);
|
|
259
|
+
// The ocean descriptor has no depth field: sample the live ground so the sheet shades
|
|
260
|
+
// shallow→deep and the shoreline foam band lands where the bed meets the surface.
|
|
261
|
+
const depthAt = useCallback((lx, lz) => ocean.level - ctx.world.ground.sampleHeight(x + lx, z + lz), [ctx.world.ground, ocean.level, x, z]);
|
|
154
262
|
if (ocean.levelAt !== undefined) {
|
|
155
263
|
return _jsx(DynamicWater, { ocean: ocean });
|
|
156
264
|
}
|
|
157
|
-
return _jsx(Ocean, { position: [x, ocean.level, z], config: config });
|
|
265
|
+
return _jsx(Ocean, { position: [x, ocean.level, z], config: config, depthAt: depthAt });
|
|
158
266
|
}
|
|
159
267
|
function Structures({ structures, field }) {
|
|
160
268
|
const placements = useMemo(() => resolveStructureBuildings(structures).map((building) => ({
|
|
@@ -1,3 +1,10 @@
|
|
|
1
1
|
import type * as THREE from "three";
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Draw a prepass with the world matrices the beauty pass already built. A postfx prepass renders the
|
|
4
|
+
* same scene a few milliseconds later in the same frame, so three's `updateMatrixWorld` walk there
|
|
5
|
+
* recomposes a graph nothing has moved — skipping it removes one full scene-graph walk per frame.
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
export declare function renderWithCurrentMatrices<T>(scene: THREE.Object3D, render: () => T): T;
|
|
9
|
+
/** Reuse the beauty pass's shadow maps and world matrices while drawing a scene prepass. @internal */
|
|
3
10
|
export declare function renderShadowlessPrepass<T>(renderer: Pick<THREE.WebGLRenderer, "shadowMap">, scene: THREE.Object3D, hiddenOverlays: THREE.Object3D[], render: () => T): T;
|
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
import { hidePostfxOverlays, restorePostfxOverlays } from "./postfxOverlay.js";
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Draw a prepass with the world matrices the beauty pass already built. A postfx prepass renders the
|
|
4
|
+
* same scene a few milliseconds later in the same frame, so three's `updateMatrixWorld` walk there
|
|
5
|
+
* recomposes a graph nothing has moved — skipping it removes one full scene-graph walk per frame.
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
export function renderWithCurrentMatrices(scene, render) {
|
|
9
|
+
const autoUpdate = scene.matrixWorldAutoUpdate;
|
|
10
|
+
scene.matrixWorldAutoUpdate = false;
|
|
11
|
+
try {
|
|
12
|
+
return render();
|
|
13
|
+
}
|
|
14
|
+
finally {
|
|
15
|
+
scene.matrixWorldAutoUpdate = autoUpdate;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Reuse the beauty pass's shadow maps and world matrices while drawing a scene prepass. @internal */
|
|
3
19
|
export function renderShadowlessPrepass(renderer, scene, hiddenOverlays, render) {
|
|
4
20
|
const shadowMap = renderer.shadowMap;
|
|
5
21
|
const autoUpdate = shadowMap.autoUpdate;
|
|
@@ -8,7 +24,7 @@ export function renderShadowlessPrepass(renderer, scene, hiddenOverlays, render)
|
|
|
8
24
|
shadowMap.autoUpdate = false;
|
|
9
25
|
shadowMap.needsUpdate = false;
|
|
10
26
|
try {
|
|
11
|
-
return render
|
|
27
|
+
return renderWithCurrentMatrices(scene, render);
|
|
12
28
|
}
|
|
13
29
|
finally {
|
|
14
30
|
shadowMap.autoUpdate = autoUpdate;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as THREE from "three";
|
|
2
2
|
import { FullScreenQuad, Pass } from "three-stdlib";
|
|
3
3
|
import { isPostfxOverlay } from "./postfxOverlay.js";
|
|
4
|
+
import { renderWithCurrentMatrices } from "./shadowPrepass.js";
|
|
4
5
|
const DEFAULT_OUTLINE_COLOR = "#101014";
|
|
5
6
|
const DEFAULT_DEPTH_THRESHOLD = 0.08;
|
|
6
7
|
const DEFAULT_NORMAL_THRESHOLD = 0.35;
|
|
@@ -197,7 +198,7 @@ export class StylizePass extends Pass {
|
|
|
197
198
|
renderer.setRenderTarget(target);
|
|
198
199
|
renderer.setClearColor(0x8080ff, 1);
|
|
199
200
|
renderer.clear();
|
|
200
|
-
renderer.render(scene, this.camera);
|
|
201
|
+
renderWithCurrentMatrices(scene, () => renderer.render(scene, this.camera));
|
|
201
202
|
renderer.setClearColor(this.clearColor, previousClearAlpha);
|
|
202
203
|
renderer.shadowMap.autoUpdate = previousShadowUpdate;
|
|
203
204
|
scene.background = previousBackground;
|
|
@@ -1,9 +1,38 @@
|
|
|
1
1
|
import * as THREE from "three";
|
|
2
2
|
import { CSM } from "three/examples/jsm/csm/CSM.js";
|
|
3
3
|
import type { DirectionalLightingConfig } from "@jgengine/core/game/playableGame";
|
|
4
|
+
import type { GraphicsProfile } from "@jgengine/core/settings/graphicsProfile";
|
|
4
5
|
import type { DaylightModulation } from "./SceneLighting.js";
|
|
5
6
|
/** @internal Default PCF penumbra in shadow-map texels for configured directional lights. */
|
|
6
7
|
export declare const DEFAULT_SHADOW_RADIUS = 1.25;
|
|
8
|
+
/** @internal Reach in metres used when neither the game nor a graphics profile sets one. */
|
|
9
|
+
export declare const DEFAULT_SHADOW_DISTANCE = 200;
|
|
10
|
+
/**
|
|
11
|
+
* Shadow reach each tier's map resolution pays for. A cascade's texels stop describing a
|
|
12
|
+
* silhouette past roughly a tenth of a metre, so a smaller map covers less ground instead
|
|
13
|
+
* of covering the same ground coarsely.
|
|
14
|
+
* @internal
|
|
15
|
+
*/
|
|
16
|
+
export declare const SHADOW_DISTANCE_BY_MAP_SIZE: Readonly<Record<number, number>>;
|
|
17
|
+
/**
|
|
18
|
+
* Cascaded-shadow reach from the graphics tier, clamped by that tier's draw distance.
|
|
19
|
+
* A game's own `shadowMaxFar` still wins.
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolveShadowDistance(profile?: GraphicsProfile, authored?: number): number;
|
|
23
|
+
/**
|
|
24
|
+
* Frames between shadow-map refreshes for the cascade at `index`, nearest first. The nearest
|
|
25
|
+
* cascades move several texels per frame and refresh every frame; the far ones cover ground a
|
|
26
|
+
* slow camera barely crosses, so they pay the depth pass a half or a quarter as often.
|
|
27
|
+
* @internal
|
|
28
|
+
*/
|
|
29
|
+
export declare function cascadeShadowCadence(index: number, cascades: number): number;
|
|
30
|
+
/**
|
|
31
|
+
* Set each cascade's shadow refresh policy for `frame`. A cascade that is not due keeps both
|
|
32
|
+
* its matrix and its map from the last refresh, so the pair stays consistent with itself.
|
|
33
|
+
* @internal
|
|
34
|
+
*/
|
|
35
|
+
export declare function scheduleCascadeRefresh(lights: readonly THREE.DirectionalLight[], frame: number): void;
|
|
7
36
|
/**
|
|
8
37
|
* Cascaded shadow maps for one directional light — outdoor scenes keep shadows past a
|
|
9
38
|
* single 40-unit ortho frustum. Uses three.js `CSM` (shader injection on standard materials).
|
|
@@ -13,8 +42,9 @@ export declare const DEFAULT_SHADOW_RADIUS = 1.25;
|
|
|
13
42
|
*
|
|
14
43
|
* @internal shell SceneLighting helper — games set `cascades` on directional lighting config.
|
|
15
44
|
*/
|
|
16
|
-
export declare function CascadedShadows({ entry, modulation }: {
|
|
45
|
+
export declare function CascadedShadows({ entry, profile, modulation, }: {
|
|
17
46
|
entry: DirectionalLightingConfig;
|
|
47
|
+
profile?: GraphicsProfile;
|
|
18
48
|
modulation?: DaylightModulation;
|
|
19
49
|
}): null;
|
|
20
50
|
/** Structural slice of CSM so the patcher is testable without a renderer. */
|
|
@@ -4,6 +4,60 @@ import * as THREE from "three";
|
|
|
4
4
|
import { CSM } from "three/examples/jsm/csm/CSM.js";
|
|
5
5
|
/** @internal Default PCF penumbra in shadow-map texels for configured directional lights. */
|
|
6
6
|
export const DEFAULT_SHADOW_RADIUS = 1.25;
|
|
7
|
+
/** @internal Reach in metres used when neither the game nor a graphics profile sets one. */
|
|
8
|
+
export const DEFAULT_SHADOW_DISTANCE = 200;
|
|
9
|
+
/**
|
|
10
|
+
* Shadow reach each tier's map resolution pays for. A cascade's texels stop describing a
|
|
11
|
+
* silhouette past roughly a tenth of a metre, so a smaller map covers less ground instead
|
|
12
|
+
* of covering the same ground coarsely.
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
export const SHADOW_DISTANCE_BY_MAP_SIZE = {
|
|
16
|
+
512: 100,
|
|
17
|
+
1024: 150,
|
|
18
|
+
2048: 200,
|
|
19
|
+
4096: 200,
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Cascaded-shadow reach from the graphics tier, clamped by that tier's draw distance.
|
|
23
|
+
* A game's own `shadowMaxFar` still wins.
|
|
24
|
+
* @internal
|
|
25
|
+
*/
|
|
26
|
+
export function resolveShadowDistance(profile, authored) {
|
|
27
|
+
if (authored !== undefined)
|
|
28
|
+
return authored;
|
|
29
|
+
if (profile === undefined)
|
|
30
|
+
return DEFAULT_SHADOW_DISTANCE;
|
|
31
|
+
const reach = SHADOW_DISTANCE_BY_MAP_SIZE[profile.shadowMapSize] ?? DEFAULT_SHADOW_DISTANCE;
|
|
32
|
+
return Math.min(profile.drawDistance, reach);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Frames between shadow-map refreshes for the cascade at `index`, nearest first. The nearest
|
|
36
|
+
* cascades move several texels per frame and refresh every frame; the far ones cover ground a
|
|
37
|
+
* slow camera barely crosses, so they pay the depth pass a half or a quarter as often.
|
|
38
|
+
* @internal
|
|
39
|
+
*/
|
|
40
|
+
export function cascadeShadowCadence(index, cascades) {
|
|
41
|
+
if (index < cascades - 2)
|
|
42
|
+
return 1;
|
|
43
|
+
if (cascades <= 2)
|
|
44
|
+
return index === 0 ? 1 : 2;
|
|
45
|
+
return 2 ** (index - (cascades - 3));
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Set each cascade's shadow refresh policy for `frame`. A cascade that is not due keeps both
|
|
49
|
+
* its matrix and its map from the last refresh, so the pair stays consistent with itself.
|
|
50
|
+
* @internal
|
|
51
|
+
*/
|
|
52
|
+
export function scheduleCascadeRefresh(lights, frame) {
|
|
53
|
+
for (let index = 0; index < lights.length; index += 1) {
|
|
54
|
+
const shadow = lights[index].shadow;
|
|
55
|
+
const cadence = cascadeShadowCadence(index, lights.length);
|
|
56
|
+
shadow.autoUpdate = cadence === 1;
|
|
57
|
+
if (cadence > 1)
|
|
58
|
+
shadow.needsUpdate = frame % cadence === 0;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
7
61
|
function isStandardMaterial(mat) {
|
|
8
62
|
return mat instanceof THREE.MeshStandardMaterial || mat instanceof THREE.MeshPhysicalMaterial;
|
|
9
63
|
}
|
|
@@ -16,11 +70,13 @@ function isStandardMaterial(mat) {
|
|
|
16
70
|
*
|
|
17
71
|
* @internal shell SceneLighting helper — games set `cascades` on directional lighting config.
|
|
18
72
|
*/
|
|
19
|
-
export function CascadedShadows({ entry, modulation }) {
|
|
73
|
+
export function CascadedShadows({ entry, profile, modulation, }) {
|
|
20
74
|
const camera = useThree((s) => s.camera);
|
|
21
75
|
const scene = useThree((s) => s.scene);
|
|
22
76
|
const csmRef = useRef(null);
|
|
77
|
+
const frameRef = useRef(0);
|
|
23
78
|
const baseIntensity = entry.intensity ?? 1.3;
|
|
79
|
+
const maxFar = resolveShadowDistance(profile, entry.shadowMaxFar);
|
|
24
80
|
useEffect(() => {
|
|
25
81
|
const cascades = Math.max(2, Math.min(4, Math.floor(entry.cascades ?? 3)));
|
|
26
82
|
const lightDirection = new THREE.Vector3(-entry.position[0], -entry.position[1], -entry.position[2]);
|
|
@@ -31,7 +87,7 @@ export function CascadedShadows({ entry, modulation }) {
|
|
|
31
87
|
camera,
|
|
32
88
|
parent: scene,
|
|
33
89
|
cascades,
|
|
34
|
-
maxFar
|
|
90
|
+
maxFar,
|
|
35
91
|
mode: "practical",
|
|
36
92
|
shadowMapSize: entry.shadowMapSize ?? 1024,
|
|
37
93
|
shadowBias: entry.shadowBias ?? -0.0004,
|
|
@@ -65,7 +121,7 @@ export function CascadedShadows({ entry, modulation }) {
|
|
|
65
121
|
entry.shadowBias,
|
|
66
122
|
entry.shadowCameraSize,
|
|
67
123
|
entry.shadowMapSize,
|
|
68
|
-
|
|
124
|
+
maxFar,
|
|
69
125
|
entry.shadowRadius,
|
|
70
126
|
]);
|
|
71
127
|
useFrame(() => {
|
|
@@ -81,6 +137,7 @@ export function CascadedShadows({ entry, modulation }) {
|
|
|
81
137
|
}
|
|
82
138
|
csm.camera = camera;
|
|
83
139
|
csm.update();
|
|
140
|
+
scheduleCascadeRefresh(csm.lights, frameRef.current++);
|
|
84
141
|
});
|
|
85
142
|
return null;
|
|
86
143
|
}
|
|
@@ -153,14 +210,23 @@ export function bindCsmMaterials(scene, csm) {
|
|
|
153
210
|
const added = ({ child }) => attach(child);
|
|
154
211
|
const removed = ({ child }) => detach(child);
|
|
155
212
|
const prior = node.onBeforeRender;
|
|
213
|
+
// One reference compare per draw: a mesh whose material has not been swapped costs nothing
|
|
214
|
+
// more than the authored hook. Re-patching only on a change keeps the swap detection.
|
|
215
|
+
let lastMaterial;
|
|
216
|
+
const mesh = node;
|
|
156
217
|
const before = function (...args) {
|
|
157
218
|
prior.apply(this, args);
|
|
158
|
-
|
|
219
|
+
if (mesh.material === lastMaterial)
|
|
220
|
+
return;
|
|
221
|
+
lastMaterial = mesh.material;
|
|
222
|
+
patchMesh(mesh);
|
|
159
223
|
};
|
|
160
224
|
node.addEventListener("childadded", added);
|
|
161
225
|
node.addEventListener("childremoved", removed);
|
|
162
|
-
if (
|
|
226
|
+
if (mesh.isMesh) {
|
|
227
|
+
lastMaterial = mesh.material;
|
|
163
228
|
node.onBeforeRender = before;
|
|
229
|
+
}
|
|
164
230
|
nodes.set(node, () => {
|
|
165
231
|
node.removeEventListener("childadded", added);
|
|
166
232
|
node.removeEventListener("childremoved", removed);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ModelConfig } from "@jgengine/core/game/playableGame";
|
|
2
|
+
import { CharacterCrowdRegistry } from "./crowdRegistry.js";
|
|
3
|
+
/** Props for {@link CharacterCrowd}. */
|
|
4
|
+
export interface CharacterCrowdProps {
|
|
5
|
+
/** Character models the world can draw; only models with a `lod` policy join the crowd path. */
|
|
6
|
+
models: readonly ModelConfig[];
|
|
7
|
+
/** Where entity markers record the characters the crowd is drawing. */
|
|
8
|
+
registry: CharacterCrowdRegistry;
|
|
9
|
+
/** Baked samples per second for the crowd clips. Default 24. */
|
|
10
|
+
fps?: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Draws far characters through the GPU crowd path: one baked rig per character kind, one instanced
|
|
14
|
+
* draw per material, every member's clip, time offset and speed sampled in the vertex shader. The
|
|
15
|
+
* entity markers decide who is far enough (their `lod` tiers) and write poses into `registry`; this
|
|
16
|
+
* layer owns the bakes, the materials and the instance buffers. Mount it in the same canvas as the
|
|
17
|
+
* entity markers; unmount it to fall back to per-entity rigs.
|
|
18
|
+
* @capability character-crowd draw distant animated characters on the GPU instead of posing their skeletons
|
|
19
|
+
*/
|
|
20
|
+
export declare function CharacterCrowd({ models, registry, fps }: CharacterCrowdProps): import("react").JSX.Element;
|