@volter/editor-threejs 0.5.189 → 0.5.191

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@volter/editor-threejs",
3
3
  "author": "Volter AI, Inc.",
4
- "version": "0.5.189",
4
+ "version": "0.5.191",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -135,12 +135,12 @@
135
135
  "serving": "./dist-node/serving.mjs"
136
136
  },
137
137
  "dependencies": {
138
- "@volter/editor-project": "0.5.189",
138
+ "@volter/editor-project": "0.5.191",
139
139
  "@sparkjsdev/spark": "^2.1.0",
140
140
  "three.quarks": "^0.16.0",
141
141
  "zod": "^4.3.6",
142
142
  "typescript": "5.9.3",
143
- "@volter/editor-sdk": "0.5.189",
143
+ "@volter/editor-sdk": "0.5.191",
144
144
  "postprocessing": "6.38.3",
145
145
  "quarks.core": "0.16.0",
146
146
  "@fortawesome/free-solid-svg-icons": "^7.2.0"
@@ -0,0 +1,40 @@
1
+ import * as THREE from 'three';
2
+ import {FullScreenQuad} from 'three/addons/postprocessing/Pass.js';
3
+
4
+ let quad: FullScreenQuad | null = null;
5
+ let material: THREE.RawShaderMaterial | null = null;
6
+
7
+ /** The capture renders exactly 2x in each axis. Linear filtering at a pixel's
8
+ * center averages its four scene-linear samples BEFORE the nonlinear display
9
+ * transform. Downsampling display-encoded canvas pixels darkens color edges.
10
+ * The caller owns the returned target; input and associated alpha stay intact. */
11
+ export function resolveSceneLinearSize(
12
+ renderer: THREE.WebGLRenderer,
13
+ input: THREE.WebGLRenderTarget,
14
+ width: number,
15
+ height: number,
16
+ ): THREE.WebGLRenderTarget {
17
+ if (input.width !== width * 2 || input.height !== height * 2)
18
+ throw new Error('Linear capture resolve requires a 2x scene target');
19
+ material ??= new THREE.RawShaderMaterial({
20
+ uniforms: {frame: {value:null}, texel: {value:new THREE.Vector2()}},
21
+ depthTest:false, depthWrite:false, blending:THREE.NoBlending,
22
+ vertexShader:`precision highp float; attribute vec3 position; attribute vec2 uv;
23
+ varying vec2 vUv; void main(){vUv=uv;gl_Position=vec4(position,1.0);}`,
24
+ fragmentShader:`precision highp float; uniform sampler2D frame; uniform vec2 texel; varying vec2 vUv;
25
+ void main(){
26
+ gl_FragColor=(texture2D(frame,vUv+texel*vec2(-.5,-.5))+
27
+ texture2D(frame,vUv+texel*vec2(.5,-.5))+
28
+ texture2D(frame,vUv+texel*vec2(-.5,.5))+
29
+ texture2D(frame,vUv+texel*vec2(.5,.5)))*.25;
30
+ }`,
31
+ });
32
+ quad ??= new FullScreenQuad(material);
33
+ material.uniforms['frame']!.value=input.texture;
34
+ material.uniforms['texel']!.value.set(1/input.width,1/input.height);
35
+ const output=new THREE.WebGLRenderTarget(width,height,{type:THREE.HalfFloatType});
36
+ const previous=renderer.getRenderTarget();
37
+ try { renderer.setRenderTarget(output); quad.render(renderer); return output; }
38
+ catch(error) { output.dispose(); throw error; }
39
+ finally { renderer.setRenderTarget(previous); }
40
+ }
@@ -3,6 +3,8 @@
3
3
  import * as THREE from 'three';
4
4
  import { acquireInspectorPreviewRenderer } from '../viewport/preview-renderer';
5
5
  import { viewportCaptureOutputPass } from './output-pass';
6
+ import type { DocumentDisplayTransform } from '../render/document-display-transform';
7
+ import {resolveSceneLinearSize} from './linear-resolve';
6
8
 
7
9
  export interface SceneCaptureOptions {
8
10
  width: number;
@@ -10,6 +12,8 @@ export interface SceneCaptureOptions {
10
12
  transparent?: boolean;
11
13
  toneMapping?: THREE.ToneMapping;
12
14
  exposure?: number;
15
+ /** Integration-owned GPU display resolve, shared with its live viewport. */
16
+ displayTransform?: DocumentDisplayTransform;
13
17
  /** Caller-owned scene-linear effect. The input carries a depth texture;
14
18
  * the returned target remains caller-owned and must contain half floats. */
15
19
  effect?: {
@@ -69,6 +73,7 @@ function capture(
69
73
  };
70
74
  let hdr: THREE.WebGLRenderTarget | undefined;
71
75
  let display: THREE.WebGLRenderTarget | undefined;
76
+ let displayInput: THREE.WebGLRenderTarget | undefined;
72
77
  let completed = false;
73
78
  try {
74
79
  hdr = new THREE.WebGLRenderTarget(renderWidth, renderHeight, { type: THREE.HalfFloatType });
@@ -92,26 +97,24 @@ function capture(
92
97
  completed = true;
93
98
  return { pixels, width, height };
94
99
  }
95
- display = new THREE.WebGLRenderTarget(renderWidth, renderHeight);
96
- viewportCaptureOutputPass(options.transparent === true).render(
97
- renderer,
98
- display,
99
- resolved,
100
- 0,
101
- false,
102
- );
103
- const pixels = new Uint8Array(renderWidth * renderHeight * 4);
104
- renderer.readRenderTargetPixels(display, 0, 0, renderWidth, renderHeight, pixels);
100
+ display = new THREE.WebGLRenderTarget(options.displayTransform ? width : renderWidth, options.displayTransform ? height : renderHeight);
101
+ if (options.displayTransform) {
102
+ displayInput=resolveSceneLinearSize(renderer,resolved,width,height);
103
+ options.displayTransform.render(renderer, displayInput, display, options.transparent === true);
104
+ }
105
+ else viewportCaptureOutputPass(options.transparent === true).render(renderer, display, resolved, 0, false);
106
+ const pixels = new Uint8Array(display.width * display.height * 4);
107
+ renderer.readRenderTargetPixels(display, 0, 0, display.width, display.height, pixels);
105
108
  const full = document.createElement('canvas');
106
- full.width = renderWidth;
107
- full.height = renderHeight;
109
+ full.width = display.width;
110
+ full.height = display.height;
108
111
  const context = full.getContext('2d');
109
112
  if (!context) throw new Error('Scene capture could not create an image canvas');
110
- const image = context.createImageData(renderWidth, renderHeight);
111
- const stride = renderWidth * 4;
112
- for (let y = 0; y < renderHeight; y++)
113
+ const image = context.createImageData(display.width, display.height);
114
+ const stride = display.width * 4;
115
+ for (let y = 0; y < display.height; y++)
113
116
  image.data.set(
114
- pixels.subarray((renderHeight - 1 - y) * stride, (renderHeight - y) * stride),
117
+ pixels.subarray((display.height - 1 - y) * stride, (display.height - y) * stride),
115
118
  y * stride,
116
119
  );
117
120
  context.putImageData(image, 0, 0);
@@ -134,6 +137,7 @@ function capture(
134
137
  renderer.shadowMap.type = previous.shadowType;
135
138
  renderer.setClearColor(previous.clearColor, previous.clearAlpha);
136
139
  display?.dispose();
140
+ displayInput?.dispose();
137
141
  hdr?.dispose();
138
142
  lease.release({ discard: !completed });
139
143
  }
@@ -154,3 +158,30 @@ export function captureSceneImage(
154
158
  ): string {
155
159
  return capture(scene, camera, options, false);
156
160
  }
161
+
162
+ /** Resolve a compositor/EXR frame through the SAME GPU display pipeline as
163
+ * the document. The input stays scene-linear; only the returned bytes are
164
+ * display encoded. Pixels retain GL's bottom-up ordering. */
165
+ export function resolveSceneLinearDisplay(
166
+ frame: LinearCaptureFrame,
167
+ transform: DocumentDisplayTransform,
168
+ ): Uint8Array {
169
+ const lease = acquireInspectorPreviewRenderer();
170
+ const renderer = lease.renderer;
171
+ const previous = renderer.getRenderTarget();
172
+ const input = new THREE.DataTexture(frame.pixels, frame.width, frame.height, THREE.RGBAFormat, THREE.HalfFloatType);
173
+ input.needsUpdate = true;
174
+ const output = new THREE.WebGLRenderTarget(frame.width, frame.height);
175
+ let completed = false;
176
+ try {
177
+ transform.render(renderer, {texture: input}, output, true);
178
+ const pixels = new Uint8Array(frame.width * frame.height * 4);
179
+ renderer.readRenderTargetPixels(output, 0, 0, frame.width, frame.height, pixels);
180
+ completed = true;
181
+ return pixels;
182
+ } finally {
183
+ renderer.setRenderTarget(previous);
184
+ input.dispose(); output.dispose();
185
+ lease.release({discard: !completed});
186
+ }
187
+ }
@@ -3,6 +3,7 @@ import { resetViewPresentation, setViewPresentation, type ViewportXray } from '@
3
3
  import { invalidateStages } from '@volter/editor-sdk/kit/stage-invalidation';
4
4
  import type { AuthoringAdapter } from '@volter/editor-project/adapter';
5
5
  import { viewportCaptureOutputPass } from '@volter/editor-threejs/capture/output-pass';
6
+ import { resolveSceneLinearSize } from '@volter/editor-threejs/capture/linear-resolve';
6
7
  import { contentWorldBounds } from '@volter/editor-threejs/viewport/content-bounds';
7
8
  import { setUserData } from '@volter/editor-threejs/ecs/user-data';
8
9
  import {
@@ -13,6 +14,7 @@ import {
13
14
  import type { EffectComposer, EffectPass, RenderPass } from 'postprocessing';
14
15
  import * as THREE from 'three';
15
16
  import { DepthDrawMaterials } from '../../render/depth-draw-materials';
17
+ import type { DocumentDisplayTransform } from '../../render/document-display-transform';
16
18
  import { threeObject } from '../../adapter/three-contract';
17
19
  import { axisViewName, cameraPresetDirection, type ModelCameraPreset } from '../asset-workflow/model-inspection';
18
20
  import { activeKeymapNavigation } from '@volter/editor-sdk/kit/keymap-presets';
@@ -127,6 +129,25 @@ export class Object3DDocumentSession {
127
129
  private boneSelectionHighlight: BoneSelectionHighlight | null = null;
128
130
  private boneSelectionSignature = '';
129
131
  private composer: EffectComposer | null = null;
132
+ private displayTransform: DocumentDisplayTransform | null = null;
133
+ private displayTarget: THREE.WebGLRenderTarget | null = null;
134
+
135
+ /** Authoring diagnostics retain their studio display; authored materials
136
+ * and scene-lit views use the integration's color pipeline. */
137
+ setDisplayTransform(transform: DocumentDisplayTransform | null): void {
138
+ if (this.displayTransform === transform) return;
139
+ this.displayTransform = transform;
140
+ if (!transform) {
141
+ this.displayTarget?.dispose();
142
+ this.displayTarget = null;
143
+ }
144
+ invalidateStages();
145
+ }
146
+
147
+ private materialDisplayTransform(): DocumentDisplayTransform | null {
148
+ return this.state.mode === 'rendered' || this.state.mode === 'preview'
149
+ ? this.displayTransform : null;
150
+ }
130
151
  /** One in-flight `import('postprocessing')` at a time — {@link ensureComposer}
131
152
  * is called from every frame. */
132
153
  private composerLoading = false;
@@ -1406,6 +1427,24 @@ export class Object3DDocumentSession {
1406
1427
  renderViewport(deltaSeconds = 0, interactive = false): void {
1407
1428
  this.advanceLook(deltaSeconds);
1408
1429
  this.ensureComposer();
1430
+ const transform = this.materialDisplayTransform();
1431
+ if (transform) {
1432
+ const size = this.renderer.getDrawingBufferSize(new THREE.Vector2());
1433
+ const target = this.displayTarget ??= new THREE.WebGLRenderTarget(size.x, size.y, {
1434
+ type: THREE.HalfFloatType,
1435
+ samples: 4,
1436
+ });
1437
+ target.setSize(size.x, size.y);
1438
+ const previous = this.renderer.getRenderTarget();
1439
+ try {
1440
+ this.renderer.setRenderTarget(target);
1441
+ this.render(camera => this.renderLinearScene(camera, target), interactive);
1442
+ transform.render(this.renderer, target, previous, false);
1443
+ } finally {
1444
+ this.renderer.setRenderTarget(previous);
1445
+ }
1446
+ return;
1447
+ }
1409
1448
  this.render((camera) => {
1410
1449
  const composer = this.composer;
1411
1450
  if (!composer) {
@@ -1491,7 +1530,9 @@ export class Object3DDocumentSession {
1491
1530
  const sceneTarget = new THREE.WebGLRenderTarget(renderWidth, renderHeight, {
1492
1531
  type: THREE.HalfFloatType,
1493
1532
  });
1494
- const target = new THREE.WebGLRenderTarget(renderWidth, renderHeight);
1533
+ const transform = this.materialDisplayTransform();
1534
+ const target = new THREE.WebGLRenderTarget(transform ? outputWidth : renderWidth, transform ? outputHeight : renderHeight);
1535
+ let resolved: THREE.WebGLRenderTarget | null = null;
1495
1536
  const previousTarget = this.renderer.getRenderTarget();
1496
1537
  const previousPixelRatio = this.renderer.getPixelRatio();
1497
1538
  const perspective = this.viewport.camera;
@@ -1510,25 +1551,24 @@ export class Object3DDocumentSession {
1510
1551
  (camera) => this.renderSolidForCapture(camera, sceneTarget, renderWidth, renderHeight),
1511
1552
  typeof size !== 'number' && size.transparent === true,
1512
1553
  );
1513
- viewportCaptureOutputPass(typeof size !== 'number' && size.transparent === true).render(
1514
- this.renderer,
1515
- target,
1516
- sceneTarget,
1517
- 0,
1518
- false,
1519
- );
1554
+ const straightAlpha = typeof size !== 'number' && size.transparent === true;
1555
+ if (transform) {
1556
+ resolved = resolveSceneLinearSize(this.renderer, sceneTarget, outputWidth, outputHeight);
1557
+ transform.render(this.renderer, resolved, target, straightAlpha);
1558
+ }
1559
+ else viewportCaptureOutputPass(straightAlpha).render(this.renderer, target, sceneTarget, 0, false);
1520
1560
 
1521
- const rowBytes = renderWidth * 4;
1522
- const pixels = new Uint8Array(rowBytes * renderHeight);
1523
- this.renderer.readRenderTargetPixels(target, 0, 0, renderWidth, renderHeight, pixels);
1561
+ const rowBytes = target.width * 4;
1562
+ const pixels = new Uint8Array(rowBytes * target.height);
1563
+ this.renderer.readRenderTargetPixels(target, 0, 0, target.width, target.height, pixels);
1524
1564
  const fullCanvas = document.createElement('canvas');
1525
- fullCanvas.width = renderWidth;
1526
- fullCanvas.height = renderHeight;
1565
+ fullCanvas.width = target.width;
1566
+ fullCanvas.height = target.height;
1527
1567
  const fullContext = fullCanvas.getContext('2d');
1528
1568
  if (!fullContext) return null;
1529
- const imageData = fullContext.createImageData(renderWidth, renderHeight);
1530
- for (let y = 0; y < renderHeight; y++) {
1531
- const sourceRow = (renderHeight - 1 - y) * rowBytes;
1569
+ const imageData = fullContext.createImageData(target.width, target.height);
1570
+ for (let y = 0; y < target.height; y++) {
1571
+ const sourceRow = (target.height - 1 - y) * rowBytes;
1532
1572
  const destinationRow = y * rowBytes;
1533
1573
  imageData.data.set(pixels.subarray(sourceRow, sourceRow + rowBytes), destinationRow);
1534
1574
  }
@@ -1555,6 +1595,7 @@ export class Object3DDocumentSession {
1555
1595
  // selection and 4 after a repaint that resized.
1556
1596
  this.composer?.setSize(this.renderWidth, this.renderHeight, false);
1557
1597
  target.dispose();
1598
+ resolved?.dispose();
1558
1599
  sceneTarget.dispose();
1559
1600
  // AND THE VIEW IS DRAWN AGAIN. Sizing the composer clears what the view showed, and a stage
1560
1601
  // that draws on demand had nothing asking it to: on the browser-substrate page every photograph
@@ -1583,6 +1624,9 @@ export class Object3DDocumentSession {
1583
1624
  if (this.selectionOutline)
1584
1625
  releaseThreeSelectionOutline(this.renderer, this.selectionOutline, this.selectionOutlinePass);
1585
1626
  this.composer?.dispose();
1627
+ this.displayTarget?.dispose();
1628
+ this.displayTarget = null;
1629
+ this.displayTransform = null;
1586
1630
  this.sceneRenderPass = null;
1587
1631
  this.selectionOutline = null;
1588
1632
  this.selectionOutlinePass = null;
@@ -1673,7 +1717,9 @@ export class Object3DDocumentSession {
1673
1717
  this.composerLoading = false;
1674
1718
  if (this.disposed || this.composer) return;
1675
1719
  const camera = this.camera();
1676
- const composer = new postprocessing.EffectComposer(this.renderer);
1720
+ const composer = new postprocessing.EffectComposer(this.renderer, {
1721
+ frameBufferType: THREE.HalfFloatType,
1722
+ });
1677
1723
  this.sceneRenderPass = new postprocessing.RenderPass(this.scene, camera);
1678
1724
  composer.addPass(this.sceneRenderPass);
1679
1725
  this.selectionOutline = acquireThreeSelectionOutline(
@@ -1739,14 +1785,32 @@ export class Object3DDocumentSession {
1739
1785
  }
1740
1786
  const previousWidth = this.renderWidth;
1741
1787
  const previousHeight = this.renderHeight;
1788
+ try {
1789
+ composer.setSize(width, height, false);
1790
+ this.renderLinearScene(camera, target);
1791
+ } finally {
1792
+ composer.setSize(previousWidth, previousHeight, false);
1793
+ this.syncComposerOutput();
1794
+ this.renderer.setRenderTarget(target);
1795
+ }
1796
+ }
1797
+
1798
+ /** Live HDR draw at the renderer's CURRENT size. Resizing the composer with
1799
+ * device pixels here would resize the canvas and multiply HiDPI twice on
1800
+ * every frame. Only an offscreen photograph changes its sizing. */
1801
+ private renderLinearScene(camera: THREE.Camera, target: THREE.WebGLRenderTarget): void {
1802
+ const composer = this.composer;
1803
+ const outlined = (this.selectionOutline?.selection.size ?? 0) > 0;
1804
+ if (!composer || !outlined || !this.sceneRenderPass || !this.selectionOutlinePass) {
1805
+ this.renderer.render(this.scene, camera);
1806
+ return;
1807
+ }
1742
1808
  try {
1743
1809
  this.sceneRenderPass.renderToScreen = false;
1744
1810
  this.selectionOutlinePass.renderToScreen = false;
1745
- composer.setSize(width, height, false);
1746
1811
  this.sceneRenderPass.render(this.renderer, composer.inputBuffer, composer.outputBuffer, 0);
1747
1812
  this.selectionOutlinePass.render(this.renderer, composer.inputBuffer, target, 0);
1748
1813
  } finally {
1749
- composer.setSize(previousWidth, previousHeight, false);
1750
1814
  this.syncComposerOutput();
1751
1815
  this.renderer.setRenderTarget(target);
1752
1816
  }
@@ -0,0 +1,14 @@
1
+ import type * as THREE from 'three';
2
+
3
+ /** An integration's scene-linear to display resolve. The stage owns its HDR
4
+ * input; the integration owns the transform and its GPU resources. Output is
5
+ * already display encoded, with associated alpha for the canvas and straight
6
+ * alpha for a transparent photograph. No readback belongs on this path. */
7
+ export interface DocumentDisplayTransform {
8
+ render(
9
+ renderer: THREE.WebGLRenderer,
10
+ input: Pick<THREE.WebGLRenderTarget, 'texture'>,
11
+ output: THREE.WebGLRenderTarget | null,
12
+ straightAlpha: boolean,
13
+ ): void;
14
+ }