@jgengine/shell 0.25.0 → 0.26.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 +65 -0
- package/dist/Shell3dPresentation.js +29 -11
- package/dist/devtools/FrameTelemetry.d.ts +12 -0
- package/dist/devtools/FrameTelemetry.js +68 -0
- package/dist/devtools/agentBridge.js +2 -0
- package/dist/devtools/devtoolsReports.d.ts +2 -0
- package/dist/devtools/devtoolsReports.js +3 -0
- package/dist/devtools/frameSampler.d.ts +127 -0
- package/dist/devtools/frameSampler.js +345 -0
- package/dist/environment/AuthoredDaylight.d.ts +28 -0
- package/dist/environment/AuthoredDaylight.js +42 -0
- package/dist/environment/Daylight.d.ts +55 -7
- package/dist/environment/Daylight.js +126 -29
- package/dist/environment/daylightCycle.d.ts +26 -4
- package/dist/environment/daylightCycle.js +36 -5
- package/dist/postfx/PostProcessing.d.ts +7 -1
- package/dist/postfx/PostProcessing.js +28 -2
- package/dist/postfx/autoExposure.d.ts +49 -0
- package/dist/postfx/autoExposure.js +189 -0
- package/dist/postfx/gradeShader.js +43 -4
- package/dist/presentationRecovery3d.js +2 -1
- package/dist/render/CascadedShadows.d.ts +3 -1
- package/dist/render/CascadedShadows.js +9 -1
- package/dist/render/EnvironmentLighting.d.ts +10 -1
- package/dist/render/EnvironmentLighting.js +11 -3
- package/dist/render/IrradianceVolumeLighting.d.ts +18 -0
- package/dist/render/IrradianceVolumeLighting.js +35 -0
- package/dist/render/SceneLighting.d.ts +14 -1
- package/dist/render/SceneLighting.js +29 -8
- package/dist/render/authoredGrade.d.ts +11 -0
- package/dist/render/authoredGrade.js +26 -0
- package/dist/render/presentationLook.d.ts +15 -0
- package/dist/render/presentationLook.js +18 -0
- package/dist/render/stableTextureSet.d.ts +9 -0
- package/dist/render/stableTextureSet.js +24 -0
- package/dist/render/triplanarMaterial.d.ts +52 -0
- package/dist/render/triplanarMaterial.js +169 -0
- package/dist/settings/appliedSettings.js +16 -13
- package/package.json +4 -4
- package/skills/jgengine/api.md +59 -6
- package/skills/jgengine/capabilities.md +4 -0
- package/skills/jgengine-ui/api.md +82 -22
- package/skills/jgengine-ui/capabilities.md +10 -0
- package/skills/jgengine-ui/reference-primitives.md +4 -2
- package/skills/jgengine-world/SKILL.md +3 -1
- package/skills/jgengine-world/api.md +107 -3
- package/skills/jgengine-world/capabilities.md +22 -2
- package/skills/jgengine-world/recipes/npc-senses.md +8 -2
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { FullScreenQuad, Pass } from "three-stdlib";
|
|
3
|
+
/**
|
|
4
|
+
* Grid the beauty buffer is averaged down to before readback, per axis. 16×16 cells × 4×4 taps
|
|
5
|
+
* covers 4096 samples of the frame — enough for a stable key luminance, small enough to read back
|
|
6
|
+
* (1 KB of encoded log-luminance) without a measurable stall.
|
|
7
|
+
*/
|
|
8
|
+
const METER_SIZE = 16;
|
|
9
|
+
const TAPS_PER_AXIS = 4;
|
|
10
|
+
/** Encoded range of the log-luminance bytes: 2^-16 .. 2^8. */
|
|
11
|
+
const LOG_MIN = -16;
|
|
12
|
+
const LOG_MAX = 8;
|
|
13
|
+
const LOG_RANGE = LOG_MAX - LOG_MIN;
|
|
14
|
+
const reduceShader = {
|
|
15
|
+
uniforms: { tDiffuse: { value: null } },
|
|
16
|
+
vertexShader: /* glsl */ `
|
|
17
|
+
varying vec2 vUv;
|
|
18
|
+
void main() {
|
|
19
|
+
vUv = uv;
|
|
20
|
+
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
21
|
+
}
|
|
22
|
+
`,
|
|
23
|
+
fragmentShader: /* glsl */ `
|
|
24
|
+
uniform sampler2D tDiffuse;
|
|
25
|
+
varying vec2 vUv;
|
|
26
|
+
|
|
27
|
+
void main() {
|
|
28
|
+
vec2 cell = vec2(${METER_SIZE.toFixed(1)});
|
|
29
|
+
vec2 texel = 1.0 / cell;
|
|
30
|
+
vec2 origin = floor(vUv * cell) * texel;
|
|
31
|
+
float logSum = 0.0;
|
|
32
|
+
for (int y = 0; y < ${TAPS_PER_AXIS}; y++) {
|
|
33
|
+
for (int x = 0; x < ${TAPS_PER_AXIS}; x++) {
|
|
34
|
+
vec2 jitter = (vec2(float(x), float(y)) + 0.5) / ${TAPS_PER_AXIS.toFixed(1)};
|
|
35
|
+
vec3 color = texture2D(tDiffuse, origin + jitter * texel).rgb;
|
|
36
|
+
float luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
|
|
37
|
+
// Log-encoded so one bright window cannot outweigh the room around it.
|
|
38
|
+
logSum += clamp(log2(max(luma, 1e-6)), ${LOG_MIN.toFixed(1)}, ${LOG_MAX.toFixed(1)});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
float encoded = (logSum / ${(TAPS_PER_AXIS * TAPS_PER_AXIS).toFixed(1)} - ${LOG_MIN.toFixed(1)}) / ${LOG_RANGE.toFixed(1)};
|
|
42
|
+
gl_FragColor = vec4(encoded, encoded, encoded, 1.0);
|
|
43
|
+
}
|
|
44
|
+
`,
|
|
45
|
+
};
|
|
46
|
+
const copyShader = {
|
|
47
|
+
uniforms: { tDiffuse: { value: null } },
|
|
48
|
+
vertexShader: reduceShader.vertexShader,
|
|
49
|
+
fragmentShader: /* glsl */ `
|
|
50
|
+
uniform sampler2D tDiffuse;
|
|
51
|
+
varying vec2 vUv;
|
|
52
|
+
void main() { gl_FragColor = texture2D(tDiffuse, vUv); }
|
|
53
|
+
`,
|
|
54
|
+
};
|
|
55
|
+
/** Linear luminance encoded in one byte by {@link ExposureMeterPass}. @internal */
|
|
56
|
+
export function decodeMeterByte(value, target, targetIndex) {
|
|
57
|
+
target[targetIndex] = Math.pow(2, (value / 255) * LOG_RANGE + LOG_MIN);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Meters the pre-tone-map beauty buffer for auto-exposure. Every `sampleInterval` frames it reduces
|
|
61
|
+
* the frame to a 16×16 grid of log-luminance (GPU) and reads that back (1 KB), then hands the decoded
|
|
62
|
+
* samples to `onSample` with the wall time since the previous sample. The pass itself is a pass-through,
|
|
63
|
+
* so it can sit anywhere before the tone-mapping output pass without changing the picture.
|
|
64
|
+
* @internal
|
|
65
|
+
*/
|
|
66
|
+
export class ExposureMeterPass extends Pass {
|
|
67
|
+
onSample;
|
|
68
|
+
sampleInterval;
|
|
69
|
+
meterTarget;
|
|
70
|
+
reduceUniforms = reduceShader.uniforms;
|
|
71
|
+
copyUniforms = copyShader.uniforms;
|
|
72
|
+
reduceQuad;
|
|
73
|
+
copyQuad;
|
|
74
|
+
pixels = new Uint8Array(METER_SIZE * METER_SIZE * 4);
|
|
75
|
+
samples = new Float32Array(METER_SIZE * METER_SIZE);
|
|
76
|
+
frames = 0;
|
|
77
|
+
taken = 0;
|
|
78
|
+
elapsed = 0;
|
|
79
|
+
pendingSeconds = 0;
|
|
80
|
+
gl = null;
|
|
81
|
+
pbo = null;
|
|
82
|
+
fence = null;
|
|
83
|
+
constructor(onSample,
|
|
84
|
+
/** Frames between readbacks once warmed up. Reading the GPU back stalls the pipeline, so this is
|
|
85
|
+
* as rare as exposure adaptation tolerates. */
|
|
86
|
+
sampleInterval = 32) {
|
|
87
|
+
super();
|
|
88
|
+
this.onSample = onSample;
|
|
89
|
+
this.sampleInterval = sampleInterval;
|
|
90
|
+
this.meterTarget = new THREE.WebGLRenderTarget(METER_SIZE, METER_SIZE, {
|
|
91
|
+
type: THREE.UnsignedByteType,
|
|
92
|
+
depthBuffer: false,
|
|
93
|
+
stencilBuffer: false,
|
|
94
|
+
});
|
|
95
|
+
this.meterTarget.texture.minFilter = THREE.NearestFilter;
|
|
96
|
+
this.meterTarget.texture.magFilter = THREE.NearestFilter;
|
|
97
|
+
this.meterTarget.texture.generateMipmaps = false;
|
|
98
|
+
this.reduceQuad = new FullScreenQuad(new THREE.ShaderMaterial(reduceShader));
|
|
99
|
+
this.copyQuad = new FullScreenQuad(new THREE.ShaderMaterial(copyShader));
|
|
100
|
+
}
|
|
101
|
+
render(renderer, writeBuffer, readBuffer, deltaTime) {
|
|
102
|
+
const previous = renderer.getRenderTarget();
|
|
103
|
+
this.reduceUniforms.tDiffuse.value = readBuffer.texture;
|
|
104
|
+
renderer.setRenderTarget(this.meterTarget);
|
|
105
|
+
this.reduceQuad.render(renderer);
|
|
106
|
+
this.frames += 1;
|
|
107
|
+
this.elapsed += deltaTime;
|
|
108
|
+
this.collectPendingSample();
|
|
109
|
+
// The first few frames sample every frame so a load starts at its metered exposure, not at the
|
|
110
|
+
// authored one; after that a readback is queued on a schedule.
|
|
111
|
+
if (this.fence === null && (this.taken < 4 || this.frames % this.sampleInterval === 0)) {
|
|
112
|
+
this.queueReadback(renderer);
|
|
113
|
+
}
|
|
114
|
+
this.copyUniforms.tDiffuse.value = readBuffer.texture;
|
|
115
|
+
if (this.renderToScreen) {
|
|
116
|
+
renderer.setRenderTarget(null);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
renderer.setRenderTarget(writeBuffer);
|
|
120
|
+
if (this.clear)
|
|
121
|
+
renderer.clear();
|
|
122
|
+
}
|
|
123
|
+
this.copyQuad.render(renderer);
|
|
124
|
+
renderer.setRenderTarget(previous);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Queues a readback through a pixel-pack buffer and a fence, so the CPU never waits on the GPU: the
|
|
128
|
+
* samples arrive a frame or two later through {@link collectPendingSample}. A renderer without WebGL2
|
|
129
|
+
* buffers falls back to the synchronous read, which costs one stall per sample.
|
|
130
|
+
*/
|
|
131
|
+
queueReadback(renderer) {
|
|
132
|
+
this.pendingSeconds = this.elapsed;
|
|
133
|
+
this.elapsed = 0;
|
|
134
|
+
this.gl ??= renderer.getContext?.() ?? null;
|
|
135
|
+
const gl = this.gl;
|
|
136
|
+
if (gl === null || typeof gl.fenceSync !== "function") {
|
|
137
|
+
renderer.readRenderTargetPixels(this.meterTarget, 0, 0, METER_SIZE, METER_SIZE, this.pixels);
|
|
138
|
+
this.emit();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
this.pbo ??= gl.createBuffer();
|
|
142
|
+
renderer.setRenderTarget(this.meterTarget);
|
|
143
|
+
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, this.pbo);
|
|
144
|
+
gl.readPixels(0, 0, METER_SIZE, METER_SIZE, gl.RGBA, gl.UNSIGNED_BYTE, 0);
|
|
145
|
+
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
|
|
146
|
+
this.fence = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0);
|
|
147
|
+
}
|
|
148
|
+
collectPendingSample() {
|
|
149
|
+
const gl = this.gl;
|
|
150
|
+
if (gl === null || this.fence === null || this.pbo === null)
|
|
151
|
+
return;
|
|
152
|
+
const status = gl.clientWaitSync(this.fence, 0, 0);
|
|
153
|
+
if (status === gl.TIMEOUT_EXPIRED)
|
|
154
|
+
return;
|
|
155
|
+
if (status === gl.WAIT_FAILED) {
|
|
156
|
+
gl.deleteSync(this.fence);
|
|
157
|
+
this.fence = null;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, this.pbo);
|
|
161
|
+
gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, this.pixels);
|
|
162
|
+
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
|
|
163
|
+
gl.deleteSync(this.fence);
|
|
164
|
+
this.fence = null;
|
|
165
|
+
this.emit();
|
|
166
|
+
}
|
|
167
|
+
emit() {
|
|
168
|
+
for (let index = 0; index < this.samples.length; index += 1) {
|
|
169
|
+
decodeMeterByte(this.pixels[index * 4] ?? 0, this.samples, index);
|
|
170
|
+
}
|
|
171
|
+
this.taken += 1;
|
|
172
|
+
this.onSample(this.samples, this.pendingSeconds);
|
|
173
|
+
}
|
|
174
|
+
dispose() {
|
|
175
|
+
if (this.gl !== null) {
|
|
176
|
+
if (this.fence !== null)
|
|
177
|
+
this.gl.deleteSync(this.fence);
|
|
178
|
+
if (this.pbo !== null)
|
|
179
|
+
this.gl.deleteBuffer(this.pbo);
|
|
180
|
+
this.fence = null;
|
|
181
|
+
this.pbo = null;
|
|
182
|
+
}
|
|
183
|
+
this.meterTarget.dispose();
|
|
184
|
+
this.reduceQuad.material.dispose();
|
|
185
|
+
this.reduceQuad.dispose();
|
|
186
|
+
this.copyQuad.material.dispose();
|
|
187
|
+
this.copyQuad.dispose();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -27,10 +27,15 @@ const fragmentShader = /* glsl */ `
|
|
|
27
27
|
|
|
28
28
|
vec3 sampleLut(vec3 color) {
|
|
29
29
|
float size = max(uLutSize, 2.0);
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
float levels = size - 1.0;
|
|
31
|
+
vec3 c = clamp(color, 0.0, 1.0) * levels;
|
|
32
|
+
// One tile per blue slice, sampled at texel centres so linear filtering never reads across
|
|
33
|
+
// into the neighbouring slice, and the two nearest slices blend the blue axis by hand.
|
|
34
|
+
float slice = floor(c.b);
|
|
35
|
+
float next = min(slice + 1.0, levels);
|
|
36
|
+
vec2 uvA = vec2((slice * size + 0.5 + c.r) / (size * size), (0.5 + c.g) / size);
|
|
37
|
+
vec2 uvB = vec2((next * size + 0.5 + c.r) / (size * size), (0.5 + c.g) / size);
|
|
38
|
+
return mix(texture2D(uLut, uvA).rgb, texture2D(uLut, uvB).rgb, c.b - slice);
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
void main() {
|
|
@@ -61,6 +66,34 @@ const vertexShader = /* glsl */ `
|
|
|
61
66
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
62
67
|
}
|
|
63
68
|
`;
|
|
69
|
+
/** Packs a baked cube into the strip layout the sampler expects: `size` tiles side by side per blue slice. */
|
|
70
|
+
function lutStripTexture(cube) {
|
|
71
|
+
const size = cube.size;
|
|
72
|
+
const data = new Uint8Array(size * size * size * 4);
|
|
73
|
+
for (let slice = 0; slice < size; slice += 1) {
|
|
74
|
+
for (let green = 0; green < size; green += 1) {
|
|
75
|
+
for (let red = 0; red < size; red += 1) {
|
|
76
|
+
const from = (slice * size * size + green * size + red) * 3;
|
|
77
|
+
const to = (green * size * size + slice * size + red) * 4;
|
|
78
|
+
data[to] = cube.data[from] ?? 0;
|
|
79
|
+
data[to + 1] = cube.data[from + 1] ?? 0;
|
|
80
|
+
data[to + 2] = cube.data[from + 2] ?? 0;
|
|
81
|
+
data[to + 3] = 255;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const texture = new THREE.DataTexture(data, size * size, size, THREE.RGBAFormat, THREE.UnsignedByteType);
|
|
86
|
+
// Display-space values sampled raw in the shader, so no colour-space decode on the way in.
|
|
87
|
+
texture.colorSpace = THREE.NoColorSpace;
|
|
88
|
+
texture.minFilter = THREE.LinearFilter;
|
|
89
|
+
texture.magFilter = THREE.LinearFilter;
|
|
90
|
+
texture.wrapS = THREE.ClampToEdgeWrapping;
|
|
91
|
+
texture.wrapT = THREE.ClampToEdgeWrapping;
|
|
92
|
+
texture.generateMipmaps = false;
|
|
93
|
+
texture.flipY = false;
|
|
94
|
+
texture.needsUpdate = true;
|
|
95
|
+
return texture;
|
|
96
|
+
}
|
|
64
97
|
/** Build the display-space colour-grade pass and load its optional LUT. Dispose the pass to release its LUT; advance `uniforms.uTime.value` each frame to animate grain. */
|
|
65
98
|
export function createGradePass(config = {}) {
|
|
66
99
|
const lift = config.lift ?? DEFAULT_LIFT;
|
|
@@ -95,6 +128,12 @@ export function createGradePass(config = {}) {
|
|
|
95
128
|
};
|
|
96
129
|
if (config.lut !== undefined) {
|
|
97
130
|
const lut = config.lut;
|
|
131
|
+
if ("cube" in lut) {
|
|
132
|
+
lutTexture = lutStripTexture(lut.cube);
|
|
133
|
+
pass.uniforms.uLut.value = lutTexture;
|
|
134
|
+
pass.uniforms.uLutSize.value = lut.cube.size;
|
|
135
|
+
return pass;
|
|
136
|
+
}
|
|
98
137
|
void new THREE.TextureLoader().loadAsync(lut.url).then((texture) => {
|
|
99
138
|
if (disposed) {
|
|
100
139
|
texture.dispose();
|
|
@@ -2,6 +2,7 @@ import { addAfterEffect, useThree } from "@react-three/fiber";
|
|
|
2
2
|
import { useTexture } from "@react-three/drei";
|
|
3
3
|
import { useLayoutEffect } from "react";
|
|
4
4
|
import { usePresentationRetryScope } from "./presentationRecovery.js";
|
|
5
|
+
import { useStableTextureSet } from "./render/stableTextureSet.js";
|
|
5
6
|
/** Registers exact loader inputs and their cache clear before a load can suspend. @internal */
|
|
6
7
|
export function useRecoverableTexture(input) {
|
|
7
8
|
const scope = usePresentationRetryScope();
|
|
@@ -9,7 +10,7 @@ export function useRecoverableTexture(input) {
|
|
|
9
10
|
const loaded = scope?.registry.register(inputs, useTexture.clear);
|
|
10
11
|
const textures = useTexture(input);
|
|
11
12
|
loaded?.();
|
|
12
|
-
return textures;
|
|
13
|
+
return useStableTextureSet(textures);
|
|
13
14
|
}
|
|
14
15
|
/** Observes the owning renderer after the restored material subtree commits and draws. @internal */
|
|
15
16
|
export function PresentationRecoveredDraw() {
|
|
@@ -1,6 +1,7 @@
|
|
|
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 { DaylightModulation } from "./SceneLighting.js";
|
|
4
5
|
/** @internal Default PCF penumbra in shadow-map texels for configured directional lights. */
|
|
5
6
|
export declare const DEFAULT_SHADOW_RADIUS = 1.25;
|
|
6
7
|
/**
|
|
@@ -12,8 +13,9 @@ export declare const DEFAULT_SHADOW_RADIUS = 1.25;
|
|
|
12
13
|
*
|
|
13
14
|
* @internal shell SceneLighting helper — games set `cascades` on directional lighting config.
|
|
14
15
|
*/
|
|
15
|
-
export declare function CascadedShadows({ entry }: {
|
|
16
|
+
export declare function CascadedShadows({ entry, modulation }: {
|
|
16
17
|
entry: DirectionalLightingConfig;
|
|
18
|
+
modulation?: DaylightModulation;
|
|
17
19
|
}): null;
|
|
18
20
|
/** Structural slice of CSM so the patcher is testable without a renderer. */
|
|
19
21
|
export interface CsmMaterialSetup {
|
|
@@ -16,10 +16,11 @@ function isStandardMaterial(mat) {
|
|
|
16
16
|
*
|
|
17
17
|
* @internal shell SceneLighting helper — games set `cascades` on directional lighting config.
|
|
18
18
|
*/
|
|
19
|
-
export function CascadedShadows({ entry }) {
|
|
19
|
+
export function CascadedShadows({ entry, modulation }) {
|
|
20
20
|
const camera = useThree((s) => s.camera);
|
|
21
21
|
const scene = useThree((s) => s.scene);
|
|
22
22
|
const csmRef = useRef(null);
|
|
23
|
+
const baseIntensity = entry.intensity ?? 1.3;
|
|
23
24
|
useEffect(() => {
|
|
24
25
|
const cascades = Math.max(2, Math.min(4, Math.floor(entry.cascades ?? 3)));
|
|
25
26
|
const lightDirection = new THREE.Vector3(-entry.position[0], -entry.position[1], -entry.position[2]);
|
|
@@ -71,6 +72,13 @@ export function CascadedShadows({ entry }) {
|
|
|
71
72
|
const csm = csmRef.current;
|
|
72
73
|
if (csm === null)
|
|
73
74
|
return;
|
|
75
|
+
if (modulation !== undefined) {
|
|
76
|
+
// Mutated in place: changing `entry.intensity` would rebuild every cascade's shadow map.
|
|
77
|
+
for (const light of csm.lights) {
|
|
78
|
+
light.intensity = baseIntensity * modulation.sunFactor;
|
|
79
|
+
light.color.set(modulation.sunColor);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
74
82
|
csm.camera = camera;
|
|
75
83
|
csm.update();
|
|
76
84
|
});
|
|
@@ -19,6 +19,15 @@ export interface EnvironmentLightingProps {
|
|
|
19
19
|
sunDirection?: readonly [number, number, number];
|
|
20
20
|
/** Sun disc tint. Default warm white. */
|
|
21
21
|
sunColor?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Day-cycle scaling for the probe's brightness. Rebuilding a PMREM every frame is not free, so a
|
|
24
|
+
* cycle dims the applied environment instead of re-baking it; without this the sky turns black at
|
|
25
|
+
* night while the IBL keeps lighting the world like noon.
|
|
26
|
+
*/
|
|
27
|
+
daylight?: {
|
|
28
|
+
sunFactor: number;
|
|
29
|
+
ambientFactor: number;
|
|
30
|
+
};
|
|
22
31
|
}
|
|
23
32
|
/**
|
|
24
33
|
* Image-based lighting from a procedural sky/horizon/ground probe with an HDR sun (not three's stock
|
|
@@ -29,7 +38,7 @@ export interface EnvironmentLightingProps {
|
|
|
29
38
|
* Opt out of cinematic IBL with `look: "flat"` on the shell presentation.
|
|
30
39
|
* @internal shell-internal default lighting; games never import it.
|
|
31
40
|
*/
|
|
32
|
-
export declare function EnvironmentLighting({ source, intensity, skyColor, groundColor, horizonTint, sunDirection, sunColor, }: EnvironmentLightingProps): null;
|
|
41
|
+
export declare function EnvironmentLighting({ source, intensity, skyColor, groundColor, horizonTint, sunDirection, sunColor, daylight, }: EnvironmentLightingProps): null;
|
|
33
42
|
/** @internal Default IBL strength for the procedural probe: enough sky fill and sun lobe to model forms. */
|
|
34
43
|
export declare const DEFAULT_IBL_INTENSITY = 0.45;
|
|
35
44
|
/** @internal Fallback probe sun when no directional light is authored: high and off to one side. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { reportTextureLoadError } from "@jgengine/core/devtools/textureErrors";
|
|
2
|
-
import { useThree } from "@react-three/fiber";
|
|
2
|
+
import { useThree, useFrame } from "@react-three/fiber";
|
|
3
3
|
import { useEffect } from "react";
|
|
4
4
|
import * as THREE from "three";
|
|
5
5
|
import { EXRLoader } from "three/examples/jsm/loaders/EXRLoader.js";
|
|
@@ -13,7 +13,7 @@ import { RGBELoader } from "three/examples/jsm/loaders/RGBELoader.js";
|
|
|
13
13
|
* Opt out of cinematic IBL with `look: "flat"` on the shell presentation.
|
|
14
14
|
* @internal shell-internal default lighting; games never import it.
|
|
15
15
|
*/
|
|
16
|
-
export function EnvironmentLighting({ source, intensity = DEFAULT_IBL_INTENSITY, skyColor = "#87b5e0", groundColor = "#3d4a38", horizonTint = "#ffffff", sunDirection, sunColor = "#fff2d6", }) {
|
|
16
|
+
export function EnvironmentLighting({ source, intensity = DEFAULT_IBL_INTENSITY, skyColor = "#87b5e0", groundColor = "#3d4a38", horizonTint = "#ffffff", sunDirection, sunColor = "#fff2d6", daylight, }) {
|
|
17
17
|
const gl = useThree((state) => state.gl);
|
|
18
18
|
const scene = useThree((state) => state.scene);
|
|
19
19
|
useEffect(() => {
|
|
@@ -32,8 +32,10 @@ export function EnvironmentLighting({ source, intensity = DEFAULT_IBL_INTENSITY,
|
|
|
32
32
|
if (cancelled)
|
|
33
33
|
return;
|
|
34
34
|
cancelled = true;
|
|
35
|
-
if (target !== undefined && scene.environment === target.texture)
|
|
35
|
+
if (target !== undefined && scene.environment === target.texture) {
|
|
36
36
|
scene.environment = previous;
|
|
37
|
+
scene.environmentIntensity = previousIntensity;
|
|
38
|
+
}
|
|
37
39
|
if (appliedRotation !== undefined && scene.environmentRotation === appliedRotation && appliedRotation.equals(appliedRotationValue))
|
|
38
40
|
scene.environmentRotation = previousRotation;
|
|
39
41
|
if (scene.environmentIntensity === appliedIntensity)
|
|
@@ -106,6 +108,12 @@ export function EnvironmentLighting({ source, intensity = DEFAULT_IBL_INTENSITY,
|
|
|
106
108
|
}
|
|
107
109
|
return release;
|
|
108
110
|
}, [gl, scene, source, intensity, skyColor, groundColor, horizonTint, sunColor, sunDirection?.[0], sunDirection?.[1], sunDirection?.[2]]);
|
|
111
|
+
useFrame(() => {
|
|
112
|
+
if (daylight === undefined || scene.environment === null)
|
|
113
|
+
return;
|
|
114
|
+
// Ambient fill and the probe's sun lobe fade together; the probe's colours stay authored.
|
|
115
|
+
scene.environmentIntensity = (source?.intensity ?? intensity) * (daylight.ambientFactor * 0.6 + daylight.sunFactor * 0.4);
|
|
116
|
+
});
|
|
109
117
|
return null;
|
|
110
118
|
}
|
|
111
119
|
/** @internal Default IBL strength for the procedural probe: enough sky fill and sun lobe to model forms. */
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type IrradianceVolume } from "@jgengine/core/render/lightProbes";
|
|
2
|
+
/** @internal Props for shell {@link IrradianceVolumeLighting}. */
|
|
3
|
+
export interface IrradianceVolumeLightingProps {
|
|
4
|
+
/** Baked volumes authored on the scene document; the one covering the camera wins. */
|
|
5
|
+
volumes: readonly IrradianceVolume[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Ambient light from an authored irradiance volume: each frame it samples the volume that covers the
|
|
9
|
+
* camera and writes the nine SH coefficients into three's scene light probe. This is what makes an
|
|
10
|
+
* interior read as its own space — a room gets the fill from its own lamps and bounce instead of the
|
|
11
|
+
* sky's hemisphere, and the ambient varies as you walk through it.
|
|
12
|
+
*
|
|
13
|
+
* One light probe serves the whole scene, so a world with several volumes is sampled by camera
|
|
14
|
+
* position; that is exact while the camera is inside a room and approximate at the doorways between
|
|
15
|
+
* them.
|
|
16
|
+
* @internal shell-internal lighting; games author the volume on the scene document.
|
|
17
|
+
*/
|
|
18
|
+
export declare function IrradianceVolumeLighting({ volumes }: IrradianceVolumeLightingProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useFrame } from "@react-three/fiber";
|
|
3
|
+
import { useMemo, useRef } from "react";
|
|
4
|
+
import * as THREE from "three";
|
|
5
|
+
import { PROBE_STRIDE, sampleIrradianceVolume, selectIrradianceVolume, SH_COEFFICIENTS, } from "@jgengine/core/render/lightProbes";
|
|
6
|
+
/**
|
|
7
|
+
* Ambient light from an authored irradiance volume: each frame it samples the volume that covers the
|
|
8
|
+
* camera and writes the nine SH coefficients into three's scene light probe. This is what makes an
|
|
9
|
+
* interior read as its own space — a room gets the fill from its own lamps and bounce instead of the
|
|
10
|
+
* sky's hemisphere, and the ambient varies as you walk through it.
|
|
11
|
+
*
|
|
12
|
+
* One light probe serves the whole scene, so a world with several volumes is sampled by camera
|
|
13
|
+
* position; that is exact while the camera is inside a room and approximate at the doorways between
|
|
14
|
+
* them.
|
|
15
|
+
* @internal shell-internal lighting; games author the volume on the scene document.
|
|
16
|
+
*/
|
|
17
|
+
export function IrradianceVolumeLighting({ volumes }) {
|
|
18
|
+
const probeRef = useRef(null);
|
|
19
|
+
const sampled = useMemo(() => new Float32Array(PROBE_STRIDE), []);
|
|
20
|
+
useFrame((state) => {
|
|
21
|
+
const probe = probeRef.current;
|
|
22
|
+
if (probe === null)
|
|
23
|
+
return;
|
|
24
|
+
const position = state.camera.position;
|
|
25
|
+
const volume = selectIrradianceVolume(volumes, [position.x, position.y, position.z]);
|
|
26
|
+
if (volume === undefined)
|
|
27
|
+
return;
|
|
28
|
+
if (!sampleIrradianceVolume(volume, [position.x, position.y, position.z], sampled))
|
|
29
|
+
return;
|
|
30
|
+
for (let band = 0; band < SH_COEFFICIENTS; band += 1) {
|
|
31
|
+
probe.sh.coefficients[band].set(sampled[band * 3], sampled[band * 3 + 1], sampled[band * 3 + 2]);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
return _jsx("lightProbe", { ref: probeRef });
|
|
35
|
+
}
|
|
@@ -3,6 +3,17 @@ import type { BackdropConfig, LightingConfig, PointLightingConfig } from "@jgeng
|
|
|
3
3
|
import type { GraphicsProfile } from "@jgengine/core/settings/graphicsProfile";
|
|
4
4
|
/** Default cap for combined dynamic point and spot lights. */
|
|
5
5
|
export declare const POINT_LIGHT_BUDGET = 8;
|
|
6
|
+
/**
|
|
7
|
+
* Per-frame scaling a day cycle applies to a game's authored sun and ambient fill, so an authored
|
|
8
|
+
* sun sets and a moon rises instead of the world staying lit like noon after dark. `sunFactor` and
|
|
9
|
+
* `ambientFactor` are 1 at the authored noon peak; colors come from the atmosphere.
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
export interface DaylightModulation {
|
|
13
|
+
sunFactor: number;
|
|
14
|
+
sunColor: string;
|
|
15
|
+
ambientFactor: number;
|
|
16
|
+
}
|
|
6
17
|
/** @internal Clamp point lights for the runtime budget. */
|
|
7
18
|
export declare function resolvePointLightBudget(entries: readonly PointLightingConfig[] | undefined): readonly PointLightingConfig[];
|
|
8
19
|
/**
|
|
@@ -12,9 +23,11 @@ export declare function resolvePointLightBudget(entries: readonly PointLightingC
|
|
|
12
23
|
* and `shadowCameraSize` is silently inert.
|
|
13
24
|
*/
|
|
14
25
|
export declare function syncShadowProjection(light: DirectionalLight): void;
|
|
15
|
-
export declare function ConfiguredLighting({ lighting, profile }: {
|
|
26
|
+
export declare function ConfiguredLighting({ lighting, profile, modulation, }: {
|
|
16
27
|
lighting: LightingConfig;
|
|
17
28
|
profile?: GraphicsProfile;
|
|
29
|
+
/** Day-cycle scaling for the first directional (the sun) and the ambient fill. */
|
|
30
|
+
modulation?: DaylightModulation;
|
|
18
31
|
}): import("react").JSX.Element;
|
|
19
32
|
export declare function BackdropFog({ fog }: {
|
|
20
33
|
fog: BackdropConfig["fog"];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useLayoutEffect, useRef } from "react";
|
|
3
|
+
import { useFrame } from "@react-three/fiber";
|
|
3
4
|
import { CascadedShadows, DEFAULT_SHADOW_RADIUS } from "./CascadedShadows.js";
|
|
4
5
|
const DEFAULT_BACKDROP_FOG_COLOR = "#1a1c22";
|
|
5
6
|
/** Default cap for combined dynamic point and spot lights. */
|
|
@@ -26,12 +27,12 @@ function resolveDynamicLights(lighting) {
|
|
|
26
27
|
function usesCascades(entry) {
|
|
27
28
|
return (entry.castShadow ?? false) && (entry.cascades ?? 1) > 1;
|
|
28
29
|
}
|
|
29
|
-
function DirectionalShadowLight({ entry, profile }) {
|
|
30
|
+
function DirectionalShadowLight({ entry, profile, modulation }) {
|
|
30
31
|
const resolved = profile === undefined ? entry : { ...entry, shadowMapSize: entry.shadowMapSize ?? profile.shadowMapSize, cascades: entry.cascades ?? profile.cascades };
|
|
31
32
|
if (usesCascades(resolved)) {
|
|
32
|
-
return _jsx(CascadedShadows, { entry: resolved });
|
|
33
|
+
return _jsx(CascadedShadows, { entry: resolved, modulation: modulation });
|
|
33
34
|
}
|
|
34
|
-
return _jsx(SingleShadowLight, { entry: resolved });
|
|
35
|
+
return _jsx(SingleShadowLight, { entry: resolved, modulation: modulation });
|
|
35
36
|
}
|
|
36
37
|
/**
|
|
37
38
|
* Refresh a directional light's shadow projection after its frustum bounds change.
|
|
@@ -42,11 +43,19 @@ function DirectionalShadowLight({ entry, profile }) {
|
|
|
42
43
|
export function syncShadowProjection(light) {
|
|
43
44
|
light.shadow.camera.updateProjectionMatrix();
|
|
44
45
|
}
|
|
45
|
-
function SingleShadowLight({ entry }) {
|
|
46
|
+
function SingleShadowLight({ entry, modulation }) {
|
|
46
47
|
const lightRef = useRef(null);
|
|
47
48
|
const size = entry.shadowCameraSize ?? 40;
|
|
48
49
|
const far = Math.max(200, size * 6);
|
|
49
50
|
const mapSize = entry.shadowMapSize ?? 1024;
|
|
51
|
+
const baseIntensity = entry.intensity ?? 1.3;
|
|
52
|
+
useFrame(() => {
|
|
53
|
+
const light = lightRef.current;
|
|
54
|
+
if (light === null || modulation === undefined)
|
|
55
|
+
return;
|
|
56
|
+
light.intensity = baseIntensity * modulation.sunFactor;
|
|
57
|
+
light.color.set(modulation.sunColor);
|
|
58
|
+
});
|
|
50
59
|
useLayoutEffect(() => {
|
|
51
60
|
if (lightRef.current !== null)
|
|
52
61
|
syncShadowProjection(lightRef.current);
|
|
@@ -60,13 +69,25 @@ function SingleShadowLight({ entry }) {
|
|
|
60
69
|
}, [mapSize]);
|
|
61
70
|
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, "shadow-radius": entry.shadowRadius ?? DEFAULT_SHADOW_RADIUS }));
|
|
62
71
|
}
|
|
63
|
-
export function ConfiguredLighting({ lighting, profile }) {
|
|
72
|
+
export function ConfiguredLighting({ lighting, profile, modulation, }) {
|
|
64
73
|
const dynamic = resolveDynamicLights(lighting);
|
|
65
|
-
|
|
74
|
+
const ambientRef = useRef(null);
|
|
75
|
+
const hemisphereRef = useRef(null);
|
|
76
|
+
const baseAmbient = lighting.ambient?.intensity ?? 0.55;
|
|
77
|
+
const baseHemisphere = lighting.hemisphere?.intensity ?? 0.55;
|
|
78
|
+
useFrame(() => {
|
|
79
|
+
if (modulation === undefined)
|
|
80
|
+
return;
|
|
81
|
+
if (ambientRef.current !== null)
|
|
82
|
+
ambientRef.current.intensity = baseAmbient * modulation.ambientFactor;
|
|
83
|
+
if (hemisphereRef.current !== null)
|
|
84
|
+
hemisphereRef.current.intensity = baseHemisphere * modulation.ambientFactor;
|
|
85
|
+
});
|
|
86
|
+
return (_jsxs(_Fragment, { children: [lighting.ambient !== undefined ? (_jsx("ambientLight", { ref: ambientRef, color: lighting.ambient.color, intensity: baseAmbient })) : null, lighting.hemisphere !== undefined ? (_jsx("hemisphereLight", { ref: hemisphereRef, args: [
|
|
66
87
|
lighting.hemisphere.skyColor ?? "#bfe3ff",
|
|
67
88
|
lighting.hemisphere.groundColor ?? "#4c6b34",
|
|
68
|
-
|
|
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)))] }));
|
|
89
|
+
baseHemisphere,
|
|
90
|
+
] })) : null, (lighting.directional ?? []).map((entry, index) => (_jsx(DirectionalShadowLight, { entry: entry, profile: profile, ...(index === 0 && modulation !== undefined ? { modulation } : {}) }, 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)))] }));
|
|
70
91
|
}
|
|
71
92
|
export function BackdropFog({ fog }) {
|
|
72
93
|
if (fog === undefined)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { DocumentColorGrade } from "@jgengine/core/editor/environment";
|
|
2
|
+
import type { ResolvedGameLook } from "@jgengine/core/render/lookPreset";
|
|
3
|
+
/**
|
|
4
|
+
* Folds a scene document's authored grade into the resolved look. The document is the authoring
|
|
5
|
+
* source, so its baked LUT and exposure range win over the game's static post config, and everything
|
|
6
|
+
* the document left unauthored stays exactly as the game or look preset set it. With no authored
|
|
7
|
+
* grade the look comes back untouched (same identity), so an ungraded game mounts the pipeline it
|
|
8
|
+
* always mounted.
|
|
9
|
+
* @internal
|
|
10
|
+
*/
|
|
11
|
+
export declare function withAuthoredGrade(look: ResolvedGameLook, grade: DocumentColorGrade | undefined): ResolvedGameLook;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Folds a scene document's authored grade into the resolved look. The document is the authoring
|
|
3
|
+
* source, so its baked LUT and exposure range win over the game's static post config, and everything
|
|
4
|
+
* the document left unauthored stays exactly as the game or look preset set it. With no authored
|
|
5
|
+
* grade the look comes back untouched (same identity), so an ungraded game mounts the pipeline it
|
|
6
|
+
* always mounted.
|
|
7
|
+
* @internal
|
|
8
|
+
*/
|
|
9
|
+
export function withAuthoredGrade(look, grade) {
|
|
10
|
+
if (grade === undefined)
|
|
11
|
+
return look;
|
|
12
|
+
const post = look.postProcessing ?? { toneMapping: "agx", bloom: false };
|
|
13
|
+
const graded = grade.lut === undefined
|
|
14
|
+
? post
|
|
15
|
+
: { ...post, grade: { ...(typeof post.grade === "object" ? post.grade : {}), lut: { cube: grade.lut } } };
|
|
16
|
+
if (grade.autoExposure === undefined) {
|
|
17
|
+
return { ...look, postProcessing: graded };
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
...look,
|
|
21
|
+
postProcessing: {
|
|
22
|
+
...graded,
|
|
23
|
+
autoExposure: { ...(typeof graded.autoExposure === "object" ? graded.autoExposure : {}), ...grade.autoExposure },
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { DocumentColorGrade } from "@jgengine/core/editor/environment";
|
|
2
|
+
import { type GameLookInput, type ResolvedGameLook } from "@jgengine/core/render/lookPreset";
|
|
3
|
+
import { type SkyEnvironmentDescriptor } from "@jgengine/core/world/features";
|
|
4
|
+
/** The look the shell renders, resolved once per authored value. @internal */
|
|
5
|
+
export interface PresentationLook {
|
|
6
|
+
look: ResolvedGameLook;
|
|
7
|
+
backdropSky: SkyEnvironmentDescriptor | undefined;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Resolve a game's look preset and backdrop sky, keeping one identity until an authored value
|
|
11
|
+
* changes. The post chain, sky dome and lighting rebuild on identity, so a fresh object per
|
|
12
|
+
* render recompiled every pass each frame.
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
export declare function usePresentationLook(input: GameLookInput, authoredGrade: DocumentColorGrade | undefined): PresentationLook;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { resolveGameLook } from "@jgengine/core/render/lookPreset";
|
|
3
|
+
import { sky as resolveSkyDescriptor } from "@jgengine/core/world/features";
|
|
4
|
+
import { withAuthoredGrade } from "./authoredGrade.js";
|
|
5
|
+
/**
|
|
6
|
+
* Resolve a game's look preset and backdrop sky, keeping one identity until an authored value
|
|
7
|
+
* changes. The post chain, sky dome and lighting rebuild on identity, so a fresh object per
|
|
8
|
+
* render recompiled every pass each frame.
|
|
9
|
+
* @internal
|
|
10
|
+
*/
|
|
11
|
+
export function usePresentationLook(input, authoredGrade) {
|
|
12
|
+
const { look, lighting, backdrop, postProcessing, hasWorldSky } = input;
|
|
13
|
+
return useMemo(() => {
|
|
14
|
+
const resolved = withAuthoredGrade(resolveGameLook({ look, lighting, backdrop, postProcessing, hasWorldSky }), authoredGrade);
|
|
15
|
+
const sky = resolved.backdrop?.sky;
|
|
16
|
+
return { look: resolved, backdropSky: sky === undefined ? undefined : resolveSkyDescriptor(sky) };
|
|
17
|
+
}, [look, lighting, backdrop, postProcessing, hasWorldSky, authoredGrade]);
|
|
18
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Whether two texture sets hold the same roles and texture instances. @internal */
|
|
2
|
+
export declare function sameTextureSet(a: unknown, b: unknown): boolean;
|
|
3
|
+
/**
|
|
4
|
+
* Keep one texture-set identity while its roles and texture instances are unchanged. The drei
|
|
5
|
+
* loader hands back a fresh keyed object per render, and materials built from it rebuilt and
|
|
6
|
+
* recompiled their shaders every frame.
|
|
7
|
+
* @internal
|
|
8
|
+
*/
|
|
9
|
+
export declare function useStableTextureSet<T extends object>(textures: T): T;
|