@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 +18 -3
- package/src/adapter/vgai-scene-game-adapter.ts +22 -13
- package/src/react/humanoid-bake.document.tsx +337 -0
- package/src/render/viewport-shading.ts +116 -0
- package/src/setup/setup-renderer.ts +36 -0
- package/tools/humanoid-bake.tool.ts +274 -0
- package/src/humanoid/bake.operation.ts +0 -326
- package/src/tools/define-tool.ts +0 -191
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.
|
|
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
|
-
"
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
493
|
-
|
|
494
|
-
|
|
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
|
+
}
|