@jgengine/shell 0.27.0 → 0.29.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 +140 -0
- 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 +69 -4
- 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/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/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 +23 -0
- package/dist/render/useRagdoll.d.ts +32 -0
- package/dist/render/useRagdoll.js +105 -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/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 +50 -13
- 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 +200 -9
- package/skills/jgengine-ui/capabilities.md +103 -1
- package/skills/jgengine-ui/reference-primitives.md +2 -0
- package/skills/jgengine-ui/reference.md +8 -0
- package/skills/jgengine-world/SKILL.md +4 -0
- package/skills/jgengine-world/api.md +128 -6
- package/skills/jgengine-world/capabilities.md +41 -1
- package/skills/jgengine-world/recipes/character-animation.md +25 -2
- package/skills/jgengine-world/reference.md +1 -1
|
@@ -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 { useCallback, 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";
|
|
@@ -49,6 +49,67 @@ function TexturedTerrainGround({ detail, layout, layerMaps, }) {
|
|
|
49
49
|
}
|
|
50
50
|
/** 1×1 white PNG: binds a valid sampler for a painted layer that has no scanned map. */
|
|
51
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
|
+
}
|
|
52
113
|
/**
|
|
53
114
|
* Scanned color maps for up to {@link MAX_TERRAIN_LAYER_MAPS} painted terrain layers, loaded through
|
|
54
115
|
* the shared recoverable texture path and tiled at each layer's own authored metres. The URL is
|
|
@@ -117,14 +178,18 @@ function TerrainGround({ terrain, field, center, sculpt, }) {
|
|
|
117
178
|
const resolvedDetail = useMemo(() => (terrain.detail === false ? undefined : resolveTerrainDetail(terrain.detail, terrain.waterLevel)), [terrain.detail, terrain.waterLevel]);
|
|
118
179
|
const quality = useTerrainQuality();
|
|
119
180
|
const layerMaps = useTerrainLayerMaps(paint);
|
|
120
|
-
const proceduralMaterial = useMemo(() => resolvedDetail === undefined || resolvedDetail.material !== undefined
|
|
181
|
+
const proceduralMaterial = useMemo(() => resolvedDetail === undefined || (resolvedDetail.material !== undefined && resolvedDetail.material.missing === "fail")
|
|
121
182
|
? undefined
|
|
122
183
|
: createTerrainDetailMaterial(resolvedDetail, undefined, { paint, quality, ...(layerMaps === undefined ? {} : { layerMaps }) }).material, [resolvedDetail, paint, quality, layerMaps]);
|
|
123
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 }));
|
|
124
186
|
if (resolvedDetail?.material !== undefined) {
|
|
125
|
-
|
|
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 }));
|
|
126
191
|
}
|
|
127
|
-
return
|
|
192
|
+
return proceduralGround;
|
|
128
193
|
}
|
|
129
194
|
function areaCenter(area) {
|
|
130
195
|
return area.position ?? [0, 0];
|
|
@@ -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;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useFrame, useLoader } from "@react-three/fiber";
|
|
3
|
+
import { Suspense, useEffect, useMemo, useRef } from "react";
|
|
4
|
+
import * as THREE from "three";
|
|
5
|
+
import { devtools } from "@jgengine/core/devtools/devtools";
|
|
6
|
+
import { warnOnce } from "@jgengine/core/devtools/warnOnce";
|
|
7
|
+
import { animationPhaseOf, characterLodProbe, clearCrowdKind, noteCharacterFrame, noteCrowdInstances, } from "./characterLod.js";
|
|
8
|
+
import { CharacterCrowdRegistry, crowdClipForSpeed, crowdClipsOf, crowdKindOf, setCrowdKindReady, } from "./crowdRegistry.js";
|
|
9
|
+
import { applyMaterialOverrideToMaterial } from "../materialOverride.js";
|
|
10
|
+
import { useRenderVisibility } from "../visibility/CullingProvider.js";
|
|
11
|
+
import { ModelFallbackBoundary } from "./SceneModels.js";
|
|
12
|
+
import { sharedGltfLoader } from "./modelLoad.js";
|
|
13
|
+
import { bakeSkinnedCrowd, createCrowdUniforms, patchCrowdMaterial, } from "./SkinnedInstances.js";
|
|
14
|
+
/** Instances one crowd batch can draw; a batch skips past this rather than allocate mid-frame. */
|
|
15
|
+
const CROWD_CAPACITY = 128;
|
|
16
|
+
const scratchMatrix = new THREE.Matrix4();
|
|
17
|
+
const scratchOffset = new THREE.Matrix4();
|
|
18
|
+
const scratchQuaternion = new THREE.Quaternion();
|
|
19
|
+
const scratchPosition = new THREE.Vector3();
|
|
20
|
+
const scratchScale = new THREE.Vector3();
|
|
21
|
+
/** A stable non-zero time offset for a member with no recorded pose, so a crowd is not in lockstep. */
|
|
22
|
+
function poseOffset(id) {
|
|
23
|
+
let hash = 0;
|
|
24
|
+
for (let index = 0; index < id.length; index += 1)
|
|
25
|
+
hash = (hash * 31 + id.charCodeAt(index)) | 0;
|
|
26
|
+
return (Math.abs(hash) % 1000) / 1000 + 0.05;
|
|
27
|
+
}
|
|
28
|
+
function CrowdBatch({ kind, model, registry, fps, }) {
|
|
29
|
+
const gltf = useLoader(sharedGltfLoader, model.url);
|
|
30
|
+
const clipNames = useMemo(() => gltf.animations.map((clip) => clip.name), [gltf]);
|
|
31
|
+
const clips = useMemo(() => crowdClipsOf(model, clipNames), [model, clipNames]);
|
|
32
|
+
const baked = useMemo(() => {
|
|
33
|
+
if (clips.length === 0)
|
|
34
|
+
return null;
|
|
35
|
+
try {
|
|
36
|
+
return bakeSkinnedCrowd({ scene: gltf.scene, animations: gltf.animations }, { clips, fps });
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
// A model can carry clips without a skin (a node-transform prop animation). It keeps its own
|
|
40
|
+
// rig rather than entering the crowd path, which is what the readiness flag then reports.
|
|
41
|
+
warnOnce(`character-crowd:${model.url}`, `[jgengine] character crowd: "${model.url}" cannot be baked (no skinned mesh) and stays on its own rig`);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}, [gltf, clips, fps, model.url]);
|
|
45
|
+
useEffect(() => () => baked?.dispose(), [baked]);
|
|
46
|
+
const uniforms = useMemo(() => (baked === null ? null : createCrowdUniforms(baked)), [baked]);
|
|
47
|
+
const materials = useMemo(() => {
|
|
48
|
+
if (baked === null || uniforms === null)
|
|
49
|
+
return [];
|
|
50
|
+
return baked.materials.map((material) => {
|
|
51
|
+
const tinted = model.material === undefined ? material.clone() : applyMaterialOverrideToMaterial(material.clone(), model.material, false, undefined);
|
|
52
|
+
return patchCrowdMaterial(tinted, uniforms);
|
|
53
|
+
});
|
|
54
|
+
}, [baked, uniforms, model]);
|
|
55
|
+
const depthMaterial = useMemo(() => (uniforms === null ? null : patchCrowdMaterial(new THREE.MeshDepthMaterial({ depthPacking: THREE.RGBADepthPacking }), uniforms)), [uniforms]);
|
|
56
|
+
const mesh = useMemo(() => {
|
|
57
|
+
if (baked === null || depthMaterial === null || materials.length === 0)
|
|
58
|
+
return null;
|
|
59
|
+
const instanced = new THREE.InstancedMesh(baked.geometry, materials, CROWD_CAPACITY);
|
|
60
|
+
instanced.customDepthMaterial = depthMaterial;
|
|
61
|
+
instanced.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 0);
|
|
62
|
+
instanced.count = 0;
|
|
63
|
+
instanced.geometry.setAttribute("crowdPlay", new THREE.InstancedBufferAttribute(new Float32Array(CROWD_CAPACITY * 3), 3));
|
|
64
|
+
instanced.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
|
65
|
+
return instanced;
|
|
66
|
+
}, [baked, materials, depthMaterial]);
|
|
67
|
+
useEffect(() => () => {
|
|
68
|
+
for (const material of materials)
|
|
69
|
+
material.dispose();
|
|
70
|
+
depthMaterial?.dispose();
|
|
71
|
+
mesh?.dispose();
|
|
72
|
+
}, [materials, depthMaterial, mesh]);
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
// A kind without a bakeable locomotion clip set keeps every character on its own rig rather than
|
|
75
|
+
// making them invisible past the crowd distance.
|
|
76
|
+
setCrowdKindReady(kind, mesh !== null && clips.length > 0);
|
|
77
|
+
return () => setCrowdKindReady(kind, false);
|
|
78
|
+
}, [kind, mesh, clips]);
|
|
79
|
+
/** The height and centering the full entity path gives this model, so a crowd copy stands in the same place. */
|
|
80
|
+
const placement = useMemo(() => {
|
|
81
|
+
if (baked === null)
|
|
82
|
+
return null;
|
|
83
|
+
const box = baked.geometry.boundingBox ?? (baked.geometry.computeBoundingBox(), baked.geometry.boundingBox);
|
|
84
|
+
const height = box.max.y - box.min.y;
|
|
85
|
+
const scale = (model.scale ?? 1) * (model.targetHeight !== undefined && height > 0 ? model.targetHeight / height : 1);
|
|
86
|
+
return {
|
|
87
|
+
scale,
|
|
88
|
+
offset: new THREE.Vector3(-scale * (box.min.x + box.max.x) / 2, (model.y ?? 0) - scale * box.min.y, -scale * (box.min.z + box.max.z) / 2),
|
|
89
|
+
};
|
|
90
|
+
}, [baked, model]);
|
|
91
|
+
const playback = useRef(new Map());
|
|
92
|
+
const visibleRef = useRenderVisibility();
|
|
93
|
+
useFrame((_state, delta) => {
|
|
94
|
+
if (mesh === null || baked === null || uniforms === null || placement === null)
|
|
95
|
+
return;
|
|
96
|
+
uniforms.crowdTime.value += delta;
|
|
97
|
+
const play = mesh.geometry.getAttribute("crowdPlay");
|
|
98
|
+
const active = playback.current;
|
|
99
|
+
let count = 0;
|
|
100
|
+
let minX = Infinity, minY = Infinity, minZ = Infinity, maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
|
101
|
+
registry.forEach(kind, (id, member) => {
|
|
102
|
+
if (count >= CROWD_CAPACITY)
|
|
103
|
+
return;
|
|
104
|
+
if (!visibleRef.current(id))
|
|
105
|
+
return;
|
|
106
|
+
const clipName = crowdClipForSpeed(clips, member.speed);
|
|
107
|
+
const clip = clipName === undefined ? 0 : baked.clipIndex.get(clipName) ?? 0;
|
|
108
|
+
let entry = active.get(id);
|
|
109
|
+
if (entry === undefined || entry.clip !== clip) {
|
|
110
|
+
// Hand the crowd the pose the rig was holding, so the swap continues the same gait instead
|
|
111
|
+
// of restarting the clip at a different phase.
|
|
112
|
+
const phase = animationPhaseOf(id);
|
|
113
|
+
entry = {
|
|
114
|
+
clip,
|
|
115
|
+
offset: phase !== undefined && phase.clip === clipName ? phase.time - uniforms.crowdTime.value : poseOffset(id),
|
|
116
|
+
};
|
|
117
|
+
active.set(id, entry);
|
|
118
|
+
}
|
|
119
|
+
scratchQuaternion.setFromAxisAngle(THREE.Object3D.DEFAULT_UP, member.rotationY);
|
|
120
|
+
scratchMatrix.compose(scratchPosition.set(member.x, member.y, member.z), scratchQuaternion, scratchScale.setScalar(placement.scale));
|
|
121
|
+
scratchMatrix.multiply(scratchOffset.makeTranslation(placement.offset.x, placement.offset.y, placement.offset.z));
|
|
122
|
+
mesh.setMatrixAt(count, scratchMatrix);
|
|
123
|
+
play.setXYZ(count, entry.clip, entry.offset, 1);
|
|
124
|
+
minX = Math.min(minX, member.x);
|
|
125
|
+
maxX = Math.max(maxX, member.x);
|
|
126
|
+
minY = Math.min(minY, member.y);
|
|
127
|
+
maxY = Math.max(maxY, member.y);
|
|
128
|
+
minZ = Math.min(minZ, member.z);
|
|
129
|
+
maxZ = Math.max(maxZ, member.z);
|
|
130
|
+
count += 1;
|
|
131
|
+
});
|
|
132
|
+
mesh.count = count;
|
|
133
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
134
|
+
play.needsUpdate = true;
|
|
135
|
+
if (count > 0) {
|
|
136
|
+
// One batch spans every drawn member, so its cull sphere has to follow them; three cannot cull
|
|
137
|
+
// an InstancedMesh per instance.
|
|
138
|
+
const sphere = mesh.boundingSphere ?? (mesh.boundingSphere = new THREE.Sphere());
|
|
139
|
+
sphere.center.set((minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2);
|
|
140
|
+
sphere.radius = Math.hypot(maxX - minX, maxY - minY, maxZ - minZ) / 2 + placement.scale * 1.2;
|
|
141
|
+
}
|
|
142
|
+
noteCrowdInstances(kind, count);
|
|
143
|
+
});
|
|
144
|
+
useEffect(() => () => clearCrowdKind(kind), [kind]);
|
|
145
|
+
if (mesh === null)
|
|
146
|
+
return null;
|
|
147
|
+
return _jsx("primitive", { object: mesh, castShadow: true, receiveShadow: true });
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Draws far characters through the GPU crowd path: one baked rig per character kind, one instanced
|
|
151
|
+
* draw per material, every member's clip, time offset and speed sampled in the vertex shader. The
|
|
152
|
+
* entity markers decide who is far enough (their `lod` tiers) and write poses into `registry`; this
|
|
153
|
+
* layer owns the bakes, the materials and the instance buffers. Mount it in the same canvas as the
|
|
154
|
+
* entity markers; unmount it to fall back to per-entity rigs.
|
|
155
|
+
* @capability character-crowd draw distant animated characters on the GPU instead of posing their skeletons
|
|
156
|
+
*/
|
|
157
|
+
export function CharacterCrowd({ models, registry, fps = 24 }) {
|
|
158
|
+
const kinds = useMemo(() => {
|
|
159
|
+
const unique = new Map();
|
|
160
|
+
for (const model of models) {
|
|
161
|
+
const kind = crowdKindOf(model);
|
|
162
|
+
if (!unique.has(kind))
|
|
163
|
+
unique.set(kind, model);
|
|
164
|
+
}
|
|
165
|
+
return [...unique.entries()];
|
|
166
|
+
}, [models]);
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
const dispose = devtools.probes.registerSystem("render/characters", characterLodProbe());
|
|
169
|
+
return () => dispose();
|
|
170
|
+
}, []);
|
|
171
|
+
useFrame(() => {
|
|
172
|
+
noteCharacterFrame();
|
|
173
|
+
});
|
|
174
|
+
useEffect(() => () => registry.clear(), [registry]);
|
|
175
|
+
return (_jsx(_Fragment, { children: kinds.map(([kind, model]) => (_jsx(ModelFallbackBoundary, { fallback: null, url: model.url, seam: "entity", children: _jsx(Suspense, { fallback: null, children: _jsx(CrowdBatch, { kind: kind, model: model, registry: registry, fps: fps }) }) }, kind))) }));
|
|
176
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useLayoutEffect, useRef } from "react";
|
|
3
|
-
import { useFrame } from "@react-three/fiber";
|
|
2
|
+
import { useEffect, useLayoutEffect, useRef } from "react";
|
|
3
|
+
import { useFrame, useThree } from "@react-three/fiber";
|
|
4
|
+
import { devtools } from "@jgengine/core/devtools/devtools";
|
|
4
5
|
import { CascadedShadows, DEFAULT_SHADOW_RADIUS } from "./CascadedShadows.js";
|
|
6
|
+
import { cullSmallShadowCasters, MIN_SHADOW_CASTER_RADIUS } from "./shadowCasters.js";
|
|
5
7
|
const DEFAULT_BACKDROP_FOG_COLOR = "#1a1c22";
|
|
6
8
|
/** Default cap for combined dynamic point and spot lights. */
|
|
7
9
|
export const POINT_LIGHT_BUDGET = 8;
|
|
@@ -30,7 +32,7 @@ function usesCascades(entry) {
|
|
|
30
32
|
function DirectionalShadowLight({ entry, profile, modulation }) {
|
|
31
33
|
const resolved = profile === undefined ? entry : { ...entry, shadowMapSize: entry.shadowMapSize ?? profile.shadowMapSize, cascades: entry.cascades ?? profile.cascades };
|
|
32
34
|
if (usesCascades(resolved)) {
|
|
33
|
-
return _jsx(CascadedShadows, { entry: resolved, modulation: modulation });
|
|
35
|
+
return _jsx(CascadedShadows, { entry: resolved, profile: profile, modulation: modulation });
|
|
34
36
|
}
|
|
35
37
|
return _jsx(SingleShadowLight, { entry: resolved, modulation: modulation });
|
|
36
38
|
}
|
|
@@ -71,6 +73,28 @@ function SingleShadowLight({ entry, modulation }) {
|
|
|
71
73
|
}
|
|
72
74
|
export function ConfiguredLighting({ lighting, profile, modulation, }) {
|
|
73
75
|
const dynamic = resolveDynamicLights(lighting);
|
|
76
|
+
const scene = useThree((s) => s.scene);
|
|
77
|
+
const shadowsCast = (lighting.directional ?? []).some((entry) => entry.castShadow === true);
|
|
78
|
+
const cullRef = useRef(null);
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (!shadowsCast)
|
|
81
|
+
return;
|
|
82
|
+
const cull = cullSmallShadowCasters(scene);
|
|
83
|
+
cullRef.current = cull;
|
|
84
|
+
return () => {
|
|
85
|
+
cull.restore();
|
|
86
|
+
cullRef.current = null;
|
|
87
|
+
};
|
|
88
|
+
}, [scene, shadowsCast]);
|
|
89
|
+
useEffect(() => devtools.probes.registerSystem("render/shadows", {
|
|
90
|
+
counters: () => cullRef.current?.stats() ?? { casters: 0, skipped: 0 },
|
|
91
|
+
state: () => {
|
|
92
|
+
const stats = cullRef.current?.stats();
|
|
93
|
+
return stats === undefined
|
|
94
|
+
? "no shadow-casting directional light"
|
|
95
|
+
: `${stats.casters} casters · ${stats.skipped} under ${MIN_SHADOW_CASTER_RADIUS} m skipped`;
|
|
96
|
+
},
|
|
97
|
+
}), []);
|
|
74
98
|
const ambientRef = useRef(null);
|
|
75
99
|
const hemisphereRef = useRef(null);
|
|
76
100
|
const baseAmbient = lighting.ambient?.intensity ?? 0.55;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type ShaderWarmupState } from "./shaderWarmup.js";
|
|
2
|
+
/** Warmup state before the gate has run, so the loading screen covers the very first frame. @internal */
|
|
3
|
+
export declare const SCENE_WARMUP_PENDING: ShaderWarmupState;
|
|
4
|
+
/**
|
|
5
|
+
* Compiles the mounted scene's shaders behind the loading screen and reports progress to it.
|
|
6
|
+
* Mounted inside the canvas, after the world, so the first round sees the world's materials.
|
|
7
|
+
* @internal
|
|
8
|
+
*/
|
|
9
|
+
export declare function SceneWarmupGate({ onState }: {
|
|
10
|
+
onState: (state: ShaderWarmupState) => void;
|
|
11
|
+
}): null;
|
|
12
|
+
/**
|
|
13
|
+
* Opaque cover over the canvas while the scene warms: the first frames of a scene compile its
|
|
14
|
+
* materials, and a player should not see those frames. Game UI and menus mount after this and
|
|
15
|
+
* paint over it, so a title screen stays usable.
|
|
16
|
+
* @internal
|
|
17
|
+
*/
|
|
18
|
+
export declare function SceneWarmupCover({ state }: {
|
|
19
|
+
state: ShaderWarmupState;
|
|
20
|
+
}): import("react").JSX.Element | null;
|