@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.
- package/LICENSE +202 -0
- package/NOTICE +12 -0
- package/package.json +62 -0
- package/src/adapter/body-marks.ts +69 -0
- package/src/adapter/constraint.ts +78 -0
- package/src/adapter/first-party-navigation-system.ts +48 -0
- package/src/adapter/hierarchy-marks.ts +156 -0
- package/src/adapter/ingest/scene-capture.ts +865 -0
- package/src/adapter/ingest/structural-ids.ts +139 -0
- package/src/adapter/ingest/visible-capture-window.ts +302 -0
- package/src/adapter/object3d-authoring-subject.ts +50 -0
- package/src/adapter/rapier-physics-adapter.ts +226 -0
- package/src/adapter/reflection-probe.ts +75 -0
- package/src/adapter/renderer-config.ts +116 -0
- package/src/adapter/trigger-volume.ts +29 -0
- package/src/ai/navigation.ts +627 -0
- package/src/animation/animation-clock.ts +479 -0
- package/src/animation/runtime-inspection.ts +45 -0
- package/src/animation/xstate-animation-binding.ts +655 -0
- package/src/animation/xstate-animation-meta.ts +360 -0
- package/src/asset-formats/camera.ts +18 -0
- package/src/asset-formats/collider.ts +66 -0
- package/src/asset-formats/index.ts +45 -0
- package/src/asset-formats/light.ts +50 -0
- package/src/asset-formats/material.ts +93 -0
- package/src/asset-formats/mesh.ts +78 -0
- package/src/asset-formats/particles.ts +426 -0
- package/src/asset-formats/render-env.ts +318 -0
- package/src/asset-formats/tuples.ts +21 -0
- package/src/asset-loaders.ts +242 -0
- package/src/asset-parse-error.ts +29 -0
- package/src/assets.ts +68 -0
- package/src/behavior/xstate-inspection.ts +51 -0
- package/src/defaults.ts +135 -0
- package/src/ecs/object-marks.ts +75 -0
- package/src/ecs/scene-index.ts +439 -0
- package/src/ecs/scene-query.ts +43 -0
- package/src/ecs/user-data.ts +272 -0
- package/src/loader.ts +134 -0
- package/src/physics/collider-dimensions.ts +121 -0
- package/src/physics/collision-system.ts +76 -0
- package/src/physics/physics-registry.ts +83 -0
- package/src/physics/transform-writer.ts +41 -0
- package/src/render/directional-shadow-fit.ts +158 -0
- package/src/render/environment-capture.ts +133 -0
- package/src/render/ibl-override-material.ts +202 -0
- package/src/render/light-camera-factory.ts +103 -0
- package/src/render/matcap-texture.ts +92 -0
- package/src/render/material-factory.ts +201 -0
- package/src/render/mesh-shadow.ts +18 -0
- package/src/render/particles-factory.ts +589 -0
- package/src/render/render-batch-system.ts +260 -0
- package/src/render/render-features.ts +128 -0
- package/src/render/render-settings.ts +71 -0
- package/src/render/soft-particle-depth.ts +360 -0
- package/src/render/spark-renderer-lifecycle.ts +64 -0
- package/src/render/structural-signature.ts +231 -0
- package/src/render/viewport-shading.ts +163 -0
- package/src/render/voxel-surface.ts +358 -0
- package/src/setup/setup-particles.ts +32 -0
- package/src/setup/setup-physics.ts +67 -0
- package/src/setup/setup-renderer.ts +524 -0
- package/src/types-n8ao.d.ts +37 -0
- package/src/types-realism-effects.d.ts +61 -0
- package/vendor/realism-effects/LICENSE.md +21 -0
- package/vendor/realism-effects/UPSTREAM.md +19 -0
- package/vendor/realism-effects/dist/index.cjs +3447 -0
- package/vendor/realism-effects/dist/index.d.ts +59 -0
- package/vendor/realism-effects/dist/index.js +3434 -0
- 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
|
+
}
|