@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.
- package/dist/adapter/manifest-interpreter.d.ts +59 -0
- package/dist/adapter/manifest-interpreter.d.ts.map +1 -0
- package/dist/adapter/manifest-interpreter.js +50 -0
- package/dist/adapter/native-debug-module.d.ts +45 -3
- package/dist/adapter/native-debug-module.d.ts.map +1 -1
- package/dist/adapter/native-debug-module.js +128 -10
- package/dist/adapter/renderer-config.d.ts +1 -1
- package/dist/adapter/renderer-config.js +1 -1
- package/dist/canvas-react/index.d.ts +5 -12
- package/dist/canvas-react/index.d.ts.map +1 -1
- package/dist/canvas-react/index.js +5 -12
- package/dist/canvas-react/pixi-react-root-factory.d.ts +34 -21
- package/dist/canvas-react/pixi-react-root-factory.d.ts.map +1 -1
- package/dist/canvas-react/pixi-react-root-factory.js +274 -36
- package/dist/core/countdown-timer.d.ts +118 -0
- package/dist/core/countdown-timer.d.ts.map +1 -0
- package/dist/core/countdown-timer.js +137 -0
- package/dist/core/deferred-commands.d.ts +127 -0
- package/dist/core/deferred-commands.d.ts.map +1 -0
- package/dist/core/deferred-commands.js +132 -0
- package/dist/core/sim-clock.d.ts +14 -2
- package/dist/core/sim-clock.d.ts.map +1 -1
- package/dist/core/sim-clock.js +14 -2
- package/dist/dev/instruments.d.ts +38 -11
- package/dist/dev/instruments.d.ts.map +1 -1
- package/dist/dev/instruments.js +81 -11
- package/dist/input/input-manager.d.ts +15 -0
- package/dist/input/input-manager.d.ts.map +1 -1
- package/dist/input/input-manager.js +15 -0
- package/dist/render/directional-shadow-fit.d.ts +88 -0
- package/dist/render/directional-shadow-fit.d.ts.map +1 -0
- package/dist/render/directional-shadow-fit.js +133 -0
- package/dist/render/environment-capture.d.ts +68 -0
- package/dist/render/environment-capture.d.ts.map +1 -0
- package/dist/render/environment-capture.js +90 -0
- package/dist/render/ibl-override-material.d.ts +97 -0
- package/dist/render/ibl-override-material.d.ts.map +1 -0
- package/dist/render/ibl-override-material.js +114 -0
- package/dist/runtime/game-input-seams.d.ts +51 -0
- package/dist/runtime/game-input-seams.d.ts.map +1 -0
- package/dist/runtime/game-input-seams.js +86 -0
- package/dist/runtime/game.d.ts +15 -0
- package/dist/runtime/game.d.ts.map +1 -1
- package/dist/runtime/game.js +40 -8
- package/dist/runtime/mount-game.d.ts.map +1 -1
- package/dist/runtime/mount-game.js +18 -2
- package/dist/runtime/mount-manifest.d.ts.map +1 -1
- package/dist/runtime/mount-manifest.js +4 -2
- package/dist/runtime/types.d.ts +1 -1
- package/dist/world3d-react/index.d.ts +15 -25
- package/dist/world3d-react/index.d.ts.map +1 -1
- package/dist/world3d-react/index.js +15 -25
- package/dist/world3d-react/r3f-root-factory.d.ts +19 -25
- package/dist/world3d-react/r3f-root-factory.d.ts.map +1 -1
- package/dist/world3d-react/r3f-root-factory.js +396 -50
- package/dist/world3d-react/rapier-physics-bridge.d.ts +24 -12
- package/dist/world3d-react/rapier-physics-bridge.d.ts.map +1 -1
- package/dist/world3d-react/rapier-physics-bridge.js +82 -49
- package/package.json +1 -1
- package/schemas/engine-capabilities.json +3 -3
- package/src/adapter/manifest-interpreter.ts +64 -0
- package/src/adapter/native-debug-module.ts +212 -15
- package/src/adapter/renderer-config.ts +1 -1
- package/src/canvas-react/index.ts +5 -21
- package/src/canvas-react/pixi-react-root-factory.tsx +301 -39
- package/src/core/countdown-timer.ts +188 -0
- package/src/core/deferred-commands.ts +174 -0
- package/src/core/sim-clock.ts +14 -2
- package/src/dev/instruments.ts +108 -11
- package/src/input/input-manager.ts +15 -0
- package/src/render/directional-shadow-fit.ts +156 -0
- package/src/render/environment-capture.ts +102 -0
- package/src/render/ibl-override-material.ts +170 -0
- package/src/runtime/game-input-seams.ts +108 -0
- package/src/runtime/game.ts +65 -7
- package/src/runtime/mount-game.ts +23 -1
- package/src/runtime/mount-manifest.ts +4 -2
- package/src/runtime/types.ts +1 -1
- package/src/world3d-react/index.ts +15 -31
- package/src/world3d-react/r3f-root-factory.tsx +444 -53
- package/src/world3d-react/rapier-physics-bridge.tsx +88 -49
- package/src/canvas-react/engine-bridge.ts +0 -59
- package/src/canvas-react/pixi-react-adapter.tsx +0 -356
- package/src/canvas-react/world-context.ts +0 -253
- package/src/world3d-react/engine-bridge.ts +0 -86
- package/src/world3d-react/r3f-adapter.tsx +0 -717
- 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
|
+
}
|
package/src/core/sim-clock.ts
CHANGED
|
@@ -105,8 +105,20 @@
|
|
|
105
105
|
*
|
|
106
106
|
* ## Deliberately absent
|
|
107
107
|
*
|
|
108
|
-
* - **No `every`/`repeat`/interval
|
|
109
|
-
* re-arming itself
|
|
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
|
package/src/dev/instruments.ts
CHANGED
|
@@ -25,13 +25,13 @@
|
|
|
25
25
|
* game registry.
|
|
26
26
|
*
|
|
27
27
|
* ── THE DOOR ────────────────────────────────────────────────────────────────
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
-
*
|
|
47
|
-
* game. TEARDOWN: the returned {@link DevInstrumentSet.dispose} — the
|
|
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
|
+
}
|