@jgengine/shell 0.18.0 → 0.18.1
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 +114 -2
- package/dist/GamePlayerShell.js +10 -4
- package/dist/JoinGate.d.ts +17 -0
- package/dist/JoinGate.js +14 -0
- package/dist/Shell3dPresentation.d.ts +8 -1
- package/dist/Shell3dPresentation.js +34 -5
- package/dist/ShellChrome.d.ts +2 -0
- package/dist/ShellChrome.js +6 -0
- package/dist/TerritoryOverlay.d.ts +21 -0
- package/dist/TerritoryOverlay.js +9 -0
- package/dist/audio/AudioComponents.js +8 -1
- package/dist/audio/audioEngine.d.ts +7 -1
- package/dist/audio/audioEngine.js +67 -5
- package/dist/camera/cameraRigs.js +7 -0
- package/dist/defineGame.js +21 -6
- package/dist/devtools/KeysPanel.js +3 -3
- package/dist/drivers/FrameDriver.d.ts +5 -1
- package/dist/drivers/FrameDriver.js +61 -24
- package/dist/drivers/HudOnlyDriver.js +9 -4
- package/dist/environment/EnvironmentScene.js +1 -1
- package/dist/input/gamepadMerge.d.ts +10 -0
- package/dist/input/gamepadMerge.js +12 -0
- package/dist/input/gamepadSource.d.ts +12 -0
- package/dist/input/gamepadSource.js +107 -0
- package/dist/materialOverride.d.ts +4 -1
- package/dist/materialOverride.js +10 -1
- package/dist/postfx/PostProcessing.d.ts +10 -7
- package/dist/postfx/PostProcessing.js +33 -15
- package/dist/postfx/gradeShader.js +13 -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/modelLoad.d.ts +10 -0
- package/dist/render/modelLoad.js +28 -0
- package/dist/render/modelRender.d.ts +3 -0
- package/dist/render/modelRender.js +6 -2
- package/dist/render/testFixtures/proceduralSpriteAtlas.d.ts +15 -0
- package/dist/render/testFixtures/proceduralSpriteAtlas.js +30 -0
- package/dist/render/testFixtures/proceduralTileLayer.d.ts +13 -0
- package/dist/render/testFixtures/proceduralTileLayer.js +30 -0
- package/dist/render/useFootIk.d.ts +9 -0
- package/dist/render/useFootIk.js +89 -0
- package/dist/render/useModelAnimation.d.ts +2 -0
- package/dist/render/useModelAnimation.js +163 -107
- package/dist/scene/CityRenderer.js +661 -236
- package/dist/scene/cityWindowMaterial.d.ts +8 -6
- package/dist/scene/cityWindowMaterial.js +80 -21
- package/dist/settings/appliedSettings.d.ts +3 -1
- package/dist/settings/appliedSettings.js +5 -2
- package/dist/terrain/terrainDetailMaterial.js +7 -3
- package/dist/useShellMultiplayerSync.d.ts +8 -1
- package/dist/useShellMultiplayerSync.js +25 -5
- 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 +1 -1
- package/skills/game-design/SKILL.md +11 -1
- package/skills/jgengine/SKILL.md +2 -0
- package/skills/jgengine/api.md +94 -19
- package/skills/jgengine/capabilities.md +28 -0
- package/skills/jgengine-ui/SKILL.md +8 -0
- package/skills/jgengine-ui/api.md +127 -16
- package/skills/jgengine-ui/capabilities.md +21 -0
- package/skills/jgengine-ui/reference.md +3 -0
- package/skills/jgengine-world/SKILL.md +6 -0
- package/skills/jgengine-world/api.md +312 -12
- package/skills/jgengine-world/capabilities.md +102 -0
- package/skills/level-design/SKILL.md +1 -1
- package/skills/jgengine-ui/references/visual-scorecard.md +0 -73
|
@@ -63,14 +63,11 @@ function syncSize(built, width, height, pixelRatio) {
|
|
|
63
63
|
* post chain: RenderPass → GTAO → UnrealBloom → SMAA → OutputPass → Grade. Rendered only
|
|
64
64
|
* when `PlayableGame.postProcessing` is set, so games without it draw unchanged.
|
|
65
65
|
*
|
|
66
|
-
* `
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* and Bokeh DOF renders scene depth again — both run on "high" only. Bloom,
|
|
70
|
-
* SMAA, tone mapping, and grade stay on every tier (SMAA is the cheap edge fix
|
|
71
|
-
* for alpha-tested foliage that MSAA samples alone leave crawling).
|
|
66
|
+
* `stages` (resolved from the player's graphics profile) controls expensive passes
|
|
67
|
+
* independently. Medium keeps AO and DOF with reduced AO sampling; low can disable
|
|
68
|
+
* them while retaining cheaper bloom, SMAA, tone mapping, and grade stages.
|
|
72
69
|
*/
|
|
73
|
-
export function PostProcessing({ config, quality = "high" }) {
|
|
70
|
+
export function PostProcessing({ config, quality = "high", stages }) {
|
|
74
71
|
const gl = useThree((s) => s.gl);
|
|
75
72
|
const scene = useThree((s) => s.scene);
|
|
76
73
|
const camera = useThree((s) => s.camera);
|
|
@@ -87,8 +84,8 @@ export function PostProcessing({ config, quality = "high" }) {
|
|
|
87
84
|
});
|
|
88
85
|
const composer = new EffectComposer(gl, target);
|
|
89
86
|
composer.addPass(new RenderPass(scene, camera));
|
|
90
|
-
const
|
|
91
|
-
if (
|
|
87
|
+
const resolvedStages = stages ?? { ao: quality === "high", bloom: true, dof: quality === "high", smaa: true };
|
|
88
|
+
if (resolvedStages.ao && config.ao !== undefined && config.ao !== false) {
|
|
92
89
|
const ao = new OverlayAwareGTAOPass(scene, camera, width, height);
|
|
93
90
|
ao.blendIntensity = config.ao.blend ?? 1;
|
|
94
91
|
ao.updateGtaoMaterial({
|
|
@@ -96,26 +93,26 @@ export function PostProcessing({ config, quality = "high" }) {
|
|
|
96
93
|
distanceExponent: 1,
|
|
97
94
|
thickness: config.ao.distanceFalloff ?? 3.6,
|
|
98
95
|
scale: config.ao.intensity ?? 2.4,
|
|
99
|
-
samples: 16,
|
|
96
|
+
samples: quality === "high" ? 16 : 8,
|
|
100
97
|
});
|
|
101
98
|
composer.addPass(ao);
|
|
102
99
|
}
|
|
103
|
-
if (config.bloom !== false) {
|
|
100
|
+
if (resolvedStages.bloom && config.bloom !== false) {
|
|
104
101
|
const b = config.bloom ?? {};
|
|
105
102
|
composer.addPass(new UnrealBloomPass(new THREE.Vector2(width, height), b.strength ?? 0.32, b.radius ?? 0.55, b.threshold ?? 0.85));
|
|
106
103
|
}
|
|
107
104
|
let dof = null;
|
|
108
|
-
if (
|
|
105
|
+
if (resolvedStages.dof && config.dof !== undefined && config.dof !== false) {
|
|
109
106
|
const d = config.dof;
|
|
110
107
|
dof = new OverlayAwareBokehPass(scene, camera, {
|
|
111
108
|
focus: d.focus ?? 18,
|
|
112
|
-
aperture: d.aperture ?? 0.00025,
|
|
109
|
+
aperture: d.aperture ?? (quality === "high" ? 0.00025 : 0.00012),
|
|
113
110
|
maxblur: d.maxBlur ?? 0.01,
|
|
114
111
|
});
|
|
115
112
|
composer.addPass(dof);
|
|
116
113
|
}
|
|
117
114
|
// SMAA must run before OutputPass (linear-sRGB). Default on for post chains.
|
|
118
|
-
if (aa === "smaa") {
|
|
115
|
+
if (resolvedStages.smaa && aa === "smaa") {
|
|
119
116
|
composer.addPass(new SMAAPass());
|
|
120
117
|
}
|
|
121
118
|
composer.addPass(new OutputPass());
|
|
@@ -131,7 +128,28 @@ export function PostProcessing({ config, quality = "high" }) {
|
|
|
131
128
|
disposeGraph(graph);
|
|
132
129
|
builtRef.current = null;
|
|
133
130
|
};
|
|
134
|
-
}, [gl, scene, camera, config, quality]);
|
|
131
|
+
}, [gl, scene, camera, config, quality, stages]);
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
const lut = config.grade !== undefined && config.grade !== false ? config.grade.lut : undefined;
|
|
134
|
+
const grade = builtRef.current?.grade;
|
|
135
|
+
if (lut === undefined || grade === null || grade === undefined)
|
|
136
|
+
return;
|
|
137
|
+
let cancelled = false;
|
|
138
|
+
const loader = new THREE.TextureLoader();
|
|
139
|
+
loader.loadAsync(lut.url).then((texture) => {
|
|
140
|
+
if (cancelled) {
|
|
141
|
+
texture.dispose();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
texture.colorSpace = THREE.SRGBColorSpace;
|
|
145
|
+
texture.minFilter = THREE.NearestFilter;
|
|
146
|
+
texture.magFilter = THREE.NearestFilter;
|
|
147
|
+
texture.generateMipmaps = false;
|
|
148
|
+
grade.uniforms.uLut.value = texture;
|
|
149
|
+
grade.uniforms.uLutSize.value = lut.size ?? 32;
|
|
150
|
+
}).catch(() => undefined);
|
|
151
|
+
return () => { cancelled = true; };
|
|
152
|
+
}, [config.grade]);
|
|
135
153
|
useEffect(() => {
|
|
136
154
|
const prevTone = gl.toneMapping;
|
|
137
155
|
const prevExposure = gl.toneMappingExposure;
|
|
@@ -17,12 +17,22 @@ const fragmentShader = /* glsl */ `
|
|
|
17
17
|
uniform float uGrain;
|
|
18
18
|
uniform float uChroma;
|
|
19
19
|
uniform float uTime;
|
|
20
|
+
uniform sampler2D uLut;
|
|
21
|
+
uniform float uLutSize;
|
|
20
22
|
varying vec2 vUv;
|
|
21
23
|
|
|
22
24
|
float hash(vec2 p) {
|
|
23
25
|
return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453);
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
vec3 sampleLut(vec3 color) {
|
|
29
|
+
float size = max(uLutSize, 2.0);
|
|
30
|
+
vec3 c = clamp(color, 0.0, 1.0) * (size - 1.0);
|
|
31
|
+
float blue = floor(c.b);
|
|
32
|
+
vec2 uv = vec2((blue * size + c.r + 0.5) / (size * size), (c.g + 0.5) / size);
|
|
33
|
+
return texture2D(uLut, uv).rgb;
|
|
34
|
+
}
|
|
35
|
+
|
|
26
36
|
void main() {
|
|
27
37
|
vec2 dir = vUv - 0.5;
|
|
28
38
|
// Chromatic aberration: split RGB along the radial direction, growing toward the edges.
|
|
@@ -35,6 +45,7 @@ const fragmentShader = /* glsl */ `
|
|
|
35
45
|
c = pow(max(c, 0.0), vec3(uGamma));
|
|
36
46
|
float luma = dot(c, vec3(0.2126, 0.7152, 0.0722));
|
|
37
47
|
c = mix(vec3(luma), c, uSaturation);
|
|
48
|
+
if (uLutSize > 1.0) c = sampleLut(c);
|
|
38
49
|
vec2 d = vUv - 0.5;
|
|
39
50
|
float vig = 1.0 - uVignette * smoothstep(0.6, 0.95, dot(d, d) * 2.2);
|
|
40
51
|
c *= vig;
|
|
@@ -65,6 +76,8 @@ export function createGradePass(config = {}) {
|
|
|
65
76
|
uGrain: { value: config.grain ?? DEFAULT_GRAIN },
|
|
66
77
|
uChroma: { value: config.chromaticAberration ?? DEFAULT_CHROMA },
|
|
67
78
|
uTime: { value: 0 },
|
|
79
|
+
uLut: { value: null },
|
|
80
|
+
uLutSize: { value: 0 },
|
|
68
81
|
},
|
|
69
82
|
vertexShader,
|
|
70
83
|
fragmentShader,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
1
2
|
import type { DirectionalLightingConfig } from "@jgengine/core/game/playableGame";
|
|
2
3
|
/**
|
|
3
4
|
* Cascaded shadow maps for one directional light — outdoor scenes keep shadows past a
|
|
@@ -11,3 +12,13 @@ import type { DirectionalLightingConfig } from "@jgengine/core/game/playableGame
|
|
|
11
12
|
export declare function CascadedShadows({ entry }: {
|
|
12
13
|
entry: DirectionalLightingConfig;
|
|
13
14
|
}): null;
|
|
15
|
+
/** Structural slice of CSM so the patcher is testable without a renderer. */
|
|
16
|
+
export interface CsmMaterialSetup {
|
|
17
|
+
setupMaterial(material: THREE.Material): void;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Run CSM's `setupMaterial` over every unpatched standard material in the scene,
|
|
21
|
+
* chaining rather than clobbering a material's own `onBeforeCompile`. Called every
|
|
22
|
+
* frame (WeakSet-guarded) so streamed meshes never render lit by all cascades at once.
|
|
23
|
+
*/
|
|
24
|
+
export declare function patchSceneMaterials(scene: THREE.Scene, csm: CsmMaterialSetup, patched: WeakSet<THREE.Material>): void;
|
|
@@ -19,7 +19,6 @@ export function CascadedShadows({ entry }) {
|
|
|
19
19
|
const scene = useThree((s) => s.scene);
|
|
20
20
|
const csmRef = useRef(null);
|
|
21
21
|
const patchedRef = useRef(new WeakSet());
|
|
22
|
-
const frameRef = useRef(0);
|
|
23
22
|
useEffect(() => {
|
|
24
23
|
const cascades = Math.max(2, Math.min(4, Math.floor(entry.cascades ?? 3)));
|
|
25
24
|
const lightDirection = new THREE.Vector3(-entry.position[0], -entry.position[1], -entry.position[2]);
|
|
@@ -47,7 +46,6 @@ export function CascadedShadows({ entry }) {
|
|
|
47
46
|
patchedRef.current = new WeakSet();
|
|
48
47
|
patchSceneMaterials(scene, csm, patchedRef.current);
|
|
49
48
|
csmRef.current = csm;
|
|
50
|
-
frameRef.current = 0;
|
|
51
49
|
return () => {
|
|
52
50
|
csm.dispose();
|
|
53
51
|
csmRef.current = null;
|
|
@@ -71,16 +69,19 @@ export function CascadedShadows({ entry }) {
|
|
|
71
69
|
if (csm === null)
|
|
72
70
|
return;
|
|
73
71
|
csm.camera = camera;
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
patchSceneMaterials(scene, csm, patchedRef.current);
|
|
78
|
-
}
|
|
72
|
+
// Every frame: a mesh that renders before setupMaterial receives all cascade
|
|
73
|
+
// lights at once (a cascades× sun) — the WeakSet keeps the traverse cheap.
|
|
74
|
+
patchSceneMaterials(scene, csm, patchedRef.current);
|
|
79
75
|
csm.update();
|
|
80
76
|
});
|
|
81
77
|
return null;
|
|
82
78
|
}
|
|
83
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Run CSM's `setupMaterial` over every unpatched standard material in the scene,
|
|
81
|
+
* chaining rather than clobbering a material's own `onBeforeCompile`. Called every
|
|
82
|
+
* frame (WeakSet-guarded) so streamed meshes never render lit by all cascades at once.
|
|
83
|
+
*/
|
|
84
|
+
export function patchSceneMaterials(scene, csm, patched) {
|
|
84
85
|
scene.traverse((obj) => {
|
|
85
86
|
const mesh = obj;
|
|
86
87
|
if (!mesh.isMesh)
|
|
@@ -89,7 +90,17 @@ function patchSceneMaterials(scene, csm, patched) {
|
|
|
89
90
|
for (const mat of materials) {
|
|
90
91
|
if (!isStandardMaterial(mat) || patched.has(mat))
|
|
91
92
|
continue;
|
|
93
|
+
const prior = mat.onBeforeCompile;
|
|
92
94
|
csm.setupMaterial(mat);
|
|
95
|
+
const csmHook = mat.onBeforeCompile;
|
|
96
|
+
if (prior !== THREE.Material.prototype.onBeforeCompile && prior !== csmHook) {
|
|
97
|
+
// setupMaterial assigns onBeforeCompile, which would drop a material's own
|
|
98
|
+
// shader surgery (rim light, detail maps) — run both, the material's first.
|
|
99
|
+
mat.onBeforeCompile = function (shader, renderer) {
|
|
100
|
+
prior.call(this, shader, renderer);
|
|
101
|
+
csmHook.call(this, shader, renderer);
|
|
102
|
+
};
|
|
103
|
+
}
|
|
93
104
|
patched.add(mat);
|
|
94
105
|
}
|
|
95
106
|
});
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import type { EnvironmentSource } from "@jgengine/core/render/environment";
|
|
1
2
|
/** @internal Props for shell {@link EnvironmentLighting}. */
|
|
2
3
|
export interface EnvironmentLightingProps {
|
|
4
|
+
/** Optional authored environment map; absent or `gradient` uses the procedural probe. */
|
|
5
|
+
source?: EnvironmentSource;
|
|
3
6
|
/** IBL intensity. Default 0.35. */
|
|
4
7
|
intensity?: number;
|
|
5
8
|
/** Upper-hemisphere tint for the procedural sky probe. Default cool daylight. */
|
|
@@ -23,4 +26,4 @@ export interface EnvironmentLightingProps {
|
|
|
23
26
|
* Opt out of cinematic IBL with `look: "flat"` on the shell presentation.
|
|
24
27
|
* @internal shell-internal default lighting; games never import it.
|
|
25
28
|
*/
|
|
26
|
-
export declare function EnvironmentLighting({ intensity, skyColor, groundColor, sunDirection, sunColor, }: EnvironmentLightingProps): null;
|
|
29
|
+
export declare function EnvironmentLighting({ source, intensity, skyColor, groundColor, sunDirection, sunColor, }: EnvironmentLightingProps): null;
|
|
@@ -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);
|
|
186
222
|
const paintCanvasRef = useRef(null);
|
|
187
223
|
const paintDrawnCountRef = useRef(0);
|
|
188
224
|
const paintVersionRef = useRef(-1);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
1
2
|
import { GLTFLoader, type GLTF } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
2
3
|
import { type AssetLoadDiagnosis } from "@jgengine/core/scene/assetDiagnostics";
|
|
3
4
|
/**
|
|
@@ -72,6 +73,15 @@ export declare function clearModelLoadFallbacks(): void;
|
|
|
72
73
|
export declare class DiagnosticGLTFLoader extends GLTFLoader {
|
|
73
74
|
load(url: string, onLoad: (gltf: GLTF) => void, onProgress?: (event: ProgressEvent) => void, onError?: (event: unknown) => void): void;
|
|
74
75
|
}
|
|
76
|
+
/** CDN-backed decoder configuration for compressed model and texture assets. */
|
|
77
|
+
export interface ModelLoaderConfig {
|
|
78
|
+
dracoDecoderPath?: string;
|
|
79
|
+
ktx2TranscoderPath?: string;
|
|
80
|
+
}
|
|
81
|
+
/** Configures shared Draco and KTX2 loaders. Repeated calls with the same paths are no-ops. */
|
|
82
|
+
export declare function configureModelLoaders(options?: ModelLoaderConfig): Readonly<Required<ModelLoaderConfig>>;
|
|
83
|
+
/** Detects GPU support for the configured KTX2 transcoder after a renderer exists. */
|
|
84
|
+
export declare function detectKtx2Support(renderer: THREE.WebGLRenderer): void;
|
|
75
85
|
/**
|
|
76
86
|
* How long the shared GLB loader has been idle, in ms — `0` while any model is still
|
|
77
87
|
* in flight. A capture host reads this to wait for streaming to finish instead of
|
package/dist/render/modelLoad.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import * as THREE from "three";
|
|
2
2
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
3
|
+
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";
|
|
4
|
+
import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js";
|
|
3
5
|
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
|
|
4
6
|
import { classifyAssetResponse, } from "@jgengine/core/scene/assetDiagnostics";
|
|
5
7
|
import { reportTextureLoadError } from "@jgengine/core/devtools/textureErrors";
|
|
@@ -157,6 +159,31 @@ export class DiagnosticGLTFLoader extends GLTFLoader {
|
|
|
157
159
|
});
|
|
158
160
|
}
|
|
159
161
|
}
|
|
162
|
+
const DEFAULT_DRACO_DECODER_PATH = "https://cdn.jsdelivr.net/npm/three@0.182.0/examples/jsm/libs/draco/";
|
|
163
|
+
const DEFAULT_KTX2_TRANSCODER_PATH = "https://cdn.jsdelivr.net/npm/three@0.182.0/examples/jsm/libs/basis/";
|
|
164
|
+
let configuredModelLoaders;
|
|
165
|
+
let ktx2Loader;
|
|
166
|
+
/** Configures shared Draco and KTX2 loaders. Repeated calls with the same paths are no-ops. */
|
|
167
|
+
export function configureModelLoaders(options = {}) {
|
|
168
|
+
const next = {
|
|
169
|
+
dracoDecoderPath: options.dracoDecoderPath ?? DEFAULT_DRACO_DECODER_PATH,
|
|
170
|
+
ktx2TranscoderPath: options.ktx2TranscoderPath ?? DEFAULT_KTX2_TRANSCODER_PATH,
|
|
171
|
+
};
|
|
172
|
+
if (configuredModelLoaders?.dracoDecoderPath === next.dracoDecoderPath && configuredModelLoaders.ktx2TranscoderPath === next.ktx2TranscoderPath) {
|
|
173
|
+
return configuredModelLoaders;
|
|
174
|
+
}
|
|
175
|
+
const draco = new DRACOLoader().setDecoderPath(next.dracoDecoderPath);
|
|
176
|
+
ktx2Loader = new KTX2Loader().setTranscoderPath(next.ktx2TranscoderPath);
|
|
177
|
+
sharedGltfLoader.setDRACOLoader(draco);
|
|
178
|
+
sharedGltfLoader.setKTX2Loader(ktx2Loader);
|
|
179
|
+
configuredModelLoaders = next;
|
|
180
|
+
return configuredModelLoaders;
|
|
181
|
+
}
|
|
182
|
+
/** Detects GPU support for the configured KTX2 transcoder after a renderer exists. */
|
|
183
|
+
export function detectKtx2Support(renderer) {
|
|
184
|
+
configureModelLoaders();
|
|
185
|
+
ktx2Loader?.detectSupport(renderer);
|
|
186
|
+
}
|
|
160
187
|
// LoadingManager reports queue transitions, not per-item events: `onStart` fires when the
|
|
161
188
|
// queue goes busy and `onLoad` when it drains (errors included, via itemEnd), so a flag plus
|
|
162
189
|
// a drain timestamp is the whole state.
|
|
@@ -187,3 +214,4 @@ export function modelLoadIdleMs() {
|
|
|
187
214
|
/** Shared GLTF loader used by shell entity/object model mounts. @internal */
|
|
188
215
|
export const sharedGltfLoader = new DiagnosticGLTFLoader(modelLoadingManager);
|
|
189
216
|
sharedGltfLoader.setMeshoptDecoder(MeshoptDecoder);
|
|
217
|
+
configureModelLoaders();
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import * as THREE from "three";
|
|
2
2
|
import type { PaintStroke } from "@jgengine/core/scene/paintLayer";
|
|
3
3
|
export declare const PAINT_TEXTURE_SIZE = 512;
|
|
4
|
+
/** Shadow participation applied to every mesh of a cloned model; mirrors `ModelConfig.shadows`. */
|
|
5
|
+
export type ModelShadowMode = "cast" | "receive" | "both" | "none";
|
|
4
6
|
/** @internal */
|
|
5
7
|
export declare function cloneModelScene(source: THREE.Object3D, options?: {
|
|
6
8
|
cloneMaterials?: boolean;
|
|
9
|
+
shadows?: ModelShadowMode;
|
|
7
10
|
}): THREE.Object3D;
|
|
8
11
|
/** @internal */
|
|
9
12
|
export declare function disposeClonedMaterials(root: THREE.Object3D): void;
|
|
@@ -4,12 +4,16 @@ export const PAINT_TEXTURE_SIZE = 512;
|
|
|
4
4
|
/** @internal */
|
|
5
5
|
export function cloneModelScene(source, options) {
|
|
6
6
|
const clone = cloneSkinned(source);
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
const shadows = options?.shadows ?? "both";
|
|
8
|
+
const cloneMaterials = options?.cloneMaterials !== false;
|
|
9
9
|
clone.traverse((node) => {
|
|
10
10
|
const mesh = node;
|
|
11
11
|
if (!mesh.isMesh)
|
|
12
12
|
return;
|
|
13
|
+
mesh.castShadow = shadows === "cast" || shadows === "both";
|
|
14
|
+
mesh.receiveShadow = shadows === "receive" || shadows === "both";
|
|
15
|
+
if (!cloneMaterials)
|
|
16
|
+
return;
|
|
13
17
|
mesh.material = Array.isArray(mesh.material)
|
|
14
18
|
? mesh.material.map((material) => material.clone())
|
|
15
19
|
: mesh.material.clone();
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { SpriteAtlas } from "@jgengine/core/assets/spriteAtlas";
|
|
2
|
+
type DrawingContext = Pick<CanvasRenderingContext2D, "clearRect" | "fillRect" | "beginPath" | "arc" | "fill"> & {
|
|
3
|
+
fillStyle: string | CanvasGradient | CanvasPattern;
|
|
4
|
+
};
|
|
5
|
+
type CanvasFixture = {
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
getContext(contextId: "2d"): DrawingContext | null;
|
|
9
|
+
toDataURL(): string;
|
|
10
|
+
};
|
|
11
|
+
/** Draw the deterministic two-frame atlas used by shell sprite-clip captures and tests.
|
|
12
|
+
* @internal
|
|
13
|
+
*/
|
|
14
|
+
export declare function drawProceduralSpriteAtlas(canvas: CanvasFixture): SpriteAtlas;
|
|
15
|
+
export {};
|