@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.
Files changed (70) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +12 -0
  3. package/package.json +62 -0
  4. package/src/adapter/body-marks.ts +69 -0
  5. package/src/adapter/constraint.ts +78 -0
  6. package/src/adapter/first-party-navigation-system.ts +48 -0
  7. package/src/adapter/hierarchy-marks.ts +156 -0
  8. package/src/adapter/ingest/scene-capture.ts +865 -0
  9. package/src/adapter/ingest/structural-ids.ts +139 -0
  10. package/src/adapter/ingest/visible-capture-window.ts +302 -0
  11. package/src/adapter/object3d-authoring-subject.ts +50 -0
  12. package/src/adapter/rapier-physics-adapter.ts +226 -0
  13. package/src/adapter/reflection-probe.ts +75 -0
  14. package/src/adapter/renderer-config.ts +116 -0
  15. package/src/adapter/trigger-volume.ts +29 -0
  16. package/src/ai/navigation.ts +627 -0
  17. package/src/animation/animation-clock.ts +479 -0
  18. package/src/animation/runtime-inspection.ts +45 -0
  19. package/src/animation/xstate-animation-binding.ts +655 -0
  20. package/src/animation/xstate-animation-meta.ts +360 -0
  21. package/src/asset-formats/camera.ts +18 -0
  22. package/src/asset-formats/collider.ts +66 -0
  23. package/src/asset-formats/index.ts +45 -0
  24. package/src/asset-formats/light.ts +50 -0
  25. package/src/asset-formats/material.ts +93 -0
  26. package/src/asset-formats/mesh.ts +78 -0
  27. package/src/asset-formats/particles.ts +426 -0
  28. package/src/asset-formats/render-env.ts +318 -0
  29. package/src/asset-formats/tuples.ts +21 -0
  30. package/src/asset-loaders.ts +242 -0
  31. package/src/asset-parse-error.ts +29 -0
  32. package/src/assets.ts +68 -0
  33. package/src/behavior/xstate-inspection.ts +51 -0
  34. package/src/defaults.ts +135 -0
  35. package/src/ecs/object-marks.ts +75 -0
  36. package/src/ecs/scene-index.ts +439 -0
  37. package/src/ecs/scene-query.ts +43 -0
  38. package/src/ecs/user-data.ts +272 -0
  39. package/src/loader.ts +134 -0
  40. package/src/physics/collider-dimensions.ts +121 -0
  41. package/src/physics/collision-system.ts +76 -0
  42. package/src/physics/physics-registry.ts +83 -0
  43. package/src/physics/transform-writer.ts +41 -0
  44. package/src/render/directional-shadow-fit.ts +158 -0
  45. package/src/render/environment-capture.ts +133 -0
  46. package/src/render/ibl-override-material.ts +202 -0
  47. package/src/render/light-camera-factory.ts +103 -0
  48. package/src/render/matcap-texture.ts +92 -0
  49. package/src/render/material-factory.ts +201 -0
  50. package/src/render/mesh-shadow.ts +18 -0
  51. package/src/render/particles-factory.ts +589 -0
  52. package/src/render/render-batch-system.ts +260 -0
  53. package/src/render/render-features.ts +128 -0
  54. package/src/render/render-settings.ts +71 -0
  55. package/src/render/soft-particle-depth.ts +360 -0
  56. package/src/render/spark-renderer-lifecycle.ts +64 -0
  57. package/src/render/structural-signature.ts +231 -0
  58. package/src/render/viewport-shading.ts +163 -0
  59. package/src/render/voxel-surface.ts +358 -0
  60. package/src/setup/setup-particles.ts +32 -0
  61. package/src/setup/setup-physics.ts +67 -0
  62. package/src/setup/setup-renderer.ts +524 -0
  63. package/src/types-n8ao.d.ts +37 -0
  64. package/src/types-realism-effects.d.ts +61 -0
  65. package/vendor/realism-effects/LICENSE.md +21 -0
  66. package/vendor/realism-effects/UPSTREAM.md +19 -0
  67. package/vendor/realism-effects/dist/index.cjs +3447 -0
  68. package/vendor/realism-effects/dist/index.d.ts +59 -0
  69. package/vendor/realism-effects/dist/index.js +3434 -0
  70. package/vendor/realism-effects/package.json +23 -0
@@ -0,0 +1,83 @@
1
+ import type RAPIER from '@dimforge/rapier3d-compat';
2
+ import type * as THREE from 'three';
3
+
4
+ /** The Rapier handles owned by a single Object3D entity. */
5
+ export interface PhysicsRefs {
6
+ body: RAPIER.RigidBody;
7
+ collider: RAPIER.Collider;
8
+ }
9
+
10
+ /**
11
+ * Maps Three.js Object3Ds to their Rapier bodies/colliders, and Rapier collider
12
+ * handles back to Object3Ds.
13
+ *
14
+ * Replaces the old bitecs RigidBodyRef/ColliderRef SoA components. Two indexes:
15
+ * - `Object3D → {body, collider}` for the postPhysics transform writer and for
16
+ * game code/editor to reach an entity's physics.
17
+ * - `colliderHandle → Object3D` reverse index for collision/trigger dispatch
18
+ * (Rapier collision events report collider handles).
19
+ */
20
+ export function createPhysicsRegistry() {
21
+ const byObject = new Map<THREE.Object3D, PhysicsRefs>();
22
+ const byColliderHandle = new Map<number, THREE.Object3D>();
23
+ const removeListeners: Array<(object3D: THREE.Object3D, refs: PhysicsRefs) => void> = [];
24
+
25
+ return {
26
+ /** Register an Object3D's body + collider, indexing both directions. */
27
+ add(object3D: THREE.Object3D, body: RAPIER.RigidBody, collider: RAPIER.Collider): void {
28
+ byObject.set(object3D, { body, collider });
29
+ byColliderHandle.set(collider.handle, object3D);
30
+ },
31
+
32
+ /** Get the physics refs for an Object3D, if any. */
33
+ get(object3D: THREE.Object3D): PhysicsRefs | undefined {
34
+ return byObject.get(object3D);
35
+ },
36
+
37
+ /** Resolve a Rapier collider handle to the Object3D that owns it. */
38
+ getByColliderHandle(handle: number): THREE.Object3D | undefined {
39
+ return byColliderHandle.get(handle);
40
+ },
41
+
42
+ /**
43
+ * Register a callback fired synchronously, just before an entry is torn
44
+ * down, for every `remove()` call (both indexes are still intact at call
45
+ * time). Used by trigger-dispatch (T1.12d) to flush any pending
46
+ * `onTriggerExit` for a sensor overlap involving this object before the
47
+ * collider handle becomes unresolvable — otherwise an entity despawned
48
+ * between the physical exit and the next `collisions.drain()` silently
49
+ * drops the exit callback on its still-live overlap partner.
50
+ */
51
+ onRemove(listener: (object3D: THREE.Object3D, refs: PhysicsRefs) => void): void {
52
+ removeListeners.push(listener);
53
+ },
54
+
55
+ /** Remove an Object3D's entry from both indexes. */
56
+ remove(object3D: THREE.Object3D): void {
57
+ const refs = byObject.get(object3D);
58
+ if (refs) {
59
+ for (const listener of removeListeners) listener(object3D, refs);
60
+ byColliderHandle.delete(refs.collider.handle);
61
+ }
62
+ byObject.delete(object3D);
63
+ },
64
+
65
+ /** Iterate every (Object3D, refs) pair — used by the transform writer. */
66
+ entries(): IterableIterator<[THREE.Object3D, PhysicsRefs]> {
67
+ return byObject.entries();
68
+ },
69
+
70
+ /** Number of registered physics entities. */
71
+ get size(): number {
72
+ return byObject.size;
73
+ },
74
+
75
+ /** Clear both indexes (does not free Rapier bodies). */
76
+ clear(): void {
77
+ byObject.clear();
78
+ byColliderHandle.clear();
79
+ },
80
+ };
81
+ }
82
+
83
+ export type PhysicsRegistry = ReturnType<typeof createPhysicsRegistry>;
@@ -0,0 +1,41 @@
1
+ import * as THREE from 'three';
2
+ import type { PhysicsRegistry } from './physics-registry';
3
+
4
+ const _quat = new THREE.Quaternion();
5
+ const _parentInv = new THREE.Matrix4();
6
+ const _parentQuat = new THREE.Quaternion();
7
+
8
+ /**
9
+ * Build the single postPhysics transform writer.
10
+ *
11
+ * Replaces the old three-step path (physicsSyncSystem → ECS Position/Rotation →
12
+ * vnode-manager.syncTransforms). Rapier always reports world-space transforms, so
13
+ * for objects parented under something other than the scene root we convert the
14
+ * body's world position/rotation into the parent's local space (P1.5) — the old
15
+ * vnode path wrote world transforms as local and mis-placed parented children.
16
+ *
17
+ * Scale is left untouched: Rapier carries no scale, so the Object3D keeps its
18
+ * authored local scale.
19
+ */
20
+ export function createTransformWriter(physics: PhysicsRegistry, sceneRoot: THREE.Object3D) {
21
+ return function writeTransforms(): void {
22
+ for (const [object3D, { body }] of physics.entries()) {
23
+ const t = body.translation();
24
+ const r = body.rotation();
25
+ const parent = object3D.parent;
26
+
27
+ if (parent && parent !== sceneRoot) {
28
+ // World → local: position through the inverse parent world matrix,
29
+ // rotation through the inverse parent world quaternion.
30
+ parent.updateWorldMatrix(true, false);
31
+ _parentInv.copy(parent.matrixWorld).invert();
32
+ object3D.position.set(t.x, t.y, t.z).applyMatrix4(_parentInv);
33
+ parent.getWorldQuaternion(_parentQuat).invert();
34
+ object3D.quaternion.copy(_parentQuat.multiply(_quat.set(r.x, r.y, r.z, r.w)));
35
+ } else {
36
+ object3D.position.set(t.x, t.y, t.z);
37
+ object3D.quaternion.set(r.x, r.y, r.z, r.w);
38
+ }
39
+ }
40
+ };
41
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Fit a directional light's orthographic shadow box to the camera, for one
3
+ * frame — the geometry three does not supply.
4
+ *
5
+ * `DirectionalLightShadow` is an ordinary `OrthographicCamera` parented to the
6
+ * light, and it sits wherever the light's authored transform puts it, forever.
7
+ * That is fine for a scene the authored box contains and wrong for every game
8
+ * whose camera travels: the moment the player walks past the box, the casters
9
+ * out there stop having shadows, and no bias, map size or intensity fixes it
10
+ * because the geometry is simply outside the frustum. Three's own addon CSM is
11
+ * the multi-cascade answer to a different question and supplies neither of the
12
+ * two pieces below.
13
+ *
14
+ * ## The box is the frustum slice's BOUNDING SPHERE
15
+ *
16
+ * The standard fit, and the reason it cannot be authored as a constant: take
17
+ * the eight corners of the camera's frustum between its near plane and
18
+ * `maxDistance`, put the box centre at their centroid, and size the box to the
19
+ * largest distance from that centroid to a corner. That radius is decided by
20
+ * the camera's FIELD OF VIEW and aspect as much as by `maxDistance` — at a
21
+ * 70-degree vertical fov over 1024x600, a 40-unit slice has half-extents of
22
+ * 47.8 x 28.0 at its far plane and the sphere around it has radius ~58.9,
23
+ * nearly three times `maxDistance / 2`. A bounding SPHERE (rather than the
24
+ * tight box) is what makes the radius independent of camera yaw, so turning in
25
+ * place cannot resize the shadow.
26
+ *
27
+ * The corners are read through the camera's own `projectionMatrixInverse`, so
28
+ * a perspective and an orthographic camera are exact with no branch on type —
29
+ * and an asymmetric frustum (`setViewOffset`) is fitted over its real corners
30
+ * rather than an assumed centre.
31
+ *
32
+ * ## Texel snapping is not a polish detail
33
+ *
34
+ * A shadow box that follows a camera continuously re-rasterizes the same
35
+ * static geometry at sub-texel offsets, and the depth-test result flips along
36
+ * every shadow edge from frame to frame — the edges CRAWL, which is far more
37
+ * distracting than the missing shadows the fit is for. Quantizing the centre
38
+ * to whole shadow texels, in the LIGHT's own basis (the shadow map's u/v axes
39
+ * are the light's local x/y), makes the rasterized footprint of a static
40
+ * caster identical between frames. Depth (local z) is deliberately NOT snapped:
41
+ * it does not affect which texel a fragment lands in.
42
+ *
43
+ * ## What the caller keeps
44
+ *
45
+ * This is one pure function over three's own objects: it reads a camera and a
46
+ * quaternion and returns three numbers-and-a-vector. It moves nothing, and in
47
+ * particular it does not touch the light — how a fitted centre reaches the
48
+ * shadow camera is the caller's, because three derives the shadow camera's
49
+ * placement FROM the light every frame (`LightShadow.updateMatrices` copies the
50
+ * light's world position and looks at `light.target`), so the thing that has to
51
+ * move is the light NODE and only the caller knows how its light is parented,
52
+ * aimed and torn down. The depth range (`shadow.camera.near`/`far`) is the
53
+ * caller's for the same reason: it is what `shadow.bias` is normalized against.
54
+ *
55
+ * {@link DirectionalShadowFit.texelWorldSize} is published because everything
56
+ * sized in texels moves with the box — a receiver-side `shadow.normalBias`
57
+ * derived from a stale texel is wrong by exactly the factor the box grew, and
58
+ * it surfaces as self-shadow acne on curved casters rather than as anything a
59
+ * reviewer would call a shadow bug.
60
+ *
61
+ * Consumers today: `top-down-strategy`'s camera-fitted sun, and
62
+ * `godot-compat`'s Godot 3 `SHADOW_ORTHOGONAL` fit.
63
+ *
64
+ * The Godot lane is ARCHIVED off main — `git fetch origin archive/godot-lane`, tag `archive/godot-lane-2026-09-19`.
65
+ */
66
+ import { type Camera, Quaternion, Vector3 } from 'three';
67
+
68
+ /** The box a directional shadow is rendered through for one frame. */
69
+ export interface DirectionalShadowFit {
70
+ /** WORLD-space centre of the box, quantized to whole shadow texels in the light's basis. */
71
+ readonly center: Vector3;
72
+ /** Half-extent on both lateral axes — the frustum slice's bounding-sphere radius. */
73
+ readonly radius: number;
74
+ /** One shadow texel in world units at this radius: `2 * radius / mapSize`. */
75
+ readonly texelWorldSize: number;
76
+ }
77
+
78
+ const _inverseLight = new Quaternion();
79
+ const _corner = new Vector3();
80
+ const _centroid = new Vector3();
81
+ const _ndc = new Vector3();
82
+ /** The four lateral NDC corners; the slice's eight are these at two depths. */
83
+ const NDC_CORNERS: readonly (readonly [number, number])[] = [
84
+ [-1, -1],
85
+ [-1, 1],
86
+ [1, -1],
87
+ [1, 1],
88
+ ];
89
+
90
+ /** View-space depth `-z` as this camera's NDC z, through the camera's OWN projection — so the
91
+ * slice is exact for a perspective and an orthographic camera alike, with no branch on type. */
92
+ function ndcDepthOf(camera: Camera, viewDepth: number): number {
93
+ return _ndc.set(0, 0, -viewDepth).applyMatrix4(camera.projectionMatrix).z;
94
+ }
95
+
96
+ /**
97
+ * The bounding sphere of `camera`'s frustum between its near plane and
98
+ * `maxDistance`, with the centre quantized to whole shadow texels in the
99
+ * LIGHT's basis.
100
+ *
101
+ * `camera` is read through its own `projectionMatrix`/`projectionMatrixInverse`/`matrixWorld`, so a
102
+ * caller must have brought those up to date (three does this for every rendered camera).
103
+ * `maxDistance` is the view depth the shadow covers, measured from the camera and clamped to its
104
+ * far plane — there is nothing to shadow past the geometry the camera draws.
105
+ * `lightOrientation` is the light's WORLD quaternion, whose local x/y are the shadow map's own axes
106
+ * and therefore the axes the snap quantizes along. A `mapSize` of zero or less disables snapping
107
+ * rather than dividing by it.
108
+ */
109
+ export function fitDirectionalShadowToCamera(
110
+ camera: Camera,
111
+ lightOrientation: Quaternion,
112
+ maxDistance: number,
113
+ mapSize: number,
114
+ ): DirectionalShadowFit {
115
+ const frustum = camera as Camera & { near?: number; far?: number };
116
+ const camNear = typeof frustum.near === 'number' ? frustum.near : 0;
117
+ const camFar = typeof frustum.far === 'number' ? frustum.far : maxDistance;
118
+ // The two clamps are degenerate-input guards: far at least a hair past near, near never past far.
119
+ let far = Math.min(maxDistance, camFar);
120
+ far = Math.max(far, camNear + 0.001);
121
+ const near = Math.min(camNear, far);
122
+ const nearNdc = ndcDepthOf(camera, near);
123
+ const farNdc = ndcDepthOf(camera, far);
124
+
125
+ // The centroid of the eight corners. Both planes contribute four symmetric corners, so for an
126
+ // ordinary camera this is `(near + far) / 2` down -Z — but it is computed rather than assumed,
127
+ // because `setViewOffset` makes a frustum asymmetric and the fit is over the real corners.
128
+ _centroid.set(0, 0, 0);
129
+ for (const ndcZ of [nearNdc, farNdc]) {
130
+ for (const [x, y] of NDC_CORNERS) {
131
+ _centroid.add(_corner.set(x, y, ndcZ).applyMatrix4(camera.projectionMatrixInverse));
132
+ }
133
+ }
134
+ _centroid.multiplyScalar(1 / 8);
135
+
136
+ let radius = 0;
137
+ for (const ndcZ of [nearNdc, farNdc]) {
138
+ for (const [x, y] of NDC_CORNERS) {
139
+ radius = Math.max(
140
+ radius,
141
+ _corner.set(x, y, ndcZ).applyMatrix4(camera.projectionMatrixInverse).distanceTo(_centroid),
142
+ );
143
+ }
144
+ }
145
+ // One texel of margin, so the snapped box never clips its own outermost texel: the snap below
146
+ // moves the centre by up to half a texel on each lateral axis.
147
+ if (mapSize > 2) radius *= mapSize / (mapSize - 2);
148
+
149
+ const center = _centroid.clone().applyMatrix4(camera.matrixWorld);
150
+ const texelWorldSize = mapSize > 0 ? (2 * radius) / mapSize : 0;
151
+ if (texelWorldSize > 0) {
152
+ center.applyQuaternion(_inverseLight.copy(lightOrientation).invert());
153
+ center.x = Math.round(center.x / texelWorldSize) * texelWorldSize;
154
+ center.y = Math.round(center.y / texelWorldSize) * texelWorldSize;
155
+ center.applyQuaternion(lightOrientation);
156
+ }
157
+ return { center, radius, texelWorldSize };
158
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Restricting a scene for the duration of ONE environment capture, and asking
3
+ * a renderer whether it can run one at all.
4
+ *
5
+ * A cube capture renders the live scene into an offscreen target, so whatever
6
+ * it must exclude has to be turned off and turned back on around that single
7
+ * render — never left flipped, and never flipped through a renderer-wide
8
+ * switch (see {@link withCaptureShadows} for the measured reason). Both
9
+ * helpers restore in a `finally`, so a capture that throws does not leave the
10
+ * game altered.
11
+ *
12
+ * Consumers today: the `reflections` capability's native probe system, and
13
+ * `godot-compat`'s versioned Godot `ReflectionProbe` binding.
14
+ *
15
+ * The Godot lane is ARCHIVED off main — `git fetch origin archive/godot-lane`, tag `archive/godot-lane-2026-09-19`.
16
+ */
17
+ import type { Light, Object3D, Scene, WebGLRenderer } from 'three';
18
+
19
+ /**
20
+ * Hide everything a capture's cull mask excludes, for exactly one capture.
21
+ *
22
+ * The object's side of the test is three's OWN `Object3D.layers.mask`, never a
23
+ * parallel `userData` copy. An engine whose own per-object visibility layers
24
+ * are a 32-bit mask (Godot's `VisualInstance.layers` is the worked case) writes
25
+ * the authored mask straight onto the object and this reads it back. Both
26
+ * default an unannotated object to `1`, which no default mask excludes, so an
27
+ * ordinary scene is untouched.
28
+ */
29
+ export function withCaptureMask(scene: Scene, mask: number, capture: () => void): void {
30
+ const hidden: { object: Object3D; visible: boolean }[] = [];
31
+ scene.traverse((object) => {
32
+ if ((object.layers.mask & mask) !== 0) return;
33
+ hidden.push({ object, visible: object.visible });
34
+ object.visible = false;
35
+ });
36
+ try {
37
+ capture();
38
+ } finally {
39
+ for (const entry of hidden) entry.object.visible = entry.visible;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Suppress every shadow-casting light for the duration of ONE capture — as
45
+ * per-light `LightShadow.intensity`, never as `renderer.shadowMap.enabled`.
46
+ *
47
+ * **The distinction is not stylistic; the global flag silently deletes every
48
+ * shadow in the game.** `renderer.shadowMap.enabled` is a program PARAMETER
49
+ * (`WebGLPrograms.getParameters` → `shadowMapEnabled`), and
50
+ * `WebGLRenderer.setProgram`'s `needsProgramChange` list does NOT watch it —
51
+ * three requires a material version bump for a flip to reach a material that is
52
+ * already compiled. So flipping it around the capture does nothing for compiled
53
+ * materials and everything for the one material compiling INSIDE the window —
54
+ * and a probe system installing a shader override sets `material.needsUpdate =
55
+ * true` in the same `update()` call that runs the capture, so every patched
56
+ * material links with `USE_SHADOWMAP` absent and keeps that program for the
57
+ * rest of the run.
58
+ *
59
+ * Measured on the real Godot 3.6 binary against that lane's emitted port (a
60
+ * white ground, a floating box, one straight-down `DirectionalLight`,
61
+ * PanoramaSky ambient, Filmic white 6): with NO probe the port's shadow pixel
62
+ * is `92,109,181` against Godot's `92,109,181`; adding ONE `ReflectionProbe`
63
+ * moved the port's shadow pixel to `218,222,232` — byte-identical to its own
64
+ * LIT ground, i.e. the shadow was gone — while Godot's moved to `109,146,201`.
65
+ * Asking the capture not to touch shadows at all restored `108,146,200`, which
66
+ * is what named the mechanism. The defect was Godot-shaped; the mechanism is a
67
+ * three.js contract, which is why the fix lives here.
68
+ *
69
+ * `LightShadow.intensity` is a UNIFORM (`shadowmap_pars_fragment`:
70
+ * `mix(1.0, shadow, shadowIntensity)`), refreshed from the light on every
71
+ * `renderer.render()`, so zeroing it for the capture costs no recompile and
72
+ * cannot outlive the `finally` that restores it.
73
+ */
74
+ export function withCaptureShadows(scene: Scene, enabled: boolean, capture: () => void): void {
75
+ if (enabled) {
76
+ capture();
77
+ return;
78
+ }
79
+ const restored: { shadow: { intensity: number }; intensity: number }[] = [];
80
+ scene.traverse((object) => {
81
+ const light = object as Light & { shadow?: { intensity: number } };
82
+ if (!light.isLight || light.shadow === undefined) return;
83
+ restored.push({ shadow: light.shadow, intensity: light.shadow.intensity });
84
+ light.shadow.intensity = 0;
85
+ });
86
+ try {
87
+ capture();
88
+ } finally {
89
+ for (const entry of restored) entry.shadow.intensity = entry.intensity;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Include or suppress the scene's global sky/environment for one cube capture.
95
+ *
96
+ * This is renderer plumbing rather than a Godot policy: the caller decides whether the capture is
97
+ * interior. Clearing both properties is necessary because Three uses `background` for the cube's
98
+ * visible pixels and `environment` for the materials being photographed. Restoring only one makes
99
+ * a nominally isolated capture retain half of the outside lighting.
100
+ */
101
+ export function withCaptureSceneEnvironment(
102
+ scene: Scene,
103
+ enabled: boolean,
104
+ capture: () => void,
105
+ ): void {
106
+ if (enabled) {
107
+ capture();
108
+ return;
109
+ }
110
+ const background = scene.background;
111
+ const environment = scene.environment;
112
+ scene.background = null;
113
+ scene.environment = null;
114
+ try {
115
+ capture();
116
+ } finally {
117
+ scene.background = background;
118
+ scene.environment = environment;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Whether this renderer can run an environment capture at all.
124
+ *
125
+ * The editor's still design-time tree and headless runtimes deliberately supply
126
+ * no GPU. They must keep the authored scene and gameplay; only the
127
+ * renderer-owned capture is unavailable, so a probe system asks this and skips
128
+ * the capture rather than refusing to mount.
129
+ */
130
+ export function canCaptureEnvironment(renderer: WebGLRenderer): boolean {
131
+ const candidate = renderer as WebGLRenderer & { compile?: unknown; coordinateSystem?: unknown };
132
+ return typeof candidate.compile === 'function' && candidate.coordinateSystem !== undefined;
133
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Replace Three's image-based-lighting functions on a `MeshStandardMaterial`,
3
+ * per fragment, without forking three.
4
+ *
5
+ * A local-probe system cannot express itself as `material.envMap`: one envMap
6
+ * per object picks ONE environment for the whole draw, and the decisive work —
7
+ * which probe volume contains this fragment, how it fades at the boundary,
8
+ * what happens where volumes overlap — is per-fragment and can differ across a
9
+ * single `InstancedMesh`. Every such system therefore does the same three
10
+ * things to three's own program, and this module is that shared mechanism:
11
+ *
12
+ * 1. take `ShaderChunk.envmap_physical_pars_fragment` and rename its two
13
+ * entry points, so the caller's replacement can still call them as its
14
+ * fallback ({@link baseIblFunctions});
15
+ * 2. publish the fragment's world position as a varying, spliced into
16
+ * `<worldpos_vertex>` under `USE_ENVMAP` ({@link IBL_WORLD_POSITION});
17
+ * 3. swap the fragment chunk for the caller's own GLSL and extend the
18
+ * program cache key so the variant is not shared with an unpatched
19
+ * material ({@link overrideMaterialIbl}).
20
+ *
21
+ * What the caller keeps is exactly what makes its system ITS system: the
22
+ * lighting model in the GLSL body, the uniforms behind it, and how probes are
23
+ * discovered, scheduled and captured. This module decides none of that — it
24
+ * hands back three's own shader object and gets out of the way.
25
+ *
26
+ * Consumers today: the `reflections` capability's native probe system, and
27
+ * `godot-compat`'s Godot `ReflectionProbe`, whose per-fragment models are
28
+ * deliberately different renderers over this one splice.
29
+ *
30
+ * The Godot lane is ARCHIVED off main — `git fetch origin archive/godot-lane`, tag `archive/godot-lane-2026-09-19`.
31
+ */
32
+ import {
33
+ CubeUVReflectionMapping,
34
+ DataTexture,
35
+ type Mesh,
36
+ type MeshStandardMaterial,
37
+ RGBAFormat,
38
+ ShaderChunk,
39
+ type Texture,
40
+ UnsignedByteType,
41
+ } from 'three';
42
+
43
+ /** The shader object three hands to `onBeforeCompile`. */
44
+ export type IblOverrideShader = Parameters<MeshStandardMaterial['onBeforeCompile']>[0];
45
+
46
+ /**
47
+ * The `varying vec3` the vertex splice publishes and the fragment body reads.
48
+ *
49
+ * It is ONE name across every consumer on purpose: the splice that declares it
50
+ * lives here, so a per-consumer name would be a string two files have to agree
51
+ * on with nothing checking that they do.
52
+ */
53
+ export const IBL_WORLD_POSITION = 'vgaiIblWorldPosition';
54
+
55
+ /**
56
+ * Three's own IBL chunk with `getIBLIrradiance`/`getIBLRadiance` renamed to
57
+ * `getBaseIBLIrradiance`/`getBaseIBLRadiance`.
58
+ *
59
+ * A replacement chunk defines the original names itself; calling the renamed
60
+ * pair is how it falls back to the scene environment where no probe applies.
61
+ */
62
+ export function baseIblFunctions(): string {
63
+ return ShaderChunk.envmap_physical_pars_fragment
64
+ .replaceAll('getIBLIrradiance', 'getBaseIBLIrradiance')
65
+ .replaceAll('getIBLRadiance', 'getBaseIBLRadiance');
66
+ }
67
+
68
+ /**
69
+ * Every material a mesh draws with that {@link overrideMaterialIbl} accepts.
70
+ *
71
+ * `isMeshStandardMaterial` is true for `MeshPhysicalMaterial` too, which is the
72
+ * intent: both compile the physical IBL chunk this module replaces.
73
+ */
74
+ export function standardMaterialsOf(mesh: Mesh): readonly MeshStandardMaterial[] {
75
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
76
+ return materials.filter((material): material is MeshStandardMaterial =>
77
+ Boolean((material as MeshStandardMaterial).isMeshStandardMaterial),
78
+ );
79
+ }
80
+
81
+ /**
82
+ * A black native texture whose only job is to compile Three's IBL branch.
83
+ *
84
+ * Local reflection systems can light a scene from captured probes when neither
85
+ * `scene.environment` nor a material `envMap` exists. Three removes both IBL
86
+ * entry points from that program unless one native input is present, so the
87
+ * caller temporarily assigns this neutral texture and restores what it found.
88
+ */
89
+ export function createIblSentinelTexture(): Texture {
90
+ const texture = new DataTexture(
91
+ new Uint8Array(16 * 16 * 4),
92
+ 16,
93
+ 16,
94
+ RGBAFormat,
95
+ UnsignedByteType,
96
+ );
97
+ texture.mapping = CubeUVReflectionMapping;
98
+ texture.needsUpdate = true;
99
+ return texture;
100
+ }
101
+
102
+ export interface IblOverrideOptions {
103
+ /**
104
+ * The GLSL that replaces `#include <envmap_physical_pars_fragment>`. Read at
105
+ * COMPILE time, not at install time, so a caller whose probe count is still
106
+ * settling does not have to reinstall to change it.
107
+ */
108
+ readonly fragment: () => string;
109
+ /**
110
+ * Appended to the material's own `customProgramCacheKey`, after a `|`. Must
111
+ * distinguish every shape of {@link IblOverrideOptions.fragment} the caller
112
+ * can produce — a probe COUNT belongs in here, because it changes the
113
+ * declared uniform array sizes.
114
+ */
115
+ readonly cacheKey: () => string;
116
+ /**
117
+ * Runs at the end of `onBeforeCompile`, with the shader three is compiling.
118
+ * Seed uniforms here: this is the only moment the caller is handed the
119
+ * object whose `uniforms` map the program will read.
120
+ */
121
+ readonly onCompile?: (shader: IblOverrideShader) => void;
122
+ }
123
+
124
+ export interface IblOverride {
125
+ readonly material: MeshStandardMaterial;
126
+ /**
127
+ * The shader three compiled this material with, or null before the first
128
+ * compile and after {@link IblOverride.restore}. Uniform writes go through
129
+ * `shader.uniforms`.
130
+ */
131
+ readonly shader: IblOverrideShader | null;
132
+ /** Put the material's own `onBeforeCompile`/`customProgramCacheKey` back. */
133
+ restore(): void;
134
+ }
135
+
136
+ /**
137
+ * Install the override. The returned handle is the only way to reach the
138
+ * compiled shader or to undo the patch; the material is otherwise untouched
139
+ * (in particular this never assigns `envMap`, which stays the caller's).
140
+ *
141
+ * The caller's own `onBeforeCompile` runs FIRST and its result is what gets
142
+ * spliced, so an override composes over a material that already had one.
143
+ */
144
+ export function overrideMaterialIbl(
145
+ material: MeshStandardMaterial,
146
+ options: IblOverrideOptions,
147
+ ): IblOverride {
148
+ const previousCompile = material.onBeforeCompile;
149
+ const previousCacheKey = material.customProgramCacheKey;
150
+ let compiled: IblOverrideShader | null = null;
151
+
152
+ material.onBeforeCompile = (shader, renderer) => {
153
+ previousCompile.call(material, shader, renderer);
154
+ requireChunk(shader.vertexShader, '#include <worldpos_vertex>');
155
+ requireChunk(shader.fragmentShader, '#include <envmap_physical_pars_fragment>');
156
+ shader.vertexShader = `varying vec3 ${IBL_WORLD_POSITION};\n${shader.vertexShader.replace(
157
+ '#include <worldpos_vertex>',
158
+ `#include <worldpos_vertex>
159
+ #if defined(USE_ENVMAP)
160
+ ${IBL_WORLD_POSITION} = worldPosition.xyz;
161
+ #endif`,
162
+ )}`;
163
+ shader.fragmentShader = `varying vec3 ${IBL_WORLD_POSITION};\n${shader.fragmentShader.replace(
164
+ '#include <envmap_physical_pars_fragment>',
165
+ options.fragment(),
166
+ )}`;
167
+ compiled = shader;
168
+ options.onCompile?.(shader);
169
+ };
170
+ material.customProgramCacheKey = () => `${previousCacheKey.call(material)}|${options.cacheKey()}`;
171
+ material.needsUpdate = true;
172
+
173
+ return {
174
+ material,
175
+ get shader() {
176
+ return compiled;
177
+ },
178
+ restore() {
179
+ material.onBeforeCompile = previousCompile;
180
+ material.customProgramCacheKey = previousCacheKey;
181
+ material.needsUpdate = true;
182
+ compiled = null;
183
+ },
184
+ };
185
+ }
186
+
187
+ /**
188
+ * A missing chunk is a NAMED error rather than a silent no-op.
189
+ *
190
+ * `String.replace` with no match returns the string unchanged, which would
191
+ * leave the varying declared and never assigned — a shader that links and
192
+ * renders the wrong thing. The only way to reach this today is a preceding
193
+ * `onBeforeCompile` in the same chain having already replaced the chunk (two
194
+ * IBL overrides stacked on one material), and that is worth a message naming
195
+ * the chunk instead of a black frame.
196
+ */
197
+ function requireChunk(source: string, chunk: string): void {
198
+ if (source.includes(chunk)) return;
199
+ throw new Error(
200
+ `overrideMaterialIbl needs three's ${chunk} shader chunk, and this material's program no longer contains it.`,
201
+ );
202
+ }