@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,1042 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The STANDARD VIEWPORT dressing — the ONE chrome-and-dressing definition every
|
|
3
|
+
* 3D document viewport inherits (owner-ratified contract). A document surface
|
|
4
|
+
* (`Object3DDocumentViewport` and everything mounted on it: the 3D board,
|
|
5
|
+
* story turntables, model/entity asset documents) gets the editor's standard
|
|
6
|
+
* look from this module instead of instantiating its own background/lighting:
|
|
7
|
+
*
|
|
8
|
+
* - `scene.environment` baked from three's own `RoomEnvironment` through
|
|
9
|
+
* `PMREMGenerator`, so PBR materials actually read;
|
|
10
|
+
* - a subtle dark vertical-gradient backdrop (never a flat hex);
|
|
11
|
+
* - one NEUTRAL directional key light for definition (it was warm; see the
|
|
12
|
+
* measurement where it is constructed);
|
|
13
|
+
* - an optional ground grid following the Scene viewport's grid conventions
|
|
14
|
+
* (1 m cells, `0x999999`, `EDITOR_LAYER` — see `editor-viewport.ts`),
|
|
15
|
+
* centred on the content and fading out with distance so it never
|
|
16
|
+
* degenerates into convergence-line artifacts at the horizon.
|
|
17
|
+
*
|
|
18
|
+
* Everything created here is EDITOR CHROME: marked per `editor-layers.ts`'s
|
|
19
|
+
* conventions (`userData.editorHelper`, and `EDITOR_LAYER` for the grid) so no
|
|
20
|
+
* hierarchy walk, pick, or export ever mistakes it for content — and it is
|
|
21
|
+
* never serialized, never touching project data (the anti-shim rule). It
|
|
22
|
+
* exists only inside the document viewport's scene at view time.
|
|
23
|
+
*
|
|
24
|
+
* Ownership: {@link applyStandardViewportDressing} owns every object and
|
|
25
|
+
* texture it creates, INCLUDING the {@link StandardEnvironment} handed to it
|
|
26
|
+
* (PMREM render targets leak if not disposed). Its returned `dispose` is the
|
|
27
|
+
* one teardown path; it is idempotent.
|
|
28
|
+
*
|
|
29
|
+
* The environment bake lives in `@volter/editor-threejs/viewport/environment`
|
|
30
|
+
* because it is the one GL-bound step — it needs a live `WebGLRenderer`,
|
|
31
|
+
* where the rest of the dressing is plain scene-graph work.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { invalidateStages } from '@volter/editor-sdk/kit/stage-invalidation';
|
|
35
|
+
import { contentWorldBounds } from '@volter/editor-threejs/viewport/content-bounds';
|
|
36
|
+
import { EDITOR_LAYER } from '@volter/editor-threejs/viewport/editor-layers';
|
|
37
|
+
import { loadEnvironmentImage, type StandardEnvironment } from '@volter/editor-threejs/viewport/environment';
|
|
38
|
+
import { setUserData } from '@volter/editor-threejs/ecs/user-data';
|
|
39
|
+
import * as THREE from 'three';
|
|
40
|
+
import { environmentImage } from '@volter/editor-sdk/kit/environment-images';
|
|
41
|
+
import { editorConsole } from '@volter/editor-sdk/kit/editor-console';
|
|
42
|
+
import {
|
|
43
|
+
studioPreset,
|
|
44
|
+
type StudioLight,
|
|
45
|
+
type SceneTakeover,
|
|
46
|
+
type StudioPreset,
|
|
47
|
+
type ViewportPresentation,
|
|
48
|
+
} from '@volter/editor-sdk/kit/viewport-presentation';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Front-right three-quarter view for project-owned R3F components. Their
|
|
52
|
+
* gameplay-forward convention is −Z, so the camera belongs on that side of
|
|
53
|
+
* the subject. The 3D board and an opened story must never show opposite
|
|
54
|
+
* faces of the same prefab.
|
|
55
|
+
*/
|
|
56
|
+
export const STANDARD_COMPONENT_CAMERA_DIRECTION = [0.8, 0.5, -1] as const;
|
|
57
|
+
|
|
58
|
+
/** The standard backdrop, dark at the ground line and lifting toward the top.
|
|
59
|
+
* These are the STUDIO tints; the painted stops blend them with the active
|
|
60
|
+
* palette (see {@link paletteBackdropStops}) so a document stage follows the
|
|
61
|
+
* editor's palette the way the Asset Lab's CSS studio stage does. */
|
|
62
|
+
const GRADIENT_BOTTOM = new THREE.Color(0x1e2530);
|
|
63
|
+
const GRADIENT_TOP = new THREE.Color(0x4c5b70);
|
|
64
|
+
|
|
65
|
+
export interface BackdropStops {
|
|
66
|
+
readonly bottom: THREE.Color;
|
|
67
|
+
readonly top: THREE.Color;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The backdrop's stops for the ACTIVE PALETTE: the ground line is the
|
|
72
|
+
* palette's panel surface and the top is its raised surface, each blended
|
|
73
|
+
* with the studio tint and lifted for ACES. Measured under the Blender
|
|
74
|
+
* palette (panel #303030, raised #3d3d3d) the stage reads as Blender's grey
|
|
75
|
+
* ground instead of a black void; under Classic Graphite it stays within a
|
|
76
|
+
* few steps of the studio constants. Falls back to the studio constants
|
|
77
|
+
* where no palette is installed (a bounded host, headless).
|
|
78
|
+
*/
|
|
79
|
+
export function paletteBackdropStops(): BackdropStops {
|
|
80
|
+
const root =
|
|
81
|
+
typeof document === 'undefined' ? null : document.querySelector('[data-vgai-palette]');
|
|
82
|
+
if (!root) return { bottom: GRADIENT_BOTTOM.clone(), top: GRADIENT_TOP.clone() };
|
|
83
|
+
const style = getComputedStyle(root);
|
|
84
|
+
const canvas = document.createElement('canvas');
|
|
85
|
+
canvas.width = canvas.height = 1;
|
|
86
|
+
const context = canvas.getContext('2d');
|
|
87
|
+
const read = (name: string, fallback: THREE.Color): THREE.Color => {
|
|
88
|
+
const value = style.getPropertyValue(name).trim();
|
|
89
|
+
if (!context || !value || !CSS.supports('color', value)) return fallback.clone();
|
|
90
|
+
// Palette surfaces may be translucent (Glass). Let the browser composite
|
|
91
|
+
// their CSS colors over the shell; Three.Color has no alpha channel.
|
|
92
|
+
context.fillStyle = fallback.getStyle();
|
|
93
|
+
context.fillRect(0, 0, 1, 1);
|
|
94
|
+
context.fillStyle = style.getPropertyValue('--vgai-surface-shell').trim();
|
|
95
|
+
context.fillRect(0, 0, 1, 1);
|
|
96
|
+
context.fillStyle = value;
|
|
97
|
+
context.fillRect(0, 0, 1, 1);
|
|
98
|
+
const [r, g, b] = context.getImageData(0, 0, 1, 1).data;
|
|
99
|
+
return new THREE.Color().setRGB(r! / 255, g! / 255, b! / 255, THREE.SRGBColorSpace);
|
|
100
|
+
};
|
|
101
|
+
const panel = read('--vgai-surface-panel', GRADIENT_BOTTOM);
|
|
102
|
+
const raised = read('--vgai-surface-raised', GRADIENT_TOP);
|
|
103
|
+
// A background texture is not tone-mapped, so what is authored here is
|
|
104
|
+
// what the screen shows. The small lift keeps the ground a step above the
|
|
105
|
+
// panel it sits beside (Blender's viewport is lighter than its editors).
|
|
106
|
+
// Measured on screen under the Blender palette: ground #40, top #50, beside
|
|
107
|
+
// Blender's own #3f–#4e.
|
|
108
|
+
const bottom = panel.lerp(GRADIENT_BOTTOM, 0.25).lerp(new THREE.Color(0xffffff), 0.03);
|
|
109
|
+
const top = raised.lerp(GRADIENT_TOP, 0.25).lerp(new THREE.Color(0xffffff), 0.04);
|
|
110
|
+
return { bottom, top };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Fires when the palette the backdrop follows changes. The theme installer
|
|
115
|
+
* stamps its root with `data-vgai-palette` / `data-vgai-material` on every
|
|
116
|
+
* switch, so the DOM is the contract here — a document stage never imports
|
|
117
|
+
* the shell's preference store (its closure is pinned,
|
|
118
|
+
* `scripts/validate-editor-closure.mjs`). Returns the unsubscribe.
|
|
119
|
+
*/
|
|
120
|
+
export function watchPaletteBackdrop(onChange: () => void): () => void {
|
|
121
|
+
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return () => {};
|
|
122
|
+
const root = document.querySelector('[data-vgai-palette]');
|
|
123
|
+
if (!root) return () => {};
|
|
124
|
+
const observer = new MutationObserver(() => {
|
|
125
|
+
onChange();
|
|
126
|
+
invalidateStages();
|
|
127
|
+
});
|
|
128
|
+
observer.observe(root, {
|
|
129
|
+
attributes: true,
|
|
130
|
+
attributeFilter: ['data-vgai-palette', 'data-vgai-material'],
|
|
131
|
+
});
|
|
132
|
+
return () => observer.disconnect();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** One 1×N vertical-gradient strip; the renderer stretches it full-frame. */
|
|
136
|
+
export function createGradientBackgroundTexture(
|
|
137
|
+
stops: BackdropStops = paletteBackdropStops(),
|
|
138
|
+
): THREE.DataTexture {
|
|
139
|
+
const height = 128;
|
|
140
|
+
const data = new Uint8Array(height * 4);
|
|
141
|
+
const color = new THREE.Color();
|
|
142
|
+
for (let row = 0; row < height; row++) {
|
|
143
|
+
// Row 0 is the BOTTOM of the texture, so the gradient runs ground → sky.
|
|
144
|
+
// `Color` holds LINEAR components under colour management while the
|
|
145
|
+
// texture below is declared sRGB, so the bytes are encoded on the way
|
|
146
|
+
// out — written raw, every stop rendered several steps darker than
|
|
147
|
+
// authored (measured: the stage read #050506 for a #1e2530 stop).
|
|
148
|
+
color
|
|
149
|
+
.copy(stops.bottom)
|
|
150
|
+
.lerp(stops.top, row / (height - 1))
|
|
151
|
+
.convertLinearToSRGB();
|
|
152
|
+
data[row * 4] = Math.round(color.r * 255);
|
|
153
|
+
data[row * 4 + 1] = Math.round(color.g * 255);
|
|
154
|
+
data[row * 4 + 2] = Math.round(color.b * 255);
|
|
155
|
+
data[row * 4 + 3] = 255;
|
|
156
|
+
}
|
|
157
|
+
const texture = new THREE.DataTexture(data, 1, height, THREE.RGBAFormat);
|
|
158
|
+
texture.colorSpace = THREE.SRGBColorSpace;
|
|
159
|
+
texture.magFilter = THREE.LinearFilter;
|
|
160
|
+
texture.minFilter = THREE.LinearFilter;
|
|
161
|
+
texture.needsUpdate = true;
|
|
162
|
+
return texture;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Grid frame for this scene's content: the Scene viewport's 50 m floor grown
|
|
167
|
+
* in 10 m steps so a large layout (the 3D board's districts) never hangs past
|
|
168
|
+
* its own ground reference — and CENTRED on the content's ground footprint, so
|
|
169
|
+
* an off-origin layout (the board grows into one quadrant) still sits inside
|
|
170
|
+
* the grid's crisp middle rather than out on its fading rim.
|
|
171
|
+
*/
|
|
172
|
+
function gridFrameForScene(scene: THREE.Object3D): { size: number; centre: [number, number] } {
|
|
173
|
+
const bounds = contentWorldBounds(scene);
|
|
174
|
+
if (bounds.isEmpty()) return { size: 50, centre: [0, 0] };
|
|
175
|
+
const extent = Math.max(bounds.max.x - bounds.min.x, bounds.max.z - bounds.min.z);
|
|
176
|
+
return {
|
|
177
|
+
size: Math.min(Math.max(Math.ceil(extent / 10) * 10 + 20, 50), 1000),
|
|
178
|
+
centre: [(bounds.min.x + bounds.max.x) / 2, (bounds.min.z + bounds.max.z) / 2],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The Scene grid's 1 m cells, coarsening in 1/2/5/10 m steps once the extent
|
|
183
|
+
* would put more than ~60 lines on screen — denser reads as moiré noise, not
|
|
184
|
+
* as a ground reference. */
|
|
185
|
+
function gridDivisions(size: number): number {
|
|
186
|
+
for (const cell of [1, 2, 5, 10]) {
|
|
187
|
+
if (size / cell <= 60) return Math.round(size / cell);
|
|
188
|
+
}
|
|
189
|
+
return Math.round(size / 10);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Fade the grid out with DISTANCE, in the grid's own shader.
|
|
194
|
+
*
|
|
195
|
+
* A finite grid drawn hard to its edge degenerates at grazing angles: the far
|
|
196
|
+
* lines compress toward the horizon into harsh convergence/moiré artifacts,
|
|
197
|
+
* and the grid ends on an abrupt square rim. Multiplying the fragment alpha by
|
|
198
|
+
* a smoothstep over the fragment's radial distance from the grid's centre
|
|
199
|
+
* dissolves both — near cells stay a crisp ground reference, the outer band
|
|
200
|
+
* (which is exactly what sits at the horizon in a low shot) melts into the
|
|
201
|
+
* backdrop before it can alias. The fade is anchored to the grid's OWN extent
|
|
202
|
+
* rather than to the camera, so no zoom level ever dissolves the whole
|
|
203
|
+
* reference; the band scales with `gridFrameForScene`, which already tracks
|
|
204
|
+
* the content. Injection at `color_fragment` is where `LineBasicMaterial` has
|
|
205
|
+
* `diffuseColor` in scope.
|
|
206
|
+
*
|
|
207
|
+
* Exported because the dressing OWNS the grid treatment: `editor-viewport.ts`
|
|
208
|
+
* applies the same fade to its own editing grid, so no viewport anywhere ends
|
|
209
|
+
* a grid on a hard aliasing rim.
|
|
210
|
+
*/
|
|
211
|
+
export function applyGridDistanceFade(material: THREE.Material, gridSize: number): void {
|
|
212
|
+
// The band ends at the grid's own extent: it exists so the floor never ends
|
|
213
|
+
// on a hard rim, not to shrink the floor. The old 0.18/0.42 band was tuned
|
|
214
|
+
// for a camera standing close — a document that opens further back (the
|
|
215
|
+
// Model document's `openingFit`) photographed a floor with NO visible grid
|
|
216
|
+
// at all, measured against Blender's uniform lattice.
|
|
217
|
+
//
|
|
218
|
+
// `gridSize` is `GridHelper`'s size, which is the grid's DIAMETER — it spans
|
|
219
|
+
// ±size/2 — while the shader below compares against a RADIUS
|
|
220
|
+
// (`length(vGridPlanePosition)`). Comparing the two directly put the whole
|
|
221
|
+
// band outside the geometry: on a 400 m grid the fade started at 220 m when
|
|
222
|
+
// the farthest point along either axis is 200 m, so alpha never left 1.0 and
|
|
223
|
+
// the floor ended on exactly the hard rim this function exists to prevent
|
|
224
|
+
// (measured against Blender: our horizon stepped from #3f3f3f to #4f4f4f in
|
|
225
|
+
// four scanlines across the full stage width, Blender's held #3f3f3f-#41
|
|
226
|
+
// throughout). The band is a fraction of the RADIUS, so it reaches zero at
|
|
227
|
+
// the grid's edge, where the fade belongs.
|
|
228
|
+
const gridRadius = gridSize / 2;
|
|
229
|
+
const fadeStart = gridRadius * 0.55;
|
|
230
|
+
const fadeEnd = gridRadius;
|
|
231
|
+
material.onBeforeCompile = (shader) => {
|
|
232
|
+
shader.uniforms['uGridFadeStart'] = { value: fadeStart };
|
|
233
|
+
shader.uniforms['uGridFadeEnd'] = { value: fadeEnd };
|
|
234
|
+
shader.vertexShader = shader.vertexShader
|
|
235
|
+
.replace('#include <common>', '#include <common>\nvarying vec2 vGridPlanePosition;')
|
|
236
|
+
.replace(
|
|
237
|
+
'#include <begin_vertex>',
|
|
238
|
+
'#include <begin_vertex>\nvGridPlanePosition = position.xz;',
|
|
239
|
+
);
|
|
240
|
+
shader.fragmentShader = shader.fragmentShader
|
|
241
|
+
.replace(
|
|
242
|
+
'#include <common>',
|
|
243
|
+
'#include <common>\nvarying vec2 vGridPlanePosition;\nuniform float uGridFadeStart;\nuniform float uGridFadeEnd;',
|
|
244
|
+
)
|
|
245
|
+
.replace(
|
|
246
|
+
'#include <color_fragment>',
|
|
247
|
+
'#include <color_fragment>\ndiffuseColor.a *= 1.0 - smoothstep(uGridFadeStart, uGridFadeEnd, length(vGridPlanePosition));',
|
|
248
|
+
);
|
|
249
|
+
};
|
|
250
|
+
// Distinct fade bands must not share one cached program.
|
|
251
|
+
material.customProgramCacheKey = () => `vgai-grid-fade:${fadeStart}:${fadeEnd}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export interface StandardViewportDressingOptions {
|
|
255
|
+
/**
|
|
256
|
+
* The baked IBL from `@volter/editor-threejs/viewport/environment`; `null` opts the
|
|
257
|
+
* scene out of an environment entirely. Ownership transfers here.
|
|
258
|
+
*/
|
|
259
|
+
readonly environment: StandardEnvironment | null;
|
|
260
|
+
/** Apply the vertical-gradient backdrop. Default `true`; a host whose
|
|
261
|
+
* backdrop lives elsewhere (the Asset Lab's alpha-canvas studio stage, an
|
|
262
|
+
* explicit SDK-contributed background) opts out. */
|
|
263
|
+
readonly background?: boolean;
|
|
264
|
+
/** Add the warm directional key light. Default `true`; a source that
|
|
265
|
+
* authors its own lights opts out — authored lighting always wins. */
|
|
266
|
+
readonly keyLight?: boolean;
|
|
267
|
+
/** Add the ground grid (content standing on y=0 by design). Default `false`. */
|
|
268
|
+
readonly grid?: boolean;
|
|
269
|
+
readonly content?: THREE.Object3D;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export interface StandardViewportDressing {
|
|
273
|
+
/** The gradient applied to `scene.background`, or `null` when opted out —
|
|
274
|
+
* hosts hand it on (e.g. as a document session's neutral background). */
|
|
275
|
+
readonly backgroundTexture: THREE.Texture | null;
|
|
276
|
+
/** The dressing's lights, for hosts whose lighting presets retune them. */
|
|
277
|
+
readonly lights: readonly THREE.Light[];
|
|
278
|
+
frameContent(content: THREE.Object3D): void;
|
|
279
|
+
/** The ONE teardown path for everything this dressing created. Idempotent. */
|
|
280
|
+
dispose(): void;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Apply the standard dressing to one document viewport scene. */
|
|
284
|
+
export function applyStandardViewportDressing(
|
|
285
|
+
scene: THREE.Scene,
|
|
286
|
+
options: StandardViewportDressingOptions,
|
|
287
|
+
): StandardViewportDressing {
|
|
288
|
+
const { environment, background = true, keyLight = true, grid = false } = options;
|
|
289
|
+
const chrome: THREE.Object3D[] = [];
|
|
290
|
+
const lights: THREE.Light[] = [];
|
|
291
|
+
let backgroundTexture: THREE.DataTexture | null = null;
|
|
292
|
+
|
|
293
|
+
if (environment) scene.environment = environment.texture;
|
|
294
|
+
|
|
295
|
+
if (background) {
|
|
296
|
+
backgroundTexture = createGradientBackgroundTexture();
|
|
297
|
+
scene.background = backgroundTexture;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (keyLight) {
|
|
301
|
+
// The kit's own key, warm, as the kit studio preset states it (`KIT_STUDIO_PRESET`). A
|
|
302
|
+
// document stage is lit by its view's presentation instead (`StagePresentationRig`, and
|
|
303
|
+
// `StageHost` turns this key off); a stage without a presentation still gets this one.
|
|
304
|
+
const key = new THREE.DirectionalLight(0xfff3dd, 1.9);
|
|
305
|
+
key.name = 'vgai:standard-dressing-key';
|
|
306
|
+
key.position.set(6, 10, -4);
|
|
307
|
+
key.castShadow = true;
|
|
308
|
+
// `userData.editorHelper` keeps it out of hierarchy walks and picks;
|
|
309
|
+
// layers stay default so it lights layer-0 content under any camera.
|
|
310
|
+
setUserData(key, 'editorHelper', true);
|
|
311
|
+
scene.add(key);
|
|
312
|
+
chrome.push(key);
|
|
313
|
+
lights.push(key);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let gridHelper: THREE.GridHelper | null = null;
|
|
317
|
+
let gridFrame = '';
|
|
318
|
+
const frameContent = (content: THREE.Object3D) => {
|
|
319
|
+
if (!grid) return;
|
|
320
|
+
const { size, centre } = gridFrameForScene(content);
|
|
321
|
+
const frame = `${size}:${centre.join(':')}`;
|
|
322
|
+
if (frame === gridFrame) return;
|
|
323
|
+
gridFrame = frame;
|
|
324
|
+
if (gridHelper) {
|
|
325
|
+
gridHelper.removeFromParent();
|
|
326
|
+
gridHelper.dispose();
|
|
327
|
+
chrome.splice(chrome.indexOf(gridHelper), 1);
|
|
328
|
+
}
|
|
329
|
+
// The Scene viewport's grid color (`editor-viewport.ts`), softened with
|
|
330
|
+
// transparency so it reads as a ground reference under the gradient
|
|
331
|
+
// rather than competing with the content.
|
|
332
|
+
const helper = new THREE.GridHelper(size, gridDivisions(size), 0x999999, 0x999999);
|
|
333
|
+
gridHelper = helper;
|
|
334
|
+
const gridMaterial = helper.material as THREE.Material;
|
|
335
|
+
gridMaterial.transparent = true;
|
|
336
|
+
gridMaterial.opacity = 0.35;
|
|
337
|
+
gridMaterial.depthWrite = false;
|
|
338
|
+
applyGridDistanceFade(gridMaterial, size);
|
|
339
|
+
helper.name = 'vgai:standard-dressing-grid';
|
|
340
|
+
// Just below the ground line so content standing exactly on y=0 never
|
|
341
|
+
// z-fights the grid lines; centred on the content's own footprint.
|
|
342
|
+
helper.position.set(centre[0], -0.02, centre[1]);
|
|
343
|
+
helper.layers.set(EDITOR_LAYER);
|
|
344
|
+
setUserData(helper, 'editorHelper', true);
|
|
345
|
+
scene.add(helper);
|
|
346
|
+
chrome.push(helper);
|
|
347
|
+
};
|
|
348
|
+
frameContent(options.content ?? scene);
|
|
349
|
+
|
|
350
|
+
let disposed = false;
|
|
351
|
+
return {
|
|
352
|
+
backgroundTexture,
|
|
353
|
+
lights,
|
|
354
|
+
frameContent,
|
|
355
|
+
dispose(): void {
|
|
356
|
+
if (disposed) return;
|
|
357
|
+
disposed = true;
|
|
358
|
+
for (const object of chrome) {
|
|
359
|
+
object.removeFromParent();
|
|
360
|
+
// GridHelper frees its geometry+material; Light frees its shadow map.
|
|
361
|
+
(object as { dispose?: () => void }).dispose?.();
|
|
362
|
+
}
|
|
363
|
+
if (environment) {
|
|
364
|
+
if (scene.environment === environment.texture) scene.environment = null;
|
|
365
|
+
environment.dispose();
|
|
366
|
+
}
|
|
367
|
+
if (backgroundTexture) {
|
|
368
|
+
if (scene.background === backgroundTexture) scene.background = null;
|
|
369
|
+
backgroundTexture.dispose();
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* THE STAGE'S LIGHTING, FROM ITS PRESENTATION — the Three half of
|
|
377
|
+
* `@volter/editor-sdk/kit/viewport-presentation`. A 3D document stage owns one rig; it draws
|
|
378
|
+
* the view's `studio` lighting (a preset's lights, world-fixed or camera-locked, and its
|
|
379
|
+
* ambient), sets the renderer's tone mapper and exposure, and answers the image-based light's
|
|
380
|
+
* strength. It replaces the lights that were fixed in code: the dressing's key and the
|
|
381
|
+
* viewport's own ambient and directional (`docs/VIEWPORT-STAGE.md` §The ruling). It moves to
|
|
382
|
+
* `@volter/editor-threejs` with the rest of the assembled viewport (ARCHITECTURE.md, the plan,
|
|
383
|
+
* unit 3).
|
|
384
|
+
*
|
|
385
|
+
* A `studio` draw lights by its preset alone: the stage darkens the content's own lights for
|
|
386
|
+
* that draw and restores them after it (`Object3DDocumentHost.darkenContentLights`). The
|
|
387
|
+
* `document` preset is the document's own view-locked studio (Blender's Solid lights), which the
|
|
388
|
+
* document manages together with its scene's lights.
|
|
389
|
+
*
|
|
390
|
+
* Not yet: the `preview` source (a sun and a sky) draws nothing, and a Blender document's
|
|
391
|
+
* `scene` source draws unlit, because Blender's render lighting lives in the Blender engine
|
|
392
|
+
* (`BlenderRuntimeView.setRendered`) rather than as lights in the document's content.
|
|
393
|
+
*/
|
|
394
|
+
const LIGHT_DISTANCE = 12;
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* GODOT'S FILMIC CURVE as three's one custom tone mapper: John Hable's curve with Godot's
|
|
398
|
+
* exposure bias of 2, divided by the curve at the white point so white stays white
|
|
399
|
+
* (`servers/rendering/renderer_rd/shaders/effects/tonemap.glsl`, `tonemap_filmic`, at
|
|
400
|
+
* Godot's default `tonemap_white` of 1.0). three's own curves map white to about 0.8, which
|
|
401
|
+
* drew Godot's sun grey. Installed once, into three's `CustomToneMapping` slot, which nothing
|
|
402
|
+
* else on the page fills.
|
|
403
|
+
*/
|
|
404
|
+
const GODOT_FILMIC = /* glsl */ `
|
|
405
|
+
vec3 vgaiHable( vec3 x ) {
|
|
406
|
+
const float A = 0.22 * 4.0;
|
|
407
|
+
const float B = 0.30 * 2.0;
|
|
408
|
+
const float C = 0.10;
|
|
409
|
+
const float D = 0.20;
|
|
410
|
+
const float E = 0.01;
|
|
411
|
+
const float F = 0.30;
|
|
412
|
+
return ( ( x * ( A * x + C * B ) + D * E ) / ( x * ( A * x + B ) + D * F ) ) - E / F;
|
|
413
|
+
}
|
|
414
|
+
vec3 CustomToneMapping( vec3 color ) {
|
|
415
|
+
color *= toneMappingExposure;
|
|
416
|
+
return clamp( vgaiHable( max( vec3( 0.0 ), color ) ) / vgaiHable( vec3( 1.0 ) ), 0.0, 1.0 );
|
|
417
|
+
}`;
|
|
418
|
+
{
|
|
419
|
+
const chunk = THREE.ShaderChunk.tonemapping_pars_fragment;
|
|
420
|
+
const stock = 'vec3 CustomToneMapping( vec3 color ) { return color; }';
|
|
421
|
+
if (chunk.includes(stock)) THREE.ShaderChunk.tonemapping_pars_fragment = chunk.replace(stock, GODOT_FILMIC);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const TONE_MAPPERS: Record<ViewportPresentation['lighting']['tone']['mapper'], THREE.ToneMapping> = {
|
|
425
|
+
none: THREE.NoToneMapping,
|
|
426
|
+
aces: THREE.ACESFilmicToneMapping,
|
|
427
|
+
agx: THREE.AgXToneMapping,
|
|
428
|
+
filmic: THREE.CustomToneMapping,
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
/** One image's sky key: a changed URL (a rebuilt asset) is a different image to load. */
|
|
432
|
+
function imageKey(image: NonNullable<ReturnType<typeof environmentImage>>): string {
|
|
433
|
+
return `image|${image.id}|${image.url}`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function fade(t: number): number {
|
|
437
|
+
return t * t * (3 - 2 * t);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** A lattice point's value, 0 to 1. */
|
|
441
|
+
function latticeHash(i: number, j: number, k: number): number {
|
|
442
|
+
let h = Math.imul(i, 374761393) ^ Math.imul(j, 668265263) ^ Math.imul(k, 1440662683);
|
|
443
|
+
h = Math.imul(h ^ (h >>> 13), 1274126177);
|
|
444
|
+
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Value noise in three dimensions, 0 to 1, smoothly interpolated between lattice points. */
|
|
448
|
+
function valueNoise(x: number, y: number, z: number): number {
|
|
449
|
+
const xi = Math.floor(x);
|
|
450
|
+
const yi = Math.floor(y);
|
|
451
|
+
const zi = Math.floor(z);
|
|
452
|
+
const u = fade(x - xi);
|
|
453
|
+
const v = fade(y - yi);
|
|
454
|
+
const w = fade(z - zi);
|
|
455
|
+
const lerp = THREE.MathUtils.lerp;
|
|
456
|
+
const x00 = lerp(latticeHash(xi, yi, zi), latticeHash(xi + 1, yi, zi), u);
|
|
457
|
+
const x10 = lerp(latticeHash(xi, yi + 1, zi), latticeHash(xi + 1, yi + 1, zi), u);
|
|
458
|
+
const x01 = lerp(latticeHash(xi, yi, zi + 1), latticeHash(xi + 1, yi, zi + 1), u);
|
|
459
|
+
const x11 = lerp(latticeHash(xi, yi + 1, zi + 1), latticeHash(xi + 1, yi + 1, zi + 1), u);
|
|
460
|
+
return lerp(lerp(x00, x10, v), lerp(x01, x11, v), w);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Five octaves of value noise, 0 to 1: the cloud layer's shapes. */
|
|
464
|
+
function fractalNoise(x: number, y: number, z: number): number {
|
|
465
|
+
let sum = 0;
|
|
466
|
+
let amplitude = 0.5;
|
|
467
|
+
let frequency = 1;
|
|
468
|
+
let total = 0;
|
|
469
|
+
for (let octave = 0; octave < 5; octave++) {
|
|
470
|
+
sum += amplitude * valueNoise(x * frequency, y * frequency, z * frequency);
|
|
471
|
+
total += amplitude;
|
|
472
|
+
amplitude *= 0.5;
|
|
473
|
+
frequency *= 2;
|
|
474
|
+
}
|
|
475
|
+
return sum / total;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** The fractal noise's values over the sphere, sorted: its quantiles turn a cloud `cover` into
|
|
479
|
+
* the threshold that clouds that share of the sky (the noise clusters near 0.5, so a raw
|
|
480
|
+
* threshold would cloud almost none or almost all of it). */
|
|
481
|
+
let noiseQuantiles: Float32Array | null = null;
|
|
482
|
+
function cloudThreshold(cover: number): number {
|
|
483
|
+
if (!noiseQuantiles) {
|
|
484
|
+
const samples = new Float32Array(4096);
|
|
485
|
+
for (let index = 0; index < samples.length; index++) {
|
|
486
|
+
// Evenly spread directions (a Fibonacci sphere), at the scale the layer is drawn around.
|
|
487
|
+
const y = 1 - (2 * (index + 0.5)) / samples.length;
|
|
488
|
+
const radius = Math.sqrt(1 - y * y);
|
|
489
|
+
const theta = index * 2.399963229728653;
|
|
490
|
+
samples[index] = fractalNoise(Math.cos(theta) * radius * 4, y * 4, Math.sin(theta) * radius * 4);
|
|
491
|
+
}
|
|
492
|
+
noiseQuantiles = samples.sort();
|
|
493
|
+
}
|
|
494
|
+
const at = THREE.MathUtils.clamp(Math.round((1 - cover) * (noiseQuantiles.length - 1)), 0, noiseQuantiles.length - 1);
|
|
495
|
+
return noiseQuantiles[at]!;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** A cloud layer as the sky states it, a partial one completed (a restored or commanded layer
|
|
499
|
+
* is not validated field by field), or `null` for a clear sky. */
|
|
500
|
+
function cloudLayer(
|
|
501
|
+
clouds: ViewportPresentation['lighting']['preview']['environment']['sky']['clouds'],
|
|
502
|
+
): { cover: number; opacity: number; scale: number } | null {
|
|
503
|
+
if (!clouds) return null;
|
|
504
|
+
const finite = (value: unknown, fallback: number) =>
|
|
505
|
+
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
506
|
+
const layer = {
|
|
507
|
+
cover: THREE.MathUtils.clamp(finite(clouds.cover, 0.5), 0, 1),
|
|
508
|
+
opacity: THREE.MathUtils.clamp(finite(clouds.opacity, 1), 0, 1),
|
|
509
|
+
scale: Math.max(finite(clouds.scale, 4), 0.1),
|
|
510
|
+
};
|
|
511
|
+
return layer.cover > 0 && layer.opacity > 0 ? layer : null;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Noise is drawn on a grid this many texels apart and interpolated between: clouds are soft,
|
|
515
|
+
* and a full-resolution strip costs a second of main thread per rebuild. */
|
|
516
|
+
const CLOUD_STEP = 4;
|
|
517
|
+
|
|
518
|
+
/** The floor's width in scene units: past any preview sun's shadow reach, and well inside the
|
|
519
|
+
* camera's far plane. */
|
|
520
|
+
const FLOOR_EXTENT = 400;
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* THE FLOOR DISSOLVES INTO THE SKY instead of ending on its square's edge: its alpha falls off
|
|
524
|
+
* with distance from its centre, from 30% to 95% of its half-width, so the horizon is the sky
|
|
525
|
+
* showing through a floor that thins toward it — soft and round, as a level's floor meets its
|
|
526
|
+
* atmosphere — never a hard line with the square's corner in it. Drawn just behind anything on
|
|
527
|
+
* its plane, so a grid lying there stays on top.
|
|
528
|
+
*/
|
|
529
|
+
function fadingFloorMaterial(): THREE.MeshStandardMaterial {
|
|
530
|
+
const material = new THREE.MeshStandardMaterial({
|
|
531
|
+
roughness: 0.9,
|
|
532
|
+
metalness: 0,
|
|
533
|
+
transparent: true,
|
|
534
|
+
depthWrite: false,
|
|
535
|
+
polygonOffset: true,
|
|
536
|
+
polygonOffsetFactor: 1,
|
|
537
|
+
polygonOffsetUnits: 1,
|
|
538
|
+
});
|
|
539
|
+
const half = FLOOR_EXTENT / 2;
|
|
540
|
+
material.onBeforeCompile = (shader) => {
|
|
541
|
+
shader.vertexShader = shader.vertexShader
|
|
542
|
+
.replace('void main() {', 'varying vec2 vFloorPlane;\nvoid main() {')
|
|
543
|
+
.replace('#include <begin_vertex>', '#include <begin_vertex>\nvFloorPlane = position.xz;');
|
|
544
|
+
shader.fragmentShader = shader.fragmentShader
|
|
545
|
+
.replace('void main() {', 'varying vec2 vFloorPlane;\nvoid main() {')
|
|
546
|
+
.replace(
|
|
547
|
+
'#include <dithering_fragment>',
|
|
548
|
+
`#include <dithering_fragment>\ngl_FragColor.a *= 1.0 - smoothstep(${(half * 0.3).toFixed(1)}, ${(half * 0.95).toFixed(1)}, length(vFloorPlane));`,
|
|
549
|
+
);
|
|
550
|
+
};
|
|
551
|
+
material.customProgramCacheKey = () => 'vgai-fading-floor';
|
|
552
|
+
return material;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export class StagePresentationRig {
|
|
556
|
+
private readonly group = new THREE.Group();
|
|
557
|
+
private readonly ambient = new THREE.AmbientLight(0xffffff, 0);
|
|
558
|
+
private lights: { readonly light: THREE.DirectionalLight; readonly spec: StudioLight }[] = [];
|
|
559
|
+
private preset: StudioPreset | null = null;
|
|
560
|
+
private presentation: ViewportPresentation | null = null;
|
|
561
|
+
private source: 'studio' | 'preview' | 'scene' = 'studio';
|
|
562
|
+
/** The `preview` source's sun (Godot's preview sun). */
|
|
563
|
+
private readonly previewGroup = new THREE.Group();
|
|
564
|
+
private readonly sun = new THREE.DirectionalLight(0xffffff, 1);
|
|
565
|
+
/** The view's floor (`overlays.floor`): a wide plane under the content taking shadows. */
|
|
566
|
+
private readonly floorBounds = new THREE.Box3();
|
|
567
|
+
/** What the floor stands under, kept so a view that shows the floor later can place it. */
|
|
568
|
+
private floorContent: THREE.Object3D | null = null;
|
|
569
|
+
private readonly floor = new THREE.Mesh(
|
|
570
|
+
new THREE.PlaneGeometry(FLOOR_EXTENT, FLOOR_EXTENT).rotateX(-Math.PI / 2),
|
|
571
|
+
fadingFloorMaterial(),
|
|
572
|
+
);
|
|
573
|
+
/** The preview sky, built from its three colours: the background drawn behind the scene and
|
|
574
|
+
* the environment that lights it, rebuilt only when the colours change. */
|
|
575
|
+
private sky: {
|
|
576
|
+
key: string;
|
|
577
|
+
/** The environment image drawn, or `null` for the procedural strip. */
|
|
578
|
+
image: string | null;
|
|
579
|
+
background: THREE.Texture;
|
|
580
|
+
environment: THREE.WebGLRenderTarget;
|
|
581
|
+
} | null = null;
|
|
582
|
+
private pmrem: THREE.PMREMGenerator | null = null;
|
|
583
|
+
private readonly direction = new THREE.Vector3();
|
|
584
|
+
/** The environment image being fetched (its sky key), so a second apply does not fetch it again. */
|
|
585
|
+
private loadingImage: string | null = null;
|
|
586
|
+
/** The image the view names while it loads or after it failed, for the draw report. */
|
|
587
|
+
private pendingImage: { id: string; state: 'unregistered' | 'loading' | 'failed' } | null = null;
|
|
588
|
+
/** Images that failed to load, by sky key: not fetched again until the registry changes. */
|
|
589
|
+
private readonly failedImages = new Set<string>();
|
|
590
|
+
/** The procedural sky as the latest apply states it: what a failed image falls back to. */
|
|
591
|
+
private latestSky: (() => void) | null = null;
|
|
592
|
+
/** The environment's turn about the vertical axis, radians. */
|
|
593
|
+
private rotation = 0;
|
|
594
|
+
private disposed = false;
|
|
595
|
+
|
|
596
|
+
/** `onReady`: an environment image arrived after the apply that asked for it; draw again. */
|
|
597
|
+
constructor(scene: THREE.Scene, private readonly onReady?: () => void) {
|
|
598
|
+
this.group.name = 'vgai:stage-presentation-rig';
|
|
599
|
+
// Kept out of hierarchy walks and picks, like every editor helper.
|
|
600
|
+
this.group.userData['editorHelper'] = true;
|
|
601
|
+
this.group.add(this.ambient);
|
|
602
|
+
scene.add(this.group);
|
|
603
|
+
this.previewGroup.name = 'vgai:stage-preview-rig';
|
|
604
|
+
this.previewGroup.userData['editorHelper'] = true;
|
|
605
|
+
this.sun.name = 'vgai:preview-sun';
|
|
606
|
+
this.sun.userData['editorHelper'] = true;
|
|
607
|
+
this.previewGroup.add(this.sun, this.sun.target);
|
|
608
|
+
this.previewGroup.visible = false;
|
|
609
|
+
scene.add(this.previewGroup);
|
|
610
|
+
this.floor.name = 'vgai:preview-floor';
|
|
611
|
+
this.floor.userData['editorHelper'] = true;
|
|
612
|
+
this.floor.receiveShadow = true;
|
|
613
|
+
// Transparent now (its fade), so it is sorted with the grid: drawn first, under it.
|
|
614
|
+
this.floor.renderOrder = -1;
|
|
615
|
+
this.floor.visible = false;
|
|
616
|
+
scene.add(this.floor);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Apply a view's presentation. `toneMapping` is the document's own mapper when it states one
|
|
620
|
+
* (a document's dressing), which outranks the view's. */
|
|
621
|
+
apply(
|
|
622
|
+
presentation: ViewportPresentation,
|
|
623
|
+
renderer: THREE.WebGLRenderer,
|
|
624
|
+
documentToneMapping?: THREE.ToneMapping,
|
|
625
|
+
options: { readonly tone?: boolean; readonly sky?: boolean; readonly floor?: boolean } = {},
|
|
626
|
+
): void {
|
|
627
|
+
this.presentation = presentation;
|
|
628
|
+
const { lighting } = presentation;
|
|
629
|
+
// A stage with no content to stand it under (the game world's) shows no floor.
|
|
630
|
+
const floorShown = options.floor !== false && presentation.overlays.floor.visible;
|
|
631
|
+
const floorAppears = floorShown && !this.floor.visible;
|
|
632
|
+
this.floor.visible = floorShown;
|
|
633
|
+
if (floorAppears && this.floorContent) this.placeFloor(this.floorContent);
|
|
634
|
+
this.floor.material.color.set(presentation.overlays.floor.color);
|
|
635
|
+
const preset = studioPreset(lighting.studioPreset);
|
|
636
|
+
if (preset !== this.preset) this.build(preset);
|
|
637
|
+
this.source = lighting.source;
|
|
638
|
+
this.group.visible = lighting.source === 'studio';
|
|
639
|
+
// A stage whose render pipeline owns the tone (the game world's) passes `tone: false`.
|
|
640
|
+
if (options.tone !== false) {
|
|
641
|
+
renderer.toneMapping = documentToneMapping ?? TONE_MAPPERS[lighting.tone.mapper];
|
|
642
|
+
renderer.toneMappingExposure = lighting.tone.exposure;
|
|
643
|
+
}
|
|
644
|
+
// The preview sun: Godot's, placed by altitude and azimuth (clockwise from north, -Z).
|
|
645
|
+
const { sun, environment } = lighting.preview;
|
|
646
|
+
const altitude = THREE.MathUtils.degToRad(sun.altitude);
|
|
647
|
+
const azimuth = THREE.MathUtils.degToRad(sun.azimuth);
|
|
648
|
+
this.sun.color.set(sun.color);
|
|
649
|
+
// Godot's energy as three's intensity, unscaled: the unit mapping is measured against
|
|
650
|
+
// Godot's own frames, not assumed.
|
|
651
|
+
this.sun.intensity = sun.enabled ? sun.energy : 0;
|
|
652
|
+
this.sun.position
|
|
653
|
+
.set(Math.sin(azimuth) * Math.cos(altitude), Math.sin(altitude), -Math.cos(azimuth) * Math.cos(altitude))
|
|
654
|
+
.multiplyScalar(LIGHT_DISTANCE);
|
|
655
|
+
this.sun.castShadow = sun.enabled && sun.shadowDistance > 0;
|
|
656
|
+
const reach = Math.min(Math.max(sun.shadowDistance, 1), 50) / 2;
|
|
657
|
+
Object.assign(this.sun.shadow.camera, { left: -reach, right: reach, top: reach, bottom: -reach });
|
|
658
|
+
this.sun.shadow.camera.updateProjectionMatrix();
|
|
659
|
+
this.sun.updateMatrixWorld();
|
|
660
|
+
this.sun.target.updateMatrixWorld();
|
|
661
|
+
this.rotation = THREE.MathUtils.degToRad(environment.rotation);
|
|
662
|
+
const wantsSky =
|
|
663
|
+
options.sky !== false &&
|
|
664
|
+
environment.enabled &&
|
|
665
|
+
(lighting.source === 'preview' || presentation.backdrop.source === 'environment');
|
|
666
|
+
if (!wantsSky) {
|
|
667
|
+
this.loadingImage = null;
|
|
668
|
+
this.pendingImage = null;
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
// An image the view names but no integration has registered (yet, or in this product), or
|
|
672
|
+
// one that failed to load, leaves the procedural sky; a registration re-applies the view.
|
|
673
|
+
const image = environment.image === null ? null : environmentImage(environment.image);
|
|
674
|
+
const sky = () =>
|
|
675
|
+
this.buildSky(environment.sky, renderer, sun.enabled ? { direction: this.sun.position.clone().normalize(), color: sun.color, energy: sun.energy } : null);
|
|
676
|
+
this.latestSky = sky;
|
|
677
|
+
if (image && !this.failedImages.has(imageKey(image))) {
|
|
678
|
+
// Nothing drawn yet: the procedural sky stands while the image loads.
|
|
679
|
+
if (!this.sky) sky();
|
|
680
|
+
this.buildImage(image, renderer);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
this.loadingImage = null;
|
|
684
|
+
this.pendingImage =
|
|
685
|
+
environment.image === null ? null : { id: environment.image, state: image ? 'failed' : 'unregistered' };
|
|
686
|
+
sky();
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* AN ENVIRONMENT IMAGE as the preview's sky: the panorama drawn behind the scene and,
|
|
691
|
+
* prefiltered, the light, exactly where the procedural strip would be. Read as half float for
|
|
692
|
+
* the same reason as the strip (a bright sun past white; linear filtering on phones). The last
|
|
693
|
+
* sky stays drawn while the image loads.
|
|
694
|
+
*/
|
|
695
|
+
private buildImage(
|
|
696
|
+
image: NonNullable<ReturnType<typeof environmentImage>>,
|
|
697
|
+
renderer: THREE.WebGLRenderer,
|
|
698
|
+
): void {
|
|
699
|
+
const key = imageKey(image);
|
|
700
|
+
if (this.sky?.key === key) {
|
|
701
|
+
this.pendingImage = null;
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
if (this.loadingImage === key) return;
|
|
705
|
+
this.loadingImage = key;
|
|
706
|
+
this.pendingImage = { id: image.id, state: 'loading' };
|
|
707
|
+
loadEnvironmentImage(image.url, image.format)
|
|
708
|
+
.then((texture) => {
|
|
709
|
+
if (this.disposed || this.loadingImage !== key) {
|
|
710
|
+
texture.dispose();
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
let environment: THREE.WebGLRenderTarget;
|
|
714
|
+
try {
|
|
715
|
+
this.pmrem ??= new THREE.PMREMGenerator(renderer);
|
|
716
|
+
environment = this.pmrem.fromEquirectangular(texture);
|
|
717
|
+
} catch (error) {
|
|
718
|
+
texture.dispose();
|
|
719
|
+
throw error;
|
|
720
|
+
}
|
|
721
|
+
this.loadingImage = null;
|
|
722
|
+
this.pendingImage = null;
|
|
723
|
+
this.disposeSky();
|
|
724
|
+
this.sky = { key, image: image.id, background: texture, environment };
|
|
725
|
+
this.onReady?.();
|
|
726
|
+
})
|
|
727
|
+
.catch((error: unknown) => {
|
|
728
|
+
if (this.disposed || this.loadingImage !== key) return;
|
|
729
|
+
this.loadingImage = null;
|
|
730
|
+
this.failedImages.add(key);
|
|
731
|
+
this.pendingImage = { id: image.id, state: 'failed' };
|
|
732
|
+
editorConsole.error(`Environment image "${image.id}" (${image.url}) did not load: ${String(error)}`, 'viewport');
|
|
733
|
+
// The view still names it, so what stands is the procedural sky, never the last image.
|
|
734
|
+
this.latestSky?.();
|
|
735
|
+
this.onReady?.();
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** Put the floor under `content`, at its lowest point, as Unreal's asset editors place their
|
|
740
|
+
* preview floor at the bottom of the mesh's bounds; empty content leaves it on the world's
|
|
741
|
+
* floor. */
|
|
742
|
+
placeFloor(content: THREE.Object3D): void {
|
|
743
|
+
this.floorContent = content;
|
|
744
|
+
if (!this.floor.visible) return;
|
|
745
|
+
const bounds = contentWorldBounds(content, this.floorBounds);
|
|
746
|
+
this.floor.position.y = bounds.isEmpty() ? 0 : bounds.min.y;
|
|
747
|
+
this.floor.updateMatrixWorld();
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** The image registry changed: an image that failed may load now. */
|
|
751
|
+
forgetFailedImages(): void {
|
|
752
|
+
this.failedImages.clear();
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** Whether this draw also lights by the scene's own lights: a preview may leave them out
|
|
756
|
+
* (Blender's Material Preview); a studio never adds them (the stage darkens them itself). */
|
|
757
|
+
sceneLightsShown(): boolean {
|
|
758
|
+
return this.source !== 'preview' || this.presentation?.lighting.preview.sceneLights !== false;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** The environment image the last draw showed and the one still loading or failed. */
|
|
762
|
+
imageReport(): { readonly shown: string | null; readonly pending: { readonly id: string; readonly state: 'unregistered' | 'loading' | 'failed' } | null } {
|
|
763
|
+
return { shown: this.sky?.image ?? null, pending: this.pendingImage };
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** The environment's turn: an image's, never the procedural strip's (its sun is the preview
|
|
767
|
+
* sun's, which does not turn with it). */
|
|
768
|
+
private turn(): number {
|
|
769
|
+
return this.sky?.image ? this.rotation : 0;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* THE PREVIEW SKY, Godot's `ProceduralSkyMaterial`: above the horizon the horizon colour runs
|
|
774
|
+
* to the top colour on the sky's top curve (Godot's 0.15), below it to the ground colour on its
|
|
775
|
+
* ground curve (Godot's 0.02), mixed in linear light (`scene/resources/3d/sky_material.cpp`). THE SUN in
|
|
776
|
+
* it follows the same material: inside the light's disc the sky is the sun's colour at its
|
|
777
|
+
* energy, and out to `sun_angle_max` (30°) it returns to the sky on a curve of `sun_curve`
|
|
778
|
+
* (0.15). A light with no angular size draws a half-degree disc.
|
|
779
|
+
*
|
|
780
|
+
* Drawn into a HALF-FLOAT equirectangular strip, because the sun is brighter than white: an 8-bit
|
|
781
|
+
* strip clipped it to 1.0, which the tone curve draws as grey 202 (measured) where Godot's
|
|
782
|
+
* sun blows out. Half, not full, float: a full-float strip cannot be linearly filtered where
|
|
783
|
+
* `OES_texture_float_linear` is missing (common on phones) and would draw black. The strip is
|
|
784
|
+
* both the backdrop and, prefiltered, the light; 1024 across,
|
|
785
|
+
* because the sun's bright core is about 3° wide and drew as two pixels at 256. Placed by
|
|
786
|
+
* three's equirectangular mapping (`atan(z, x)`, `asin(y)`); row 0 is straight down.
|
|
787
|
+
*/
|
|
788
|
+
private buildSky(
|
|
789
|
+
colours: ViewportPresentation['lighting']['preview']['environment']['sky'],
|
|
790
|
+
renderer: THREE.WebGLRenderer,
|
|
791
|
+
sun: { readonly direction: THREE.Vector3; readonly color: string; readonly energy: number } | null,
|
|
792
|
+
): void {
|
|
793
|
+
const sunKey = sun
|
|
794
|
+
? `${sun.direction.toArray().map((value) => value.toFixed(4)).join(',')}|${sun.color}|${sun.energy}`
|
|
795
|
+
: 'none';
|
|
796
|
+
const clouds = cloudLayer(colours.clouds);
|
|
797
|
+
const cloudKey = clouds ? `${clouds.cover}|${clouds.opacity}|${clouds.scale}` : 'clear';
|
|
798
|
+
const key = `${colours.top}|${colours.horizon}|${colours.ground}|${colours.topCurve}|${colours.groundCurve}|${cloudKey}|${sunKey}`;
|
|
799
|
+
if (this.sky?.key === key) return;
|
|
800
|
+
this.disposeSky();
|
|
801
|
+
const width = 1024;
|
|
802
|
+
const height = 512;
|
|
803
|
+
const DISC = THREE.MathUtils.degToRad(0.5);
|
|
804
|
+
const GLOW = THREE.MathUtils.degToRad(30);
|
|
805
|
+
const SUN_CURVE = 0.15;
|
|
806
|
+
const top = new THREE.Color(colours.top);
|
|
807
|
+
const horizon = new THREE.Color(colours.horizon);
|
|
808
|
+
const ground = new THREE.Color(colours.ground);
|
|
809
|
+
const light = sun ? new THREE.Color(sun.color).multiplyScalar(sun.energy) : null;
|
|
810
|
+
const data = new Uint16Array(width * height * 4);
|
|
811
|
+
// Half-float's range: three warns on every value past it, which a bright sun reaches.
|
|
812
|
+
const half = (value: number) => THREE.DataUtils.toHalfFloat(Math.min(value, 65504));
|
|
813
|
+
// A curve of zero divides by zero at the horizon row.
|
|
814
|
+
const topCurve = Math.max(colours.topCurve, 0.001);
|
|
815
|
+
const groundCurve = Math.max(colours.groundCurve, 0.001);
|
|
816
|
+
const band = new THREE.Color();
|
|
817
|
+
const pixel = new THREE.Color();
|
|
818
|
+
// A cloud is lit white, taking a little of the horizon's colour.
|
|
819
|
+
const cloudColour = new THREE.Color(0.92, 0.93, 0.95).lerp(horizon, 0.2);
|
|
820
|
+
// The cloud layer's noise over the upper half, on a coarse grid sampled on each direction
|
|
821
|
+
// (so the strip's two edges meet without a seam), and the value that clouds `cover` of it.
|
|
822
|
+
const cloudColumns = width / CLOUD_STEP;
|
|
823
|
+
const cloudRows = height / 2 / CLOUD_STEP + 1;
|
|
824
|
+
const cloudEdge = clouds ? cloudThreshold(clouds.cover) : 1;
|
|
825
|
+
let cloudField: Float32Array | null = null;
|
|
826
|
+
if (clouds) {
|
|
827
|
+
cloudField = new Float32Array(cloudColumns * cloudRows);
|
|
828
|
+
for (let gridRow = 0; gridRow < cloudRows; gridRow++) {
|
|
829
|
+
const row = Math.min(height / 2 + gridRow * CLOUD_STEP, height - 1);
|
|
830
|
+
const elevation = ((row + 0.5) / height - 0.5) * Math.PI;
|
|
831
|
+
for (let gridColumn = 0; gridColumn < cloudColumns; gridColumn++) {
|
|
832
|
+
const longitude = ((gridColumn * CLOUD_STEP + 0.5) / width - 0.5) * Math.PI * 2;
|
|
833
|
+
cloudField[gridRow * cloudColumns + gridColumn] = fractalNoise(
|
|
834
|
+
Math.cos(longitude) * Math.cos(elevation) * clouds.scale,
|
|
835
|
+
Math.sin(elevation) * clouds.scale,
|
|
836
|
+
Math.sin(longitude) * Math.cos(elevation) * clouds.scale,
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
const direction = new THREE.Vector3();
|
|
842
|
+
for (let row = 0; row < height; row++) {
|
|
843
|
+
const elevation = ((row + 0.5) / height - 0.5) * Math.PI;
|
|
844
|
+
const angle = Math.PI / 2 - elevation; // 0 = straight up, PI = straight down
|
|
845
|
+
if (angle <= Math.PI / 2) {
|
|
846
|
+
const c = 1 - angle / (Math.PI / 2);
|
|
847
|
+
band.copy(horizon).lerp(top, THREE.MathUtils.clamp(1 - Math.pow(1 - c, 1 / topCurve), 0, 1));
|
|
848
|
+
} else {
|
|
849
|
+
const c = (angle - Math.PI / 2) / (Math.PI / 2);
|
|
850
|
+
band.copy(horizon).lerp(ground, THREE.MathUtils.clamp(1 - Math.pow(1 - c, 1 / groundCurve), 0, 1));
|
|
851
|
+
}
|
|
852
|
+
// Clouds thin into the horizon: none at it, full a quarter of the way up.
|
|
853
|
+
const cloudRise = clouds && elevation > 0 ? THREE.MathUtils.smoothstep(Math.sin(elevation), 0, 0.25) : 0;
|
|
854
|
+
for (let column = 0; column < width; column++) {
|
|
855
|
+
pixel.copy(band);
|
|
856
|
+
if (clouds && cloudField && cloudRise > 0) {
|
|
857
|
+
// Bilinear between the grid's four nearest samples; the grid wraps in longitude.
|
|
858
|
+
const gx = column / CLOUD_STEP;
|
|
859
|
+
const gy = (row - height / 2) / CLOUD_STEP;
|
|
860
|
+
const x0 = Math.floor(gx);
|
|
861
|
+
const y0 = Math.max(0, Math.floor(gy));
|
|
862
|
+
const fx = gx - x0;
|
|
863
|
+
const fy = gy - Math.floor(gy);
|
|
864
|
+
const at = (x: number, y: number) =>
|
|
865
|
+
cloudField[Math.min(y, cloudRows - 1) * cloudColumns + (x % cloudColumns)]!;
|
|
866
|
+
const noise = THREE.MathUtils.lerp(
|
|
867
|
+
THREE.MathUtils.lerp(at(x0, y0), at(x0 + 1, y0), fx),
|
|
868
|
+
THREE.MathUtils.lerp(at(x0, y0 + 1), at(x0 + 1, y0 + 1), fx),
|
|
869
|
+
fy,
|
|
870
|
+
);
|
|
871
|
+
// A soft edge a noise decile wide: clouds fade into the sky rather than cut out of it.
|
|
872
|
+
const density = THREE.MathUtils.smoothstep(noise, cloudEdge - 0.1, cloudEdge + 0.1);
|
|
873
|
+
pixel.lerp(cloudColour, density * clouds.opacity * cloudRise);
|
|
874
|
+
}
|
|
875
|
+
if (light && sun && elevation > -Math.PI / 2) {
|
|
876
|
+
const longitude = ((column + 0.5) / width - 0.5) * Math.PI * 2;
|
|
877
|
+
direction.set(
|
|
878
|
+
Math.cos(longitude) * Math.cos(elevation),
|
|
879
|
+
Math.sin(elevation),
|
|
880
|
+
Math.sin(longitude) * Math.cos(elevation),
|
|
881
|
+
);
|
|
882
|
+
const toSun = direction.angleTo(sun.direction);
|
|
883
|
+
if (toSun < DISC) pixel.copy(light);
|
|
884
|
+
else if (toSun < GLOW) {
|
|
885
|
+
const c = (toSun - DISC) / (GLOW - DISC);
|
|
886
|
+
pixel.copy(light).lerp(band, THREE.MathUtils.clamp(1 - Math.pow(1 - c, 1 / SUN_CURVE), 0, 1));
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
const at = (row * width + column) * 4;
|
|
890
|
+
data[at] = half(pixel.r);
|
|
891
|
+
data[at + 1] = half(pixel.g);
|
|
892
|
+
data[at + 2] = half(pixel.b);
|
|
893
|
+
data[at + 3] = half(1);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
const background = new THREE.DataTexture(data, width, height, THREE.RGBAFormat, THREE.HalfFloatType);
|
|
897
|
+
background.mapping = THREE.EquirectangularReflectionMapping;
|
|
898
|
+
background.colorSpace = THREE.LinearSRGBColorSpace;
|
|
899
|
+
background.magFilter = THREE.LinearFilter;
|
|
900
|
+
background.minFilter = THREE.LinearFilter;
|
|
901
|
+
background.needsUpdate = true;
|
|
902
|
+
this.pmrem ??= new THREE.PMREMGenerator(renderer);
|
|
903
|
+
const environment = this.pmrem.fromEquirectangular(background);
|
|
904
|
+
this.sky = { key, image: null, background, environment };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
private disposeSky(): void {
|
|
908
|
+
this.sky?.background.dispose();
|
|
909
|
+
// The prefiltered target, not only its texture: its framebuffer is the target's.
|
|
910
|
+
this.sky?.environment.dispose();
|
|
911
|
+
this.sky = null;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** The environment this draw lights by: the studio's own at the preset's strength, the
|
|
915
|
+
* preview sky at its energy, or `null` when the scene's own decides. */
|
|
916
|
+
environment(): { readonly texture: THREE.Texture | null; readonly intensity: number; readonly rotation: number } | null {
|
|
917
|
+
const lighting = this.presentation?.lighting;
|
|
918
|
+
if (!lighting) return null;
|
|
919
|
+
if (this.source === 'studio') return { texture: null, intensity: this.preset?.environmentIntensity ?? 0, rotation: 0 };
|
|
920
|
+
if (this.source === 'preview') {
|
|
921
|
+
return lighting.preview.environment.enabled
|
|
922
|
+
? { texture: this.sky?.environment.texture ?? null, intensity: lighting.preview.environment.energy, rotation: this.turn() }
|
|
923
|
+
: { texture: null, intensity: 0, rotation: 0 };
|
|
924
|
+
}
|
|
925
|
+
return null;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* What this draw shows behind the scene, or `'keep'` for the stage's own backdrop (the look's
|
|
930
|
+
* fill, and a scene's own background as the stage already mirrors it).
|
|
931
|
+
*/
|
|
932
|
+
backdrop():
|
|
933
|
+
| 'keep'
|
|
934
|
+
| {
|
|
935
|
+
readonly value: THREE.Color | THREE.Texture | null;
|
|
936
|
+
readonly blur: number;
|
|
937
|
+
readonly intensity: number;
|
|
938
|
+
readonly rotation: number;
|
|
939
|
+
} {
|
|
940
|
+
const backdrop = this.presentation?.backdrop;
|
|
941
|
+
if (!backdrop || backdrop.source === 'fill' || backdrop.source === 'scene') return 'keep';
|
|
942
|
+
if (backdrop.source === 'transparent') return { value: null, blur: 0, intensity: 1, rotation: 0 };
|
|
943
|
+
if (backdrop.source === 'color') return { value: new THREE.Color(backdrop.color), blur: 0, intensity: 1, rotation: 0 };
|
|
944
|
+
// `environment`: the preview sky drawn behind the scene, at the view's opacity and blur.
|
|
945
|
+
return this.sky
|
|
946
|
+
? { value: this.sky.background, blur: backdrop.blur, intensity: backdrop.opacity, rotation: this.turn() }
|
|
947
|
+
: 'keep';
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Before every draw: the source this draw lights by, given what the scene holds. A view whose
|
|
952
|
+
* lighting is `auto` gives way to the scene's own when the scene has any of its `takeover`
|
|
953
|
+
* (the kit's rule, Godot's preview); without `auto` the view's source stands (Blender, Unity).
|
|
954
|
+
*/
|
|
955
|
+
resolveSource(scene: Readonly<Partial<Record<SceneTakeover, boolean>>>): 'studio' | 'preview' | 'scene' {
|
|
956
|
+
const lighting = this.presentation?.lighting;
|
|
957
|
+
let source: 'studio' | 'preview' | 'scene' = lighting?.source ?? 'studio';
|
|
958
|
+
if (lighting?.auto && source !== 'scene' && lighting.auto.takeover.some((part) => scene[part] === true)) {
|
|
959
|
+
source = 'scene';
|
|
960
|
+
}
|
|
961
|
+
this.source = source;
|
|
962
|
+
this.group.visible = source === 'studio';
|
|
963
|
+
this.previewGroup.visible = source === 'preview';
|
|
964
|
+
return source;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** The rig's objects in its scene, for a stage that moves its editor objects between scenes. */
|
|
968
|
+
roots(): readonly THREE.Object3D[] {
|
|
969
|
+
return [this.group, this.previewGroup, this.floor];
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** Whether the preset's own lights are showing. */
|
|
973
|
+
lightsVisible(): boolean {
|
|
974
|
+
return this.group.visible && this.lights.length > 0;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/** The studio preset the view names. */
|
|
978
|
+
presetId(): string | null {
|
|
979
|
+
return this.preset?.id ?? null;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
/** Before every draw: point the camera-locked lights along the camera. */
|
|
984
|
+
update(camera: THREE.Camera): void {
|
|
985
|
+
if (!this.group.visible) return;
|
|
986
|
+
for (const { light, spec } of this.lights) {
|
|
987
|
+
this.direction.set(...spec.direction).normalize();
|
|
988
|
+
if (spec.space === 'camera') this.direction.applyQuaternion(camera.quaternion);
|
|
989
|
+
light.position.copy(this.direction).multiplyScalar(-LIGHT_DISTANCE);
|
|
990
|
+
light.target.position.set(0, 0, 0);
|
|
991
|
+
light.updateMatrixWorld();
|
|
992
|
+
light.target.updateMatrixWorld();
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
dispose(): void {
|
|
997
|
+
this.disposed = true;
|
|
998
|
+
this.clearLights();
|
|
999
|
+
this.group.removeFromParent();
|
|
1000
|
+
this.previewGroup.removeFromParent();
|
|
1001
|
+
this.floor.removeFromParent();
|
|
1002
|
+
this.floor.geometry.dispose();
|
|
1003
|
+
this.floor.material.dispose();
|
|
1004
|
+
this.sun.dispose();
|
|
1005
|
+
this.disposeSky();
|
|
1006
|
+
this.pmrem?.dispose();
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
private build(preset: StudioPreset): void {
|
|
1010
|
+
this.clearLights();
|
|
1011
|
+
this.preset = preset;
|
|
1012
|
+
this.ambient.color.set(preset.ambient.color);
|
|
1013
|
+
this.ambient.intensity = preset.ambient.intensity;
|
|
1014
|
+
for (const spec of preset.lights) {
|
|
1015
|
+
const light = new THREE.DirectionalLight(spec.color, spec.intensity);
|
|
1016
|
+
light.name = `vgai:studio-light:${preset.id}`;
|
|
1017
|
+
light.castShadow = spec.castShadow === true;
|
|
1018
|
+
light.userData['editorHelper'] = true;
|
|
1019
|
+
this.group.add(light);
|
|
1020
|
+
this.group.add(light.target);
|
|
1021
|
+
this.lights.push({ light, spec });
|
|
1022
|
+
}
|
|
1023
|
+
// World lights never move again; camera lights move every draw.
|
|
1024
|
+
this.direction.set(0, 0, 0);
|
|
1025
|
+
for (const { light, spec } of this.lights) {
|
|
1026
|
+
if (spec.space !== 'world') continue;
|
|
1027
|
+
this.direction.set(...spec.direction).normalize();
|
|
1028
|
+
light.position.copy(this.direction).multiplyScalar(-LIGHT_DISTANCE);
|
|
1029
|
+
light.updateMatrixWorld();
|
|
1030
|
+
light.target.updateMatrixWorld();
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
private clearLights(): void {
|
|
1035
|
+
for (const { light } of this.lights) {
|
|
1036
|
+
light.removeFromParent();
|
|
1037
|
+
light.target.removeFromParent();
|
|
1038
|
+
light.dispose();
|
|
1039
|
+
}
|
|
1040
|
+
this.lights = [];
|
|
1041
|
+
}
|
|
1042
|
+
}
|