@vgai/engine 0.5.21 → 0.5.22

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 (87) hide show
  1. package/dist/adapter/manifest-interpreter.d.ts +59 -0
  2. package/dist/adapter/manifest-interpreter.d.ts.map +1 -0
  3. package/dist/adapter/manifest-interpreter.js +50 -0
  4. package/dist/adapter/native-debug-module.d.ts +45 -3
  5. package/dist/adapter/native-debug-module.d.ts.map +1 -1
  6. package/dist/adapter/native-debug-module.js +128 -10
  7. package/dist/adapter/renderer-config.d.ts +1 -1
  8. package/dist/adapter/renderer-config.js +1 -1
  9. package/dist/canvas-react/index.d.ts +5 -12
  10. package/dist/canvas-react/index.d.ts.map +1 -1
  11. package/dist/canvas-react/index.js +5 -12
  12. package/dist/canvas-react/pixi-react-root-factory.d.ts +34 -21
  13. package/dist/canvas-react/pixi-react-root-factory.d.ts.map +1 -1
  14. package/dist/canvas-react/pixi-react-root-factory.js +274 -36
  15. package/dist/core/countdown-timer.d.ts +118 -0
  16. package/dist/core/countdown-timer.d.ts.map +1 -0
  17. package/dist/core/countdown-timer.js +137 -0
  18. package/dist/core/deferred-commands.d.ts +127 -0
  19. package/dist/core/deferred-commands.d.ts.map +1 -0
  20. package/dist/core/deferred-commands.js +132 -0
  21. package/dist/core/sim-clock.d.ts +14 -2
  22. package/dist/core/sim-clock.d.ts.map +1 -1
  23. package/dist/core/sim-clock.js +14 -2
  24. package/dist/dev/instruments.d.ts +38 -11
  25. package/dist/dev/instruments.d.ts.map +1 -1
  26. package/dist/dev/instruments.js +81 -11
  27. package/dist/input/input-manager.d.ts +15 -0
  28. package/dist/input/input-manager.d.ts.map +1 -1
  29. package/dist/input/input-manager.js +15 -0
  30. package/dist/render/directional-shadow-fit.d.ts +88 -0
  31. package/dist/render/directional-shadow-fit.d.ts.map +1 -0
  32. package/dist/render/directional-shadow-fit.js +133 -0
  33. package/dist/render/environment-capture.d.ts +68 -0
  34. package/dist/render/environment-capture.d.ts.map +1 -0
  35. package/dist/render/environment-capture.js +90 -0
  36. package/dist/render/ibl-override-material.d.ts +97 -0
  37. package/dist/render/ibl-override-material.d.ts.map +1 -0
  38. package/dist/render/ibl-override-material.js +114 -0
  39. package/dist/runtime/game-input-seams.d.ts +51 -0
  40. package/dist/runtime/game-input-seams.d.ts.map +1 -0
  41. package/dist/runtime/game-input-seams.js +86 -0
  42. package/dist/runtime/game.d.ts +15 -0
  43. package/dist/runtime/game.d.ts.map +1 -1
  44. package/dist/runtime/game.js +40 -8
  45. package/dist/runtime/mount-game.d.ts.map +1 -1
  46. package/dist/runtime/mount-game.js +18 -2
  47. package/dist/runtime/mount-manifest.d.ts.map +1 -1
  48. package/dist/runtime/mount-manifest.js +4 -2
  49. package/dist/runtime/types.d.ts +1 -1
  50. package/dist/world3d-react/index.d.ts +15 -25
  51. package/dist/world3d-react/index.d.ts.map +1 -1
  52. package/dist/world3d-react/index.js +15 -25
  53. package/dist/world3d-react/r3f-root-factory.d.ts +19 -25
  54. package/dist/world3d-react/r3f-root-factory.d.ts.map +1 -1
  55. package/dist/world3d-react/r3f-root-factory.js +396 -50
  56. package/dist/world3d-react/rapier-physics-bridge.d.ts +24 -12
  57. package/dist/world3d-react/rapier-physics-bridge.d.ts.map +1 -1
  58. package/dist/world3d-react/rapier-physics-bridge.js +82 -49
  59. package/package.json +1 -1
  60. package/schemas/engine-capabilities.json +3 -3
  61. package/src/adapter/manifest-interpreter.ts +64 -0
  62. package/src/adapter/native-debug-module.ts +212 -15
  63. package/src/adapter/renderer-config.ts +1 -1
  64. package/src/canvas-react/index.ts +5 -21
  65. package/src/canvas-react/pixi-react-root-factory.tsx +301 -39
  66. package/src/core/countdown-timer.ts +188 -0
  67. package/src/core/deferred-commands.ts +174 -0
  68. package/src/core/sim-clock.ts +14 -2
  69. package/src/dev/instruments.ts +108 -11
  70. package/src/input/input-manager.ts +15 -0
  71. package/src/render/directional-shadow-fit.ts +156 -0
  72. package/src/render/environment-capture.ts +102 -0
  73. package/src/render/ibl-override-material.ts +170 -0
  74. package/src/runtime/game-input-seams.ts +108 -0
  75. package/src/runtime/game.ts +65 -7
  76. package/src/runtime/mount-game.ts +23 -1
  77. package/src/runtime/mount-manifest.ts +4 -2
  78. package/src/runtime/types.ts +1 -1
  79. package/src/world3d-react/index.ts +15 -31
  80. package/src/world3d-react/r3f-root-factory.tsx +444 -53
  81. package/src/world3d-react/rapier-physics-bridge.tsx +88 -49
  82. package/src/canvas-react/engine-bridge.ts +0 -59
  83. package/src/canvas-react/pixi-react-adapter.tsx +0 -356
  84. package/src/canvas-react/world-context.ts +0 -253
  85. package/src/world3d-react/engine-bridge.ts +0 -86
  86. package/src/world3d-react/r3f-adapter.tsx +0 -717
  87. package/src/world3d-react/world-context.ts +0 -358
@@ -0,0 +1,174 @@
1
+ /**
2
+ * `DeferredCommands` — work the caller runs at ONE point it chooses, plus the
3
+ * subject-keyed disposal that has to land after it.
4
+ *
5
+ * The hazard is not exotic and it is not a foreign engine's: it is what a
6
+ * Rapier world does to anything that mutates it from inside a callback. Disable
7
+ * a collider while the solver is iterating, or free the node a collision
8
+ * handler was dispatched ON, and the failure is either a corrupt step or
9
+ * Rapier's own "recursive use of an object ... unsafe aliasing in rust". The
10
+ * remedy is always the same shape — write down WHAT to do, do it once the frame
11
+ * is out of anybody's way — and every caller that needs it was previously
12
+ * hand-rolling an array.
13
+ *
14
+ * ## The one point is the CALLER's, deliberately
15
+ *
16
+ * There is no `queueMicrotask`, no `setTimeout(0)`, no `requestAnimationFrame`
17
+ * and no subscription anywhere in this module. Whoever builds the queue decides
18
+ * where {@link DeferredCommands.drain} runs — after physics, at the tail of a
19
+ * world's update, wherever the frame's quiet moment actually is. A queue nobody
20
+ * drains simply does not run its work, which is a visible failure rather than a
21
+ * silent reordering onto a timeline the game does not control.
22
+ *
23
+ * ## This is NOT `SimClock`, and `after(0)` is not a substitute
24
+ *
25
+ * `core/sim-clock.ts` schedules on SIM TIME: `after(0, fn)` scheduled mid-frame
26
+ * does land in that frame's tail flush (`runtime/game.ts` calls `flush(simT)`
27
+ * after every phase of every world), so the resemblance is real and worth
28
+ * naming. Three things make it the wrong tool for deferral:
29
+ *
30
+ * - **A paused world never flushes.** `flush` is inside `runFrameImpl`'s
31
+ * `advanced` guard, so a game that pauses between the defer and the drain
32
+ * holds the work forever. End-of-frame deferral has to run on the frame,
33
+ * not on the clock.
34
+ * - **No subject identity.** Deferring destruction needs "this object, once",
35
+ * and needs the two questions that fall out of it —
36
+ * {@link DeferredCommands.isDisposePending} (a handler must be able to skip
37
+ * a subject that is already going away) and
38
+ * {@link DeferredCommands.wasDisposed} (an owner sweeping its own indexes
39
+ * after the drain). A time-ordered timer set answers neither.
40
+ * - **No same-drain re-entry.** A sim timer scheduled during a flush is
41
+ * deliberately never due in that flush; a deferred command that defers one
42
+ * more command must still land this frame, or the second write arrives a
43
+ * frame after the first and the two are no longer atomic.
44
+ *
45
+ * ## Ordering, and why disposal goes last
46
+ *
47
+ * {@link DeferredCommands.drain} runs every deferred command first, re-draining
48
+ * until the queue is empty, and only then disposes every pending subject. That
49
+ * order is not cosmetic: a deferred write to a subject that is ALSO queued for
50
+ * disposal must still happen — it is what the caller asked for — and disposing
51
+ * first would make that write operate on a freed handle. A subject queued twice
52
+ * is disposed once, and the FIRST thunk wins (every caller for one subject
53
+ * passes the same one).
54
+ *
55
+ * The re-drain is bounded by {@link MAX_DRAIN_PASSES}. A command that defers
56
+ * itself forever is a bug in the caller, and a bounded loop names it in a
57
+ * millisecond instead of hanging the frame.
58
+ *
59
+ * ## Why disposal carries its own thunk
60
+ *
61
+ * Disposal is the one thing here that is not surface-neutral. A `PIXI.Container`
62
+ * ends with `removeFromParent()` + `destroy({ children })`; a `THREE.Object3D`
63
+ * ends by detaching and disposing the geometries and materials underneath it;
64
+ * a physics-backed entity has a Rapier body to pull first. This queue does none
65
+ * of that — it takes the thunk from whoever owns the surface and runs it at the
66
+ * drain. So the ORDERING lives here once and the surface knowledge stays where
67
+ * the surface is, which is what lets one queue serve a 2D world and a 3D one.
68
+ *
69
+ * ## Resource ownership
70
+ *
71
+ * **Owner:** whoever calls {@link createDeferredCommands}, and that owner is
72
+ * also the only caller of `drain()` — a queue is per-DRAINER, not per-process,
73
+ * so two mounted games are two queues and neither can see the other's work.
74
+ * This module registers nothing anywhere: there is no game-scoped slot (unlike
75
+ * `sim-clock.ts`, which needs one because every world on a Game shares one
76
+ * clock) and no module-level state at all.
77
+ * **Sharers:** the queued subjects, by reference, and only until the drain that
78
+ * disposes them.
79
+ * **Teardown:** dropping the queue. There is deliberately no `dispose()` —
80
+ * nothing here holds a handle, a listener or a timer, and a queue dropped with
81
+ * work still in it has simply not run that work, which is what discarding a
82
+ * frame means.
83
+ */
84
+
85
+ /**
86
+ * How many times {@link DeferredCommands.drain} re-drains a queue that keeps
87
+ * refilling itself before declaring the caller's own logic non-terminating.
88
+ *
89
+ * A bound that correct code cannot reach (a deferred command that defers one
90
+ * more, ten deep) while catching the infinite case immediately.
91
+ */
92
+ export const MAX_DRAIN_PASSES = 16;
93
+
94
+ /** The queue. Build one per drainer with {@link createDeferredCommands}. */
95
+ export interface DeferredCommands {
96
+ /**
97
+ * Run `command` at the next {@link DeferredCommands.drain}.
98
+ *
99
+ * ```ts
100
+ * // inside a collision handler, where the solver still owns the world:
101
+ * frame.defer(() => collider.setSensor(true));
102
+ * ```
103
+ */
104
+ defer(command: () => void): void;
105
+ /**
106
+ * Dispose `subject` at the next drain, after every deferred command has run.
107
+ * Queuing the same subject twice disposes it once — the first `dispose`
108
+ * thunk wins.
109
+ */
110
+ deferDispose(subject: object, dispose: () => void): void;
111
+ /**
112
+ * Is `subject` queued for disposal and not yet disposed? Read by a handler
113
+ * that must skip a subject already on its way out.
114
+ */
115
+ isDisposePending(subject: object): boolean;
116
+ /**
117
+ * Has THIS queue already disposed `subject`? Read after a drain by an owner
118
+ * sweeping its own indexes (a group, a registry, a spatial bucket) — "am I
119
+ * destroyed" is a surface-specific question that a plain `Object3D` has no
120
+ * answer to, so the queue is asked instead.
121
+ */
122
+ wasDisposed(subject: object): boolean;
123
+ /** Run every deferred command (re-draining), then dispose every pending
124
+ * subject. Called once per frame, by the owner, at the point it chose. */
125
+ drain(): void;
126
+ }
127
+
128
+ /** Build one queue. See the module header for who owns it and who drains it. */
129
+ export function createDeferredCommands(): DeferredCommands {
130
+ let commands: (() => void)[] = [];
131
+ const doomed = new Map<object, () => void>();
132
+ const disposed = new WeakSet<object>();
133
+
134
+ return {
135
+ defer(command): void {
136
+ commands.push(command);
137
+ },
138
+
139
+ deferDispose(subject, dispose): void {
140
+ if (!doomed.has(subject)) doomed.set(subject, dispose);
141
+ },
142
+
143
+ isDisposePending(subject): boolean {
144
+ return doomed.has(subject);
145
+ },
146
+
147
+ wasDisposed(subject): boolean {
148
+ return disposed.has(subject);
149
+ },
150
+
151
+ drain(): void {
152
+ let passes = 0;
153
+ while (commands.length > 0) {
154
+ if (++passes > MAX_DRAIN_PASSES) {
155
+ throw new Error(
156
+ `DeferredCommands.drain: still queueing more work after ${MAX_DRAIN_PASSES} passes. ` +
157
+ 'Something passed to defer() defers itself, so this drain would never terminate.',
158
+ );
159
+ }
160
+ const batch = commands;
161
+ commands = [];
162
+ for (const command of batch) command();
163
+ }
164
+
165
+ // Subjects last, and only after every deferred command has run — see the
166
+ // module header's ordering section.
167
+ for (const [subject, dispose] of doomed) {
168
+ dispose();
169
+ disposed.add(subject);
170
+ }
171
+ doomed.clear();
172
+ },
173
+ };
174
+ }
@@ -105,8 +105,20 @@
105
105
  *
106
106
  * ## Deliberately absent
107
107
  *
108
- * - **No `every`/`repeat`/interval.** Nothing needs one, and `after`
109
- * re-arming itself is three lines the game owns.
108
+ * - **No `every`/`repeat`/interval that is `core/countdown-timer.ts`.**
109
+ * This header used to say an `after` re-arming itself was "three lines the
110
+ * game owns"; it is three lines that DRIFT, because re-arming schedules the
111
+ * next fire from the moment the callback ran, so every long frame
112
+ * permanently lengthens the interval. A repeating interval needs
113
+ * overshoot carry and multi-fire on a big step to keep its event count
114
+ * right, and it is stepped by its owner rather than by the clock. Both
115
+ * belong to a countdown object, not to a sim-time one-shot.
116
+ * - **No end-of-frame command queue — that is
117
+ * `core/deferred-commands.ts`.** `after(0, fn)` looks like one (the flush
118
+ * below does run at the tail of the frame that scheduled it) and is not:
119
+ * it holds the work forever across a pause, it has no subject identity, and
120
+ * a command it schedules during a flush waits a whole extra frame. See that
121
+ * module's header for all three.
110
122
  * - **No fiber kernel, no coroutine emulation, no `task.spawn`.** Calling an
111
123
  * `async function` *is* `task.spawn` — the language already has it.
112
124
  * - **No wall clock.** This module reads no system timer and creates no
@@ -25,13 +25,13 @@
25
25
  * game registry.
26
26
  *
27
27
  * ── THE DOOR ────────────────────────────────────────────────────────────────
28
- * This module is IMPLEMENTATION ONLY. It publishes nothing itself: the dev-tools
29
- * capability's own registry adopts this list (`installDevToolsInstruments`) and
30
- * publishes each instrument through the SAME door every declaration in that
31
- * capability uses — one provider per reading (`instrument.timescale`), one
32
- * command per action (`instrument.timescale.set`), plus the `dev-tools` index
33
- * provider. That is deliberate and load-bearing: the registry IS the product,
34
- * so every reader of an instrument reaches the SAME registration.
28
+ * The HOST publishes these itself ({@link publishDevInstruments}, called by
29
+ * the mount install paths): one provider per reading
30
+ * (`instrument.timescale`), one command per action
31
+ * (`instrument.timescale.set`), plus the `instruments` index provider. The
32
+ * registry IS the product, so every reader of an instrument reaches the SAME
33
+ * registration and a game writes zero lines and mounts nothing to have
34
+ * them.
35
35
  *
36
36
  * ── DEGRADING ───────────────────────────────────────────────────────────────
37
37
  * An instrument whose subject this game does not have (no physics adapter ⇒ no
@@ -42,16 +42,17 @@
42
42
  *
43
43
  * ── RESOURCE OWNERSHIP, STATED ONCE ─────────────────────────────────────────
44
44
  * {@link createDevInstruments} owns everything it allocates: the frame-time
45
- * ring buffer and its ONE `game.onRenderStep` subscription. SHARER: the
46
- * dev-tools capability's `DevToolsBridge`, which creates one set per mounted
47
- * game. TEARDOWN: the returned {@link DevInstrumentSet.dispose} — the ONE path
48
- * that ends it. Nothing else here holds a resource; the time-scale, pause and
45
+ * ring buffer and its ONE `game.onRenderStep` subscription. SHARER: the host
46
+ * install path ({@link publishDevInstruments}), which creates one set per
47
+ * mounted game. TEARDOWN: the returned {@link DevInstrumentSet.dispose} — the
48
+ * ONE path that ends it. Nothing else here holds a resource; the time-scale, pause and
49
49
  * collider instruments only drive state that `Game` already owns.
50
50
  */
51
51
 
52
52
  import { z } from 'zod';
53
53
  import { nodeKeyedPhysics } from '../adapter/system-adapter';
54
54
  import { clampTimeScale, TIME_SCALE_RANGE } from '../core/frame-pacing';
55
+ import { getDebugRegistry } from '../runtime/debug-registry';
55
56
  import type { Game } from '../runtime/game';
56
57
 
57
58
  /** What shape an instrument's reading is, and what drives it. Data, not
@@ -304,3 +305,99 @@ export function createDevInstruments(game: Game): DevInstrumentSet {
304
305
  function now(): number {
305
306
  return typeof performance === 'undefined' ? Date.now() : performance.now();
306
307
  }
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // Host publication — the instruments' OWN door
311
+ // ---------------------------------------------------------------------------
312
+ //
313
+ // The HOST publishes these (the editor's mount install path), so every game
314
+ // gets them for literally zero lines — no capability, no bridge component, no
315
+ // mount. One provider per reading (`instrument.timescale`), one command per
316
+ // action (`instrument.timescale.set`), plus ONE index provider
317
+ // (`instruments`) carrying every descriptor with this instant's values, so an
318
+ // outside reader gets the whole panel in one round trip. Naming is unchanged
319
+ // from when the dev-tools capability published them, so every existing reader
320
+ // keeps working; only the publisher moved realms.
321
+
322
+ /** The index provider's name. */
323
+ export const INSTRUMENTS_PROVIDER = 'instruments';
324
+
325
+ /** The registry scope the host publishes under. Not a mounted world —
326
+ * instruments are game-scoped host furniture, like the hooks' `__react__`. */
327
+ const INSTRUMENTS_WORLD_ID = '__instruments__';
328
+
329
+ /** One instrument in the index: descriptor plus this instant's value. A
330
+ * throwing read is reported as itself, never swallowed. */
331
+ export interface PublishedInstrumentReading {
332
+ readonly name: string;
333
+ readonly id: string;
334
+ readonly label: string;
335
+ readonly hint: string;
336
+ readonly unit?: string;
337
+ readonly control: DevInstrumentControl;
338
+ readonly actions: readonly {
339
+ readonly name: string;
340
+ readonly id: string;
341
+ readonly label: string;
342
+ readonly hint: string;
343
+ }[];
344
+ readonly value: unknown;
345
+ readonly error?: string;
346
+ }
347
+
348
+ function readingOf(instrument: DevInstrument): PublishedInstrumentReading {
349
+ const base = {
350
+ name: `instrument.${instrument.id}`,
351
+ id: instrument.id,
352
+ label: instrument.label,
353
+ hint: instrument.hint,
354
+ ...(instrument.unit === undefined ? {} : { unit: instrument.unit }),
355
+ control: instrument.control,
356
+ actions: instrument.actions.map((action) => ({
357
+ name: `instrument.${instrument.id}.${action.id}`,
358
+ id: action.id,
359
+ label: action.label,
360
+ hint: action.hint,
361
+ })),
362
+ };
363
+ try {
364
+ return { ...base, value: instrument.read() };
365
+ } catch (error) {
366
+ return { ...base, value: null, error: error instanceof Error ? error.message : String(error) };
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Create this game's instrument set and register every door on its debug
372
+ * registry. Called once per mounted Game by the host install path; the
373
+ * returned disposer ends the set's one resource (the frame-time
374
+ * subscription) — the registrations die with the Game itself.
375
+ */
376
+ export function publishDevInstruments(game: Game): () => void {
377
+ const registry = getDebugRegistry(game);
378
+ if (!registry) {
379
+ throw new Error('Cannot publish dev instruments: mounted Game has no debug registry.');
380
+ }
381
+ const set = createDevInstruments(game);
382
+ const scope = registry.forRoot(INSTRUMENTS_WORLD_ID);
383
+ for (const instrument of set.instruments) {
384
+ scope.registerStateProvider(`instrument.${instrument.id}`, () => instrument.read(), {
385
+ tier: 'assisted',
386
+ });
387
+ for (const action of instrument.actions) {
388
+ scope.registerCommand(
389
+ `instrument.${instrument.id}.${action.id}`,
390
+ {
391
+ description: `${instrument.label}: ${action.label} — ${action.hint}`,
392
+ locus: 'client',
393
+ args: action.args,
394
+ },
395
+ (...values: unknown[]) => action.run(values),
396
+ );
397
+ }
398
+ }
399
+ scope.registerStateProvider(INSTRUMENTS_PROVIDER, () => set.instruments.map(readingOf), {
400
+ tier: 'assisted',
401
+ });
402
+ return () => set.dispose();
403
+ }
@@ -1797,6 +1797,21 @@ export class InputManager {
1797
1797
  return this.gamepadButtonsJustUp.has(`${index}:${button}`);
1798
1798
  }
1799
1799
 
1800
+ /**
1801
+ * The RAW `Gamepad.axes[axis]` of the first connected pad, gated on `inputActive`.
1802
+ *
1803
+ * Deliberately un-deadzoned, and it must stay that way. The engine's own stick deadzone lives on
1804
+ * the ACTION path (`gamepad_axis` / `gamepad_axis_pair`, default
1805
+ * `GAMEPAD_AXIS_DEFAULT_DEADZONE`), where a game declares it and can override it per binding.
1806
+ * This door exists for a caller that must apply a DIFFERENT rule — every foreign-engine compat
1807
+ * lane does: Unity's `Gamepad.leftStick` carries a `StickDeadzone` processor with its own
1808
+ * 0.125/0.925 (`unity-compat/input-system-device.ts` reproduces it), and Godot's `get_axis`
1809
+ * has its own. Baking one engine's constants in here would silently give every other lane the
1810
+ * wrong feel under its own API name.
1811
+ *
1812
+ * So: a caller that wants "a stick" wants a deadzone and must say WHOSE. A caller that wants
1813
+ * the hardware number calls this.
1814
+ */
1800
1815
  getDeviceGamepadAxis(axis: number): number {
1801
1816
  if (!this.inputActive) return 0;
1802
1817
  const index = this.firstConnectedGamepadIndex();
@@ -0,0 +1,156 @@
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
+ import { type Camera, Quaternion, Vector3 } from 'three';
65
+
66
+ /** The box a directional shadow is rendered through for one frame. */
67
+ export interface DirectionalShadowFit {
68
+ /** WORLD-space centre of the box, quantized to whole shadow texels in the light's basis. */
69
+ readonly center: Vector3;
70
+ /** Half-extent on both lateral axes — the frustum slice's bounding-sphere radius. */
71
+ readonly radius: number;
72
+ /** One shadow texel in world units at this radius: `2 * radius / mapSize`. */
73
+ readonly texelWorldSize: number;
74
+ }
75
+
76
+ const _inverseLight = new Quaternion();
77
+ const _corner = new Vector3();
78
+ const _centroid = new Vector3();
79
+ const _ndc = new Vector3();
80
+ /** The four lateral NDC corners; the slice's eight are these at two depths. */
81
+ const NDC_CORNERS: readonly (readonly [number, number])[] = [
82
+ [-1, -1],
83
+ [-1, 1],
84
+ [1, -1],
85
+ [1, 1],
86
+ ];
87
+
88
+ /** View-space depth `-z` as this camera's NDC z, through the camera's OWN projection — so the
89
+ * slice is exact for a perspective and an orthographic camera alike, with no branch on type. */
90
+ function ndcDepthOf(camera: Camera, viewDepth: number): number {
91
+ return _ndc.set(0, 0, -viewDepth).applyMatrix4(camera.projectionMatrix).z;
92
+ }
93
+
94
+ /**
95
+ * The bounding sphere of `camera`'s frustum between its near plane and
96
+ * `maxDistance`, with the centre quantized to whole shadow texels in the
97
+ * LIGHT's basis.
98
+ *
99
+ * `camera` is read through its own `projectionMatrix`/`projectionMatrixInverse`/`matrixWorld`, so a
100
+ * caller must have brought those up to date (three does this for every rendered camera).
101
+ * `maxDistance` is the view depth the shadow covers, measured from the camera and clamped to its
102
+ * far plane — there is nothing to shadow past the geometry the camera draws.
103
+ * `lightOrientation` is the light's WORLD quaternion, whose local x/y are the shadow map's own axes
104
+ * and therefore the axes the snap quantizes along. A `mapSize` of zero or less disables snapping
105
+ * rather than dividing by it.
106
+ */
107
+ export function fitDirectionalShadowToCamera(
108
+ camera: Camera,
109
+ lightOrientation: Quaternion,
110
+ maxDistance: number,
111
+ mapSize: number,
112
+ ): DirectionalShadowFit {
113
+ const frustum = camera as Camera & { near?: number; far?: number };
114
+ const camNear = typeof frustum.near === 'number' ? frustum.near : 0;
115
+ const camFar = typeof frustum.far === 'number' ? frustum.far : maxDistance;
116
+ // The two clamps are degenerate-input guards: far at least a hair past near, near never past far.
117
+ let far = Math.min(maxDistance, camFar);
118
+ far = Math.max(far, camNear + 0.001);
119
+ const near = Math.min(camNear, far);
120
+ const nearNdc = ndcDepthOf(camera, near);
121
+ const farNdc = ndcDepthOf(camera, far);
122
+
123
+ // The centroid of the eight corners. Both planes contribute four symmetric corners, so for an
124
+ // ordinary camera this is `(near + far) / 2` down -Z — but it is computed rather than assumed,
125
+ // because `setViewOffset` makes a frustum asymmetric and the fit is over the real corners.
126
+ _centroid.set(0, 0, 0);
127
+ for (const ndcZ of [nearNdc, farNdc]) {
128
+ for (const [x, y] of NDC_CORNERS) {
129
+ _centroid.add(_corner.set(x, y, ndcZ).applyMatrix4(camera.projectionMatrixInverse));
130
+ }
131
+ }
132
+ _centroid.multiplyScalar(1 / 8);
133
+
134
+ let radius = 0;
135
+ for (const ndcZ of [nearNdc, farNdc]) {
136
+ for (const [x, y] of NDC_CORNERS) {
137
+ radius = Math.max(
138
+ radius,
139
+ _corner.set(x, y, ndcZ).applyMatrix4(camera.projectionMatrixInverse).distanceTo(_centroid),
140
+ );
141
+ }
142
+ }
143
+ // One texel of margin, so the snapped box never clips its own outermost texel: the snap below
144
+ // moves the centre by up to half a texel on each lateral axis.
145
+ if (mapSize > 2) radius *= mapSize / (mapSize - 2);
146
+
147
+ const center = _centroid.clone().applyMatrix4(camera.matrixWorld);
148
+ const texelWorldSize = mapSize > 0 ? (2 * radius) / mapSize : 0;
149
+ if (texelWorldSize > 0) {
150
+ center.applyQuaternion(_inverseLight.copy(lightOrientation).invert());
151
+ center.x = Math.round(center.x / texelWorldSize) * texelWorldSize;
152
+ center.y = Math.round(center.y / texelWorldSize) * texelWorldSize;
153
+ center.applyQuaternion(lightOrientation);
154
+ }
155
+ return { center, radius, texelWorldSize };
156
+ }
@@ -0,0 +1,102 @@
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 Godot 3 `ReflectionProbe`.
14
+ */
15
+ import type { Light, Object3D, Scene, WebGLRenderer } from 'three';
16
+
17
+ /**
18
+ * Hide everything a capture's cull mask excludes, for exactly one capture.
19
+ *
20
+ * The object's side of the test is three's OWN `Object3D.layers.mask`, never a
21
+ * parallel `userData` copy. An engine whose own per-object visibility layers
22
+ * are a 32-bit mask (Godot's `VisualInstance.layers` is the worked case) writes
23
+ * the authored mask straight onto the object and this reads it back. Both
24
+ * default an unannotated object to `1`, which no default mask excludes, so an
25
+ * ordinary scene is untouched.
26
+ */
27
+ export function withCaptureMask(scene: Scene, mask: number, capture: () => void): void {
28
+ const hidden: { object: Object3D; visible: boolean }[] = [];
29
+ scene.traverse((object) => {
30
+ if ((object.layers.mask & mask) !== 0) return;
31
+ hidden.push({ object, visible: object.visible });
32
+ object.visible = false;
33
+ });
34
+ try {
35
+ capture();
36
+ } finally {
37
+ for (const entry of hidden) entry.object.visible = entry.visible;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Suppress every shadow-casting light for the duration of ONE capture — as
43
+ * per-light `LightShadow.intensity`, never as `renderer.shadowMap.enabled`.
44
+ *
45
+ * **The distinction is not stylistic; the global flag silently deletes every
46
+ * shadow in the game.** `renderer.shadowMap.enabled` is a program PARAMETER
47
+ * (`WebGLPrograms.getParameters` → `shadowMapEnabled`), and
48
+ * `WebGLRenderer.setProgram`'s `needsProgramChange` list does NOT watch it —
49
+ * three requires a material version bump for a flip to reach a material that is
50
+ * already compiled. So flipping it around the capture does nothing for compiled
51
+ * materials and everything for the one material compiling INSIDE the window —
52
+ * and a probe system installing a shader override sets `material.needsUpdate =
53
+ * true` in the same `update()` call that runs the capture, so every patched
54
+ * material links with `USE_SHADOWMAP` absent and keeps that program for the
55
+ * rest of the run.
56
+ *
57
+ * Measured on the real Godot 3.6 binary against that lane's emitted port (a
58
+ * white ground, a floating box, one straight-down `DirectionalLight`,
59
+ * PanoramaSky ambient, Filmic white 6): with NO probe the port's shadow pixel
60
+ * is `92,109,181` against Godot's `92,109,181`; adding ONE `ReflectionProbe`
61
+ * moved the port's shadow pixel to `218,222,232` — byte-identical to its own
62
+ * LIT ground, i.e. the shadow was gone — while Godot's moved to `109,146,201`.
63
+ * Asking the capture not to touch shadows at all restored `108,146,200`, which
64
+ * is what named the mechanism. The defect was Godot-shaped; the mechanism is a
65
+ * three.js contract, which is why the fix lives here.
66
+ *
67
+ * `LightShadow.intensity` is a UNIFORM (`shadowmap_pars_fragment`:
68
+ * `mix(1.0, shadow, shadowIntensity)`), refreshed from the light on every
69
+ * `renderer.render()`, so zeroing it for the capture costs no recompile and
70
+ * cannot outlive the `finally` that restores it.
71
+ */
72
+ export function withCaptureShadows(scene: Scene, enabled: boolean, capture: () => void): void {
73
+ if (enabled) {
74
+ capture();
75
+ return;
76
+ }
77
+ const restored: { shadow: { intensity: number }; intensity: number }[] = [];
78
+ scene.traverse((object) => {
79
+ const light = object as Light & { shadow?: { intensity: number } };
80
+ if (!light.isLight || light.shadow === undefined) return;
81
+ restored.push({ shadow: light.shadow, intensity: light.shadow.intensity });
82
+ light.shadow.intensity = 0;
83
+ });
84
+ try {
85
+ capture();
86
+ } finally {
87
+ for (const entry of restored) entry.shadow.intensity = entry.intensity;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Whether this renderer can run an environment capture at all.
93
+ *
94
+ * The editor's still design-time tree and headless runtimes deliberately supply
95
+ * no GPU. They must keep the authored scene and gameplay; only the
96
+ * renderer-owned capture is unavailable, so a probe system asks this and skips
97
+ * the capture rather than refusing to mount.
98
+ */
99
+ export function canCaptureEnvironment(renderer: WebGLRenderer): boolean {
100
+ const candidate = renderer as WebGLRenderer & { compile?: unknown; coordinateSystem?: unknown };
101
+ return typeof candidate.compile === 'function' && candidate.coordinateSystem !== undefined;
102
+ }