@vgai/engine 0.4.0 → 0.4.1

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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@vgai/engine",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "Apache-2.0",
5
- "version": "0.4.0",
5
+ "version": "0.4.1",
6
6
  "type": "module",
7
7
  "description": "The vgai game engine — core loop, GameComponent model, physics, scene loader, adapter seam.",
8
8
  "homepage": "https://github.com/volter-ai/vgai-engine#readme",
@@ -15,8 +15,18 @@
15
15
  "access": "public"
16
16
  },
17
17
  "vgai": {
18
- "operations": [
19
- "./src/humanoid/bake.operation.ts"
18
+ "tools": [
19
+ {
20
+ "entry": "./tools/humanoid-bake.tool.ts",
21
+ "contributions": [
22
+ {
23
+ "id": "humanoid-builder",
24
+ "point": "workspace.document",
25
+ "title": "Humanoid Builder",
26
+ "entry": "./src/react/humanoid-bake.document.tsx"
27
+ }
28
+ ]
29
+ }
20
30
  ]
21
31
  },
22
32
  "exports": {
@@ -26,6 +36,7 @@
26
36
  },
27
37
  "files": [
28
38
  "src",
39
+ "tools",
29
40
  "dist",
30
41
  "schemas"
31
42
  ],
@@ -55,10 +66,14 @@
55
66
  "zod": "^4.3.6"
56
67
  },
57
68
  "peerDependencies": {
69
+ "@vgai/editor-sdk": "*",
58
70
  "react": ">=18",
59
71
  "react-dom": ">=18"
60
72
  },
61
73
  "peerDependenciesMeta": {
74
+ "@vgai/editor-sdk": {
75
+ "optional": true
76
+ },
62
77
  "react": {
63
78
  "optional": true
64
79
  },
@@ -32,6 +32,7 @@ import { createTransformWriter } from '../physics/transform-writer';
32
32
  import { createTriggerDispatch } from '../physics/trigger-dispatch';
33
33
  import { RenderBatchSystem } from '../render/render-batch-system';
34
34
  import { type RenderScope, resolveRenderSettings } from '../render/render-settings';
35
+ import { type ViewportShadingMode, ViewportShadingRenderer } from '../render/viewport-shading';
35
36
  import {
36
37
  createDebugRegistry,
37
38
  type DebugRegistry,
@@ -53,11 +54,7 @@ import { hasUserData, setUserData } from '../scene/user-data';
53
54
  import { type AudioContext as GameAudio, setupAudio } from '../setup/setup-audio';
54
55
  import { setupParticles } from '../setup/setup-particles';
55
56
  import { setupPhysics, updatePhysicsDebug } from '../setup/setup-physics';
56
- import {
57
- applyRendererSettings,
58
- applyScenePostProcessing,
59
- createSceneView,
60
- } from '../setup/setup-renderer';
57
+ import { applySceneRenderPipeline, createSceneView } from '../setup/setup-renderer';
61
58
  import {
62
59
  createAudioSystemAdapter,
63
60
  createInputManagerAdapter,
@@ -120,6 +117,8 @@ export interface VgaiMountedGame extends MountedGame {
120
117
  readonly firstParty: true;
121
118
  readonly ctx: GameContext;
122
119
  readonly composer: EffectComposer | null;
120
+ /** Render-only developer view; native game materials are restored before the frame returns. */
121
+ setViewportShadingMode(mode: ViewportShadingMode): void;
123
122
  /** Warm-restart: dispose the current game, re-run a new setup on the same ctx. */
124
123
  hotReload(newSetup: GameSetupFn, editorPreview?: EditorPreview): Promise<void>;
125
124
  /**
@@ -231,6 +230,8 @@ export class VgaiSceneGameAdapter implements GameAdapter {
231
230
  !headless && rendererContext && typeof rendererContext.getExtension === 'function'
232
231
  ? createWebGLGpuTimer(rendererContext)
233
232
  : null;
233
+ const viewportShading = new ViewportShadingRenderer();
234
+ let viewportShadingMode: ViewportShadingMode = 'solid';
234
235
 
235
236
  // --- System runner (engine-level systems) ---
236
237
  const systems = createSystemRunner(host.game?.profiler.systemObserver, 'threejs');
@@ -274,7 +275,15 @@ export class VgaiSceneGameAdapter implements GameAdapter {
274
275
  (dt) => {
275
276
  if (host.game?.profiler.enabled) gpuTimer?.begin();
276
277
  try {
277
- composer.render(dt);
278
+ viewportShading.render(
279
+ scene,
280
+ viewportShadingMode,
281
+ () => composer.render(dt),
282
+ // Scene authoring can temporarily attach editor infrastructure
283
+ // (gizmos/helpers on their own layer) to this same native scene.
284
+ // Only replace materials the actual game camera can render.
285
+ (mesh) => camera.layers.test(mesh.layers),
286
+ );
278
287
  } finally {
279
288
  gpuTimer?.end();
280
289
  }
@@ -489,13 +498,9 @@ export class VgaiSceneGameAdapter implements GameAdapter {
489
498
  if (headless) return;
490
499
  const scope = inst.environment?.rendering as RenderScope | undefined;
491
500
  const settings = resolveRenderSettings(scope);
492
- // live renderer settings (shadows, resolution scale)
493
- applyRendererSettings(renderer, scope);
494
- // post-processing master switch: (re)build when the scene defines effects OR
495
- // the master is off (to strip the default bloom pass down to a plain scene).
496
- if (inst.environment?.postProcessing || !settings['postProcessing']) {
497
- applyScenePostProcessing(composer, renderer, scene, camera, inst.environment);
498
- }
501
+ // Always apply the whole authored scene pipeline. Tone mapping is an
502
+ // authored setting even when the scene has no post-processing effects.
503
+ applySceneRenderPipeline(composer, renderer, scene, camera, inst.environment);
499
504
  // transparent batching: collapse static/mover meshes sharing a signature
500
505
  if (settings['autoBatch']) {
501
506
  batchSystem = new RenderBatchSystem(scene, settings);
@@ -701,6 +706,7 @@ export class VgaiSceneGameAdapter implements GameAdapter {
701
706
  }
702
707
  safeStep('input.dispose', () => input.dispose());
703
708
  safeStep('gpuTimer.dispose', () => gpuTimer?.dispose());
709
+ safeStep('viewportShading.dispose', () => viewportShading.dispose());
704
710
  };
705
711
 
706
712
  const hotReload = async (
@@ -820,6 +826,9 @@ export class VgaiSceneGameAdapter implements GameAdapter {
820
826
  systems: systemAdapters,
821
827
  ctx,
822
828
  composer: headless ? null : composer,
829
+ setViewportShadingMode: (mode) => {
830
+ viewportShadingMode = mode;
831
+ },
823
832
  hotReload,
824
833
  frame: {
825
834
  runPhase: (phase, dt) => systems.runPhase(phase, dt),
@@ -0,0 +1,337 @@
1
+ import type { ToolContributionProps } from '@vgai/editor-sdk/contributions';
2
+ import { useState } from 'react';
3
+
4
+ type ProportionKey =
5
+ | 'legLength'
6
+ | 'armLength'
7
+ | 'torsoLength'
8
+ | 'shoulderWidth'
9
+ | 'hipWidth'
10
+ | 'headSize'
11
+ | 'limbThickness'
12
+ | 'torsoGirth';
13
+
14
+ const PROPORTIONS: ReadonlyArray<{
15
+ key: ProportionKey;
16
+ label: string;
17
+ min: number;
18
+ max: number;
19
+ hint: string;
20
+ }> = [
21
+ { key: 'legLength', label: 'Leg length', min: 0.5, max: 2, hint: 'Hip height and stride' },
22
+ { key: 'armLength', label: 'Arm length', min: 0.5, max: 2, hint: 'Upper arm and forearm' },
23
+ { key: 'torsoLength', label: 'Torso length', min: 0.5, max: 2, hint: 'Spine and neck' },
24
+ { key: 'shoulderWidth', label: 'Shoulder width', min: 0.5, max: 2, hint: 'Clavicle breadth' },
25
+ { key: 'hipWidth', label: 'Hip width', min: 0.5, max: 2, hint: 'Pelvis breadth' },
26
+ { key: 'headSize', label: 'Head size', min: 0.5, max: 2, hint: 'Head and neck scale' },
27
+ { key: 'limbThickness', label: 'Limb thickness', min: 0.4, max: 2.5, hint: 'Arm and leg build' },
28
+ { key: 'torsoGirth', label: 'Torso girth', min: 0.4, max: 2.5, hint: 'Torso and pelvis build' },
29
+ ];
30
+
31
+ const initialProportions: Record<ProportionKey, number> = {
32
+ legLength: 1,
33
+ armLength: 1,
34
+ torsoLength: 1,
35
+ shoulderWidth: 1,
36
+ hipWidth: 1,
37
+ headSize: 1,
38
+ limbThickness: 1,
39
+ torsoGirth: 1,
40
+ };
41
+
42
+ type Outcome = Awaited<ReturnType<ToolContributionProps['client']['runProjectTool']>>;
43
+
44
+ function SliderRow({
45
+ definition,
46
+ value,
47
+ onChange,
48
+ }: {
49
+ definition: (typeof PROPORTIONS)[number];
50
+ value: number;
51
+ onChange(value: number): void;
52
+ }) {
53
+ return (
54
+ <label style={sliderRowStyle}>
55
+ <span>
56
+ <strong style={{ display: 'block', fontSize: 12 }}>{definition.label}</strong>
57
+ <span style={hintStyle}>{definition.hint}</span>
58
+ </span>
59
+ <input
60
+ type="range"
61
+ min={definition.min}
62
+ max={definition.max}
63
+ step={0.01}
64
+ value={value}
65
+ onChange={(event) => onChange(Number(event.target.value))}
66
+ style={{ width: '100%', accentColor: 'var(--vgai-accent)' }}
67
+ />
68
+ <input
69
+ className="vgai-input"
70
+ type="number"
71
+ min={definition.min}
72
+ max={definition.max}
73
+ step={0.01}
74
+ value={value}
75
+ onChange={(event) => onChange(Number(event.target.value))}
76
+ style={numberStyle}
77
+ />
78
+ </label>
79
+ );
80
+ }
81
+
82
+ export default function HumanoidBuilder({ tool, client }: ToolContributionProps) {
83
+ const [name, setName] = useState('humanoid');
84
+ const [height, setHeight] = useState('');
85
+ const [proportions, setProportions] = useState<Record<ProportionKey, number>>(initialProportions);
86
+ const [clipSource, setClipSource] = useState('');
87
+ const [clipNames, setClipNames] = useState('Idle, Walk, Run');
88
+ const [running, setRunning] = useState<'validate' | 'bake' | null>(null);
89
+ const [outcome, setOutcome] = useState<Outcome | null>(null);
90
+
91
+ const run = async (dryRun: boolean) => {
92
+ setRunning(dryRun ? 'validate' : 'bake');
93
+ setOutcome(null);
94
+ try {
95
+ const params: Record<string, number> = { ...proportions };
96
+ if (height.trim() !== '') params['height'] = Number(height);
97
+ setOutcome(
98
+ await client.runProjectTool(
99
+ tool.name,
100
+ {
101
+ name,
102
+ params,
103
+ ...(clipSource.trim() ? { clipSource: clipSource.trim() } : {}),
104
+ clipNames: clipNames
105
+ .split(',')
106
+ .map((item) => item.trim())
107
+ .filter(Boolean),
108
+ dryRun,
109
+ },
110
+ { confirm: true },
111
+ ),
112
+ );
113
+ } finally {
114
+ setRunning(null);
115
+ }
116
+ };
117
+
118
+ const result = outcome?.ok ? (outcome.data as Record<string, unknown>) : null;
119
+
120
+ return (
121
+ <main style={rootStyle} data-testid="humanoid-builder">
122
+ <header style={{ marginBottom: 22 }}>
123
+ <div style={eyebrowStyle}>@vgai/engine contribution</div>
124
+ <h2 style={{ margin: '8px 0 4px', fontSize: 22 }}>Humanoid Builder</h2>
125
+ <p style={{ ...hintStyle, maxWidth: 740, lineHeight: 1.5 }}>
126
+ Shape a reusable skinned humanoid, optionally bake standard clips, then add the generated
127
+ prefab to a scene. The reference material is intentionally diagnostic—not a finished
128
+ character style.
129
+ </p>
130
+ </header>
131
+
132
+ <div style={columnsStyle}>
133
+ <section style={panelStyle}>
134
+ <h3 style={sectionHeadingStyle}>Asset</h3>
135
+ <label style={fieldStyle}>
136
+ <span style={fieldLabelStyle}>Asset name</span>
137
+ <input
138
+ className="vgai-input"
139
+ value={name}
140
+ onChange={(event) => setName(event.target.value)}
141
+ style={inputStyle}
142
+ />
143
+ <span style={hintStyle}>Lowercase filename used for the GLB and prefab.</span>
144
+ </label>
145
+ <label style={fieldStyle}>
146
+ <span style={fieldLabelStyle}>Standing height</span>
147
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
148
+ <input
149
+ className="vgai-input"
150
+ type="number"
151
+ min={0.3}
152
+ max={4}
153
+ step={0.01}
154
+ value={height}
155
+ placeholder="Natural"
156
+ onChange={(event) => setHeight(event.target.value)}
157
+ style={{ ...inputStyle, flex: 1 }}
158
+ />
159
+ <span style={hintStyle}>metres</span>
160
+ </div>
161
+ <span style={hintStyle}>Leave blank to use the proportions’ natural height.</span>
162
+ </label>
163
+
164
+ <h3 style={{ ...sectionHeadingStyle, marginTop: 24 }}>Animation</h3>
165
+ <label style={fieldStyle}>
166
+ <span style={fieldLabelStyle}>Clip source</span>
167
+ <input
168
+ className="vgai-input"
169
+ value={clipSource}
170
+ placeholder="/models/animations.glb (optional)"
171
+ onChange={(event) => setClipSource(event.target.value)}
172
+ style={inputStyle}
173
+ />
174
+ <span style={hintStyle}>A GLB inside this project’s public/ directory.</span>
175
+ </label>
176
+ <label style={fieldStyle}>
177
+ <span style={fieldLabelStyle}>Clips to bake</span>
178
+ <input
179
+ className="vgai-input"
180
+ value={clipNames}
181
+ onChange={(event) => setClipNames(event.target.value)}
182
+ style={inputStyle}
183
+ />
184
+ <span style={hintStyle}>Comma-separated exact clip names.</span>
185
+ </label>
186
+ </section>
187
+
188
+ <section style={panelStyle}>
189
+ <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}>
190
+ <h3 style={sectionHeadingStyle}>Body proportions</h3>
191
+ <button
192
+ className="vgai-btn"
193
+ data-variant="ghost"
194
+ data-size="compact"
195
+ type="button"
196
+ onClick={() => setProportions(initialProportions)}
197
+ >
198
+ Reset
199
+ </button>
200
+ </div>
201
+ <div style={{ display: 'grid', gap: 11 }}>
202
+ {PROPORTIONS.map((definition) => (
203
+ <SliderRow
204
+ key={definition.key}
205
+ definition={definition}
206
+ value={proportions[definition.key]}
207
+ onChange={(value) =>
208
+ setProportions((current) => ({ ...current, [definition.key]: value }))
209
+ }
210
+ />
211
+ ))}
212
+ </div>
213
+ </section>
214
+ </div>
215
+
216
+ <footer style={footerStyle}>
217
+ <div style={{ display: 'flex', gap: 8 }}>
218
+ <button
219
+ className="vgai-btn"
220
+ data-variant="ghost"
221
+ data-size="default"
222
+ type="button"
223
+ disabled={running !== null}
224
+ aria-busy={running === 'validate'}
225
+ onClick={() => void run(true)}
226
+ >
227
+ {running === 'validate' ? 'Validating…' : 'Validate without writing'}
228
+ </button>
229
+ <button
230
+ className="vgai-btn"
231
+ data-variant="solid"
232
+ data-size="default"
233
+ type="button"
234
+ disabled={running !== null}
235
+ aria-busy={running === 'bake'}
236
+ onClick={() => void run(false)}
237
+ >
238
+ {running === 'bake' ? 'Baking…' : 'Bake humanoid'}
239
+ </button>
240
+ </div>
241
+ <span style={hintStyle}>
242
+ Outputs go to public/models/generated and public/prefabs/generated.
243
+ </span>
244
+ </footer>
245
+
246
+ {outcome && (
247
+ <section style={{ ...panelStyle, marginTop: 14 }} aria-live="polite">
248
+ {outcome.ok ? (
249
+ <>
250
+ <h3 style={sectionHeadingStyle}>
251
+ {result?.['dryRun'] ? 'Validation passed' : 'Humanoid baked'}
252
+ </h3>
253
+ <div style={resultGridStyle}>
254
+ <span style={hintStyle}>Model</span>
255
+ <code>{String(result?.['model'] ?? '')}</code>
256
+ <span style={hintStyle}>Prefab</span>
257
+ <code>{String(result?.['prefab'] ?? '')}</code>
258
+ <span style={hintStyle}>Vertices</span>
259
+ <span>{String(result?.['vertexCount'] ?? '')}</span>
260
+ <span style={hintStyle}>Clips</span>
261
+ <span>
262
+ {Array.isArray(result?.['clips']) ? result['clips'].join(', ') || 'None' : 'None'}
263
+ </span>
264
+ </div>
265
+ </>
266
+ ) : (
267
+ <>
268
+ <h3 style={sectionHeadingStyle}>Could not bake humanoid</h3>
269
+ <div style={{ color: 'var(--vgai-danger)' }}>
270
+ {outcome.error.code}: {outcome.error.message}
271
+ </div>
272
+ </>
273
+ )}
274
+ </section>
275
+ )}
276
+ </main>
277
+ );
278
+ }
279
+
280
+ const rootStyle: React.CSSProperties = {
281
+ padding: 24,
282
+ maxWidth: 1080,
283
+ margin: '0 auto',
284
+ color: 'inherit',
285
+ fontFamily: 'var(--vgai-font-sans)',
286
+ };
287
+ const columnsStyle: React.CSSProperties = {
288
+ display: 'grid',
289
+ gridTemplateColumns: 'minmax(260px, 0.8fr) minmax(430px, 1.4fr)',
290
+ gap: 14,
291
+ alignItems: 'start',
292
+ };
293
+ const panelStyle: React.CSSProperties = {
294
+ padding: 16,
295
+ border: '1px solid var(--vgai-boundary-default)',
296
+ borderRadius: 'var(--vgai-radius-lg)',
297
+ background: 'var(--vgai-surface-panel)',
298
+ };
299
+ const sectionHeadingStyle: React.CSSProperties = { margin: '0 0 14px', fontSize: 14 };
300
+ const eyebrowStyle: React.CSSProperties = {
301
+ color: 'var(--vgai-content-muted)',
302
+ fontSize: 10,
303
+ fontWeight: 700,
304
+ textTransform: 'uppercase',
305
+ letterSpacing: '0.1em',
306
+ };
307
+ const fieldStyle: React.CSSProperties = { display: 'grid', gap: 6, marginTop: 14 };
308
+ const fieldLabelStyle: React.CSSProperties = { fontSize: 12, fontWeight: 600 };
309
+ const hintStyle: React.CSSProperties = { color: 'var(--vgai-content-muted)', fontSize: 11 };
310
+ const inputStyle: React.CSSProperties = {
311
+ boxSizing: 'border-box',
312
+ width: '100%',
313
+ };
314
+ const numberStyle: React.CSSProperties = { ...inputStyle, width: 68 };
315
+ const sliderRowStyle: React.CSSProperties = {
316
+ display: 'grid',
317
+ gridTemplateColumns: '130px minmax(110px, 1fr) 68px',
318
+ alignItems: 'center',
319
+ gap: 10,
320
+ };
321
+ const footerStyle: React.CSSProperties = {
322
+ display: 'flex',
323
+ alignItems: 'center',
324
+ justifyContent: 'space-between',
325
+ gap: 12,
326
+ marginTop: 14,
327
+ padding: 14,
328
+ border: '1px solid var(--vgai-boundary-default)',
329
+ borderRadius: 'var(--vgai-radius-lg)',
330
+ background: 'var(--vgai-surface-raised)',
331
+ };
332
+ const resultGridStyle: React.CSSProperties = {
333
+ display: 'grid',
334
+ gridTemplateColumns: '70px minmax(0, 1fr)',
335
+ gap: '8px 12px',
336
+ alignItems: 'baseline',
337
+ };
@@ -0,0 +1,116 @@
1
+ import * as THREE from 'three';
2
+
3
+ /** Temporary developer-facing shading modes. These never become scene data. */
4
+ export type ViewportShadingMode = 'solid' | 'unlit' | 'wireframe' | 'normals' | 'overdraw';
5
+
6
+ type MaterialPair = {
7
+ unlit: THREE.MeshBasicMaterial;
8
+ wireframe: THREE.MeshBasicMaterial;
9
+ };
10
+
11
+ function materialColor(material: THREE.Material): THREE.Color {
12
+ const color = (material as THREE.Material & { color?: THREE.Color }).color;
13
+ return color?.clone() ?? new THREE.Color(0xbec7d1);
14
+ }
15
+
16
+ function textureOf(material: THREE.Material, key: 'map' | 'alphaMap'): THREE.Texture | null {
17
+ return (
18
+ (material as THREE.Material & { map?: THREE.Texture | null; alphaMap?: THREE.Texture | null })[
19
+ key
20
+ ] ?? null
21
+ );
22
+ }
23
+
24
+ /**
25
+ * Applies a render-only material view and restores every native material in a
26
+ * `finally` block. Gameplay and authoring therefore always observe the real
27
+ * materials; only renderer work inside {@link render} sees the diagnostic view.
28
+ */
29
+ export class ViewportShadingRenderer {
30
+ private readonly _derived = new Map<THREE.Material, MaterialPair>();
31
+ private readonly _normals = new THREE.MeshNormalMaterial();
32
+ private readonly _overdraw = new THREE.MeshBasicMaterial({
33
+ color: 0xffffff,
34
+ transparent: true,
35
+ opacity: 0.08,
36
+ depthTest: false,
37
+ depthWrite: false,
38
+ blending: THREE.AdditiveBlending,
39
+ side: THREE.DoubleSide,
40
+ toneMapped: false,
41
+ });
42
+
43
+ render(
44
+ scene: THREE.Scene,
45
+ mode: ViewportShadingMode,
46
+ draw: () => void,
47
+ include: (mesh: THREE.Mesh) => boolean = () => true,
48
+ ): void {
49
+ if (mode === 'solid') {
50
+ draw();
51
+ return;
52
+ }
53
+
54
+ const originals: Array<{
55
+ mesh: THREE.Mesh;
56
+ material: THREE.Material | THREE.Material[];
57
+ }> = [];
58
+ scene.traverse((object) => {
59
+ const mesh = object as THREE.Mesh;
60
+ if (!mesh.isMesh || !mesh.material || !include(mesh)) return;
61
+ originals.push({ mesh, material: mesh.material });
62
+ mesh.material = Array.isArray(mesh.material)
63
+ ? mesh.material.map((material) => this._materialFor(material, mode))
64
+ : this._materialFor(mesh.material, mode);
65
+ });
66
+
67
+ try {
68
+ draw();
69
+ } finally {
70
+ for (const { mesh, material } of originals) mesh.material = material;
71
+ }
72
+ }
73
+
74
+ dispose(): void {
75
+ for (const pair of this._derived.values()) {
76
+ pair.unlit.dispose();
77
+ pair.wireframe.dispose();
78
+ }
79
+ this._derived.clear();
80
+ this._normals.dispose();
81
+ this._overdraw.dispose();
82
+ }
83
+
84
+ private _materialFor(
85
+ material: THREE.Material,
86
+ mode: Exclude<ViewportShadingMode, 'solid'>,
87
+ ): THREE.Material {
88
+ if (mode === 'normals') return this._normals;
89
+ if (mode === 'overdraw') return this._overdraw;
90
+
91
+ let pair = this._derived.get(material);
92
+ if (!pair) {
93
+ const common: THREE.MeshBasicMaterialParameters = {
94
+ color: materialColor(material),
95
+ map: textureOf(material, 'map'),
96
+ alphaMap: textureOf(material, 'alphaMap'),
97
+ alphaTest: material.alphaTest,
98
+ opacity: material.opacity,
99
+ transparent: material.transparent,
100
+ side: material.side,
101
+ depthTest: material.depthTest,
102
+ depthWrite: material.depthWrite,
103
+ vertexColors: Boolean(
104
+ (material as THREE.Material & { vertexColors?: boolean }).vertexColors,
105
+ ),
106
+ fog: (material as THREE.Material & { fog?: boolean }).fog ?? true,
107
+ };
108
+ pair = {
109
+ unlit: new THREE.MeshBasicMaterial(common),
110
+ wireframe: new THREE.MeshBasicMaterial({ ...common, wireframe: true }),
111
+ };
112
+ this._derived.set(material, pair);
113
+ }
114
+ return pair[mode];
115
+ }
116
+ }
@@ -534,3 +534,39 @@ export function applyScenePostProcessing(
534
534
  composer.addPass(new EffectPass(camera, ...effects));
535
535
  }
536
536
  }
537
+
538
+ /**
539
+ * Apply every authored scene-level renderer setting through one path.
540
+ *
541
+ * Tone mapping is part of the scene environment, not conditional on the
542
+ * presence of post-processing effects. Keeping renderer settings and the
543
+ * composer rebuild together prevents editor/runtime drift for scenes that
544
+ * only author exposure or a tone-mapping mode.
545
+ */
546
+ export function applySceneRenderPipeline(
547
+ composer: EffectComposer,
548
+ renderer: THREE.WebGLRenderer,
549
+ scene: THREE.Scene,
550
+ camera: THREE.Camera,
551
+ env?: SceneEnvironment,
552
+ options?: {
553
+ basePixelRatio?: number;
554
+ objectMap?: Map<string, THREE.Object3D>;
555
+ entityTags?: Map<string, string[]>;
556
+ },
557
+ ): void {
558
+ applyRendererSettings(
559
+ renderer,
560
+ env?.rendering as RenderScope | undefined,
561
+ options?.basePixelRatio,
562
+ );
563
+ applyScenePostProcessing(
564
+ composer,
565
+ renderer,
566
+ scene,
567
+ camera,
568
+ env,
569
+ options?.objectMap,
570
+ options?.entityTags,
571
+ );
572
+ }