@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.
Files changed (99) hide show
  1. package/contributions/animation-timeline.utility.tsx +44 -0
  2. package/contributions/three-integration.service.ts +13 -0
  3. package/package.json +96 -6
  4. package/src/adapter/renderer-config.ts +3 -4
  5. package/src/adapter/three-contract.ts +72 -0
  6. package/src/ecs/object-marks.ts +1 -1
  7. package/src/ecs/user-data.ts +0 -9
  8. package/src/host-hierarchy-objects.ts +31 -0
  9. package/src/kit/animation/three-clips-subject.ts +190 -0
  10. package/src/kit/asset-compare.ts +294 -0
  11. package/src/kit/asset-preview-command.ts +265 -0
  12. package/src/kit/asset-preview-framing.ts +357 -0
  13. package/src/kit/asset-preview.ts +2802 -0
  14. package/src/kit/asset-workflow/model-inspection.ts +830 -0
  15. package/src/kit/authoring/component-instance-root.ts +171 -0
  16. package/src/kit/authoring/design-time-settle.ts +343 -0
  17. package/src/kit/authoring/live-object-transform.ts +62 -0
  18. package/src/kit/authoring/object3d-document-session-registry.ts +154 -0
  19. package/src/kit/authoring/object3d-document-session.ts +1965 -0
  20. package/src/kit/authoring/object3d-gesture-controller.ts +113 -0
  21. package/src/kit/authoring/quarks-particle-systems.ts +19 -0
  22. package/src/kit/authoring/shell-viewport-policy.ts +48 -0
  23. package/src/kit/authoring/source-object3d-authoring-adapter.ts +526 -0
  24. package/src/kit/authoring/three-projection-core.ts +226 -0
  25. package/src/kit/authoring/viewport-pick-context.ts +39 -0
  26. package/src/kit/authoring/viewport-raycast.ts +240 -0
  27. package/src/kit/authoring/world-hidden-viewport.ts +95 -0
  28. package/src/kit/camera-authoring.ts +175 -0
  29. package/src/kit/components/CameraInfo.tsx +56 -0
  30. package/src/kit/components/InspectorObjectPreview.tsx +57 -0
  31. package/src/kit/components/Object3DDocumentToolbar.tsx +549 -0
  32. package/src/kit/components/Object3DDocumentViewport.tsx +58 -0
  33. package/src/kit/components/StageHost.tsx +2547 -0
  34. package/src/kit/components/StageOverlays.tsx +21 -0
  35. package/src/kit/components/StatsOverlay.tsx +78 -0
  36. package/src/kit/components/ToolObject3DPreview.tsx +39 -0
  37. package/src/kit/components/ViewportFurniture.tsx +655 -0
  38. package/src/kit/components/ViewportOverlay.tsx +215 -0
  39. package/src/kit/components/ViewportShadingMenu.tsx +340 -0
  40. package/src/kit/components/ViewportViewMenu.tsx +155 -0
  41. package/src/kit/components/asset-viewers/EntityModelDocument.tsx +121 -0
  42. package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
  43. package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +395 -0
  44. package/src/kit/components/asset-viewers/LutAssetDocument.tsx +444 -0
  45. package/src/kit/components/asset-viewers/ModelAssetDocument.tsx +105 -0
  46. package/src/kit/components/asset-viewers/Object3DPreview.tsx +356 -0
  47. package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +527 -0
  48. package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
  49. package/src/kit/components/asset-viewers/three-asset-viewers.tsx +132 -0
  50. package/src/kit/components/object3d-contribution-surfaces.tsx +33 -0
  51. package/src/kit/components/stage-keyboard.tsx +40 -0
  52. package/src/kit/components/stage-overlay-set.tsx +105 -0
  53. package/src/kit/components/stage-presence-markers.ts +482 -0
  54. package/src/kit/components/stage-transform-chrome.ts +30 -0
  55. package/src/kit/components/stage-transform-tools.tsx +73 -0
  56. package/src/kit/components/stage-view-name.ts +30 -0
  57. package/src/kit/components/standard-viewport-dressing.ts +1042 -0
  58. package/src/kit/components/world-root-binding.ts +64 -0
  59. package/src/kit/constraint-helper.ts +338 -0
  60. package/src/kit/editor-shell-store.ts +814 -0
  61. package/src/kit/editor-viewport.ts +6621 -0
  62. package/src/kit/entity-lod.ts +31 -0
  63. package/src/kit/entity-object.ts +92 -0
  64. package/src/kit/hierarchy-mark-reader.ts +74 -0
  65. package/src/kit/instanced-presentation.ts +164 -0
  66. package/src/kit/live-module-source.ts +230 -0
  67. package/src/kit/model-thumbnail.ts +539 -0
  68. package/src/kit/play-camera-flight.ts +300 -0
  69. package/src/kit/projection/three.ts +898 -0
  70. package/src/kit/reflection-probe-helper.ts +142 -0
  71. package/src/kit/scene-document-viewport.ts +51 -0
  72. package/src/kit/scene-framing.ts +315 -0
  73. package/src/kit/scene-view-fog.ts +89 -0
  74. package/src/kit/spatial-handle-visuals.ts +332 -0
  75. package/src/kit/stories/three-story-model.ts +66 -0
  76. package/src/kit/three-canvas-render.ts +44 -0
  77. package/src/kit/three-hierarchy-row-media.ts +26 -0
  78. package/src/kit/three-inspection-media.ts +73 -0
  79. package/src/kit/three-integration.ts +86 -0
  80. package/src/kit/three-state.ts +33 -0
  81. package/src/kit/three-viewport/bone-selection-highlight.ts +119 -0
  82. package/src/kit/three-viewport/camera-fit.ts +41 -0
  83. package/src/kit/three-viewport/interactive-renderer.ts +132 -0
  84. package/src/kit/three-viewport/selection-brackets.ts +355 -0
  85. package/src/kit/three-viewport/selection-outline.ts +333 -0
  86. package/src/kit/three-viewport/skeleton-helper.ts +61 -0
  87. package/src/kit/three-viewport/source-color.ts +197 -0
  88. package/src/kit/three-viewport/studio-environment.ts +96 -0
  89. package/src/kit/trigger-volume-helper.ts +116 -0
  90. package/src/kit/viewport-actions.ts +128 -0
  91. package/src/kit/viewport-authoring-policy.ts +154 -0
  92. package/src/kit/viewport-commands.ts +318 -0
  93. package/src/kit/viewport-hotkeys.ts +119 -0
  94. package/src/kit/viewport-shading-boundary.ts +12 -0
  95. package/src/kit/viewport-status-facet.ts +53 -0
  96. package/src/object3d-contributions.ts +494 -0
  97. package/src/render/viewport-shading.ts +6 -2
  98. package/src/viewport-api.ts +92 -0
  99. 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
+ }