@volter/editor-threejs 0.5.66 → 0.5.68
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/contributions/animation-timeline.utility.tsx +44 -0
- package/contributions/three-integration.service.ts +13 -0
- package/package.json +96 -6
- package/src/adapter/renderer-config.ts +3 -4
- package/src/adapter/three-contract.ts +72 -0
- package/src/ecs/object-marks.ts +1 -1
- package/src/ecs/user-data.ts +0 -9
- package/src/host-hierarchy-objects.ts +31 -0
- package/src/kit/animation/three-clips-subject.ts +190 -0
- package/src/kit/asset-compare.ts +294 -0
- package/src/kit/asset-preview-command.ts +265 -0
- package/src/kit/asset-preview-framing.ts +357 -0
- package/src/kit/asset-preview.ts +2802 -0
- package/src/kit/asset-workflow/model-inspection.ts +830 -0
- package/src/kit/authoring/component-instance-root.ts +171 -0
- package/src/kit/authoring/design-time-settle.ts +343 -0
- package/src/kit/authoring/live-object-transform.ts +62 -0
- package/src/kit/authoring/object3d-document-session-registry.ts +154 -0
- package/src/kit/authoring/object3d-document-session.ts +1965 -0
- package/src/kit/authoring/object3d-gesture-controller.ts +113 -0
- package/src/kit/authoring/quarks-particle-systems.ts +19 -0
- package/src/kit/authoring/shell-viewport-policy.ts +48 -0
- package/src/kit/authoring/source-object3d-authoring-adapter.ts +526 -0
- package/src/kit/authoring/three-projection-core.ts +226 -0
- package/src/kit/authoring/viewport-pick-context.ts +39 -0
- package/src/kit/authoring/viewport-raycast.ts +240 -0
- package/src/kit/authoring/world-hidden-viewport.ts +95 -0
- package/src/kit/camera-authoring.ts +175 -0
- package/src/kit/components/CameraInfo.tsx +56 -0
- package/src/kit/components/InspectorObjectPreview.tsx +57 -0
- package/src/kit/components/Object3DDocumentToolbar.tsx +549 -0
- package/src/kit/components/Object3DDocumentViewport.tsx +58 -0
- package/src/kit/components/StageHost.tsx +2547 -0
- package/src/kit/components/StageOverlays.tsx +21 -0
- package/src/kit/components/StatsOverlay.tsx +78 -0
- package/src/kit/components/ToolObject3DPreview.tsx +39 -0
- package/src/kit/components/ViewportFurniture.tsx +655 -0
- package/src/kit/components/ViewportOverlay.tsx +215 -0
- package/src/kit/components/ViewportShadingMenu.tsx +340 -0
- package/src/kit/components/ViewportViewMenu.tsx +155 -0
- package/src/kit/components/asset-viewers/EntityModelDocument.tsx +121 -0
- package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
- package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +395 -0
- package/src/kit/components/asset-viewers/LutAssetDocument.tsx +444 -0
- package/src/kit/components/asset-viewers/ModelAssetDocument.tsx +105 -0
- package/src/kit/components/asset-viewers/Object3DPreview.tsx +356 -0
- package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +527 -0
- package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
- package/src/kit/components/asset-viewers/three-asset-viewers.tsx +132 -0
- package/src/kit/components/object3d-contribution-surfaces.tsx +33 -0
- package/src/kit/components/stage-keyboard.tsx +40 -0
- package/src/kit/components/stage-overlay-set.tsx +105 -0
- package/src/kit/components/stage-presence-markers.ts +482 -0
- package/src/kit/components/stage-transform-chrome.ts +30 -0
- package/src/kit/components/stage-transform-tools.tsx +73 -0
- package/src/kit/components/stage-view-name.ts +30 -0
- package/src/kit/components/standard-viewport-dressing.ts +1042 -0
- package/src/kit/components/world-root-binding.ts +64 -0
- package/src/kit/constraint-helper.ts +338 -0
- package/src/kit/editor-shell-store.ts +814 -0
- package/src/kit/editor-viewport.ts +6621 -0
- package/src/kit/entity-lod.ts +31 -0
- package/src/kit/entity-object.ts +92 -0
- package/src/kit/hierarchy-mark-reader.ts +74 -0
- package/src/kit/instanced-presentation.ts +164 -0
- package/src/kit/live-module-source.ts +230 -0
- package/src/kit/model-thumbnail.ts +539 -0
- package/src/kit/play-camera-flight.ts +300 -0
- package/src/kit/projection/three.ts +898 -0
- package/src/kit/reflection-probe-helper.ts +142 -0
- package/src/kit/scene-document-viewport.ts +51 -0
- package/src/kit/scene-framing.ts +315 -0
- package/src/kit/scene-view-fog.ts +89 -0
- package/src/kit/spatial-handle-visuals.ts +332 -0
- package/src/kit/stories/three-story-model.ts +66 -0
- package/src/kit/three-canvas-render.ts +44 -0
- package/src/kit/three-hierarchy-row-media.ts +26 -0
- package/src/kit/three-inspection-media.ts +73 -0
- package/src/kit/three-integration.ts +86 -0
- package/src/kit/three-state.ts +33 -0
- package/src/kit/three-viewport/bone-selection-highlight.ts +119 -0
- package/src/kit/three-viewport/camera-fit.ts +41 -0
- package/src/kit/three-viewport/interactive-renderer.ts +132 -0
- package/src/kit/three-viewport/selection-brackets.ts +355 -0
- package/src/kit/three-viewport/selection-outline.ts +333 -0
- package/src/kit/three-viewport/skeleton-helper.ts +61 -0
- package/src/kit/three-viewport/source-color.ts +197 -0
- package/src/kit/three-viewport/studio-environment.ts +96 -0
- package/src/kit/trigger-volume-helper.ts +116 -0
- package/src/kit/viewport-actions.ts +128 -0
- package/src/kit/viewport-authoring-policy.ts +154 -0
- package/src/kit/viewport-commands.ts +318 -0
- package/src/kit/viewport-hotkeys.ts +119 -0
- package/src/kit/viewport-shading-boundary.ts +12 -0
- package/src/kit/viewport-status-facet.ts +53 -0
- package/src/object3d-contributions.ts +494 -0
- package/src/render/viewport-shading.ts +6 -2
- package/src/viewport-api.ts +92 -0
- package/src/viewport-door.ts +237 -0
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
import { faCode, faSliders, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
|
|
2
|
+
import { Checkbox, ColorInput, NumberInput, Select, themeVars } from '@volter/editor-sdk/widgets';
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import * as THREE from 'three';
|
|
5
|
+
import { readProjectTextFile } from '@volter/editor-sdk/kit/editor-api';
|
|
6
|
+
import type { InspectionSection } from '@volter/editor-sdk/kit/inspection-model';
|
|
7
|
+
import { CONTRIBUTED_SECTION_ORDER, PROPERTIES_SECTION_ORDER } from '@volter/editor-sdk/kit/inspection-model';
|
|
8
|
+
import { AssetEditorShell } from '@volter/editor-sdk/kit/components/AssetEditorShell';
|
|
9
|
+
import { subscribeProjectAsset } from '@volter/editor-sdk/kit/components/asset-editor-persistence';
|
|
10
|
+
import {
|
|
11
|
+
FALLBACK_FRAGMENT_SHADER,
|
|
12
|
+
FALLBACK_VERTEX_SHADER,
|
|
13
|
+
parseShaderDiagnostics,
|
|
14
|
+
previewShaderSource,
|
|
15
|
+
reflectShaderUniforms,
|
|
16
|
+
type ShaderDiagnostic,
|
|
17
|
+
type ShaderStage,
|
|
18
|
+
type ShaderUniformDescriptor,
|
|
19
|
+
shaderCompanionPath,
|
|
20
|
+
shaderStageFromPath,
|
|
21
|
+
} from '@volter/editor-sdk/kit/components/asset-viewers/shader-source';
|
|
22
|
+
|
|
23
|
+
type PreviewGeometry = 'sphere' | 'box' | 'plane';
|
|
24
|
+
type UniformPreviewValue = number | boolean | string | number[];
|
|
25
|
+
|
|
26
|
+
interface ShaderFileState {
|
|
27
|
+
readonly path: string | null;
|
|
28
|
+
readonly source: string;
|
|
29
|
+
readonly available: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type ShaderFiles = Record<ShaderStage, ShaderFileState>;
|
|
33
|
+
|
|
34
|
+
function fallbackFile(stage: ShaderStage, path: string | null = null): ShaderFileState {
|
|
35
|
+
const source = stage === 'vertex' ? FALLBACK_VERTEX_SHADER : FALLBACK_FRAGMENT_SHADER;
|
|
36
|
+
return { path, source, available: false };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function basename(path: string | null): string {
|
|
40
|
+
return path?.split('/').pop() ?? 'Preview fallback';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isColorUniform(uniform: ShaderUniformDescriptor): boolean {
|
|
44
|
+
return uniform.type === 'vec3' && /(colou?r|tint|albedo|emissive)/i.test(uniform.name);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function automaticUniform(
|
|
48
|
+
uniform: ShaderUniformDescriptor,
|
|
49
|
+
): 'time' | 'resolution' | 'mouse' | null {
|
|
50
|
+
if (/^(u|i)?time$/i.test(uniform.name) && /^(float|double)$/.test(uniform.type)) return 'time';
|
|
51
|
+
if (/^(u|i)?resolution$/i.test(uniform.name) && /^([diu]?vec)[234]$/.test(uniform.type))
|
|
52
|
+
return 'resolution';
|
|
53
|
+
if (/^(u|i)?mouse$/i.test(uniform.name) && /^([diu]?vec)[234]$/.test(uniform.type))
|
|
54
|
+
return 'mouse';
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function defaultUniformValue(uniform: ShaderUniformDescriptor): UniformPreviewValue {
|
|
59
|
+
if (isColorUniform(uniform)) {
|
|
60
|
+
if (/horizon/i.test(uniform.name)) return '#badbe9';
|
|
61
|
+
if (/zenith/i.test(uniform.name)) return '#297aba';
|
|
62
|
+
if (/sun/i.test(uniform.name)) return '#fff0c2';
|
|
63
|
+
return '#6f8cff';
|
|
64
|
+
}
|
|
65
|
+
if (uniform.type === 'bool') return false;
|
|
66
|
+
if (/^(float|double|int|uint)$/.test(uniform.type)) return 0;
|
|
67
|
+
const vector = uniform.type.match(/^(?:[biu]?vec)([234])$/);
|
|
68
|
+
if (vector) return Array.from({ length: Number.parseInt(vector[1]!, 10) }, () => 0);
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function createCheckerTexture(): THREE.DataTexture {
|
|
73
|
+
const bytes = new Uint8Array([
|
|
74
|
+
38, 43, 58, 255, 176, 190, 255, 255, 176, 190, 255, 255, 38, 43, 58, 255,
|
|
75
|
+
]);
|
|
76
|
+
const texture = new THREE.DataTexture(bytes, 2, 2, THREE.RGBAFormat);
|
|
77
|
+
texture.colorSpace = THREE.SRGBColorSpace;
|
|
78
|
+
texture.magFilter = THREE.NearestFilter;
|
|
79
|
+
texture.needsUpdate = true;
|
|
80
|
+
return texture;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: this is a bounded dispatcher from native GLSL uniform types to their native Three.js preview values.
|
|
84
|
+
function createThreeUniformValue(
|
|
85
|
+
uniform: ShaderUniformDescriptor,
|
|
86
|
+
value: UniformPreviewValue,
|
|
87
|
+
ownedTextures: THREE.Texture[],
|
|
88
|
+
): unknown {
|
|
89
|
+
if (uniform.arraySize) {
|
|
90
|
+
const { arraySize, ...element } = uniform;
|
|
91
|
+
return Array.from({ length: arraySize }, () =>
|
|
92
|
+
createThreeUniformValue(element, value, ownedTextures),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (/^sampler2D/.test(uniform.type)) {
|
|
96
|
+
const texture = createCheckerTexture();
|
|
97
|
+
ownedTextures.push(texture);
|
|
98
|
+
return texture;
|
|
99
|
+
}
|
|
100
|
+
if (/^sampler/.test(uniform.type)) return null;
|
|
101
|
+
if (uniform.type === 'mat3') return new THREE.Matrix3();
|
|
102
|
+
if (uniform.type === 'mat4') return new THREE.Matrix4();
|
|
103
|
+
if (isColorUniform(uniform))
|
|
104
|
+
return new THREE.Color(typeof value === 'string' ? value : '#6f8cff');
|
|
105
|
+
const vector = uniform.type.match(/^(?:[biu]?vec)([234])$/);
|
|
106
|
+
if (vector) {
|
|
107
|
+
const values = Array.isArray(value) ? value : [];
|
|
108
|
+
const count = Number.parseInt(vector[1]!, 10);
|
|
109
|
+
if (count === 2) return new THREE.Vector2(values[0] ?? 0, values[1] ?? 0);
|
|
110
|
+
if (count === 3) return new THREE.Vector3(values[0] ?? 0, values[1] ?? 0, values[2] ?? 0);
|
|
111
|
+
return new THREE.Vector4(values[0] ?? 0, values[1] ?? 0, values[2] ?? 0, values[3] ?? 0);
|
|
112
|
+
}
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one bounded native-uniform dispatcher keeps automatic inputs and mutable Three.js value types visible in one place.
|
|
117
|
+
function updateThreeUniformValue(
|
|
118
|
+
target: { value: unknown },
|
|
119
|
+
descriptor: ShaderUniformDescriptor,
|
|
120
|
+
previewValue: UniformPreviewValue,
|
|
121
|
+
elapsed: number,
|
|
122
|
+
size: { width: number; height: number },
|
|
123
|
+
): void {
|
|
124
|
+
const automatic = automaticUniform(descriptor);
|
|
125
|
+
if (automatic === 'time') {
|
|
126
|
+
target.value = elapsed;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (target.value instanceof THREE.Color && typeof previewValue === 'string') {
|
|
130
|
+
target.value.set(previewValue);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const values = Array.isArray(previewValue) ? previewValue : [];
|
|
134
|
+
if (target.value instanceof THREE.Vector2) {
|
|
135
|
+
target.value.set(
|
|
136
|
+
automatic === 'resolution' ? size.width : (values[0] ?? 0),
|
|
137
|
+
automatic === 'resolution' ? size.height : (values[1] ?? 0),
|
|
138
|
+
);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (target.value instanceof THREE.Vector3) {
|
|
142
|
+
target.value.set(
|
|
143
|
+
automatic === 'resolution' ? size.width : (values[0] ?? 0),
|
|
144
|
+
automatic === 'resolution' ? size.height : (values[1] ?? 0),
|
|
145
|
+
automatic === 'resolution' ? 1 : (values[2] ?? 0),
|
|
146
|
+
);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (target.value instanceof THREE.Vector4) {
|
|
150
|
+
target.value.set(
|
|
151
|
+
automatic === 'resolution' ? size.width : (values[0] ?? 0),
|
|
152
|
+
automatic === 'resolution' ? size.height : (values[1] ?? 0),
|
|
153
|
+
automatic === 'resolution' ? 1 : (values[2] ?? 0),
|
|
154
|
+
values[3] ?? 0,
|
|
155
|
+
);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (automatic !== 'mouse' && !descriptor.arraySize && !/^sampler|^mat/.test(descriptor.type)) {
|
|
159
|
+
target.value = previewValue;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function geometryFor(kind: PreviewGeometry): THREE.BufferGeometry {
|
|
164
|
+
if (kind === 'box') return new THREE.BoxGeometry(1.35, 1.35, 1.35, 48, 48, 48);
|
|
165
|
+
if (kind === 'plane') return new THREE.PlaneGeometry(2.2, 2.2, 96, 96);
|
|
166
|
+
return new THREE.SphereGeometry(0.95, 96, 64);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function ShaderPreview({
|
|
170
|
+
vertexSource,
|
|
171
|
+
fragmentSource,
|
|
172
|
+
descriptors,
|
|
173
|
+
valuesRef,
|
|
174
|
+
geometry,
|
|
175
|
+
onDiagnostics,
|
|
176
|
+
}: {
|
|
177
|
+
vertexSource: string;
|
|
178
|
+
fragmentSource: string;
|
|
179
|
+
descriptors: readonly ShaderUniformDescriptor[];
|
|
180
|
+
valuesRef: React.RefObject<Record<string, UniformPreviewValue>>;
|
|
181
|
+
geometry: PreviewGeometry;
|
|
182
|
+
onDiagnostics: (diagnostics: ShaderDiagnostic[]) => void;
|
|
183
|
+
}) {
|
|
184
|
+
const hostRef = useRef<HTMLDivElement>(null);
|
|
185
|
+
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
const host = hostRef.current;
|
|
188
|
+
if (!host) return;
|
|
189
|
+
let disposed = false;
|
|
190
|
+
let frame = 0;
|
|
191
|
+
const canvas = document.createElement('canvas');
|
|
192
|
+
canvas.setAttribute('aria-label', 'Live shader preview');
|
|
193
|
+
canvas.style.width = '100%';
|
|
194
|
+
canvas.style.height = '100%';
|
|
195
|
+
canvas.style.display = 'block';
|
|
196
|
+
host.replaceChildren(canvas);
|
|
197
|
+
|
|
198
|
+
let renderer: THREE.WebGLRenderer;
|
|
199
|
+
try {
|
|
200
|
+
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
|
|
201
|
+
} catch (cause) {
|
|
202
|
+
onDiagnostics([
|
|
203
|
+
{
|
|
204
|
+
stage: 'program',
|
|
205
|
+
severity: 'error',
|
|
206
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
207
|
+
},
|
|
208
|
+
]);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
212
|
+
renderer.setClearColor(0x11141d, 1);
|
|
213
|
+
renderer.debug.checkShaderErrors = true;
|
|
214
|
+
|
|
215
|
+
const scene = new THREE.Scene();
|
|
216
|
+
const camera = new THREE.PerspectiveCamera(38, 1, 0.01, 100);
|
|
217
|
+
camera.position.set(0, 0, 3.4);
|
|
218
|
+
const ownedTextures: THREE.Texture[] = [];
|
|
219
|
+
const uniforms: Record<string, THREE.IUniform> = {};
|
|
220
|
+
for (const descriptor of descriptors) {
|
|
221
|
+
uniforms[descriptor.name] = {
|
|
222
|
+
value: createThreeUniformValue(
|
|
223
|
+
descriptor,
|
|
224
|
+
valuesRef.current[descriptor.name] ?? defaultUniformValue(descriptor),
|
|
225
|
+
ownedTextures,
|
|
226
|
+
),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
const material = new THREE.ShaderMaterial({
|
|
230
|
+
vertexShader: previewShaderSource(vertexSource),
|
|
231
|
+
fragmentShader: previewShaderSource(fragmentSource),
|
|
232
|
+
uniforms,
|
|
233
|
+
side: THREE.DoubleSide,
|
|
234
|
+
toneMapped: false,
|
|
235
|
+
});
|
|
236
|
+
const meshGeometry = geometryFor(geometry);
|
|
237
|
+
const mesh = new THREE.Mesh(meshGeometry, material);
|
|
238
|
+
scene.add(mesh);
|
|
239
|
+
|
|
240
|
+
const diagnostics: ShaderDiagnostic[] = [];
|
|
241
|
+
renderer.debug.onShaderError = (gl, program, vertexShader, fragmentShader) => {
|
|
242
|
+
// Three's callback and the DOM context can resolve WebGL opaque handles
|
|
243
|
+
// through separate declaration universes. They are the same handles at
|
|
244
|
+
// runtime; normalize them at this boundary instead of widening either API.
|
|
245
|
+
const compatibleProgram = program as Parameters<typeof gl.getProgramInfoLog>[0];
|
|
246
|
+
const compatibleVertexShader = vertexShader as Parameters<typeof gl.getShaderInfoLog>[0];
|
|
247
|
+
const compatibleFragmentShader = fragmentShader as Parameters<typeof gl.getShaderInfoLog>[0];
|
|
248
|
+
diagnostics.push(
|
|
249
|
+
...parseShaderDiagnostics(gl.getProgramInfoLog(compatibleProgram) ?? '', 'program'),
|
|
250
|
+
...parseShaderDiagnostics(gl.getShaderInfoLog(compatibleVertexShader) ?? '', 'vertex'),
|
|
251
|
+
...parseShaderDiagnostics(gl.getShaderInfoLog(compatibleFragmentShader) ?? '', 'fragment'),
|
|
252
|
+
);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const size = { width: 1, height: 1 };
|
|
256
|
+
const resize = () => {
|
|
257
|
+
const bounds = host.getBoundingClientRect();
|
|
258
|
+
size.width = Math.max(1, Math.floor(bounds.width));
|
|
259
|
+
size.height = Math.max(1, Math.floor(bounds.height));
|
|
260
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
261
|
+
renderer.setSize(size.width, size.height, false);
|
|
262
|
+
camera.aspect = size.width / size.height;
|
|
263
|
+
camera.position.z = 3.4 / Math.min(1, camera.aspect);
|
|
264
|
+
camera.updateProjectionMatrix();
|
|
265
|
+
};
|
|
266
|
+
resize();
|
|
267
|
+
const observer = new ResizeObserver(resize);
|
|
268
|
+
observer.observe(host);
|
|
269
|
+
|
|
270
|
+
const clock = new THREE.Clock();
|
|
271
|
+
try {
|
|
272
|
+
renderer.compile(scene, camera);
|
|
273
|
+
renderer.render(scene, camera);
|
|
274
|
+
onDiagnostics(
|
|
275
|
+
diagnostics.filter(
|
|
276
|
+
(diagnostic, index, all) =>
|
|
277
|
+
all.findIndex(
|
|
278
|
+
(candidate) =>
|
|
279
|
+
candidate.stage === diagnostic.stage &&
|
|
280
|
+
candidate.line === diagnostic.line &&
|
|
281
|
+
candidate.message === diagnostic.message,
|
|
282
|
+
) === index,
|
|
283
|
+
),
|
|
284
|
+
);
|
|
285
|
+
} catch (cause) {
|
|
286
|
+
onDiagnostics([
|
|
287
|
+
{
|
|
288
|
+
stage: 'program',
|
|
289
|
+
severity: 'error',
|
|
290
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
291
|
+
},
|
|
292
|
+
]);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const render = () => {
|
|
296
|
+
if (disposed) return;
|
|
297
|
+
const elapsed = clock.getElapsedTime();
|
|
298
|
+
for (const descriptor of descriptors) {
|
|
299
|
+
const uniform = uniforms[descriptor.name];
|
|
300
|
+
if (!uniform) continue;
|
|
301
|
+
updateThreeUniformValue(
|
|
302
|
+
uniform,
|
|
303
|
+
descriptor,
|
|
304
|
+
valuesRef.current[descriptor.name] ?? defaultUniformValue(descriptor),
|
|
305
|
+
elapsed,
|
|
306
|
+
size,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
if (geometry !== 'plane') {
|
|
310
|
+
mesh.rotation.y = elapsed * 0.18;
|
|
311
|
+
mesh.rotation.x = Math.sin(elapsed * 0.22) * 0.08;
|
|
312
|
+
}
|
|
313
|
+
renderer.render(scene, camera);
|
|
314
|
+
frame = requestAnimationFrame(render);
|
|
315
|
+
};
|
|
316
|
+
frame = requestAnimationFrame(render);
|
|
317
|
+
|
|
318
|
+
return () => {
|
|
319
|
+
disposed = true;
|
|
320
|
+
cancelAnimationFrame(frame);
|
|
321
|
+
observer.disconnect();
|
|
322
|
+
renderer.debug.onShaderError = null;
|
|
323
|
+
meshGeometry.dispose();
|
|
324
|
+
material.dispose();
|
|
325
|
+
for (const texture of ownedTextures) texture.dispose();
|
|
326
|
+
renderer.dispose();
|
|
327
|
+
canvas.remove();
|
|
328
|
+
};
|
|
329
|
+
}, [descriptors, fragmentSource, geometry, onDiagnostics, valuesRef, vertexSource]);
|
|
330
|
+
|
|
331
|
+
return (
|
|
332
|
+
<div
|
|
333
|
+
ref={hostRef}
|
|
334
|
+
data-testid="shader-preview"
|
|
335
|
+
style={{ width: '100%', height: '100%', minHeight: 240, background: '#11141d' }}
|
|
336
|
+
/>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function UniformControls({
|
|
341
|
+
descriptors,
|
|
342
|
+
values,
|
|
343
|
+
onChange,
|
|
344
|
+
}: {
|
|
345
|
+
descriptors: readonly ShaderUniformDescriptor[];
|
|
346
|
+
values: Record<string, UniformPreviewValue>;
|
|
347
|
+
onChange: (name: string, value: UniformPreviewValue) => void;
|
|
348
|
+
}) {
|
|
349
|
+
if (descriptors.length === 0) {
|
|
350
|
+
return <div style={{ color: themeVars.content.muted }}>No uniform declarations found.</div>;
|
|
351
|
+
}
|
|
352
|
+
return (
|
|
353
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: 4 }}>
|
|
354
|
+
{descriptors.map((uniform) => (
|
|
355
|
+
<UniformControlRow
|
|
356
|
+
key={uniform.name}
|
|
357
|
+
uniform={uniform}
|
|
358
|
+
value={values[uniform.name] ?? defaultUniformValue(uniform)}
|
|
359
|
+
onChange={(value) => onChange(uniform.name, value)}
|
|
360
|
+
/>
|
|
361
|
+
))}
|
|
362
|
+
<div style={{ color: themeVars.content.muted, fontSize: 10, lineHeight: 1.4 }}>
|
|
363
|
+
Preview values are session-only. Author runtime defaults beside the ShaderMaterial in
|
|
364
|
+
TS/TSX.
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: this is the bounded visual-control projection of native GLSL uniform types.
|
|
371
|
+
function UniformControlRow({
|
|
372
|
+
uniform,
|
|
373
|
+
value,
|
|
374
|
+
onChange,
|
|
375
|
+
}: {
|
|
376
|
+
uniform: ShaderUniformDescriptor;
|
|
377
|
+
value: UniformPreviewValue;
|
|
378
|
+
onChange: (value: UniformPreviewValue) => void;
|
|
379
|
+
}) {
|
|
380
|
+
const automatic = automaticUniform(uniform);
|
|
381
|
+
const vector = uniform.type.match(/^(?:[biu]?vec)([234])$/);
|
|
382
|
+
let control: React.ReactNode;
|
|
383
|
+
if (automatic) {
|
|
384
|
+
control = (
|
|
385
|
+
<div style={{ color: themeVars.content.muted, fontSize: 10 }}>
|
|
386
|
+
Driven by preview {automatic}
|
|
387
|
+
</div>
|
|
388
|
+
);
|
|
389
|
+
} else if (isColorUniform(uniform)) {
|
|
390
|
+
control = (
|
|
391
|
+
<ColorInput value={typeof value === 'string' ? value : '#6f8cff'} onChange={onChange} />
|
|
392
|
+
);
|
|
393
|
+
} else if (uniform.type === 'bool') {
|
|
394
|
+
control = (
|
|
395
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: 7, fontSize: 11 }}>
|
|
396
|
+
<Checkbox checked={Boolean(value)} onChange={(event) => onChange(event.target.checked)} />
|
|
397
|
+
{value ? 'True' : 'False'}
|
|
398
|
+
</label>
|
|
399
|
+
);
|
|
400
|
+
} else if (/^(float|double|int|uint)$/.test(uniform.type)) {
|
|
401
|
+
control = (
|
|
402
|
+
<NumberInput
|
|
403
|
+
value={typeof value === 'number' ? value : 0}
|
|
404
|
+
onChange={onChange}
|
|
405
|
+
step={uniform.type === 'int' || uniform.type === 'uint' ? 1 : 0.01}
|
|
406
|
+
precision={uniform.type === 'int' || uniform.type === 'uint' ? 0 : 3}
|
|
407
|
+
/>
|
|
408
|
+
);
|
|
409
|
+
} else if (vector && Array.isArray(value)) {
|
|
410
|
+
control = (
|
|
411
|
+
<div
|
|
412
|
+
style={{
|
|
413
|
+
display: 'grid',
|
|
414
|
+
gridTemplateColumns: `repeat(${vector[1]}, minmax(0, 1fr))`,
|
|
415
|
+
gap: 4,
|
|
416
|
+
}}
|
|
417
|
+
>
|
|
418
|
+
{value.map((component, index) => (
|
|
419
|
+
<NumberInput
|
|
420
|
+
key={`${uniform.name}:${index}`}
|
|
421
|
+
value={component}
|
|
422
|
+
label={'xyzw'[index]!}
|
|
423
|
+
onChange={(next) => {
|
|
424
|
+
const copy = [...value];
|
|
425
|
+
copy[index] = next;
|
|
426
|
+
onChange(copy);
|
|
427
|
+
}}
|
|
428
|
+
step={0.01}
|
|
429
|
+
/>
|
|
430
|
+
))}
|
|
431
|
+
</div>
|
|
432
|
+
);
|
|
433
|
+
} else {
|
|
434
|
+
control = (
|
|
435
|
+
<div style={{ color: themeVars.content.muted, fontSize: 10 }}>
|
|
436
|
+
Reflected for runtime binding; no scalar preview control.
|
|
437
|
+
</div>
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
return (
|
|
441
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
|
|
442
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
|
|
443
|
+
<span style={{ color: themeVars.content.primary, fontSize: 11 }}>{uniform.name}</span>
|
|
444
|
+
<span style={{ color: themeVars.content.muted, fontSize: 10 }}>{uniform.type}</span>
|
|
445
|
+
</div>
|
|
446
|
+
{control}
|
|
447
|
+
</div>
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function DiagnosticList({
|
|
452
|
+
diagnostics,
|
|
453
|
+
files,
|
|
454
|
+
}: {
|
|
455
|
+
diagnostics: readonly ShaderDiagnostic[];
|
|
456
|
+
files: ShaderFiles;
|
|
457
|
+
}) {
|
|
458
|
+
if (diagnostics.length === 0) {
|
|
459
|
+
return (
|
|
460
|
+
<div style={{ color: themeVars.semantic.success, fontSize: 11, padding: 4 }}>
|
|
461
|
+
Shader program compiled successfully.
|
|
462
|
+
</div>
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
return (
|
|
466
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, padding: 4 }}>
|
|
467
|
+
{diagnostics.map((diagnostic, index) => {
|
|
468
|
+
const file = diagnostic.stage === 'program' ? null : files[diagnostic.stage];
|
|
469
|
+
return (
|
|
470
|
+
<div
|
|
471
|
+
key={`${diagnostic.stage}:${diagnostic.line ?? 0}:${diagnostic.message}:${index}`}
|
|
472
|
+
style={{
|
|
473
|
+
color:
|
|
474
|
+
diagnostic.severity === 'warning'
|
|
475
|
+
? themeVars.semantic.warning
|
|
476
|
+
: themeVars.semantic.danger,
|
|
477
|
+
fontSize: 10,
|
|
478
|
+
lineHeight: 1.45,
|
|
479
|
+
}}
|
|
480
|
+
>
|
|
481
|
+
{file?.available ? basename(file.path) : diagnostic.stage}
|
|
482
|
+
{diagnostic.line
|
|
483
|
+
? `:${diagnostic.line}${diagnostic.column ? `:${diagnostic.column}` : ''}`
|
|
484
|
+
: ''}
|
|
485
|
+
{' · '}
|
|
486
|
+
{diagnostic.message}
|
|
487
|
+
</div>
|
|
488
|
+
);
|
|
489
|
+
})}
|
|
490
|
+
</div>
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function loadShaderFiles(assetPath: string): Promise<ShaderFiles> {
|
|
495
|
+
const source = await readProjectTextFile(assetPath);
|
|
496
|
+
if (source === null) throw new Error(`Could not read ${assetPath}.`);
|
|
497
|
+
const primaryStage = shaderStageFromPath(assetPath, source);
|
|
498
|
+
const companionPath = shaderCompanionPath(assetPath);
|
|
499
|
+
const companionSource = companionPath ? await readProjectTextFile(companionPath) : null;
|
|
500
|
+
const files: ShaderFiles = {
|
|
501
|
+
vertex: fallbackFile('vertex'),
|
|
502
|
+
fragment: fallbackFile('fragment'),
|
|
503
|
+
};
|
|
504
|
+
files[primaryStage] = { path: assetPath, source, available: true };
|
|
505
|
+
const companionStage: ShaderStage = primaryStage === 'vertex' ? 'fragment' : 'vertex';
|
|
506
|
+
files[companionStage] =
|
|
507
|
+
companionSource === null
|
|
508
|
+
? fallbackFile(companionStage, companionPath)
|
|
509
|
+
: {
|
|
510
|
+
path: companionPath,
|
|
511
|
+
source: companionSource,
|
|
512
|
+
available: true,
|
|
513
|
+
};
|
|
514
|
+
return files;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export function ShaderAssetDocument({
|
|
518
|
+
documentId,
|
|
519
|
+
assetPath,
|
|
520
|
+
active = true,
|
|
521
|
+
}: {
|
|
522
|
+
documentId: string;
|
|
523
|
+
assetPath: string;
|
|
524
|
+
active?: boolean;
|
|
525
|
+
}) {
|
|
526
|
+
const [files, setFiles] = useState<ShaderFiles | null>(null);
|
|
527
|
+
const [fileRevision, setFileRevision] = useState(0);
|
|
528
|
+
const [geometry, setGeometry] = useState<PreviewGeometry>('sphere');
|
|
529
|
+
const [diagnostics, setDiagnostics] = useState<ShaderDiagnostic[]>([]);
|
|
530
|
+
const [uniformValues, setUniformValues] = useState<Record<string, UniformPreviewValue>>({});
|
|
531
|
+
const [loadError, setLoadError] = useState<string | null>(null);
|
|
532
|
+
|
|
533
|
+
useEffect(() => {
|
|
534
|
+
if (!active) return;
|
|
535
|
+
const reload = () => setFileRevision((revision) => revision + 1);
|
|
536
|
+
const companionPath = shaderCompanionPath(assetPath);
|
|
537
|
+
const unsubscribers = [
|
|
538
|
+
subscribeProjectAsset(assetPath, reload),
|
|
539
|
+
...(companionPath ? [subscribeProjectAsset(companionPath, reload)] : []),
|
|
540
|
+
];
|
|
541
|
+
return () => {
|
|
542
|
+
for (const unsubscribe of unsubscribers) unsubscribe();
|
|
543
|
+
};
|
|
544
|
+
}, [active, assetPath]);
|
|
545
|
+
|
|
546
|
+
useEffect(() => {
|
|
547
|
+
if (!active) return;
|
|
548
|
+
let cancelled = false;
|
|
549
|
+
setFiles(null);
|
|
550
|
+
setLoadError(null);
|
|
551
|
+
void loadShaderFiles(assetPath).then(
|
|
552
|
+
(loaded) => {
|
|
553
|
+
if (cancelled) return;
|
|
554
|
+
setFiles(loaded);
|
|
555
|
+
},
|
|
556
|
+
(cause) => {
|
|
557
|
+
if (!cancelled) setLoadError(cause instanceof Error ? cause.message : String(cause));
|
|
558
|
+
},
|
|
559
|
+
);
|
|
560
|
+
return () => {
|
|
561
|
+
cancelled = true;
|
|
562
|
+
};
|
|
563
|
+
}, [active, assetPath, fileRevision]);
|
|
564
|
+
|
|
565
|
+
const descriptors = useMemo(
|
|
566
|
+
() => (files ? reflectShaderUniforms(files.vertex.source, files.fragment.source) : []),
|
|
567
|
+
[files],
|
|
568
|
+
);
|
|
569
|
+
useEffect(() => {
|
|
570
|
+
setUniformValues((current) =>
|
|
571
|
+
Object.fromEntries(
|
|
572
|
+
descriptors.map((descriptor) => [
|
|
573
|
+
descriptor.name,
|
|
574
|
+
current[descriptor.name] ?? defaultUniformValue(descriptor),
|
|
575
|
+
]),
|
|
576
|
+
),
|
|
577
|
+
);
|
|
578
|
+
}, [descriptors]);
|
|
579
|
+
const uniformValuesRef = useRef(uniformValues);
|
|
580
|
+
uniformValuesRef.current = uniformValues;
|
|
581
|
+
|
|
582
|
+
const sections = useMemo<InspectionSection[]>(
|
|
583
|
+
() => [
|
|
584
|
+
{
|
|
585
|
+
id: 'shader-pair',
|
|
586
|
+
title: 'Shader Pair',
|
|
587
|
+
icon: faCode,
|
|
588
|
+
order: PROPERTIES_SECTION_ORDER,
|
|
589
|
+
body: {
|
|
590
|
+
kind: 'custom',
|
|
591
|
+
render: () => (
|
|
592
|
+
<div
|
|
593
|
+
style={{ display: 'flex', flexDirection: 'column', gap: 5, padding: 4, fontSize: 10 }}
|
|
594
|
+
>
|
|
595
|
+
<span>
|
|
596
|
+
Vertex · {files ? basename(files.vertex.path) : 'Loading…'}
|
|
597
|
+
{files && !files.vertex.available ? ' (preview fallback)' : ''}
|
|
598
|
+
</span>
|
|
599
|
+
<span>
|
|
600
|
+
Fragment · {files ? basename(files.fragment.path) : 'Loading…'}
|
|
601
|
+
{files && !files.fragment.available ? ' (preview fallback)' : ''}
|
|
602
|
+
</span>
|
|
603
|
+
<span style={{ color: themeVars.content.muted }}>
|
|
604
|
+
Ordinary sibling GLSL files; runtime composition stays in TS/TSX.
|
|
605
|
+
</span>
|
|
606
|
+
</div>
|
|
607
|
+
),
|
|
608
|
+
},
|
|
609
|
+
},
|
|
610
|
+
{
|
|
611
|
+
id: 'shader-uniforms',
|
|
612
|
+
title: 'Uniforms',
|
|
613
|
+
icon: faSliders,
|
|
614
|
+
order: CONTRIBUTED_SECTION_ORDER,
|
|
615
|
+
description: `${descriptors.length} reflected`,
|
|
616
|
+
body: {
|
|
617
|
+
kind: 'custom',
|
|
618
|
+
data: Object.fromEntries(
|
|
619
|
+
descriptors.map((descriptor) => [descriptor.name, uniformValues[descriptor.name]]),
|
|
620
|
+
),
|
|
621
|
+
render: () => (
|
|
622
|
+
<UniformControls
|
|
623
|
+
descriptors={descriptors}
|
|
624
|
+
values={uniformValues}
|
|
625
|
+
onChange={(name, value) =>
|
|
626
|
+
setUniformValues((current) => ({ ...current, [name]: value }))
|
|
627
|
+
}
|
|
628
|
+
/>
|
|
629
|
+
),
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
id: 'shader-diagnostics',
|
|
634
|
+
title: 'Diagnostics',
|
|
635
|
+
icon: diagnostics.length > 0 ? faTriangleExclamation : faCode,
|
|
636
|
+
order: CONTRIBUTED_SECTION_ORDER + 100,
|
|
637
|
+
description:
|
|
638
|
+
diagnostics.length === 0
|
|
639
|
+
? 'Compiled'
|
|
640
|
+
: `${diagnostics.length} issue${diagnostics.length === 1 ? '' : 's'}`,
|
|
641
|
+
body: {
|
|
642
|
+
kind: 'custom',
|
|
643
|
+
data: { diagnostics },
|
|
644
|
+
render: () => files && <DiagnosticList diagnostics={diagnostics} files={files} />,
|
|
645
|
+
},
|
|
646
|
+
},
|
|
647
|
+
],
|
|
648
|
+
[descriptors, diagnostics, files, uniformValues],
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
if (loadError) {
|
|
652
|
+
return (
|
|
653
|
+
<AssetEditorShell
|
|
654
|
+
documentId={documentId}
|
|
655
|
+
active={active}
|
|
656
|
+
type="shader"
|
|
657
|
+
title={basename(assetPath)}
|
|
658
|
+
status="Shader unavailable"
|
|
659
|
+
>
|
|
660
|
+
<div role="alert" style={{ padding: 20, color: themeVars.semantic.danger }}>
|
|
661
|
+
{loadError}
|
|
662
|
+
</div>
|
|
663
|
+
</AssetEditorShell>
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
if (!files) {
|
|
667
|
+
return (
|
|
668
|
+
<AssetEditorShell
|
|
669
|
+
documentId={documentId}
|
|
670
|
+
active={active}
|
|
671
|
+
type="shader"
|
|
672
|
+
title={basename(assetPath)}
|
|
673
|
+
status="Loading GLSL"
|
|
674
|
+
>
|
|
675
|
+
<div style={{ padding: 20, color: themeVars.content.muted }}>Loading shader files…</div>
|
|
676
|
+
</AssetEditorShell>
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return (
|
|
681
|
+
<AssetEditorShell
|
|
682
|
+
documentId={documentId}
|
|
683
|
+
active={active}
|
|
684
|
+
type="shader"
|
|
685
|
+
title={basename(assetPath)}
|
|
686
|
+
sections={sections}
|
|
687
|
+
status={
|
|
688
|
+
diagnostics.length === 0
|
|
689
|
+
? `GLSL · compiles · ${descriptors.length} uniforms`
|
|
690
|
+
: `GLSL · ${diagnostics.length} compile issues`
|
|
691
|
+
}
|
|
692
|
+
fill
|
|
693
|
+
>
|
|
694
|
+
<div
|
|
695
|
+
style={{
|
|
696
|
+
width: '100%',
|
|
697
|
+
height: '100%',
|
|
698
|
+
minHeight: 0,
|
|
699
|
+
position: 'relative',
|
|
700
|
+
background: themeVars.surface.panel,
|
|
701
|
+
}}
|
|
702
|
+
>
|
|
703
|
+
<ShaderPreview
|
|
704
|
+
vertexSource={files.vertex.source}
|
|
705
|
+
fragmentSource={files.fragment.source}
|
|
706
|
+
descriptors={descriptors}
|
|
707
|
+
valuesRef={uniformValuesRef}
|
|
708
|
+
geometry={geometry}
|
|
709
|
+
onDiagnostics={setDiagnostics}
|
|
710
|
+
/>
|
|
711
|
+
<div style={{ position: 'absolute', left: 10, top: 10 }}>
|
|
712
|
+
<Select
|
|
713
|
+
aria-label="Preview geometry"
|
|
714
|
+
value={geometry}
|
|
715
|
+
onChange={(event) => setGeometry(event.target.value as PreviewGeometry)}
|
|
716
|
+
>
|
|
717
|
+
<option value="sphere">Sphere</option>
|
|
718
|
+
<option value="box">Box</option>
|
|
719
|
+
<option value="plane">Plane</option>
|
|
720
|
+
</Select>
|
|
721
|
+
</div>
|
|
722
|
+
{diagnostics.length > 0 && (
|
|
723
|
+
<div
|
|
724
|
+
style={{
|
|
725
|
+
position: 'absolute',
|
|
726
|
+
left: 12,
|
|
727
|
+
right: 12,
|
|
728
|
+
bottom: 12,
|
|
729
|
+
maxHeight: 120,
|
|
730
|
+
overflow: 'auto',
|
|
731
|
+
padding: 8,
|
|
732
|
+
border: `1px solid ${themeVars.semantic.danger}`,
|
|
733
|
+
borderRadius: 6,
|
|
734
|
+
background: themeVars.surface.overlay,
|
|
735
|
+
}}
|
|
736
|
+
>
|
|
737
|
+
<DiagnosticList diagnostics={diagnostics} files={files} />
|
|
738
|
+
</div>
|
|
739
|
+
)}
|
|
740
|
+
</div>
|
|
741
|
+
</AssetEditorShell>
|
|
742
|
+
);
|
|
743
|
+
}
|