@jgengine/shell 0.25.0 → 0.27.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 +109 -0
- package/dist/GamePlayerShell.d.ts +8 -0
- package/dist/GamePlayerShell.js +15 -1
- 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/EnvironmentScene.js +51 -8
- 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/decalGeometry.d.ts +26 -0
- package/dist/render/decalGeometry.js +54 -0
- package/dist/render/decalTexture.d.ts +13 -0
- package/dist/render/decalTexture.js +114 -0
- package/dist/render/modelWind.d.ts +28 -0
- package/dist/render/modelWind.js +103 -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/render/useModelInstance.d.ts +1 -1
- package/dist/render/useModelInstance.js +3 -1
- package/dist/scatter/scatterModels.js +2 -0
- package/dist/scene/DecalRenderer.d.ts +2 -0
- package/dist/scene/DecalRenderer.js +74 -0
- package/dist/scene/GrassFieldRenderer.js +3 -2
- package/dist/scene/builtinSceneKindRenderers.js +2 -0
- package/dist/settings/appliedSettings.js +16 -13
- package/dist/terrain/GrassCover.d.ts +21 -0
- package/dist/terrain/GrassCover.js +84 -0
- package/dist/terrain/grassCoverGeometry.d.ts +40 -0
- package/dist/terrain/grassCoverGeometry.js +112 -0
- package/dist/terrain/terrainDetailMaterial.d.ts +12 -0
- package/dist/terrain/terrainDetailMaterial.js +54 -1
- package/dist/terrain/terrainSurfaces.d.ts +6 -0
- package/dist/terrain/terrainSurfaces.js +10 -0
- package/dist/world/WorldScene.js +2 -1
- 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 +114 -23
- package/skills/jgengine-ui/capabilities.md +14 -0
- package/skills/jgengine-ui/reference-primitives.md +4 -2
- package/skills/jgengine-world/SKILL.md +7 -1
- package/skills/jgengine-world/api.md +222 -8
- package/skills/jgengine-world/capabilities.md +42 -2
- package/skills/jgengine-world/recipes/npc-senses.md +8 -2
- package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
|
@@ -7,6 +7,8 @@ import { GTAOPass } from "three/examples/jsm/postprocessing/GTAOPass.js";
|
|
|
7
7
|
import { BokehPass } from "three/examples/jsm/postprocessing/BokehPass.js";
|
|
8
8
|
import { SMAAPass } from "three/examples/jsm/postprocessing/SMAAPass.js";
|
|
9
9
|
import { DEFAULT_AO } from "@jgengine/core/render/postProcessing";
|
|
10
|
+
import { adaptExposure, DEFAULT_AUTO_EXPOSURE, exposureForKey, keyLuminanceFromSamples } from "@jgengine/core/render/colorPipeline";
|
|
11
|
+
import { ExposureMeterPass } from "./autoExposure.js";
|
|
10
12
|
import { createGradePass } from "./gradeShader.js";
|
|
11
13
|
import { StylizePass } from "./stylizePass.js";
|
|
12
14
|
import { renderShadowlessPrepass } from "./shadowPrepass.js";
|
|
@@ -38,6 +40,14 @@ const TONE_MAPPING = {
|
|
|
38
40
|
linear: THREE.LinearToneMapping,
|
|
39
41
|
none: THREE.NoToneMapping,
|
|
40
42
|
};
|
|
43
|
+
/**
|
|
44
|
+
* Rounds an adapted exposure to 1/32 of a stop. Without it the sky's display-colour cache (which keys
|
|
45
|
+
* off the renderer's exposure) re-solves its swatches every frame the meter nudges.
|
|
46
|
+
* @internal
|
|
47
|
+
*/
|
|
48
|
+
export function quantizeExposure(value) {
|
|
49
|
+
return Math.round(value * 32) / 32;
|
|
50
|
+
}
|
|
41
51
|
/** Release each pass and the composer's owned targets. @internal */
|
|
42
52
|
export function disposeGraph(built) {
|
|
43
53
|
for (const pass of built.composer.passes)
|
|
@@ -58,7 +68,7 @@ export function resolveAoStage(ao, tierAllowsAo) {
|
|
|
58
68
|
/**
|
|
59
69
|
* Mounts an `EffectComposer` inside the shell Canvas and takes over rendering
|
|
60
70
|
* (priority-1 `useFrame`, which disables R3F auto-render) to run the configured
|
|
61
|
-
* post chain: RenderPass → GTAO → UnrealBloom → SMAA → OutputPass → Stylize → Grade. Rendered only
|
|
71
|
+
* post chain: RenderPass → GTAO → UnrealBloom → SMAA → exposure meter → OutputPass → Stylize → Grade. Rendered only
|
|
62
72
|
* when `PlayableGame.postProcessing` is set, so games without it draw unchanged.
|
|
63
73
|
*
|
|
64
74
|
* `stages` (resolved from the player's graphics profile) controls expensive passes
|
|
@@ -113,6 +123,22 @@ export function PostProcessing({ config, quality = "high", stages }) {
|
|
|
113
123
|
if (resolvedStages.smaa && aa === "smaa") {
|
|
114
124
|
composer.addPass(new SMAAPass());
|
|
115
125
|
}
|
|
126
|
+
// Clamped auto-exposure meters the pre-tone-map buffer, so the meter sits before OutputPass and
|
|
127
|
+
// hands the adaptation to the renderer's exposure — the one value the sky swatches also read.
|
|
128
|
+
const autoExposure = config.autoExposure === false ? null : { ...DEFAULT_AUTO_EXPOSURE, ...(config.autoExposure ?? {}) };
|
|
129
|
+
const authoredExposure = config.exposure ?? 1;
|
|
130
|
+
if (autoExposure !== null) {
|
|
131
|
+
let current = authoredExposure;
|
|
132
|
+
let sampled = false;
|
|
133
|
+
composer.addPass(new ExposureMeterPass((samples, seconds) => {
|
|
134
|
+
const target = authoredExposure * exposureForKey(keyLuminanceFromSamples(samples), autoExposure);
|
|
135
|
+
// The first sample lands on its own target; fading in from the authored exposure would
|
|
136
|
+
// open every load on a visible exposure slideshow.
|
|
137
|
+
current = sampled ? adaptExposure(current, target, seconds, autoExposure.adaptationSeconds) : target;
|
|
138
|
+
sampled = true;
|
|
139
|
+
gl.toneMappingExposure = quantizeExposure(current);
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
116
142
|
composer.addPass(new OutputPass());
|
|
117
143
|
if (config.stylize !== undefined && config.stylize !== false) {
|
|
118
144
|
composer.addPass(new StylizePass(scene, camera, config.stylize));
|
|
@@ -133,7 +159,7 @@ export function PostProcessing({ config, quality = "high", stages }) {
|
|
|
133
159
|
useEffect(() => {
|
|
134
160
|
const prevTone = gl.toneMapping;
|
|
135
161
|
const prevExposure = gl.toneMappingExposure;
|
|
136
|
-
gl.toneMapping = TONE_MAPPING[config.toneMapping ?? "
|
|
162
|
+
gl.toneMapping = TONE_MAPPING[config.toneMapping ?? "agx"];
|
|
137
163
|
gl.toneMappingExposure = config.exposure ?? 1;
|
|
138
164
|
return () => {
|
|
139
165
|
gl.toneMapping = prevTone;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { Pass } from "three-stdlib";
|
|
3
|
+
/** Linear luminance encoded in one byte by {@link ExposureMeterPass}. @internal */
|
|
4
|
+
export declare function decodeMeterByte(value: number, target: Float32Array, targetIndex: number): void;
|
|
5
|
+
/**
|
|
6
|
+
* Meters the pre-tone-map beauty buffer for auto-exposure. Every `sampleInterval` frames it reduces
|
|
7
|
+
* the frame to a 16×16 grid of log-luminance (GPU) and reads that back (1 KB), then hands the decoded
|
|
8
|
+
* samples to `onSample` with the wall time since the previous sample. The pass itself is a pass-through,
|
|
9
|
+
* so it can sit anywhere before the tone-mapping output pass without changing the picture.
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
export declare class ExposureMeterPass extends Pass {
|
|
13
|
+
private readonly onSample;
|
|
14
|
+
/** Frames between readbacks once warmed up. Reading the GPU back stalls the pipeline, so this is
|
|
15
|
+
* as rare as exposure adaptation tolerates. */
|
|
16
|
+
private readonly sampleInterval;
|
|
17
|
+
readonly meterTarget: THREE.WebGLRenderTarget;
|
|
18
|
+
readonly reduceUniforms: {
|
|
19
|
+
tDiffuse: {
|
|
20
|
+
value: THREE.Texture | null;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
private readonly copyUniforms;
|
|
24
|
+
private readonly reduceQuad;
|
|
25
|
+
private readonly copyQuad;
|
|
26
|
+
private readonly pixels;
|
|
27
|
+
private readonly samples;
|
|
28
|
+
private frames;
|
|
29
|
+
private taken;
|
|
30
|
+
private elapsed;
|
|
31
|
+
private pendingSeconds;
|
|
32
|
+
private gl;
|
|
33
|
+
private pbo;
|
|
34
|
+
private fence;
|
|
35
|
+
constructor(onSample: (samples: Float32Array, seconds: number) => void,
|
|
36
|
+
/** Frames between readbacks once warmed up. Reading the GPU back stalls the pipeline, so this is
|
|
37
|
+
* as rare as exposure adaptation tolerates. */
|
|
38
|
+
sampleInterval?: number);
|
|
39
|
+
render(renderer: THREE.WebGLRenderer, writeBuffer: THREE.WebGLRenderTarget, readBuffer: THREE.WebGLRenderTarget, deltaTime: number): void;
|
|
40
|
+
/**
|
|
41
|
+
* Queues a readback through a pixel-pack buffer and a fence, so the CPU never waits on the GPU: the
|
|
42
|
+
* samples arrive a frame or two later through {@link collectPendingSample}. A renderer without WebGL2
|
|
43
|
+
* buffers falls back to the synchronous read, which costs one stall per sample.
|
|
44
|
+
*/
|
|
45
|
+
private queueReadback;
|
|
46
|
+
private collectPendingSample;
|
|
47
|
+
private emit;
|
|
48
|
+
dispose(): void;
|
|
49
|
+
}
|
|
@@ -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"];
|