@vgai/engine 0.5.13 → 0.5.14
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/dist/adapter/adapter-module.d.ts +447 -0
- package/dist/adapter/adapter-module.d.ts.map +1 -0
- package/dist/adapter/adapter-module.js +308 -0
- package/dist/adapter/authoring.d.ts +357 -10
- package/dist/adapter/authoring.d.ts.map +1 -1
- package/dist/adapter/authoring.js +87 -1
- package/dist/adapter/constraint.d.ts +9 -3
- package/dist/adapter/constraint.d.ts.map +1 -1
- package/dist/adapter/finders/finder-result.d.ts +28 -0
- package/dist/adapter/finders/finder-result.d.ts.map +1 -0
- package/dist/adapter/finders/finder-result.js +10 -0
- package/dist/adapter/finders/index.d.ts +38 -0
- package/dist/adapter/finders/index.d.ts.map +1 -0
- package/dist/adapter/finders/index.js +41 -0
- package/dist/adapter/finders/prefabs-from-stories.d.ts +64 -0
- package/dist/adapter/finders/prefabs-from-stories.d.ts.map +1 -0
- package/dist/adapter/finders/prefabs-from-stories.js +92 -0
- package/dist/adapter/finders/scenes-from-entrypoint-selection.d.ts +67 -0
- package/dist/adapter/finders/scenes-from-entrypoint-selection.d.ts.map +1 -0
- package/dist/adapter/finders/scenes-from-entrypoint-selection.js +226 -0
- package/dist/adapter/first-party-systems.d.ts.map +1 -1
- package/dist/adapter/first-party-systems.js +29 -0
- package/dist/adapter/index.d.ts +7 -3
- package/dist/adapter/index.d.ts.map +1 -1
- package/dist/adapter/index.js +5 -2
- package/dist/adapter/ingest/contract-system-adapters.d.ts +24 -1
- package/dist/adapter/ingest/contract-system-adapters.d.ts.map +1 -1
- package/dist/adapter/ingest/contract-system-adapters.js +100 -34
- package/dist/adapter/ingest/game-contract.d.ts +188 -2
- package/dist/adapter/ingest/game-contract.d.ts.map +1 -1
- package/dist/adapter/ingest/game-contract.js +107 -0
- package/dist/adapter/ingest/mount-readiness.d.ts +84 -0
- package/dist/adapter/ingest/mount-readiness.d.ts.map +1 -0
- package/dist/adapter/ingest/mount-readiness.js +81 -0
- package/dist/adapter/ingest/scene-capture.d.ts +91 -2
- package/dist/adapter/ingest/scene-capture.d.ts.map +1 -1
- package/dist/adapter/ingest/scene-capture.js +110 -18
- package/dist/adapter/ingest/structural-ids.d.ts +11 -5
- package/dist/adapter/ingest/structural-ids.d.ts.map +1 -1
- package/dist/adapter/ingest/structural-ids.js +18 -9
- package/dist/adapter/ingest/upstream-pin.d.ts +4 -2
- package/dist/adapter/ingest/upstream-pin.d.ts.map +1 -1
- package/dist/adapter/ingest/upstream-pin.js +4 -2
- package/dist/adapter/ingest/visible-capture-window.d.ts +116 -0
- package/dist/adapter/ingest/visible-capture-window.d.ts.map +1 -0
- package/dist/adapter/ingest/visible-capture-window.js +141 -0
- package/dist/adapter/loop-gate-report.d.ts +3 -4
- package/dist/adapter/loop-gate-report.d.ts.map +1 -1
- package/dist/adapter/loop-gate-report.js +3 -4
- package/dist/adapter/root-adapter.d.ts +22 -2
- package/dist/adapter/root-adapter.d.ts.map +1 -1
- package/dist/adapter/setup-three-root-adapter.d.ts.map +1 -1
- package/dist/adapter/setup-three-root-adapter.js +15 -0
- package/dist/adapter/system-adapter.d.ts +161 -2
- package/dist/adapter/system-adapter.d.ts.map +1 -1
- package/dist/adapter/system-adapter.js +18 -1
- package/dist/animation/camera-ownership.d.ts +4 -2
- package/dist/animation/camera-ownership.d.ts.map +1 -1
- package/dist/animation/camera-ownership.js +58 -1
- package/dist/animation/runtime-inspection.d.ts +70 -0
- package/dist/animation/runtime-inspection.d.ts.map +1 -0
- package/dist/animation/runtime-inspection.js +25 -0
- package/dist/animation/xstate-animation-binding.d.ts +9 -1
- package/dist/animation/xstate-animation-binding.d.ts.map +1 -1
- package/dist/animation/xstate-animation-binding.js +48 -1
- package/dist/asset-formats/material.d.ts +1 -1
- package/dist/asset-formats/mesh.d.ts +1 -1
- package/dist/asset-formats/particles.d.ts +13 -0
- package/dist/asset-formats/particles.d.ts.map +1 -1
- package/dist/asset-formats/particles.js +28 -0
- package/dist/behavior/xstate-inspection.d.ts +24 -0
- package/dist/behavior/xstate-inspection.d.ts.map +1 -0
- package/dist/behavior/xstate-inspection.js +25 -0
- package/dist/core/game-loop.d.ts +7 -5
- package/dist/core/game-loop.d.ts.map +1 -1
- package/dist/core/game-loop.js +39 -18
- package/dist/core/types.d.ts +11 -12
- package/dist/core/types.d.ts.map +1 -1
- package/dist/data/data-asset.js +1 -1
- package/dist/dev/instruments.d.ts.map +1 -1
- package/dist/dev/instruments.js +5 -1
- package/dist/dev/render-debug-adapter.d.ts +19 -2
- package/dist/dev/render-debug-adapter.d.ts.map +1 -1
- package/dist/dev/render-debug-adapter.js +10 -0
- package/dist/ecs/user-data.d.ts +10 -2
- package/dist/ecs/user-data.d.ts.map +1 -1
- package/dist/ecs/user-data.js +6 -2
- package/dist/input/binding-identity.d.ts +10 -0
- package/dist/input/binding-identity.d.ts.map +1 -0
- package/dist/input/binding-identity.js +36 -0
- package/dist/input/input-manager.d.ts +18 -185
- package/dist/input/input-manager.d.ts.map +1 -1
- package/dist/input/input-manager.js +78 -68
- package/dist/manifest/load.d.ts +14 -0
- package/dist/manifest/load.d.ts.map +1 -1
- package/dist/manifest/load.js +6 -1
- package/dist/manifest/schema.d.ts +18 -0
- package/dist/manifest/schema.d.ts.map +1 -1
- package/dist/manifest/schema.js +64 -7
- package/dist/pixi/authoring.d.ts +27 -0
- package/dist/pixi/authoring.d.ts.map +1 -1
- package/dist/pixi/authoring.js +10 -3
- package/dist/pixi/index.d.ts +1 -1
- package/dist/pixi/index.d.ts.map +1 -1
- package/dist/pixi/index.js +1 -1
- package/dist/pixi/ingest.d.ts +8 -0
- package/dist/pixi/ingest.d.ts.map +1 -1
- package/dist/pixi/ingest.js +10 -1
- package/dist/pixi/render-pass-bracket.d.ts +56 -0
- package/dist/pixi/render-pass-bracket.d.ts.map +1 -0
- package/dist/pixi/render-pass-bracket.js +98 -0
- package/dist/pixi/scene-capture.d.ts +32 -1
- package/dist/pixi/scene-capture.d.ts.map +1 -1
- package/dist/pixi/scene-capture.js +21 -9
- package/dist/pixi/system-adapters.d.ts +29 -0
- package/dist/pixi/system-adapters.d.ts.map +1 -1
- package/dist/pixi/system-adapters.js +27 -0
- package/dist/render/particles-factory.d.ts +16 -2
- package/dist/render/particles-factory.d.ts.map +1 -1
- package/dist/render/particles-factory.js +88 -5
- package/dist/render/soft-particle-depth.d.ts +149 -0
- package/dist/render/soft-particle-depth.d.ts.map +1 -0
- package/dist/render/soft-particle-depth.js +305 -0
- package/dist/render/voxel-surface.d.ts +117 -0
- package/dist/render/voxel-surface.d.ts.map +1 -0
- package/dist/render/voxel-surface.js +263 -0
- package/dist/runtime/create-runtime.js +7 -0
- package/dist/runtime/debug-bridge.d.ts +15 -15
- package/dist/runtime/debug-bridge.d.ts.map +1 -1
- package/dist/runtime/debug-bridge.js +27 -26
- package/dist/runtime/game.d.ts.map +1 -1
- package/dist/runtime/game.js +1 -2
- package/dist/setup/setup-particles.d.ts.map +1 -1
- package/dist/setup/setup-particles.js +9 -0
- package/dist/world3d-react/r3f-adapter.d.ts.map +1 -1
- package/dist/world3d-react/r3f-adapter.js +42 -3
- package/dist/world3d-react/rapier-physics-bridge.d.ts.map +1 -1
- package/dist/world3d-react/rapier-physics-bridge.js +201 -11
- package/dist-config/config.js.map +1 -1
- package/package.json +4 -2
- package/schemas/engine-capabilities.json +12 -12
- package/schemas/vgai-project.schema.json +41 -2
- package/src/adapter/adapter-module.ts +607 -0
- package/src/adapter/authoring.ts +440 -10
- package/src/adapter/constraint.ts +10 -3
- package/src/adapter/finders/finder-result.ts +29 -0
- package/src/adapter/finders/index.ts +62 -0
- package/src/adapter/finders/prefabs-from-stories.ts +142 -0
- package/src/adapter/finders/scenes-from-entrypoint-selection.ts +304 -0
- package/src/adapter/first-party-systems.ts +30 -0
- package/src/adapter/index.ts +32 -1
- package/src/adapter/ingest/contract-system-adapters.ts +116 -32
- package/src/adapter/ingest/game-contract.ts +265 -2
- package/src/adapter/ingest/mount-readiness.ts +140 -0
- package/src/adapter/ingest/scene-capture.ts +199 -22
- package/src/adapter/ingest/structural-ids.ts +21 -9
- package/src/adapter/ingest/upstream-pin.ts +4 -2
- package/src/adapter/ingest/visible-capture-window.ts +213 -0
- package/src/adapter/loop-gate-report.ts +3 -4
- package/src/adapter/root-adapter.ts +22 -2
- package/src/adapter/setup-three-root-adapter.ts +16 -0
- package/src/adapter/system-adapter.ts +188 -2
- package/src/animation/camera-ownership.ts +68 -3
- package/src/animation/runtime-inspection.ts +104 -0
- package/src/animation/xstate-animation-binding.ts +53 -1
- package/src/asset-formats/particles.ts +31 -0
- package/src/behavior/xstate-inspection.ts +51 -0
- package/src/core/game-loop.ts +39 -17
- package/src/core/types.ts +11 -12
- package/src/data/data-asset.ts +1 -1
- package/src/dev/instruments.ts +5 -1
- package/src/dev/render-debug-adapter.ts +22 -2
- package/src/ecs/user-data.ts +10 -2
- package/src/input/binding-identity.ts +37 -0
- package/src/input/input-manager.ts +93 -68
- package/src/manifest/load.ts +23 -2
- package/src/manifest/schema.ts +76 -7
- package/src/pixi/authoring.ts +37 -5
- package/src/pixi/index.ts +2 -0
- package/src/pixi/ingest.ts +15 -2
- package/src/pixi/render-pass-bracket.ts +117 -0
- package/src/pixi/scene-capture.ts +63 -13
- package/src/pixi/system-adapters.ts +41 -0
- package/src/render/particles-factory.ts +89 -6
- package/src/render/soft-particle-depth.ts +336 -0
- package/src/render/voxel-surface.ts +358 -0
- package/src/runtime/create-runtime.ts +7 -0
- package/src/runtime/debug-bridge.ts +32 -31
- package/src/runtime/game.ts +1 -2
- package/src/setup/setup-particles.ts +9 -0
- package/src/world3d-react/r3f-adapter.tsx +48 -2
- package/src/world3d-react/rapier-physics-bridge.tsx +249 -11
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Converts ParticlesDescriptor JSON → three.quarks ParticleSystem.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* A published engine entry point (the export map is the wildcard `./*`): the
|
|
6
|
+
* one in-repo caller is the godot-compat capability's `createCpuParticles3D`,
|
|
7
|
+
* and user games outside this repo reach it directly.
|
|
6
8
|
* The schema mirrors quarks' native JSON format, so most fields pass through
|
|
7
9
|
* directly to quarks' fromJSON or constructor methods.
|
|
8
10
|
*/
|
|
@@ -44,6 +46,7 @@ import type {
|
|
|
44
46
|
ParticlesDescriptor,
|
|
45
47
|
ValueGeneratorJSON,
|
|
46
48
|
} from '../asset-formats/particles';
|
|
49
|
+
import { armSoftParticleDepth, disarmSoftParticleDepth } from './soft-particle-depth';
|
|
47
50
|
|
|
48
51
|
// --- Render mode mapping ---
|
|
49
52
|
|
|
@@ -60,16 +63,82 @@ const RENDER_MODE_MAP: Record<string, RenderMode> = {
|
|
|
60
63
|
|
|
61
64
|
import { textureLoader } from '../loader';
|
|
62
65
|
|
|
66
|
+
type ParticleMapSampler = NonNullable<ParticlesDescriptor['material']['mapSampler']>;
|
|
67
|
+
|
|
68
|
+
const WRAP: Record<NonNullable<ParticleMapSampler['wrap']>, THREE.Wrapping> = {
|
|
69
|
+
clamp: THREE.ClampToEdgeWrapping,
|
|
70
|
+
repeat: THREE.RepeatWrapping,
|
|
71
|
+
mirror: THREE.MirroredRepeatWrapping,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** `minFilter` is where the two sampler axes MEET — `filter` chooses linear vs nearest and
|
|
75
|
+
* `mipmaps` chooses whether the mip chain is sampled at all — and three spells the four
|
|
76
|
+
* combinations as four constants. A table rather than nested conditionals, which read as a
|
|
77
|
+
* puzzle. (`magFilter` has no mip half; it is the `filter` axis alone.) */
|
|
78
|
+
const MIN_FILTER = {
|
|
79
|
+
'linear/mips': THREE.LinearMipmapLinearFilter,
|
|
80
|
+
'linear/no-mips': THREE.LinearFilter,
|
|
81
|
+
'nearest/mips': THREE.NearestMipmapNearestFilter,
|
|
82
|
+
'nearest/no-mips': THREE.NearestFilter,
|
|
83
|
+
} as const;
|
|
84
|
+
|
|
63
85
|
const particleTexCache = new Map<string, THREE.Texture>();
|
|
64
86
|
|
|
65
|
-
|
|
66
|
-
|
|
87
|
+
/**
|
|
88
|
+
* The sprite texture for a material's `map`, loaded once per URL+sampler.
|
|
89
|
+
*
|
|
90
|
+
* Two things are decided here rather than left to three's loader defaults:
|
|
91
|
+
*
|
|
92
|
+
* - **COLOUR SPACE.** `THREE.TextureLoader` leaves `colorSpace` at `NoColorSpace`, which tells
|
|
93
|
+
* three the texels are already linear — so an ordinary sRGB PNG sprite is composited
|
|
94
|
+
* UNDECODED and every mid-tone comes out far too bright. A `map` is a colour texture by
|
|
95
|
+
* definition (three's own `GLTFLoader` states `SRGBColorSpace` on `baseColorTexture` for the
|
|
96
|
+
* same reason), so this states sRGB unconditionally. It is not a descriptor field: there is no
|
|
97
|
+
* correct second value for this slot. Measured 2026-08-14 against real Godot 3.6/GLES3 — a
|
|
98
|
+
* 128,128,128 sprite read back 0.5015 there and 0.7373 here, and after this line 0.5019; the
|
|
99
|
+
* whole A/B is in `godot-compat/cpu-particles-3d.ts`'s header.
|
|
100
|
+
* - **SAMPLER.** `material.mapSampler`, when the descriptor carries one. The cache key includes
|
|
101
|
+
* it because two emitters may name ONE image and want different sampling of it — a URL-only
|
|
102
|
+
* key would hand the second one the first's configured texture.
|
|
103
|
+
*
|
|
104
|
+
* `flipY` is deliberately untouched: the billboard quad this shader draws is built by
|
|
105
|
+
* `three.quarks` with three's own V-up UVs, so three's `flipY = true` is the consistent half of
|
|
106
|
+
* that pair.
|
|
107
|
+
*/
|
|
108
|
+
function loadParticleTexture(url: string, sampler?: ParticleMapSampler): THREE.Texture {
|
|
109
|
+
const key = sampler === undefined ? url : `${url}\u0000${JSON.stringify(sampler)}`;
|
|
110
|
+
const cached = particleTexCache.get(key);
|
|
67
111
|
if (cached) return cached;
|
|
68
112
|
const tex = textureLoader.load(url);
|
|
69
|
-
|
|
113
|
+
tex.colorSpace = THREE.SRGBColorSpace;
|
|
114
|
+
if (sampler !== undefined) applyMapSampler(tex, sampler);
|
|
115
|
+
particleTexCache.set(key, tex);
|
|
70
116
|
return tex;
|
|
71
117
|
}
|
|
72
118
|
|
|
119
|
+
/** One `mapSampler` onto three's own texture properties. An unstated field is left at three's
|
|
120
|
+
* default rather than restated — see {@link loadParticleTexture}. */
|
|
121
|
+
function applyMapSampler(tex: THREE.Texture, sampler: ParticleMapSampler): void {
|
|
122
|
+
if (sampler.wrap !== undefined) {
|
|
123
|
+
tex.wrapS = WRAP[sampler.wrap];
|
|
124
|
+
tex.wrapT = WRAP[sampler.wrap];
|
|
125
|
+
}
|
|
126
|
+
if (sampler.filter !== undefined || sampler.mipmaps !== undefined) {
|
|
127
|
+
// An unstated axis takes three's own default, which is linear and mipmapped.
|
|
128
|
+
const filter = sampler.filter ?? 'linear';
|
|
129
|
+
const mips = sampler.mipmaps === false ? 'no-mips' : 'mips';
|
|
130
|
+
if (sampler.filter !== undefined) {
|
|
131
|
+
tex.magFilter = filter === 'linear' ? THREE.LinearFilter : THREE.NearestFilter;
|
|
132
|
+
}
|
|
133
|
+
tex.minFilter = MIN_FILTER[`${filter}/${mips}`];
|
|
134
|
+
}
|
|
135
|
+
if (sampler.mipmaps !== undefined) tex.generateMipmaps = sampler.mipmaps;
|
|
136
|
+
// Anisotropy rides through unclamped: three's own `WebGLTextures` uploads
|
|
137
|
+
// `Math.min(texture.anisotropy, capabilities.getMaxAnisotropy())`, so the renderer's real
|
|
138
|
+
// capability is what binds and a second clamp here would have no effect.
|
|
139
|
+
if (sampler.anisotropy !== undefined) tex.anisotropy = sampler.anisotropy;
|
|
140
|
+
}
|
|
141
|
+
|
|
73
142
|
// --- Helpers ---
|
|
74
143
|
|
|
75
144
|
/** Strip keys with undefined values so exactOptionalPropertyTypes is satisfied. */
|
|
@@ -206,7 +275,7 @@ function createBehavior(json: BehaviorJSON): any {
|
|
|
206
275
|
|
|
207
276
|
function createParticleMaterial(mat: ParticlesDescriptor['material']): THREE.Material {
|
|
208
277
|
const textures: Record<string, THREE.Texture> = {};
|
|
209
|
-
if (mat.map) textures['map'] = loadParticleTexture(mat.map);
|
|
278
|
+
if (mat.map) textures['map'] = loadParticleTexture(mat.map, mat.mapSampler);
|
|
210
279
|
|
|
211
280
|
const blending = mat.blending === 'normal' ? THREE.NormalBlending : THREE.AdditiveBlending;
|
|
212
281
|
const transparent = mat.transparent !== false;
|
|
@@ -271,7 +340,7 @@ export interface ParticleSystemResult {
|
|
|
271
340
|
* The non-JSON companion to a {@link ParticlesDescriptor} — the runtime objects
|
|
272
341
|
* a JSON document cannot carry, and an authoring toggle. Every field is
|
|
273
342
|
* optional and the whole argument is optional, so existing single-argument
|
|
274
|
-
* callers
|
|
343
|
+
* callers are unchanged.
|
|
275
344
|
*
|
|
276
345
|
* This is how a caller with fidelity a plain descriptor cannot express reaches
|
|
277
346
|
* the SAME factory instead of hand-building its own `new ParticleSystem(...)`:
|
|
@@ -439,22 +508,36 @@ export function scrubParticleSystemTo(system: ParticleSystem, time: number, step
|
|
|
439
508
|
|
|
440
509
|
/**
|
|
441
510
|
* Register a particle system with a BatchedRenderer.
|
|
511
|
+
*
|
|
512
|
+
* This is also the ONE seat that arms the scene-depth prepass: a system built
|
|
513
|
+
* with `softParticles` compiles its batch with three.quarks' `SOFT_PARTICLES`
|
|
514
|
+
* define, which samples a `depthTexture` uniform nothing else in this engine
|
|
515
|
+
* would ever fill (see `render/soft-particle-depth.ts` for what an unfilled one
|
|
516
|
+
* does to the frame). Arming here — rather than in
|
|
517
|
+
* {@link createParticleSystemFromData} — is what makes it correct: the uniform
|
|
518
|
+
* lives on the BATCH, and no batch exists until a renderer is handed the
|
|
519
|
+
* system.
|
|
442
520
|
*/
|
|
443
521
|
export function registerParticleSystem(
|
|
444
522
|
batchedRenderer: BatchedRenderer,
|
|
445
523
|
system: ParticleSystem,
|
|
446
524
|
): void {
|
|
447
525
|
batchedRenderer.addSystem(system);
|
|
526
|
+
if (system.softParticles) armSoftParticleDepth(batchedRenderer);
|
|
448
527
|
}
|
|
449
528
|
|
|
450
529
|
/**
|
|
451
530
|
* Unregister a particle system from a BatchedRenderer.
|
|
531
|
+
*
|
|
532
|
+
* Disarms the depth prepass — one decrement against the arming above, so a
|
|
533
|
+
* renderer hosting several soft systems stays armed until its last one leaves.
|
|
452
534
|
*/
|
|
453
535
|
export function unregisterParticleSystem(
|
|
454
536
|
batchedRenderer: BatchedRenderer,
|
|
455
537
|
system: ParticleSystem,
|
|
456
538
|
): void {
|
|
457
539
|
batchedRenderer.deleteSystem(system);
|
|
540
|
+
if (system.softParticles) disarmSoftParticleDepth(batchedRenderer);
|
|
458
541
|
}
|
|
459
542
|
|
|
460
543
|
/**
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scene DEPTH for three.quarks' soft particles — the pass that makes
|
|
3
|
+
* `ParticlesDescriptor.softParticles` a real effect instead of a footgun.
|
|
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. {@link armSoftParticleDepth}
|
|
65
|
+
* is called from `particles-factory.ts`'s `registerParticleSystem` when the
|
|
66
|
+
* system it is registering asked for `softParticles`; with the registry empty
|
|
67
|
+
* {@link SoftParticleDepthPass.render} returns before it touches the scene, so a
|
|
68
|
+
* world with no soft particles pays one `Set.size` read per frame and nothing
|
|
69
|
+
* else. Armed, per frame per camera it costs: one scene traverse (to find the
|
|
70
|
+
* transparent surfaces to hide), one extra geometry-only scene draw with shadow
|
|
71
|
+
* updates suppressed, and a drawing-buffer-sized depth target
|
|
72
|
+
* (`UnsignedIntType` depth + an unread RGBA8 colour attachment ≈ 8
|
|
73
|
+
* bytes/pixel).
|
|
74
|
+
*
|
|
75
|
+
* ## Why TRANSPARENT surfaces are hidden
|
|
76
|
+
*
|
|
77
|
+
* Godot's own `DEPTH_TEXTURE` — the one `proximity_fade_enabled` samples — is
|
|
78
|
+
* the OPAQUE pass's depth: `BaseMaterial3D`'s default `depth_draw_opaque` means
|
|
79
|
+
* a transparent material contributes no depth. Reproducing that is not
|
|
80
|
+
* cosmetic; without it the starter kit's coin would fade its stars against the
|
|
81
|
+
* translucent glow quad sitting in front of them rather than against the floor.
|
|
82
|
+
*
|
|
83
|
+
* ## Resource ownership
|
|
84
|
+
*
|
|
85
|
+
* - {@link farDepthTexture}'s 1x1 texture is a module singleton, created on
|
|
86
|
+
* first use and NEVER disposed: it is bound into every renderer's uniforms as
|
|
87
|
+
* the safe default, so there is no moment at which the last user is known.
|
|
88
|
+
* One texel, one allocation per process.
|
|
89
|
+
* - The armed-renderer registry is written by exactly two functions,
|
|
90
|
+
* {@link armSoftParticleDepth} / {@link disarmSoftParticleDepth}, whose only
|
|
91
|
+
* callers are `registerParticleSystem` / `unregisterParticleSystem`.
|
|
92
|
+
* - The render target and override material belong to the
|
|
93
|
+
* {@link SoftParticleDepthPass} instance and die with its `dispose()`. The
|
|
94
|
+
* pass itself is owned by whichever adapter mount created it (the R3F adapter
|
|
95
|
+
* and `setup-three-root-adapter`), and that mount's teardown is the one path
|
|
96
|
+
* allowed to end it.
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
import * as THREE from 'three';
|
|
100
|
+
import type { BatchedRenderer } from 'three.quarks';
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The renderers hosting at least one system that asked for `softParticles`, and
|
|
104
|
+
* HOW MANY — a renderer may batch several emitters and must stay armed until
|
|
105
|
+
* the last one leaves.
|
|
106
|
+
*
|
|
107
|
+
* Module-scoped because the pass is per-MOUNT while a renderer is per-emitter:
|
|
108
|
+
* two worlds may be mounted at once and each pass filters this map down to the
|
|
109
|
+
* renderers actually parented under the scene it is rendering.
|
|
110
|
+
*/
|
|
111
|
+
const armed = new Map<BatchedRenderer, number>();
|
|
112
|
+
|
|
113
|
+
let farDepth: THREE.DataTexture | undefined;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A 1x1 texture whose red channel is 1.0 — "the depth buffer says nothing is in
|
|
117
|
+
* front of anything".
|
|
118
|
+
*
|
|
119
|
+
* Bound to a `BatchedRenderer` the moment it is constructed, so a batch that
|
|
120
|
+
* later compiles with `SOFT_PARTICLES` inherits it (`BatchedRenderer.addSystem`
|
|
121
|
+
* applies `this.depthTexture` to each batch it creates). Feeding
|
|
122
|
+
* `linearize_depth` a `1.0` yields `(zFar * zNear) / (zFar - (zFar - zNear))`,
|
|
123
|
+
* which is `zFar`, so the fade term is `saturate(k * (zFar - linearDepth))` — 1
|
|
124
|
+
* for every fragment in front of the far plane. The fade is INERT rather than
|
|
125
|
+
* wrong, which is what makes an undriven surface (a design-time preview, a host
|
|
126
|
+
* with no pass) render exactly as it did before soft particles existed.
|
|
127
|
+
*/
|
|
128
|
+
export function farDepthTexture(): THREE.DataTexture {
|
|
129
|
+
if (farDepth === undefined) {
|
|
130
|
+
farDepth = new THREE.DataTexture(new Uint8Array([255, 255, 255, 255]), 1, 1);
|
|
131
|
+
farDepth.name = 'soft-particle-far-depth';
|
|
132
|
+
farDepth.needsUpdate = true;
|
|
133
|
+
}
|
|
134
|
+
return farDepth;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Record that `renderer` hosts one more soft-particle system, so passes stop skipping it. */
|
|
138
|
+
export function armSoftParticleDepth(renderer: BatchedRenderer): void {
|
|
139
|
+
armed.set(renderer, (armed.get(renderer) ?? 0) + 1);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The other half of {@link armSoftParticleDepth}. On the LAST soft system the
|
|
144
|
+
* renderer is dropped from the registry and handed the inert far-depth default
|
|
145
|
+
* back, so a batch that outlives its registration never keeps sampling a scene
|
|
146
|
+
* depth nobody is refreshing.
|
|
147
|
+
*/
|
|
148
|
+
export function disarmSoftParticleDepth(renderer: BatchedRenderer): void {
|
|
149
|
+
const remaining = (armed.get(renderer) ?? 0) - 1;
|
|
150
|
+
if (remaining > 0) {
|
|
151
|
+
armed.set(renderer, remaining);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (!armed.delete(renderer)) return;
|
|
155
|
+
renderer.setDepthTexture(farDepthTexture());
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Test/diagnostic read of the registry — how many renderers a pass would consider. */
|
|
159
|
+
export function armedSoftParticleRendererCount(): number {
|
|
160
|
+
return armed.size;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The subset of `THREE.WebGLRenderer` this pass drives, so a host can supply its own. */
|
|
164
|
+
export interface SoftParticleDepthRenderer {
|
|
165
|
+
getDrawingBufferSize(target: THREE.Vector2): THREE.Vector2;
|
|
166
|
+
getRenderTarget(): THREE.WebGLRenderTarget | null;
|
|
167
|
+
setRenderTarget(target: THREE.WebGLRenderTarget | null): void;
|
|
168
|
+
render(scene: THREE.Object3D, camera: THREE.Camera): void;
|
|
169
|
+
/** Optional because a stub renderer legitimately has none — see {@link SoftParticleDepthPass}. */
|
|
170
|
+
readonly shadowMap?: { autoUpdate: boolean; needsUpdate: boolean };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface SoftParticleDepthPass {
|
|
174
|
+
/**
|
|
175
|
+
* Draw scene depth and hand it to every armed renderer under `scene`.
|
|
176
|
+
*
|
|
177
|
+
* A no-op — not one draw call, not one traversal — while nothing is armed.
|
|
178
|
+
* Call it after gameplay has moved the frame's transforms and before the
|
|
179
|
+
* frame is drawn.
|
|
180
|
+
*/
|
|
181
|
+
render(renderer: SoftParticleDepthRenderer, scene: THREE.Scene, camera: THREE.Camera): void;
|
|
182
|
+
dispose(): void;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Is `node` inside `root`'s subtree? Walks parents, so it is O(depth), not O(scene). */
|
|
186
|
+
function isUnder(node: THREE.Object3D, root: THREE.Object3D): boolean {
|
|
187
|
+
for (let at: THREE.Object3D | null = node; at !== null; at = at.parent) {
|
|
188
|
+
if (at === root) return true;
|
|
189
|
+
}
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Godot's `depth_draw_opaque`: a transparent surface contributes no depth. */
|
|
194
|
+
function drawsIntoDepth(object: THREE.Object3D): boolean {
|
|
195
|
+
const material = (object as THREE.Mesh).material as THREE.Material | THREE.Material[] | undefined;
|
|
196
|
+
if (material === undefined) return true;
|
|
197
|
+
return Array.isArray(material)
|
|
198
|
+
? !material.every((entry) => entry.transparent)
|
|
199
|
+
: !material.transparent;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The three renderer members this prepass CANNOT do without — the render-target
|
|
204
|
+
* swap it draws through, and the buffer size it sizes that target from.
|
|
205
|
+
*
|
|
206
|
+
* A real `WebGLRenderer` has all of them. A DESIGN-TIME settle does not: a
|
|
207
|
+
* stand-in that only has to answer `render` mounts a world so the editor can
|
|
208
|
+
* read it, and reaching for `getDrawingBufferSize` on one threw
|
|
209
|
+
* `renderer.getDrawingBufferSize is not a function` out of the first driven
|
|
210
|
+
* frame — so a world whose particles merely *declare* a proximity fade could
|
|
211
|
+
* not be settled at all. A prepass is an EFFECT: with no renderer to run it on,
|
|
212
|
+
* the right answer is the inert far-depth default every batch is already bound
|
|
213
|
+
* to (see {@link farDepthTexture}), which is pixel-identical to a batch that
|
|
214
|
+
* never asked for soft particles. So this degrades, loudly and once, instead of
|
|
215
|
+
* taking the frame down with it.
|
|
216
|
+
*/
|
|
217
|
+
const REQUIRED_RENDERER_MEMBERS = [
|
|
218
|
+
'getDrawingBufferSize',
|
|
219
|
+
'getRenderTarget',
|
|
220
|
+
'setRenderTarget',
|
|
221
|
+
'render',
|
|
222
|
+
] as const;
|
|
223
|
+
|
|
224
|
+
/** The required members `renderer` does not implement, in declaration order. */
|
|
225
|
+
function missingRendererMembers(renderer: SoftParticleDepthRenderer): string[] {
|
|
226
|
+
const held = renderer as unknown as Record<string, unknown>;
|
|
227
|
+
return REQUIRED_RENDERER_MEMBERS.filter((name) => typeof held[name] !== 'function');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function createSoftParticleDepthPass(): SoftParticleDepthPass {
|
|
231
|
+
let target: THREE.WebGLRenderTarget | null = null;
|
|
232
|
+
/** Warned about already — one line per pass, not one per frame. */
|
|
233
|
+
let warnedIncapable = false;
|
|
234
|
+
const override = new THREE.MeshBasicMaterial({ colorWrite: false });
|
|
235
|
+
override.name = 'soft-particle-depth-prepass';
|
|
236
|
+
const size = new THREE.Vector2();
|
|
237
|
+
const hidden: THREE.Object3D[] = [];
|
|
238
|
+
|
|
239
|
+
const resize = (width: number, height: number): THREE.WebGLRenderTarget => {
|
|
240
|
+
if (target !== null && target.width === width && target.height === height) return target;
|
|
241
|
+
target?.dispose();
|
|
242
|
+
const depthTexture = new THREE.DepthTexture(width, height, THREE.UnsignedIntType);
|
|
243
|
+
depthTexture.name = 'soft-particle-scene-depth';
|
|
244
|
+
const next = new THREE.WebGLRenderTarget(width, height, { depthBuffer: true, depthTexture });
|
|
245
|
+
target = next;
|
|
246
|
+
return next;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
render(renderer, scene, camera): void {
|
|
251
|
+
if (armed.size === 0) return;
|
|
252
|
+
const consumers: BatchedRenderer[] = [];
|
|
253
|
+
for (const candidate of armed.keys()) {
|
|
254
|
+
if (isUnder(candidate, scene)) consumers.push(candidate);
|
|
255
|
+
}
|
|
256
|
+
if (consumers.length === 0) return;
|
|
257
|
+
|
|
258
|
+
// See REQUIRED_RENDERER_MEMBERS: a renderer that cannot swap render
|
|
259
|
+
// targets cannot run a prepass, and the frame is worth more than the
|
|
260
|
+
// fade. Every consumer keeps the far-depth default it was armed with.
|
|
261
|
+
const missing = missingRendererMembers(renderer);
|
|
262
|
+
if (missing.length > 0) {
|
|
263
|
+
if (!warnedIncapable) {
|
|
264
|
+
warnedIncapable = true;
|
|
265
|
+
// biome-ignore lint/suspicious/noConsole: deliberate loud degrade — the documented alternative to throwing out of the frame
|
|
266
|
+
console.warn(
|
|
267
|
+
`soft-particle depth prepass skipped: this renderer implements none of ${missing.join(', ')}. ` +
|
|
268
|
+
`${String(consumers.length)} particle batch(es) keep the inert far-depth default, so they ` +
|
|
269
|
+
'draw unfaded — the same pixels as a batch that never declared a proximity fade. A real ' +
|
|
270
|
+
'WebGLRenderer has these; a design-time settle stand-in does not.',
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
renderer.getDrawingBufferSize(size);
|
|
277
|
+
const width = Math.max(1, Math.floor(size.x));
|
|
278
|
+
const height = Math.max(1, Math.floor(size.y));
|
|
279
|
+
const rt = resize(width, height);
|
|
280
|
+
|
|
281
|
+
// Hidden for the prepass: the particles themselves (they are what samples
|
|
282
|
+
// this depth) and every transparent surface (Godot's opaque-only depth).
|
|
283
|
+
hidden.length = 0;
|
|
284
|
+
for (const consumer of consumers) {
|
|
285
|
+
if (consumer.visible) {
|
|
286
|
+
consumer.visible = false;
|
|
287
|
+
hidden.push(consumer);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
scene.traverse((object) => {
|
|
291
|
+
if (!object.visible) return;
|
|
292
|
+
if (drawsIntoDepth(object)) return;
|
|
293
|
+
object.visible = false;
|
|
294
|
+
hidden.push(object);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const previousTarget = renderer.getRenderTarget();
|
|
298
|
+
const previousOverride = scene.overrideMaterial;
|
|
299
|
+
// three re-renders every shadow map on EVERY `render()` call while
|
|
300
|
+
// `shadowMap.autoUpdate` is on, so without this the prepass would double
|
|
301
|
+
// the frame's shadow cost for a pass that writes depth and nothing else.
|
|
302
|
+
// Both flags are restored below; the frame's own draw re-renders them.
|
|
303
|
+
const shadows = renderer.shadowMap;
|
|
304
|
+
const previousAutoUpdate = shadows?.autoUpdate ?? false;
|
|
305
|
+
const previousNeedsUpdate = shadows?.needsUpdate ?? false;
|
|
306
|
+
if (shadows !== undefined) {
|
|
307
|
+
shadows.autoUpdate = false;
|
|
308
|
+
shadows.needsUpdate = false;
|
|
309
|
+
}
|
|
310
|
+
scene.overrideMaterial = override;
|
|
311
|
+
try {
|
|
312
|
+
renderer.setRenderTarget(rt);
|
|
313
|
+
renderer.render(scene, camera);
|
|
314
|
+
} finally {
|
|
315
|
+
renderer.setRenderTarget(previousTarget);
|
|
316
|
+
scene.overrideMaterial = previousOverride;
|
|
317
|
+
if (shadows !== undefined) {
|
|
318
|
+
shadows.autoUpdate = previousAutoUpdate;
|
|
319
|
+
shadows.needsUpdate = previousNeedsUpdate;
|
|
320
|
+
}
|
|
321
|
+
for (const object of hidden) object.visible = true;
|
|
322
|
+
hidden.length = 0;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
for (const consumer of consumers) consumer.setDepthTexture(rt.depthTexture);
|
|
326
|
+
},
|
|
327
|
+
dispose(): void {
|
|
328
|
+
// Every consumer this pass fed now points at a texture that is about to
|
|
329
|
+
// go away. Hand them the inert default back rather than a disposed one.
|
|
330
|
+
for (const candidate of armed.keys()) candidate.setDepthTexture(farDepthTexture());
|
|
331
|
+
target?.dispose();
|
|
332
|
+
target = null;
|
|
333
|
+
override.dispose();
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}
|