@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,260 @@
1
+ import * as THREE from 'three';
2
+ import { getUserData } from '../ecs/user-data';
3
+ import type { ResolvedRenderSettings } from './render-settings';
4
+ import { staticBatchSignature } from './structural-signature';
5
+
6
+ /**
7
+ * Runtime STATIC render-batching (the framework-level transparent perf delivery).
8
+ *
9
+ * Scans a loaded scene graph for STATIC meshes sharing geometry+material and draws
10
+ * each group through ONE InstancedMesh. The originals are DETACHED from the render
11
+ * tree (so the renderer never walks them — zero per-frame cost), but retained here
12
+ * as the logical handles: re-attached on teardown, and resolvable via raycast().
13
+ * N logical objects → 1 draw call, ~0 CPU/frame. This is the classic "static
14
+ * batching" technique (cf. Unity/PlayCanvas static batch groups).
15
+ *
16
+ * Strategy 1 (keep identity, don't merge). Moving entities (boids-style,
17
+ * all-frame churn) are a separate concern, and the engine does not answer it:
18
+ * a game's production answer is the `static-batch` capability's `<Frozen>`
19
+ * (see the scope note below).
20
+ *
21
+ * Eligibility (anything failing stays an ordinary draw — degrade, don't break):
22
+ * - plain THREE.Mesh (not Instanced/Skinned/Batched)
23
+ * - single, opaque material (transparency needs per-object sorting)
24
+ * - group of ≥2 sharing (geometry, material, shadow flags)
25
+ * - (there is no per-entity opt-out setting: this docblock used to name
26
+ * `entity.render.autoBatch === false`, a render-feature field NO CODE HERE
27
+ * OR ANYWHERE EVER READ. The field was deleted with R-2's coverage fix;
28
+ * opting a subtree out is the `static-batch` capability's `<Frozen>`
29
+ * declaration, per the scope note below.)
30
+ * - STATIC members (no physics/components/animation) → detached; MOVING members →
31
+ * kept in-graph hidden + dirty-synced each frame. A bucket may split into both.
32
+ *
33
+ * Scope / limitations (v1):
34
+ * - NOT WIRED: nothing in the engine calls build(), and nothing constructs
35
+ * this class. The only remaining reference is the hosted editor's
36
+ * project-script module map (`editor/src/served-bundle-runtime-modules.ts`),
37
+ * which loads the module so a project script MAY drive it; the example
38
+ * that used to is gone. That is settled, not pending: a game's
39
+ * production answer is the `static-batch` capability's `<Frozen>`, a
40
+ * DECLARED mount-static subtree in the game's own TSX, and `dev/static-
41
+ * batch-advisor.ts` is what routes an author to it by measurement. Full-
42
+ * auto inference over a scene nobody declared static is what this class
43
+ * would need to become, and it was rejected (issue #1503).
44
+ * - build() is a one-shot scan of the scene handed to it. Entities spawned
45
+ * AFTER build do not join a batch; call build() again to re-scan. Removing a batched
46
+ * source leaves a stale instance until rebuild. (Static scenery is load-time stable.)
47
+ * - The batch culls as ONE unit (no per-instance frustum culling yet — Tier-2).
48
+ */
49
+
50
+ interface Group {
51
+ instanced: THREE.InstancedMesh;
52
+ sources: THREE.Mesh[]; // instanceId → source (raycast mapping + restore on teardown)
53
+ dynamic: boolean; // true = sources kept in the graph & re-synced each frame (movers)
54
+ cache?: Float32Array; // dynamic only: last-synced matrixWorld elements (16/source)
55
+ }
56
+
57
+ // Structural signature — groups meshes that are VALUE-identical, not object-identical.
58
+ // The engine scene-loader instantiates a fresh geometry+material per entity (no dedup),
59
+ // so keying on .uuid would never batch a real scene; a TSX/R3F world writing inline
60
+ // `<meshStandardMaterial>` elements has exactly the same property. That key lives in
61
+ // `render/structural-signature.ts` — ONE owner, shared with the `static-batch`
62
+ // capability's `<Frozen>`, so the two cannot drift into disagreeing about what
63
+ // "the same draw" means. `staticBatchSignature` is this file's original key, moved.
64
+ const SIG = staticBatchSignature;
65
+
66
+ /** Match Three.js renderer visibility: a hidden ancestor hides the whole subtree. */
67
+ function isEffectivelyVisible(obj: THREE.Object3D): boolean {
68
+ let cur: THREE.Object3D | null = obj;
69
+ while (cur) {
70
+ if (!cur.visible) return false;
71
+ cur = cur.parent;
72
+ }
73
+ return true;
74
+ }
75
+
76
+ function isBatchCandidate(object: THREE.Object3D, batchRoot: THREE.Group): object is THREE.Mesh {
77
+ const mesh = object as THREE.Mesh;
78
+ if (!mesh.isMesh) return false;
79
+ if ((mesh as THREE.InstancedMesh).isInstancedMesh) return false;
80
+ if ((mesh as THREE.SkinnedMesh).isSkinnedMesh) return false;
81
+ if ((mesh as unknown as THREE.BatchedMesh).isBatchedMesh) return false;
82
+ if (!isEffectivelyVisible(mesh)) return false;
83
+ if (!mesh.geometry || !mesh.material || Array.isArray(mesh.material)) return false;
84
+ if ((mesh.material as THREE.Material).transparent) return false;
85
+ return batchRoot !== mesh.parent;
86
+ }
87
+
88
+ /**
89
+ * Static-batching eligibility: only objects that won't move are safe to freeze
90
+ * and instance. An object (or an ancestor) carrying its own animation mixer is
91
+ * a likely mover → left as an ordinary draw. Plain scenery is eligible. This is
92
+ * the safe, transparent default; a future dynamic-batch path (markMoved-driven)
93
+ * can include movers.
94
+ */
95
+ function isLikelyStatic(obj: THREE.Object3D): boolean {
96
+ let cur: THREE.Object3D | null = obj;
97
+ while (cur) {
98
+ if (getUserData(cur, '_animMixer')) return false;
99
+ cur = cur.parent;
100
+ }
101
+ return true;
102
+ }
103
+
104
+ export class RenderBatchSystem {
105
+ private root = new THREE.Group();
106
+ private groups: Group[] = [];
107
+ private dynamicGroups: Group[] = []; // subset needing per-frame sync
108
+ // Original parent of each detached (static) source, to re-attach on teardown.
109
+ private parentOf = new WeakMap<THREE.Mesh, THREE.Object3D>();
110
+ // instanceId mapping for raycast: which group + slot an InstancedMesh belongs to.
111
+ private membersOf = new WeakMap<THREE.InstancedMesh, THREE.Mesh[]>();
112
+
113
+ constructor(
114
+ private scene: THREE.Scene,
115
+ private settings: ResolvedRenderSettings,
116
+ ) {
117
+ this.root.name = 'RenderBatchSystem';
118
+ this.root.matrixAutoUpdate = false; // instances carry world matrices directly
119
+ }
120
+
121
+ /** Scan the scene and build instanced groups. Idempotent (rebuilds). */
122
+ build(): void {
123
+ this.teardown();
124
+ this.scene.add(this.root);
125
+ this.scene.updateMatrixWorld(true);
126
+
127
+ // collect eligible candidates, grouped by (geometry, material)
128
+ const buckets = new Map<string, THREE.Mesh[]>();
129
+ this.scene.traverse((obj) => {
130
+ if (!isBatchCandidate(obj, this.root)) return;
131
+ const key = SIG(obj);
132
+ const arr = buckets.get(key) ?? [];
133
+ arr.push(obj);
134
+ buckets.set(key, arr);
135
+ });
136
+
137
+ // Each signature bucket is partitioned into STATIC (detached, zero-cost) and
138
+ // DYNAMIC (kept in-place, dirty-synced) members, each ≥2 forming its own batch.
139
+ for (const bucket of buckets.values()) {
140
+ const staticMembers: THREE.Mesh[] = [];
141
+ const dynamicMembers: THREE.Mesh[] = [];
142
+ for (const m of bucket) (isLikelyStatic(m) ? staticMembers : dynamicMembers).push(m);
143
+ if (staticMembers.length >= 2) this.makeGroup(staticMembers, false);
144
+ if (dynamicMembers.length >= 2) this.makeGroup(dynamicMembers, true);
145
+ }
146
+ }
147
+
148
+ private makeGroup(sources: THREE.Mesh[], dynamic: boolean): void {
149
+ const frustum = this.settings['frustumCulling'] !== false;
150
+ const tmp = new THREE.Matrix4();
151
+ const proto = sources[0]!;
152
+ const inst = new THREE.InstancedMesh(
153
+ proto.geometry,
154
+ proto.material as THREE.Material,
155
+ sources.length,
156
+ );
157
+ inst.frustumCulled = frustum && !dynamic; // moving batches can leave the padded sphere
158
+ // Inherit shadow flags from the prototype so batched objects still cast/receive
159
+ // shadows (otherwise detaching the sources would silently drop them from shadows).
160
+ inst.castShadow = proto.castShadow;
161
+ inst.receiveShadow = proto.receiveShadow;
162
+ const cache = dynamic ? new Float32Array(sources.length * 16) : undefined;
163
+ for (let i = 0; i < sources.length; i++) {
164
+ const s = sources[i]!;
165
+ tmp.copy(s.matrixWorld);
166
+ inst.setMatrixAt(i, tmp);
167
+ if (dynamic) {
168
+ // keep the source in the graph (physics/animation still updates its matrixWorld),
169
+ // just hide its own draw; we copy its world matrix into the instance each frame.
170
+ cache!.set(tmp.elements, i * 16);
171
+ s.visible = false;
172
+ } else if (s.parent) {
173
+ // static: detach entirely so the renderer never walks it (zero per-frame cost)
174
+ this.parentOf.set(s, s.parent);
175
+ s.parent.remove(s);
176
+ }
177
+ }
178
+ inst.instanceMatrix.needsUpdate = true;
179
+ if (frustum && !dynamic) inst.computeBoundingSphere();
180
+ this.membersOf.set(inst, sources);
181
+ this.root.add(inst);
182
+ const group: Group = cache
183
+ ? { instanced: inst, sources, dynamic, cache }
184
+ : { instanced: inst, sources, dynamic };
185
+ this.groups.push(group);
186
+ if (dynamic) this.dynamicGroups.push(group);
187
+ }
188
+
189
+ /**
190
+ * Per-frame sync for DYNAMIC groups only (static are detached & immutable → free).
191
+ * Called in the preRender phase. Refreshes world matrices once (the engine's movers
192
+ * set matrixWorldNeedsUpdate via physics/animation), then re-writes only the instances
193
+ * whose source moved (element-wise cache compare). No-op when there are no movers.
194
+ */
195
+ update(): void {
196
+ if (this.dynamicGroups.length === 0) return;
197
+ this.scene.updateMatrixWorld(false); // refresh moved subtrees (force=false → cheap)
198
+ for (const g of this.dynamicGroups) {
199
+ const cache = g.cache!;
200
+ let dirty = false;
201
+ for (let i = 0; i < g.sources.length; i++) {
202
+ const el = g.sources[i]!.matrixWorld.elements;
203
+ const off = i * 16;
204
+ let changed = false;
205
+ for (let k = 0; k < 16; k++) {
206
+ if (el[k] !== cache[off + k]) {
207
+ changed = true;
208
+ break;
209
+ }
210
+ }
211
+ if (!changed) continue;
212
+ g.instanced.setMatrixAt(i, g.sources[i]!.matrixWorld);
213
+ cache.set(el, off);
214
+ dirty = true;
215
+ }
216
+ if (dirty) g.instanced.instanceMatrix.needsUpdate = true;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Raycast against the batched instances, mapping a hit back to the logical source
222
+ * Object3D (so editor/gameplay picking still resolves a detached entity).
223
+ */
224
+ raycast(raycaster: THREE.Raycaster): THREE.Mesh | null {
225
+ const hits = raycaster.intersectObject(this.root, true);
226
+ for (const h of hits) {
227
+ const members = this.membersOf.get(h.object as THREE.InstancedMesh);
228
+ if (members && h.instanceId != null) return members[h.instanceId] ?? null;
229
+ }
230
+ return null;
231
+ }
232
+
233
+ /** Number of draw calls the batcher contributes (1 per group). */
234
+ get drawCalls(): number {
235
+ return this.groups.length;
236
+ }
237
+
238
+ /** How many source meshes were collapsed into instances. */
239
+ get batchedCount(): number {
240
+ return this.groups.reduce((n, g) => n + g.sources.length, 0);
241
+ }
242
+
243
+ teardown(): void {
244
+ for (const g of this.groups) {
245
+ for (const s of g.sources) {
246
+ if (g.dynamic)
247
+ s.visible = true; // dynamic sources stayed in-graph, hidden
248
+ else {
249
+ const parent = this.parentOf.get(s); // static sources were detached — re-attach
250
+ if (parent) parent.add(s);
251
+ }
252
+ }
253
+ g.instanced.dispose();
254
+ this.root.remove(g.instanced);
255
+ }
256
+ this.groups = [];
257
+ this.dynamicGroups = [];
258
+ if (this.root.parent) this.root.parent.remove(this.root);
259
+ }
260
+ }
@@ -0,0 +1,128 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * The render-feature registry — single source of truth for every togglable render
5
+ * setting. The render-env schema (`asset-formats/render-env.ts`) and the cascade
6
+ * resolver (`resolveRenderSettings`) are both derived from this list, so
7
+ * "everything togglable" stays consistent by construction (same posture as the
8
+ * component/material/instancer registries).
9
+ *
10
+ * EVERY ENTRY HERE MUST HAVE A RUNTIME READER of its resolved value, and that is
11
+ * now enforced: `schema-reader-coverage.test.ts` derives its walk from this
12
+ * array, so adding a feature adds a described schema path that fails coverage
13
+ * until `schema-consumption-map.ts` names the file that reads it.
14
+ *
15
+ * Three entries were deleted when that enforcement first ran, having reached it
16
+ * with no reader at all: `autoBatch` (its only mention was a COMMENT in
17
+ * `render-batch-system.ts`, a class whose own header says nothing calls it),
18
+ * `lod`, and `backend` (which offered a `webgpu` option while
19
+ * `setup-renderer.ts` unconditionally constructs a `THREE.WebGLRenderer`). A
20
+ * toggle that changes nothing is worse than a missing one — it tells an author
21
+ * the engine has a dial it does not have.
22
+ */
23
+
24
+ export type RenderFeatureValue = boolean | number | string;
25
+
26
+ export interface RenderFeature {
27
+ /** Stable key used in schema, cascade, and UI. */
28
+ key: string;
29
+ /** Editor display label. */
30
+ label: string;
31
+ /** Schema `.describe()` text (powers JSON Schema / inspector docs). */
32
+ description: string;
33
+ /** Control shape. */
34
+ kind: 'toggle' | 'number' | 'enum';
35
+ /** Engine default — the "fast by default" baseline. */
36
+ default: RenderFeatureValue;
37
+ /**
38
+ * `live` re-applies on change; `reload` needs a renderer re-init (e.g. backend,
39
+ * antialias). The editor badges `reload` features and re-inits the viewport.
40
+ */
41
+ apply: 'live' | 'reload';
42
+ /** Whether this feature may be overridden per-entity (the entity cascade layer). */
43
+ perEntity: boolean;
44
+ /** Allowed values for `enum` kind. */
45
+ options?: readonly string[];
46
+ /** [min, max] for `number` kind. */
47
+ range?: readonly [number, number];
48
+ }
49
+
50
+ export const RENDER_FEATURES: readonly RenderFeature[] = [
51
+ {
52
+ key: 'frustumCulling',
53
+ label: 'Frustum culling',
54
+ description: 'Per-instance frustum culling for batched content.',
55
+ kind: 'toggle',
56
+ default: true,
57
+ apply: 'live',
58
+ perEntity: true,
59
+ },
60
+ {
61
+ key: 'shadows',
62
+ label: 'Shadows',
63
+ description: 'Enable the shadow map. Per-entity cast/receive still applies.',
64
+ kind: 'toggle',
65
+ default: true,
66
+ apply: 'live',
67
+ perEntity: true,
68
+ },
69
+ {
70
+ key: 'postProcessing',
71
+ label: 'Post-processing',
72
+ description:
73
+ 'Master switch for the post-processing stack (individual effects nest beneath it).',
74
+ kind: 'toggle',
75
+ default: true,
76
+ apply: 'live',
77
+ perEntity: false,
78
+ },
79
+ {
80
+ key: 'resolutionScale',
81
+ label: 'Resolution scale',
82
+ description: 'Device-pixel-ratio multiplier (1.0 = native). The cheapest perf dial.',
83
+ kind: 'number',
84
+ default: 1,
85
+ apply: 'live',
86
+ perEntity: false,
87
+ range: [0.25, 2],
88
+ },
89
+ {
90
+ key: 'antialias',
91
+ label: 'Antialiasing',
92
+ description: 'MSAA via renderer construction. Applies on reload (renderer re-init).',
93
+ kind: 'toggle',
94
+ default: true,
95
+ apply: 'reload',
96
+ perEntity: false,
97
+ },
98
+ ];
99
+
100
+ /** Map for O(1) lookup by key. */
101
+ export const RENDER_FEATURE_BY_KEY: ReadonlyMap<string, RenderFeature> = new Map(
102
+ RENDER_FEATURES.map((f) => [f.key, f]),
103
+ );
104
+
105
+ /**
106
+ * Build a Zod object for the render settings, derived from the registry so the
107
+ * schema can never drift from the feature list. All fields optional — absence
108
+ * means "inherit" in the cascade. `perEntityOnly` builds the per-entity override
109
+ * schema (only features whose `perEntity` is true).
110
+ */
111
+ export function buildRenderingZod(opts: { perEntityOnly?: boolean } = {}): z.ZodTypeAny {
112
+ const shape: Record<string, z.ZodTypeAny> = {};
113
+ for (const f of RENDER_FEATURES) {
114
+ if (opts.perEntityOnly && !f.perEntity) continue;
115
+ let base: z.ZodTypeAny;
116
+ if (f.kind === 'toggle') base = z.boolean();
117
+ else if (f.kind === 'number') base = z.number();
118
+ else base = z.enum(f.options as [string, ...string[]]);
119
+ shape[f.key] = base.optional().describe(f.description);
120
+ }
121
+ return z
122
+ .object(shape)
123
+ .describe(
124
+ opts.perEntityOnly
125
+ ? 'Per-entity render overrides (omit a field to inherit from the scene/engine default)'
126
+ : 'Scene render settings (omit a field to inherit the engine default)',
127
+ );
128
+ }
@@ -0,0 +1,71 @@
1
+ import { RENDER_FEATURES, type RenderFeatureValue } from './render-features';
2
+
3
+ /**
4
+ * Cascade resolution for render settings: engine default → scene → entity, with
5
+ * tri-state inherit (an absent value inherits from the next level up)..
6
+ */
7
+
8
+ /** A partial override map (scene-level or entity-level). Absent key = inherit. */
9
+ export type RenderScope = Partial<Record<string, RenderFeatureValue>>;
10
+
11
+ /** Fully-resolved settings: every registry key present. */
12
+ export type ResolvedRenderSettings = Record<string, RenderFeatureValue>;
13
+
14
+ /**
15
+ * Resolve effective settings. `entity` overrides only apply to features whose
16
+ * `perEntity` flag is set; non-per-entity features resolve across engine→scene
17
+ * only. `??` walks up the chain on `undefined` (inherit) but lets an explicit
18
+ * `false`/`0` override (they are not nullish).
19
+ */
20
+ export function resolveRenderSettings(
21
+ scene?: RenderScope,
22
+ entity?: RenderScope,
23
+ ): ResolvedRenderSettings {
24
+ const out: ResolvedRenderSettings = {};
25
+ for (const f of RENDER_FEATURES) {
26
+ const entityVal = f.perEntity ? entity?.[f.key] : undefined;
27
+ out[f.key] = entityVal ?? scene?.[f.key] ?? f.default;
28
+ }
29
+ return out;
30
+ }
31
+
32
+ /**
33
+ * A small observable holding the live scene-level resolved settings. Live
34
+ * features re-apply when a value changes (subscribers are notified with the
35
+ * changed key). This is the seam a future player-facing graphics menu would
36
+ * drive — for now it backs the editor toggles + the runtime systems.
37
+ */
38
+ export class RenderSettings {
39
+ private values: ResolvedRenderSettings;
40
+ private listeners = new Set<(key: string, value: RenderFeatureValue) => void>();
41
+
42
+ constructor(scene?: RenderScope) {
43
+ this.values = resolveRenderSettings(scene);
44
+ }
45
+
46
+ get(key: string): RenderFeatureValue {
47
+ return this.values[key]!;
48
+ }
49
+
50
+ /** Set a scene-level value; notifies subscribers if it changed. */
51
+ set(key: string, value: RenderFeatureValue): void {
52
+ if (this.values[key] === value) return;
53
+ this.values[key] = value;
54
+ for (const fn of this.listeners) fn(key, value);
55
+ }
56
+
57
+ /** Resolve the effective value for an entity (entity override → scene → default). */
58
+ forEntity(key: string, entity?: RenderScope): RenderFeatureValue {
59
+ const f = entity?.[key];
60
+ return f ?? this.values[key]!;
61
+ }
62
+
63
+ subscribe(fn: (key: string, value: RenderFeatureValue) => void): () => void {
64
+ this.listeners.add(fn);
65
+ return () => this.listeners.delete(fn);
66
+ }
67
+
68
+ snapshot(): ResolvedRenderSettings {
69
+ return { ...this.values };
70
+ }
71
+ }