@volter/threejs-runtime 0.5.65
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/LICENSE +202 -0
- package/NOTICE +12 -0
- package/package.json +62 -0
- package/src/adapter/body-marks.ts +69 -0
- package/src/adapter/constraint.ts +78 -0
- package/src/adapter/first-party-navigation-system.ts +48 -0
- package/src/adapter/hierarchy-marks.ts +156 -0
- package/src/adapter/ingest/scene-capture.ts +865 -0
- package/src/adapter/ingest/structural-ids.ts +139 -0
- package/src/adapter/ingest/visible-capture-window.ts +302 -0
- package/src/adapter/object3d-authoring-subject.ts +50 -0
- package/src/adapter/rapier-physics-adapter.ts +226 -0
- package/src/adapter/reflection-probe.ts +75 -0
- package/src/adapter/renderer-config.ts +116 -0
- package/src/adapter/trigger-volume.ts +29 -0
- package/src/ai/navigation.ts +627 -0
- package/src/animation/animation-clock.ts +479 -0
- package/src/animation/runtime-inspection.ts +45 -0
- package/src/animation/xstate-animation-binding.ts +655 -0
- package/src/animation/xstate-animation-meta.ts +360 -0
- package/src/asset-formats/camera.ts +18 -0
- package/src/asset-formats/collider.ts +66 -0
- package/src/asset-formats/index.ts +45 -0
- package/src/asset-formats/light.ts +50 -0
- package/src/asset-formats/material.ts +93 -0
- package/src/asset-formats/mesh.ts +78 -0
- package/src/asset-formats/particles.ts +426 -0
- package/src/asset-formats/render-env.ts +318 -0
- package/src/asset-formats/tuples.ts +21 -0
- package/src/asset-loaders.ts +242 -0
- package/src/asset-parse-error.ts +29 -0
- package/src/assets.ts +68 -0
- package/src/behavior/xstate-inspection.ts +51 -0
- package/src/defaults.ts +135 -0
- package/src/ecs/object-marks.ts +75 -0
- package/src/ecs/scene-index.ts +439 -0
- package/src/ecs/scene-query.ts +43 -0
- package/src/ecs/user-data.ts +272 -0
- package/src/loader.ts +134 -0
- package/src/physics/collider-dimensions.ts +121 -0
- package/src/physics/collision-system.ts +76 -0
- package/src/physics/physics-registry.ts +83 -0
- package/src/physics/transform-writer.ts +41 -0
- package/src/render/directional-shadow-fit.ts +158 -0
- package/src/render/environment-capture.ts +133 -0
- package/src/render/ibl-override-material.ts +202 -0
- package/src/render/light-camera-factory.ts +103 -0
- package/src/render/matcap-texture.ts +92 -0
- package/src/render/material-factory.ts +201 -0
- package/src/render/mesh-shadow.ts +18 -0
- package/src/render/particles-factory.ts +589 -0
- package/src/render/render-batch-system.ts +260 -0
- package/src/render/render-features.ts +128 -0
- package/src/render/render-settings.ts +71 -0
- package/src/render/soft-particle-depth.ts +360 -0
- package/src/render/spark-renderer-lifecycle.ts +64 -0
- package/src/render/structural-signature.ts +231 -0
- package/src/render/viewport-shading.ts +163 -0
- package/src/render/voxel-surface.ts +358 -0
- package/src/setup/setup-particles.ts +32 -0
- package/src/setup/setup-physics.ts +67 -0
- package/src/setup/setup-renderer.ts +524 -0
- package/src/types-n8ao.d.ts +37 -0
- package/src/types-realism-effects.d.ts +61 -0
- package/vendor/realism-effects/LICENSE.md +21 -0
- package/vendor/realism-effects/UPSTREAM.md +19 -0
- package/vendor/realism-effects/dist/index.cjs +3447 -0
- package/vendor/realism-effects/dist/index.d.ts +59 -0
- package/vendor/realism-effects/dist/index.js +3434 -0
- package/vendor/realism-effects/package.json +23 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opaque scene DEPTH for native consumers, including three.quarks soft particles and translated
|
|
3
|
+
* Godot `hint_depth_texture` shader uniforms.
|
|
4
|
+
*
|
|
5
|
+
* ## What three.quarks needs, and what it does when nobody supplies it
|
|
6
|
+
*
|
|
7
|
+
* A batch built with `softParticles` compiles its fragment stage with
|
|
8
|
+
* `SOFT_PARTICLES` defined, which brings in these two chunks verbatim
|
|
9
|
+
* (`three.quarks@0.16.0`, `src/shaders/chunks/soft_pars_fragment.glsl.ts` and
|
|
10
|
+
* `soft_fragment.glsl.ts`):
|
|
11
|
+
*
|
|
12
|
+
* ```glsl
|
|
13
|
+
* uniform sampler2D depthTexture;
|
|
14
|
+
* uniform vec4 projParams;
|
|
15
|
+
* uniform vec2 softParams;
|
|
16
|
+
*
|
|
17
|
+
* varying vec4 projPosition;
|
|
18
|
+
* varying float linearDepth;
|
|
19
|
+
*
|
|
20
|
+
* #define SOFT_NEAR_FADE softParams.x
|
|
21
|
+
* #define SOFT_INV_FADE_DISTANCE softParams.y
|
|
22
|
+
*
|
|
23
|
+
* #define zNear projParams.x
|
|
24
|
+
* #define zFar projParams.y
|
|
25
|
+
*
|
|
26
|
+
* float linearize_depth(float d)
|
|
27
|
+
* {
|
|
28
|
+
* return (zFar * zNear) / (zFar - d * (zFar - zNear));
|
|
29
|
+
* }
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* ```glsl
|
|
33
|
+
* vec2 p2 = projPosition.xy / projPosition.w;
|
|
34
|
+
*
|
|
35
|
+
* p2 = 0.5 * p2 + 0.5;
|
|
36
|
+
*
|
|
37
|
+
* float readDepth = texture2D(depthTexture, p2.xy).r;
|
|
38
|
+
* float viewDepth = linearize_depth(readDepth);
|
|
39
|
+
*
|
|
40
|
+
* float softParticlesFade = saturate(SOFT_INV_FADE_DISTANCE * ((viewDepth - SOFT_NEAR_FADE) - linearDepth));
|
|
41
|
+
*
|
|
42
|
+
* gl_FragColor *= softParticlesFade;
|
|
43
|
+
* ```
|
|
44
|
+
*
|
|
45
|
+
* `depthTexture` is a `new Uniform(null)` until something calls
|
|
46
|
+
* `BatchedRenderer.setDepthTexture` / `VFXBatch.applyDepthTexture`
|
|
47
|
+
* (`BatchedRenderer.ts:191-196`, `VFXBatch.ts:93-101`). With it null the sampler
|
|
48
|
+
* reads black, `readDepth` is `0`, `viewDepth` collapses to `zNear`, and
|
|
49
|
+
* `softParticlesFade` saturates to `0` — every particle multiplied away. That is
|
|
50
|
+
* the failure this module exists to make impossible: {@link farDepthTexture} is
|
|
51
|
+
* bound to every `BatchedRenderer` this engine constructs, so the WORST case is
|
|
52
|
+
* `readDepth = 1` ⇒ `viewDepth = zFar` ⇒ fade `1`, i.e. pixel-identical to a
|
|
53
|
+
* batch that never had `SOFT_PARTICLES` at all.
|
|
54
|
+
*
|
|
55
|
+
* ## The pass, and what it costs
|
|
56
|
+
*
|
|
57
|
+
* The fade needs the depth of the geometry BEHIND the particle, which cannot be
|
|
58
|
+
* the depth attachment the particle is being drawn into (sampling a texture
|
|
59
|
+
* attached to the bound framebuffer is a feedback loop). So it is a real
|
|
60
|
+
* PREPASS: one extra `renderer.render(scene, camera)` into a depth-only target,
|
|
61
|
+
* with the armed particle renderers and every transparent surface hidden and a
|
|
62
|
+
* colour-write-disabled override material in place.
|
|
63
|
+
*
|
|
64
|
+
* That cost is paid ONLY while something is armed. Soft particles enter through
|
|
65
|
+
* {@link armSoftParticleDepth}; translated depth-texture shader programs enter through
|
|
66
|
+
* {@link armSceneDepthTexture}. With both registries empty {@link SoftParticleDepthPass.render}
|
|
67
|
+
* returns before it touches the scene. Armed, per frame per camera it costs: one scene traverse (to find the
|
|
68
|
+
* transparent surfaces to hide), one extra geometry-only scene draw with shadow
|
|
69
|
+
* updates suppressed, and a drawing-buffer-sized depth target
|
|
70
|
+
* (`UnsignedIntType` depth + an unread RGBA8 colour attachment ≈ 8
|
|
71
|
+
* bytes/pixel).
|
|
72
|
+
*
|
|
73
|
+
* ## Why TRANSPARENT surfaces are hidden
|
|
74
|
+
*
|
|
75
|
+
* Godot's own `DEPTH_TEXTURE` — the one `proximity_fade_enabled` samples — is
|
|
76
|
+
* the OPAQUE pass's depth: `BaseMaterial3D`'s default `depth_draw_opaque` means
|
|
77
|
+
* a transparent material contributes no depth. Reproducing that is not
|
|
78
|
+
* cosmetic; without it the starter kit's coin would fade its stars against the
|
|
79
|
+
* translucent glow quad sitting in front of them rather than against the floor.
|
|
80
|
+
*
|
|
81
|
+
* ## Resource ownership
|
|
82
|
+
*
|
|
83
|
+
* - {@link farDepthTexture}'s 1x1 texture is a module singleton, created on
|
|
84
|
+
* first use and NEVER disposed: it is bound into every renderer's uniforms as
|
|
85
|
+
* the safe default, so there is no moment at which the last user is known.
|
|
86
|
+
* One texel, one allocation per process.
|
|
87
|
+
* - The armed-renderer registry is written by exactly two functions,
|
|
88
|
+
* {@link armSoftParticleDepth} / {@link disarmSoftParticleDepth}, whose only
|
|
89
|
+
* callers are `registerParticleSystem` / `unregisterParticleSystem`.
|
|
90
|
+
* - The render target and override material belong to the
|
|
91
|
+
* {@link SoftParticleDepthPass} instance and die with its `dispose()`. The
|
|
92
|
+
* pass is owned by the renderer loop that calls it: an adapter mount for a
|
|
93
|
+
* runtime renderer, or the editor Scene viewport for its renderer. That
|
|
94
|
+
* owner's teardown is the one path allowed to end it.
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
import * as THREE from 'three';
|
|
98
|
+
import type { BatchedRenderer } from 'three.quarks';
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The renderers hosting at least one system that asked for `softParticles`, and
|
|
102
|
+
* HOW MANY — a renderer may batch several emitters and must stay armed until
|
|
103
|
+
* the last one leaves.
|
|
104
|
+
*
|
|
105
|
+
* Module-scoped because the pass is per-MOUNT while a renderer is per-emitter:
|
|
106
|
+
* two worlds may be mounted at once and each pass filters this map down to the
|
|
107
|
+
* renderers actually parented under the scene it is rendering.
|
|
108
|
+
*/
|
|
109
|
+
const armed = new Map<BatchedRenderer, number>();
|
|
110
|
+
|
|
111
|
+
/** Any native material/program that samples Godot's opaque-pass depth texture. */
|
|
112
|
+
export interface SceneDepthTextureConsumer {
|
|
113
|
+
setDepthTexture(texture: THREE.Texture): void;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const depthTextureConsumers = new Map<SceneDepthTextureConsumer, number>();
|
|
117
|
+
|
|
118
|
+
let farDepth: THREE.DataTexture | undefined;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A 1x1 texture whose red channel is 1.0 — "the depth buffer says nothing is in
|
|
122
|
+
* front of anything".
|
|
123
|
+
*
|
|
124
|
+
* Bound to a `BatchedRenderer` the moment it is constructed, so a batch that
|
|
125
|
+
* later compiles with `SOFT_PARTICLES` inherits it (`BatchedRenderer.addSystem`
|
|
126
|
+
* applies `this.depthTexture` to each batch it creates). Feeding
|
|
127
|
+
* `linearize_depth` a `1.0` yields `(zFar * zNear) / (zFar - (zFar - zNear))`,
|
|
128
|
+
* which is `zFar`, so the fade term is `saturate(k * (zFar - linearDepth))` — 1
|
|
129
|
+
* for every fragment in front of the far plane. The fade is INERT rather than
|
|
130
|
+
* wrong, which is what makes an undriven surface (a design-time preview, a host
|
|
131
|
+
* with no pass) render exactly as it did before soft particles existed.
|
|
132
|
+
*/
|
|
133
|
+
export function farDepthTexture(): THREE.DataTexture {
|
|
134
|
+
if (farDepth === undefined) {
|
|
135
|
+
farDepth = new THREE.DataTexture(new Uint8Array([255, 255, 255, 255]), 1, 1);
|
|
136
|
+
farDepth.name = 'soft-particle-far-depth';
|
|
137
|
+
farDepth.needsUpdate = true;
|
|
138
|
+
}
|
|
139
|
+
return farDepth;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Record that `renderer` hosts one more soft-particle system, so passes stop skipping it. */
|
|
143
|
+
export function armSoftParticleDepth(renderer: BatchedRenderer): void {
|
|
144
|
+
armed.set(renderer, (armed.get(renderer) ?? 0) + 1);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The other half of {@link armSoftParticleDepth}. On the LAST soft system the
|
|
149
|
+
* renderer is dropped from the registry and handed the inert far-depth default
|
|
150
|
+
* back, so a batch that outlives its registration never keeps sampling a scene
|
|
151
|
+
* depth nobody is refreshing.
|
|
152
|
+
*/
|
|
153
|
+
export function disarmSoftParticleDepth(renderer: BatchedRenderer): void {
|
|
154
|
+
const remaining = (armed.get(renderer) ?? 0) - 1;
|
|
155
|
+
if (remaining > 0) {
|
|
156
|
+
armed.set(renderer, remaining);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (!armed.delete(renderer)) return;
|
|
160
|
+
renderer.setDepthTexture(farDepthTexture());
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Retain one consumer of the same opaque scene-depth image Godot exposes as hint_depth_texture. */
|
|
164
|
+
export function armSceneDepthTexture(consumer: SceneDepthTextureConsumer): void {
|
|
165
|
+
depthTextureConsumers.set(consumer, (depthTextureConsumers.get(consumer) ?? 0) + 1);
|
|
166
|
+
consumer.setDepthTexture(farDepthTexture());
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Release one retained Godot depth-texture consumer without leaving a disposed target bound. */
|
|
170
|
+
export function disarmSceneDepthTexture(consumer: SceneDepthTextureConsumer): void {
|
|
171
|
+
const remaining = (depthTextureConsumers.get(consumer) ?? 0) - 1;
|
|
172
|
+
if (remaining > 0) {
|
|
173
|
+
depthTextureConsumers.set(consumer, remaining);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (!depthTextureConsumers.delete(consumer)) return;
|
|
177
|
+
consumer.setDepthTexture(farDepthTexture());
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Test/diagnostic read of the registry — how many renderers a pass would consider. */
|
|
181
|
+
export function armedSoftParticleRendererCount(): number {
|
|
182
|
+
return armed.size;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The subset of `THREE.WebGLRenderer` this pass drives, so a host can supply its own. */
|
|
186
|
+
export interface SoftParticleDepthRenderer {
|
|
187
|
+
getDrawingBufferSize(target: THREE.Vector2): THREE.Vector2;
|
|
188
|
+
getRenderTarget(): THREE.WebGLRenderTarget | null;
|
|
189
|
+
setRenderTarget(target: THREE.WebGLRenderTarget | null): void;
|
|
190
|
+
render(scene: THREE.Object3D, camera: THREE.Camera): void;
|
|
191
|
+
/** Optional because a stub renderer legitimately has none — see {@link SoftParticleDepthPass}. */
|
|
192
|
+
readonly shadowMap?: { autoUpdate: boolean; needsUpdate: boolean };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface SoftParticleDepthPass {
|
|
196
|
+
/**
|
|
197
|
+
* Draw scene depth and hand it to every armed renderer under `scene`.
|
|
198
|
+
*
|
|
199
|
+
* A no-op — not one draw call, not one traversal — while nothing is armed.
|
|
200
|
+
* Call it after gameplay has moved the frame's transforms and before the
|
|
201
|
+
* frame is drawn.
|
|
202
|
+
*/
|
|
203
|
+
render(renderer: SoftParticleDepthRenderer, scene: THREE.Scene, camera: THREE.Camera): void;
|
|
204
|
+
dispose(): void;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Is `node` inside `root`'s subtree? Walks parents, so it is O(depth), not O(scene). */
|
|
208
|
+
function isUnder(node: THREE.Object3D, root: THREE.Object3D): boolean {
|
|
209
|
+
for (let at: THREE.Object3D | null = node; at !== null; at = at.parent) {
|
|
210
|
+
if (at === root) return true;
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Godot's `depth_draw_opaque`: a transparent surface contributes no depth. */
|
|
216
|
+
function drawsIntoDepth(object: THREE.Object3D): boolean {
|
|
217
|
+
const material = (object as THREE.Mesh).material as THREE.Material | THREE.Material[] | undefined;
|
|
218
|
+
if (material === undefined) return true;
|
|
219
|
+
return Array.isArray(material)
|
|
220
|
+
? !material.every((entry) => entry.transparent)
|
|
221
|
+
: !material.transparent;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The three renderer members this prepass CANNOT do without — the render-target
|
|
226
|
+
* swap it draws through, and the buffer size it sizes that target from.
|
|
227
|
+
*
|
|
228
|
+
* A real `WebGLRenderer` has all of them. A design-time stand-in that only has
|
|
229
|
+
* to answer `render` may mount a world so another host can read it, but cannot
|
|
230
|
+
* run this effect. The editor does not pass that stand-in here: its real
|
|
231
|
+
* viewport renderer owns its own pass. Other incomplete hosts still degrade
|
|
232
|
+
* loudly and once instead of taking the frame down.
|
|
233
|
+
*/
|
|
234
|
+
const REQUIRED_RENDERER_MEMBERS = [
|
|
235
|
+
'getDrawingBufferSize',
|
|
236
|
+
'getRenderTarget',
|
|
237
|
+
'setRenderTarget',
|
|
238
|
+
'render',
|
|
239
|
+
] as const;
|
|
240
|
+
|
|
241
|
+
/** The required members `renderer` does not implement, in declaration order. */
|
|
242
|
+
function missingRendererMembers(renderer: SoftParticleDepthRenderer): string[] {
|
|
243
|
+
const held = renderer as unknown as Record<string, unknown>;
|
|
244
|
+
return REQUIRED_RENDERER_MEMBERS.filter((name) => typeof held[name] !== 'function');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function createSoftParticleDepthPass(): SoftParticleDepthPass {
|
|
248
|
+
let target: THREE.WebGLRenderTarget | null = null;
|
|
249
|
+
/** Warned about already — one line per pass, not one per frame. */
|
|
250
|
+
let warnedIncapable = false;
|
|
251
|
+
const override = new THREE.MeshBasicMaterial({ colorWrite: false });
|
|
252
|
+
override.name = 'soft-particle-depth-prepass';
|
|
253
|
+
const size = new THREE.Vector2();
|
|
254
|
+
const hidden: THREE.Object3D[] = [];
|
|
255
|
+
|
|
256
|
+
const resize = (width: number, height: number): THREE.WebGLRenderTarget => {
|
|
257
|
+
if (target !== null && target.width === width && target.height === height) return target;
|
|
258
|
+
target?.dispose();
|
|
259
|
+
const depthTexture = new THREE.DepthTexture(width, height, THREE.UnsignedIntType);
|
|
260
|
+
depthTexture.name = 'soft-particle-scene-depth';
|
|
261
|
+
const next = new THREE.WebGLRenderTarget(width, height, { depthBuffer: true, depthTexture });
|
|
262
|
+
target = next;
|
|
263
|
+
return next;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
render(renderer, scene, camera): void {
|
|
268
|
+
if (armed.size === 0 && depthTextureConsumers.size === 0) return;
|
|
269
|
+
const consumers: BatchedRenderer[] = [];
|
|
270
|
+
for (const candidate of armed.keys()) {
|
|
271
|
+
if (isUnder(candidate, scene)) consumers.push(candidate);
|
|
272
|
+
}
|
|
273
|
+
if (consumers.length === 0 && depthTextureConsumers.size === 0) return;
|
|
274
|
+
|
|
275
|
+
// See REQUIRED_RENDERER_MEMBERS: a renderer that cannot swap render
|
|
276
|
+
// targets cannot run a prepass, and the frame is worth more than the
|
|
277
|
+
// fade. Every consumer keeps the far-depth default it was armed with.
|
|
278
|
+
const missing = missingRendererMembers(renderer);
|
|
279
|
+
if (missing.length > 0) {
|
|
280
|
+
if (!warnedIncapable) {
|
|
281
|
+
warnedIncapable = true;
|
|
282
|
+
// biome-ignore lint/suspicious/noConsole: deliberate loud degrade — the documented alternative to throwing out of the frame
|
|
283
|
+
console.warn(
|
|
284
|
+
`opaque scene-depth prepass skipped: this renderer is missing ${missing.join(', ')}. ` +
|
|
285
|
+
`${String(consumers.length)} particle batch(es) and ${String(depthTextureConsumers.size)} ` +
|
|
286
|
+
'shader consumer(s) keep the inert far-depth default. A real ' +
|
|
287
|
+
'WebGLRenderer has these; a design-time settle stand-in does not.',
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
renderer.getDrawingBufferSize(size);
|
|
294
|
+
const width = Math.max(1, Math.floor(size.x));
|
|
295
|
+
const height = Math.max(1, Math.floor(size.y));
|
|
296
|
+
const rt = resize(width, height);
|
|
297
|
+
|
|
298
|
+
// Hidden for the prepass: the particles themselves (they are what samples
|
|
299
|
+
// this depth) and every transparent surface (Godot's opaque-only depth).
|
|
300
|
+
hidden.length = 0;
|
|
301
|
+
for (const consumer of consumers) {
|
|
302
|
+
if (consumer.visible) {
|
|
303
|
+
consumer.visible = false;
|
|
304
|
+
hidden.push(consumer);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
scene.traverse((object) => {
|
|
308
|
+
if (!object.visible) return;
|
|
309
|
+
if (drawsIntoDepth(object)) return;
|
|
310
|
+
object.visible = false;
|
|
311
|
+
hidden.push(object);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const previousTarget = renderer.getRenderTarget();
|
|
315
|
+
const previousOverride = scene.overrideMaterial;
|
|
316
|
+
// three re-renders every shadow map on EVERY `render()` call while
|
|
317
|
+
// `shadowMap.autoUpdate` is on, so without this the prepass would double
|
|
318
|
+
// the frame's shadow cost for a pass that writes depth and nothing else.
|
|
319
|
+
// Both flags are restored below; the frame's own draw re-renders them.
|
|
320
|
+
const shadows = renderer.shadowMap;
|
|
321
|
+
const previousAutoUpdate = shadows?.autoUpdate ?? false;
|
|
322
|
+
const previousNeedsUpdate = shadows?.needsUpdate ?? false;
|
|
323
|
+
if (shadows !== undefined) {
|
|
324
|
+
shadows.autoUpdate = false;
|
|
325
|
+
shadows.needsUpdate = false;
|
|
326
|
+
}
|
|
327
|
+
scene.overrideMaterial = override;
|
|
328
|
+
try {
|
|
329
|
+
renderer.setRenderTarget(rt);
|
|
330
|
+
renderer.render(scene, camera);
|
|
331
|
+
} finally {
|
|
332
|
+
renderer.setRenderTarget(previousTarget);
|
|
333
|
+
scene.overrideMaterial = previousOverride;
|
|
334
|
+
if (shadows !== undefined) {
|
|
335
|
+
shadows.autoUpdate = previousAutoUpdate;
|
|
336
|
+
shadows.needsUpdate = previousNeedsUpdate;
|
|
337
|
+
}
|
|
338
|
+
for (const object of hidden) object.visible = true;
|
|
339
|
+
hidden.length = 0;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const renderedDepth = rt.depthTexture;
|
|
343
|
+
if (renderedDepth === null) {
|
|
344
|
+
throw new Error('opaque scene-depth prepass lost its authored depth texture.');
|
|
345
|
+
}
|
|
346
|
+
for (const consumer of consumers) consumer.setDepthTexture(renderedDepth);
|
|
347
|
+
for (const consumer of depthTextureConsumers.keys()) consumer.setDepthTexture(renderedDepth);
|
|
348
|
+
},
|
|
349
|
+
dispose(): void {
|
|
350
|
+
// Every consumer this pass fed now points at a texture that is about to
|
|
351
|
+
// go away. Hand them the inert default back rather than a disposed one.
|
|
352
|
+
for (const candidate of armed.keys()) candidate.setDepthTexture(farDepthTexture());
|
|
353
|
+
for (const consumer of depthTextureConsumers.keys())
|
|
354
|
+
consumer.setDepthTexture(farDepthTexture());
|
|
355
|
+
target?.dispose();
|
|
356
|
+
target = null;
|
|
357
|
+
override.dispose();
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { SparkRenderer } from '@sparkjsdev/spark';
|
|
2
|
+
import type * as THREE from 'three';
|
|
3
|
+
import { hasUserData } from '../ecs/user-data';
|
|
4
|
+
|
|
5
|
+
const IDLE_POLL_MS = 16;
|
|
6
|
+
const MAX_IDLE_WAIT_MS = 3_000;
|
|
7
|
+
export const SPARK_DISCOVERY_INTERVAL_MS = 500;
|
|
8
|
+
|
|
9
|
+
/** Gate fallback discovery so ordinary non-splat scenes are never traversed every frame. */
|
|
10
|
+
export function shouldDiscoverGaussianSplat(lastCheckAt: number, now: number): boolean {
|
|
11
|
+
return now - lastCheckAt >= SPARK_DISCOVERY_INTERVAL_MS;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** True when a native scene currently contains at least one Gaussian payload. */
|
|
15
|
+
export function sceneHasGaussianSplat(scene: THREE.Object3D): boolean {
|
|
16
|
+
let found = false;
|
|
17
|
+
scene.traverse((object) => {
|
|
18
|
+
if (!found && hasUserData(object, 'gaussianSplat')) found = true;
|
|
19
|
+
});
|
|
20
|
+
return found;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Stop and dispose Spark after its asynchronous depth-sort worker is idle.
|
|
25
|
+
*
|
|
26
|
+
* SparkRenderer.dispose() rejects any worker calls still in flight. Spark's
|
|
27
|
+
* automatic update path intentionally does not await those calls, so disposing
|
|
28
|
+
* synchronously during an editor stop otherwise produces an unhandled
|
|
29
|
+
* `Worker terminate` rejection. Disable future work immediately, then release
|
|
30
|
+
* the renderer once the current sort has naturally settled.
|
|
31
|
+
*/
|
|
32
|
+
export function disposeSparkRendererWhenIdle(renderer: SparkRenderer): void {
|
|
33
|
+
renderer.autoUpdate = false;
|
|
34
|
+
renderer.sortDirty = false;
|
|
35
|
+
|
|
36
|
+
if (renderer.updateTimeoutId !== -1) {
|
|
37
|
+
clearTimeout(renderer.updateTimeoutId);
|
|
38
|
+
renderer.updateTimeoutId = -1;
|
|
39
|
+
}
|
|
40
|
+
if (renderer.sortTimeoutId !== -1) {
|
|
41
|
+
clearTimeout(renderer.sortTimeoutId);
|
|
42
|
+
renderer.sortTimeoutId = -1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let remainingIdleWaitMs = MAX_IDLE_WAIT_MS;
|
|
46
|
+
const disposeWhenIdle = (): void => {
|
|
47
|
+
if (renderer.sorting) {
|
|
48
|
+
if (remainingIdleWaitMs > 0) {
|
|
49
|
+
remainingIdleWaitMs -= IDLE_POLL_MS;
|
|
50
|
+
setTimeout(disposeWhenIdle, IDLE_POLL_MS);
|
|
51
|
+
} else {
|
|
52
|
+
// A stuck upstream worker is safer to abandon than to turn a routine
|
|
53
|
+
// editor stop into an unhandled rejection. Page teardown will reclaim
|
|
54
|
+
// the worker; normal sorts complete in a few milliseconds.
|
|
55
|
+
// biome-ignore lint/suspicious/noConsole: This rare upstream worker leak must degrade loudly.
|
|
56
|
+
console.warn('SparkRenderer did not become idle; skipped worker termination.');
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
renderer.dispose();
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
disposeWhenIdle();
|
|
64
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STRUCTURAL IDENTITY AND ELIGIBILITY for static render batching — the one
|
|
3
|
+
* place that answers "may this draw be collapsed at all?" and "are these two
|
|
4
|
+
* meshes the same thing drawn twice?".
|
|
5
|
+
*
|
|
6
|
+
* Three consumers, which is the whole reason it is a module rather than three
|
|
7
|
+
* private helpers: `render/render-batch-system.ts` (the engine's own
|
|
8
|
+
* lab-driven instancer), `dev/render-census.ts`'s structural scan (what the
|
|
9
|
+
* advisor and `render.families` measure), and the `static-batch` capability's
|
|
10
|
+
* `<Frozen>` (what a game actually installs). A batcher and the advisor that
|
|
11
|
+
* routes people to it MUST agree about what is batchable, or the advisor
|
|
12
|
+
* promises a win the wrapper then declines to take.
|
|
13
|
+
*
|
|
14
|
+
* ── THE UUID TRAP, WHICH IS WHY THIS MODULE EXISTS ──────────────────────────
|
|
15
|
+
* The obvious key for "can these two meshes be drawn as one" is object
|
|
16
|
+
* identity: same `geometry.uuid`, same `material.uuid`. It is also the key
|
|
17
|
+
* that silently batches NOTHING in the shape that matters most. A freshly
|
|
18
|
+
* scaffolded TSX/R3F world writes its materials INLINE —
|
|
19
|
+
*
|
|
20
|
+
* {crates.map((c) => (
|
|
21
|
+
* <mesh key={c.id} position={c.at}>
|
|
22
|
+
* <boxGeometry args={[1, 1, 1]} />
|
|
23
|
+
* <meshStandardMaterial color="#8a6a44" />
|
|
24
|
+
* </mesh>
|
|
25
|
+
* ))}
|
|
26
|
+
*
|
|
27
|
+
* — and every one of those `<meshStandardMaterial>` elements constructs its
|
|
28
|
+
* OWN `THREE.MeshStandardMaterial`. Five hundred crates are five hundred
|
|
29
|
+
* distinct uuids describing one identical appearance. A uuid-keyed grouper
|
|
30
|
+
* reports five hundred families of one, finds nothing to do, and is indistin-
|
|
31
|
+
* guishable from a correct batcher over an unbatchable scene. The engine's own
|
|
32
|
+
* scene construction has the same property, which is why
|
|
33
|
+
* `render/render-batch-system.ts` has keyed on VALUE since it was written.
|
|
34
|
+
*
|
|
35
|
+
* So the key here is the STRUCTURE: geometry `type` + its construction
|
|
36
|
+
* `parameters`, material `type` + the props that decide what the draw looks
|
|
37
|
+
* like. Two independently constructed `BoxGeometry(1,1,1)` +
|
|
38
|
+
* `MeshStandardMaterial({color:'#8a6a44'})` pairs are the same draw, and this
|
|
39
|
+
* module says so.
|
|
40
|
+
*
|
|
41
|
+
* ── WHERE VALUE CANNOT ANSWER, IDENTITY IS THE SAFE FALLBACK ────────────────
|
|
42
|
+
* Twice below, a structural comparison would be a guess, and the answer is
|
|
43
|
+
* `uuid:` — a key nothing else can equal, so the members simply do not group.
|
|
44
|
+
* Not grouping costs a draw call; grouping two things that only LOOK alike
|
|
45
|
+
* renders the wrong picture.
|
|
46
|
+
* - geometry with no `.parameters` (a loaded GLTF mesh, a hand-built
|
|
47
|
+
* `BufferGeometry`): its vertices are its identity and comparing them is
|
|
48
|
+
* not a signature, it is a diff.
|
|
49
|
+
* - a shader material ({@link materialMergeSignature} only): its appearance
|
|
50
|
+
* lives in shader source and uniforms that no fixed prop list can read.
|
|
51
|
+
*
|
|
52
|
+
* ── TWO KEYS, DELIBERATELY ──────────────────────────────────────────────────
|
|
53
|
+
* {@link staticBatchSignature} is `RenderBatchSystem`'s key, extracted here
|
|
54
|
+
* unchanged so there is one owner of the answer rather than two that drift.
|
|
55
|
+
* {@link materialMergeSignature} is STRICTER, and it is what the `static-batch`
|
|
56
|
+
* capability's `<Frozen>` groups by: that path hands ONE material instance to
|
|
57
|
+
* a merged/instanced product, so its members must be interchangeable, not
|
|
58
|
+
* merely similar. The looser key predates it and is kept exactly as it was —
|
|
59
|
+
* see that constant's own note.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import type * as THREE from 'three';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The `userData` key a node opts OUT of static batching with — the one
|
|
66
|
+
* declaration that beats every measurement:
|
|
67
|
+
*
|
|
68
|
+
* <group name="Beacons" userData={{ staticBatch: false }}>
|
|
69
|
+
*
|
|
70
|
+
* Set on a node, it covers that node's whole subtree (a walker stops there).
|
|
71
|
+
* It is deliberately `userData` and not a component prop: the thing being
|
|
72
|
+
* excluded is a three node, and every authoring lane — TSX, a loaded GLTF, a
|
|
73
|
+
* hand-built graph — can set `userData` on one.
|
|
74
|
+
*/
|
|
75
|
+
export const STATIC_BATCH_OPT_OUT_KEY = 'staticBatch';
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Why one node may not be collapsed into a batch. `null` from
|
|
79
|
+
* {@link staticBatchSkipReason} means it may.
|
|
80
|
+
*
|
|
81
|
+
* Each of these is a case where a batched draw would render something DIFFERENT
|
|
82
|
+
* from the originals, not merely a case that is awkward to implement:
|
|
83
|
+
* - `instanced` / `batched` — already one draw; swallowing it would flatten
|
|
84
|
+
* per-instance transforms the batch does not carry.
|
|
85
|
+
* - `skinned` — its vertices are posed by a bone matrix palette every frame;
|
|
86
|
+
* baking one pose freezes the character mid-stride.
|
|
87
|
+
* - `morph-targets` — same, driven by influences instead of bones.
|
|
88
|
+
* - `multi-material` — the geometry's `groups` select a material per range;
|
|
89
|
+
* a batch carries one material.
|
|
90
|
+
* - `transparent` — blending is order-dependent and three sorts TRANSPARENT
|
|
91
|
+
* OBJECTS, not triangles. Collapsing them fixes their relative order to
|
|
92
|
+
* whatever the merge happened to write.
|
|
93
|
+
* - `opted-out` — see {@link STATIC_BATCH_OPT_OUT_KEY}.
|
|
94
|
+
*/
|
|
95
|
+
export type StaticBatchSkipReason =
|
|
96
|
+
| 'not-a-mesh'
|
|
97
|
+
| 'instanced'
|
|
98
|
+
| 'batched'
|
|
99
|
+
| 'skinned'
|
|
100
|
+
| 'no-geometry'
|
|
101
|
+
| 'multi-material'
|
|
102
|
+
| 'morph-targets'
|
|
103
|
+
| 'transparent'
|
|
104
|
+
| 'opted-out';
|
|
105
|
+
|
|
106
|
+
interface MeshKinds {
|
|
107
|
+
isMesh?: boolean;
|
|
108
|
+
isInstancedMesh?: boolean;
|
|
109
|
+
isBatchedMesh?: boolean;
|
|
110
|
+
isSkinnedMesh?: boolean;
|
|
111
|
+
geometry?: THREE.BufferGeometry;
|
|
112
|
+
material?: THREE.Material | THREE.Material[];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Why `node` may not join a static batch, or `null` when it may. Pure, cheap,
|
|
117
|
+
* and the SINGLE owner of that answer — see the module note for why the
|
|
118
|
+
* batcher and the advisor cannot each keep their own copy.
|
|
119
|
+
*
|
|
120
|
+
* Checks the node itself only. Subtree exclusion (an opted-out ancestor) is
|
|
121
|
+
* the caller's walk, because the walkers that need this already prune.
|
|
122
|
+
*/
|
|
123
|
+
export function staticBatchSkipReason(node: THREE.Object3D): StaticBatchSkipReason | null {
|
|
124
|
+
if (node.userData?.[STATIC_BATCH_OPT_OUT_KEY] === false) return 'opted-out';
|
|
125
|
+
const mesh = node as THREE.Object3D & MeshKinds;
|
|
126
|
+
if (mesh.isInstancedMesh) return 'instanced';
|
|
127
|
+
if (mesh.isBatchedMesh) return 'batched';
|
|
128
|
+
if (mesh.isSkinnedMesh) return 'skinned';
|
|
129
|
+
if (!mesh.isMesh) return 'not-a-mesh';
|
|
130
|
+
if (!mesh.geometry || !mesh.material) return 'no-geometry';
|
|
131
|
+
if (Array.isArray(mesh.material)) return 'multi-material';
|
|
132
|
+
if (Object.keys(mesh.geometry.morphAttributes).length > 0) return 'morph-targets';
|
|
133
|
+
if (mesh.material.transparent) return 'transparent';
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Geometry identity: construction `type` + `parameters` for the parametric
|
|
139
|
+
* primitives, object identity for everything else. See the module note.
|
|
140
|
+
*/
|
|
141
|
+
export function geometrySignature(geometry: THREE.BufferGeometry): string {
|
|
142
|
+
const params = (geometry as unknown as { parameters?: object }).parameters;
|
|
143
|
+
return params ? `${geometry.type}:${JSON.stringify(params)}` : `uuid:${geometry.uuid}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Material identity as `RenderBatchSystem` has always computed it: type plus
|
|
148
|
+
* the standard-material props that change the draw.
|
|
149
|
+
*
|
|
150
|
+
* FROZEN ON PURPOSE. This is one half of {@link staticBatchSignature}, which
|
|
151
|
+
* is a live batcher's grouping key; widening or narrowing it silently
|
|
152
|
+
* regroups that batcher's scenes. New discrimination goes in
|
|
153
|
+
* {@link materialMergeSignature}, which is free to be stricter because
|
|
154
|
+
* stricter only ever means "batches less".
|
|
155
|
+
*/
|
|
156
|
+
export function materialSignature(material: THREE.Material): string {
|
|
157
|
+
const standard = material as THREE.MeshStandardMaterial;
|
|
158
|
+
const color = standard.color?.getHexString?.() ?? '';
|
|
159
|
+
const map = standard.map?.uuid ?? '';
|
|
160
|
+
return `${material.type}:${color}:${standard.roughness ?? ''}:${standard.metalness ?? ''}:${map}:${material.side}:${material.transparent}:${material.vertexColors}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* One mesh's batch key: geometry, material, and the shadow flags.
|
|
165
|
+
*
|
|
166
|
+
* The shadow flags are part of the key because a batched product carries ONE
|
|
167
|
+
* `castShadow`/`receiveShadow` pair for every member it swallowed — mixing a
|
|
168
|
+
* caster and a non-caster into one draw changes the picture.
|
|
169
|
+
*/
|
|
170
|
+
export function staticBatchSignature(mesh: THREE.Mesh): string {
|
|
171
|
+
const geometry = mesh.geometry as THREE.BufferGeometry;
|
|
172
|
+
const material = mesh.material as THREE.Material;
|
|
173
|
+
return `${geometrySignature(geometry)}|${materialSignature(material)}|${mesh.castShadow ? 1 : 0}${mesh.receiveShadow ? 1 : 0}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Which vertex attributes a geometry carries, and whether it is indexed —
|
|
178
|
+
* the compatibility precondition for `mergeGeometries`, which refuses a batch
|
|
179
|
+
* whose members disagree.
|
|
180
|
+
*
|
|
181
|
+
* Item size is in the key too: two geometries can both have `uv` and disagree
|
|
182
|
+
* about whether it is 2- or 3-component, which merges into silent garbage
|
|
183
|
+
* rather than a refusal.
|
|
184
|
+
*/
|
|
185
|
+
export function geometryLayoutSignature(geometry: THREE.BufferGeometry): string {
|
|
186
|
+
const attributes = Object.keys(geometry.attributes)
|
|
187
|
+
.sort()
|
|
188
|
+
.map((name) => `${name}:${geometry.attributes[name]?.itemSize ?? '?'}`)
|
|
189
|
+
.join(',');
|
|
190
|
+
return `${attributes}${geometry.getIndex() ? '|i' : ''}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Material identity for a path that will SHARE one material instance between
|
|
195
|
+
* every member it collapses — stricter than {@link materialSignature}.
|
|
196
|
+
*
|
|
197
|
+
* Everything the looser key reads, plus the props that decide the picture
|
|
198
|
+
* without touching colour/roughness/metalness: opacity and the depth/blend
|
|
199
|
+
* state, emissive, the remaining standard maps, and the flags that change the
|
|
200
|
+
* compiled program. A shader material answers with its uuid instead (see the
|
|
201
|
+
* module note): its appearance is source and uniforms, and there is no honest
|
|
202
|
+
* fixed-prop reading of it.
|
|
203
|
+
*/
|
|
204
|
+
export function materialMergeSignature(material: THREE.Material): string {
|
|
205
|
+
const shader = material as THREE.ShaderMaterial & { isRawShaderMaterial?: boolean };
|
|
206
|
+
if (shader.isShaderMaterial || shader.isRawShaderMaterial) return `uuid:${material.uuid}`;
|
|
207
|
+
|
|
208
|
+
const rich = material as THREE.MeshPhysicalMaterial;
|
|
209
|
+
const maps = [rich.normalMap, rich.aoMap, rich.emissiveMap, rich.roughnessMap, rich.metalnessMap]
|
|
210
|
+
.map((map) => map?.uuid ?? '')
|
|
211
|
+
.join(',');
|
|
212
|
+
// A material with a patched `onBeforeCompile` declares its variant through
|
|
213
|
+
// this hook (that is what three itself keys its program cache on), so a
|
|
214
|
+
// non-empty value discriminates here too.
|
|
215
|
+
const program = material.customProgramCacheKey?.() ?? '';
|
|
216
|
+
return [
|
|
217
|
+
materialSignature(material),
|
|
218
|
+
material.opacity,
|
|
219
|
+
material.depthWrite,
|
|
220
|
+
material.depthTest,
|
|
221
|
+
material.alphaTest,
|
|
222
|
+
material.blending,
|
|
223
|
+
material.toneMapped,
|
|
224
|
+
material.visible,
|
|
225
|
+
rich.emissive?.getHexString?.() ?? '',
|
|
226
|
+
rich.flatShading ?? '',
|
|
227
|
+
rich.wireframe ?? '',
|
|
228
|
+
maps,
|
|
229
|
+
program,
|
|
230
|
+
].join('|');
|
|
231
|
+
}
|