rayzee 8.8.2 → 9.1.0

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 (104) hide show
  1. package/README.md +210 -20
  2. package/dist/{NeuralRetouch-CYOJ21Wn.js → NeuralRetouch-BRquqfmZ.js} +1 -1
  3. package/dist/{NeuralSuperRes-Dc6jRYke.js → NeuralSuperRes-CbNvQjCw.js} +5 -2
  4. package/dist/{ToneMapGPU-BVE852iN.js → ToneMapGPU-DpUZDMbQ.js} +2 -1
  5. package/dist/rayzee.es.js +10795 -5324
  6. package/dist/rayzee.umd.js +279 -268
  7. package/package.json +1 -1
  8. package/src/AssetConfig.js +4 -0
  9. package/src/EngineDefaults.js +60 -76
  10. package/src/EngineEvents.js +7 -0
  11. package/src/EngineIssues.js +6 -0
  12. package/src/Headless.js +3 -1
  13. package/src/Passes/AIUpscaler.js +2 -1
  14. package/src/Passes/OIDNDenoiser.js +3 -6
  15. package/src/Passes/OIDNTemporalHistory.js +25 -7
  16. package/src/PathTracerApp.js +313 -25
  17. package/src/Processor/ArchiveCache.js +120 -0
  18. package/src/Processor/ArchiveReader.js +179 -32
  19. package/src/Processor/AssetLoader.js +523 -76
  20. package/src/Processor/AtmosphereModel.js +465 -0
  21. package/src/Processor/BVHBuilder.js +4 -1
  22. package/src/Processor/BVHLeafFold.js +100 -0
  23. package/src/Processor/BVHRefitter.js +98 -214
  24. package/src/Processor/ChunkedRecords.js +189 -4
  25. package/src/Processor/EmissiveTriangleBuilder.js +8 -0
  26. package/src/Processor/GeometryExtractor.js +238 -40
  27. package/src/Processor/InstanceTable.js +55 -37
  28. package/src/Processor/PBRT/PBRTCurves.js +104 -27
  29. package/src/Processor/PBRT/PBRTParser.js +132 -22
  30. package/src/Processor/PBRT/PBRTSceneBuilder.js +210 -55
  31. package/src/Processor/PBRT/buffers.js +27 -0
  32. package/src/Processor/PBRT/index.js +55 -15
  33. package/src/Processor/PackedRayBuffer.js +10 -13
  34. package/src/Processor/ParallelBVHBuilder.js +36 -15
  35. package/src/Processor/PhysicalSky.js +308 -0
  36. package/src/Processor/QueueManager.js +1 -0
  37. package/src/Processor/SceneProcessor.js +1132 -181
  38. package/src/Processor/SplitBLAS.js +249 -0
  39. package/src/Processor/SunPosition.js +41 -0
  40. package/src/Processor/TextureCreator.js +21 -0
  41. package/src/Processor/Workers/AIUpscalerWorker.js +79 -52
  42. package/src/Processor/Workers/BVHWorker.js +17 -13
  43. package/src/Processor/ZipReader.js +207 -0
  44. package/src/RenderSettings.js +49 -8
  45. package/src/SceneState/SceneState.js +367 -0
  46. package/src/SceneState/portable.js +52 -0
  47. package/src/Stages/MotionVector.js +8 -42
  48. package/src/Stages/NRD.js +30 -21
  49. package/src/Stages/NormalDepth.js +12 -23
  50. package/src/Stages/PathTracer.js +114 -6
  51. package/src/Stages/PathTracerStage.js +251 -104
  52. package/src/Storage/BLASCache.js +190 -0
  53. package/src/Storage/CDFCache.js +77 -0
  54. package/src/Storage/DownloadCache.js +266 -0
  55. package/src/Storage/GeometrySpill.js +217 -0
  56. package/src/Storage/SceneGraphCodec.js +503 -0
  57. package/src/Storage/SpillStore.js +112 -0
  58. package/src/Storage/StorageManager.js +934 -0
  59. package/src/Storage/StorageOps.js +137 -0
  60. package/src/Storage/StorageWorker.js +37 -0
  61. package/src/Storage/events.js +29 -0
  62. package/src/Storage/identity.js +77 -0
  63. package/src/Storage/index.js +9 -0
  64. package/src/Storage/inlineTransport.js +24 -0
  65. package/src/Storage/locks.js +99 -0
  66. package/src/Storage/openStorage.js +87 -0
  67. package/src/Storage/sceneCachePolicy.js +12 -0
  68. package/src/Storage/shared.js +9 -0
  69. package/src/Storage/transport.js +62 -0
  70. package/src/TSL/Atmosphere.js +684 -0
  71. package/src/TSL/BVHTraversal.js +288 -239
  72. package/src/TSL/CameraRay.js +99 -14
  73. package/src/TSL/Common.js +11 -2
  74. package/src/TSL/DebugKernel.js +2 -2
  75. package/src/TSL/Displacement.js +6 -6
  76. package/src/TSL/EmissiveSampling.js +5 -5
  77. package/src/TSL/EnvironmentCDF.js +176 -0
  78. package/src/TSL/GenerateKernel.js +5 -5
  79. package/src/TSL/HitFacet.js +4 -4
  80. package/src/TSL/LightsDirect.js +8 -8
  81. package/src/TSL/LightsSampling.js +55 -37
  82. package/src/TSL/Random.js +1 -0
  83. package/src/TSL/ShadeKernel.js +75 -19
  84. package/src/TSL/Sun.js +40 -0
  85. package/src/TSL/TextureSampling.js +6 -7
  86. package/src/index.js +22 -0
  87. package/src/managers/CameraManager.js +412 -56
  88. package/src/managers/DenoisingManager.js +4 -3
  89. package/src/managers/EnvironmentManager.js +263 -33
  90. package/src/managers/GoboManager.js +19 -8
  91. package/src/managers/IESManager.js +3 -3
  92. package/src/managers/InteractionManager.js +30 -2
  93. package/src/managers/LightManager.js +98 -5
  94. package/src/managers/MaterialDataManager.js +22 -2
  95. package/src/managers/TransformManager.js +26 -0
  96. package/src/managers/UniformManager.js +11 -7
  97. package/src/managers/VideoRenderManager.js +38 -17
  98. package/src/managers/ViewCamera.js +77 -0
  99. package/src/managers/WalkControls.js +262 -0
  100. package/src/managers/helpers/OutlineHelper.js +22 -2
  101. package/src/managers/timeline/CameraTrack.js +235 -0
  102. package/src/managers/timeline/TimelineManager.js +146 -0
  103. package/src/version.js +3 -0
  104. package/src/Processor/ProceduralSky.js +0 -291
@@ -2,6 +2,13 @@ import {
2
2
  EventDispatcher, DirectionalLight, PointLight, SpotLight, RectAreaLight,
3
3
  Object3D, MathUtils
4
4
  } from 'three';
5
+ import { toPortable, fromPortable } from '../SceneState/portable.js';
6
+
7
+ // Set on lights a host added, which live in the light scene alone: a rebuild re-transfers the
8
+ // model's lights and keeps these.
9
+ const USER_LIGHT = '__rayzeeUserLight';
10
+
11
+ const LIGHT_TYPES = { DirectionalLight, PointLight, SpotLight, RectAreaLight };
5
12
 
6
13
  /**
7
14
  * Manages scene lights: add, remove, transfer from mesh scene to WebGPU
@@ -84,6 +91,8 @@ export class LightManager extends EventDispatcher {
84
91
 
85
92
  }
86
93
 
94
+ light.userData[ USER_LIGHT ] = true;
95
+
87
96
  // Blender-style emission controls common to every light type.
88
97
  light.userData.temperature = 6500;
89
98
  light.userData.useTemperature = false;
@@ -158,10 +167,11 @@ export class LightManager extends EventDispatcher {
158
167
  * Clones lights from the mesh scene into the WebGPU light scene,
159
168
  * then updates GPU uniform buffers.
160
169
  * @param {import('three').Scene} meshScene
170
+ * @param {{keepUserLights?: boolean}} [options] - keep the lights a host added (an incremental rebuild)
161
171
  */
162
- transferSceneLights( meshScene ) {
172
+ transferSceneLights( meshScene, { keepUserLights = false } = {} ) {
163
173
 
164
- this._removeAllLights();
174
+ this._removeAllLights( keepUserLights ? light => ! light.userData?.[ USER_LIGHT ] : undefined );
165
175
 
166
176
  const sourceLights = meshScene.getObjectsByProperty( 'isLight', true );
167
177
 
@@ -228,6 +238,89 @@ export class LightManager extends EventDispatcher {
228
238
 
229
239
  }
230
240
 
241
+ /**
242
+ * Every light as plain data, for a saved session: the model's (with any edits) and the host's.
243
+ * @returns {Object[]}
244
+ */
245
+ serialize() {
246
+
247
+ return this.scene.getObjectsByProperty( 'isLight', true ).map( light => {
248
+
249
+ const record = {
250
+ type: light.type,
251
+ name: light.name,
252
+ visible: light.visible,
253
+ color: [ light.color.r, light.color.g, light.color.b ],
254
+ intensity: light.intensity,
255
+ position: light.position.toArray(),
256
+ quaternion: light.quaternion.toArray(),
257
+ scale: light.scale.toArray(),
258
+ userData: toPortable( light.userData ) ?? {},
259
+ };
260
+
261
+ if ( light.isSpotLight ) Object.assign( record, { angle: light.angle, penumbra: light.penumbra, distance: light.distance, decay: light.decay } );
262
+ if ( light.isPointLight ) Object.assign( record, { distance: light.distance, decay: light.decay } );
263
+ if ( light.isRectAreaLight ) Object.assign( record, { width: light.width, height: light.height } );
264
+ if ( light.target && ( light.isSpotLight || light.isDirectionalLight ) ) record.target = light.target.position.toArray();
265
+ return record;
266
+
267
+ } );
268
+
269
+ }
270
+
271
+ /**
272
+ * Replaces every light with the ones {@link serialize} described.
273
+ * @param {Object[]} records
274
+ * @returns {import('three').Light[]} the new lights, in record order (unknown types skipped)
275
+ */
276
+ restore( records ) {
277
+
278
+ this.sceneHelpers?.clear();
279
+ this._removeAllLights();
280
+
281
+ const lights = [];
282
+ for ( const record of records ?? [] ) {
283
+
284
+ const Type = LIGHT_TYPES[ record.type ];
285
+ if ( ! Type ) continue;
286
+
287
+ const light = new Type();
288
+ light.name = record.name ?? '';
289
+ light.visible = record.visible !== false;
290
+ light.color.setRGB( ...record.color );
291
+ light.intensity = record.intensity;
292
+ light.position.fromArray( record.position );
293
+ light.quaternion.fromArray( record.quaternion );
294
+ light.scale.fromArray( record.scale ?? [ 1, 1, 1 ] );
295
+ light.userData = fromPortable( record.userData ) ?? {};
296
+
297
+ for ( const key of [ 'angle', 'penumbra', 'distance', 'decay', 'width', 'height' ] ) {
298
+
299
+ if ( typeof record[ key ] === 'number' ) light[ key ] = record[ key ];
300
+
301
+ }
302
+
303
+ if ( record.target ) {
304
+
305
+ const target = new Object3D();
306
+ target.position.fromArray( record.target );
307
+ this.scene.add( target );
308
+ light.target = target;
309
+
310
+ }
311
+
312
+ this.scene.add( light );
313
+ lights.push( light );
314
+
315
+ }
316
+
317
+ this.updateLights();
318
+ this._syncHelpers();
319
+ this._onReset?.();
320
+ return lights;
321
+
322
+ }
323
+
231
324
  // ── Aliases (match Sub-API surface for zero-churn migration) ──
232
325
 
233
326
  /** @see addLight */
@@ -295,10 +388,10 @@ export class LightManager extends EventDispatcher {
295
388
 
296
389
  // ── Private ───────────────────────────────────────────────────
297
390
 
298
- /** Syncs helpers in sceneHelpers with current scene lights. */
299
- _removeAllLights() {
391
+ /** @param {function(import('three').Light): boolean} [which] - all lights when omitted */
392
+ _removeAllLights( which ) {
300
393
 
301
- this.scene.getObjectsByProperty( 'isLight', true ).forEach( light => {
394
+ this.scene.getObjectsByProperty( 'isLight', true ).filter( light => ! which || which( light ) ).forEach( light => {
302
395
 
303
396
  this._onLightRemoved?.( light );
304
397
  if ( light.target ) this.scene.remove( light.target );
@@ -17,6 +17,7 @@ import { packMaterial, UNIT_RANGE_PROPERTIES, clampUnit } from '../Processor/Mat
17
17
  import { resolveMaterialTextures, MATERIAL_VALUE_SOURCE } from '../Processor/GeometryExtractor.js';
18
18
  import { convertLinearTriple, convertLinearTriples, getWorkingMatrixSpace } from '../Color/WorkingMatrix.js';
19
19
  import { createLogger, fmt } from '../utils/Logger.js';
20
+ import { toPortable } from '../SceneState/portable.js';
20
21
 
21
22
  /** Material buffers already moved into the working space, so a re-init cannot convert twice. */
22
23
  const convertedBuffers = new WeakSet();
@@ -30,6 +31,9 @@ const TRI_FLAGS_OFFSET = T.MATERIAL_FLAGS_OFFSET;
30
31
  // Material properties that affect the shadow-ray opaque-blocker flag.
31
32
  const BLOCKER_PROPS = new Set( [ 'transmission', 'transparent', 'opacity', 'alphaMode' ] );
32
33
 
34
+ /** Material properties whose edit rewrites triangle records (side, shadow blocker, emitters). */
35
+ export const TRIANGLE_PATCH_PROPERTIES = new Set( [ 'side', ...BLOCKER_PROPS, 'emissive', 'emissiveIntensity' ] );
36
+
33
37
  // Map slot → sRGB pool (true) or linear pool.
34
38
  const TEXTURE_POOLS = [
35
39
  [ 'map', true ], [ 'emissiveMap', true ], [ 'sheenColorMap', true ], [ 'specularColorMap', true ],
@@ -98,7 +102,7 @@ export class MaterialDataManager {
98
102
  */
99
103
  this.callbacks = {};
100
104
 
101
- // Per material index: createMaterialObject's sources, and properties a host set since.
105
+ // Per material index: createMaterialObject's sources, and properties a host set since (name → portable value).
102
106
  this._sources = [];
103
107
  this._hostSet = [];
104
108
 
@@ -310,6 +314,22 @@ export class MaterialDataManager {
310
314
 
311
315
  }
312
316
 
317
+ /**
318
+ * Every property a host set, per material, as portable values (see SceneState/portable.js).
319
+ * @returns {Array<{index: number, props: Object<string, *>}>}
320
+ */
321
+ serializeHostEdits() {
322
+
323
+ const out = [];
324
+ this._hostSet.forEach( ( props, index ) => {
325
+
326
+ if ( props?.size ) out.push( { index, props: Object.fromEntries( props ) } );
327
+
328
+ } );
329
+ return out;
330
+
331
+ }
332
+
313
333
  /**
314
334
  * Update a single material property in the storage buffer.
315
335
  * @param {number} materialIndex
@@ -481,7 +501,7 @@ export class MaterialDataManager {
481
501
  }
482
502
 
483
503
  this.materialStorageAttr.needsUpdate = true;
484
- ( this._hostSet[ materialIndex ] ??= new Set() ).add( property );
504
+ ( this._hostSet[ materialIndex ] ??= new Map() ).set( property, toPortable( value ) );
485
505
 
486
506
  // Recompute triangle-data opaque-blocker flag when any input to it changes.
487
507
  if ( BLOCKER_PROPS.has( property ) ) {
@@ -14,6 +14,13 @@ import { EngineEvents } from '../EngineEvents.js';
14
14
  // does not mutate its arguments, so this can be a shared constant.
15
15
  const FORWARD = new Vector3( 0, 0, - 1 );
16
16
 
17
+ const rootScene = object => {
18
+
19
+ while ( object.parent ) object = object.parent;
20
+ return object.isScene ? object : null;
21
+
22
+ };
23
+
17
24
  export class TransformManager {
18
25
 
19
26
  constructor( { camera, canvas, orbitControls, app } ) {
@@ -33,6 +40,8 @@ export class TransformManager {
33
40
  this._meshes = null;
34
41
  this._refitInFlight = false;
35
42
  this._baselineComputed = false;
43
+ // Objects the gizmo moved, so a saved session can put them back.
44
+ this._moved = new Set();
36
45
 
37
46
  // Light transform state — spot/directional lights aim via a separate
38
47
  // `.target` Object3D rather than their own rotation (see attach()).
@@ -56,6 +65,21 @@ export class TransformManager {
56
65
  setMeshData( meshes ) {
57
66
 
58
67
  this._meshes = meshes;
68
+ for ( const object of this._moved ) if ( ! rootScene( object ) ) this._moved.delete( object );
69
+
70
+ }
71
+
72
+ /** Objects the gizmo has moved that are still in the scene (lights are saved with the lights). */
73
+ get movedObjects() {
74
+
75
+ return [ ...this._moved ].filter( object => rootScene( object ) );
76
+
77
+ }
78
+
79
+ /** Counts an object as moved, as a gizmo drag would — for a restored session. */
80
+ noteMoved( object ) {
81
+
82
+ if ( object && ! object.isLight ) this._moved.add( object );
59
83
 
60
84
  }
61
85
 
@@ -335,6 +359,7 @@ export class TransformManager {
335
359
  // instead costs a pass over every vertex, and corrupts any other object sharing
336
360
  // this geometry.
337
361
  this._app.updateMeshTransforms( affectedIndices );
362
+ this._moved.add( this._attached );
338
363
 
339
364
  } catch ( err ) {
340
365
 
@@ -393,6 +418,7 @@ export class TransformManager {
393
418
  this._controls.dispose();
394
419
 
395
420
  this._meshes = null;
421
+ this._moved.clear();
396
422
  this._baselineComputed = false;
397
423
  this._tempForward = null;
398
424
  this._lightTargetDistance = null;
@@ -6,9 +6,9 @@
6
6
  */
7
7
 
8
8
  import { uniform, uniformArray } from 'three/tsl';
9
- import { Vector2, Matrix4, Vector3, Color, MathUtils } from 'three';
9
+ import { Vector2, Matrix4, Vector3, Vector4, Color, MathUtils } from 'three';
10
10
  import { samplingTechniqueUniform } from '../TSL/Random.js';
11
- import { ENGINE_DEFAULTS as DEFAULT_STATE } from '../EngineDefaults.js';
11
+ import { ENGINE_DEFAULTS as DEFAULT_STATE, CAMERA_PROJECTION_IDS } from '../EngineDefaults.js';
12
12
 
13
13
  /**
14
14
  * Map of uniform names to their WGSL shader names (where different).
@@ -249,9 +249,11 @@ export class UniformManager {
249
249
  ub( 'enableGroundCatcher', DEFAULT_STATE.enableGroundCatcher );
250
250
  u( 'groundCatcherHeight', DEFAULT_STATE.groundCatcherHeight, 'float' );
251
251
 
252
- // Sun parameters
252
+ // The physical sky's sun (TSL/Sun.js): world direction, disc-average radiance, and
253
+ // ( cos half-angle, solid angle, 1 / sin² half-angle, horizon dip sin )
253
254
  u( 'sunDirection', new Vector3( 0, 1, 0 ), 'vec3' );
254
- u( 'sunAngularSize', 0.0087, 'float' );
255
+ u( 'sunRadiance', new Vector3(), 'vec3' );
256
+ u( 'sunParams', new Vector4( 1, 0, 0, 0 ), 'vec4' );
255
257
  ub( 'hasSun', false );
256
258
 
257
259
  // Lighting
@@ -278,9 +280,9 @@ export class UniformManager {
278
280
  u( 'cameraViewMatrix', new Matrix4(), 'mat4' );
279
281
  u( 'cameraProjectionMatrix', new Matrix4(), 'mat4' );
280
282
 
281
- // Projection: 0 = pinhole, 1 = equirectangular panorama. Ranges are radians (UI carries degrees).
283
+ // Projection: CAMERA_PROJECTION_IDS. Ranges are radians (UI carries degrees).
282
284
  const radRange = ( [ min, max ] ) => new Vector2( min, max ).multiplyScalar( MathUtils.DEG2RAD );
283
- u( 'cameraProjection', DEFAULT_STATE.cameraProjection === 'equirectangular' ? 1 : 0, 'int' );
285
+ u( 'cameraProjection', CAMERA_PROJECTION_IDS[ DEFAULT_STATE.cameraProjection ], 'int' );
284
286
  u( 'panoLonRange', radRange( DEFAULT_STATE.panoramaLonRange ), 'vec2' );
285
287
  u( 'panoLatRange', radRange( DEFAULT_STATE.panoramaLatRange ), 'vec2' );
286
288
  ub( 'panoLevelHorizon', DEFAULT_STATE.panoramaLevelHorizon );
@@ -292,7 +294,9 @@ export class UniformManager {
292
294
  u( 'aperture', DEFAULT_STATE.aperture, 'float' );
293
295
  u( 'apertureScale', 1.0, 'float' );
294
296
  u( 'anamorphicRatio', DEFAULT_STATE.anamorphicRatio ?? 1.0, 'float' );
295
- u( 'sceneScale', 1.0, 'float' );
297
+ u( 'unitsPerMetre', DEFAULT_STATE.unitsPerMetre, 'float' );
298
+ u( 'dofMode', DEFAULT_STATE.dofMode === 'look' ? 1 : 0, 'int' );
299
+ u( 'dofBlur', DEFAULT_STATE.dofBlur, 'float' );
296
300
 
297
301
  // Sampling — use the module-level uniform from Random.js so TSL sees the same node
298
302
  this._uniforms.set( 'samplingTechnique', samplingTechniqueUniform );
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * VideoRenderManager — Drives offline frame-by-frame animation rendering.
3
3
  *
4
- * Seeks the animation to each frame time, accumulates SPP until convergence,
5
- * optionally denoises, captures the output canvas, and delivers each frame
6
- * via a callback for encoding.
4
+ * Seeks the animation clip and the timeline, when given one, to each frame time, accumulates SPP
5
+ * until convergence, optionally denoises, captures the output canvas, and delivers each frame via a
6
+ * callback for encoding.
7
7
  */
8
8
 
9
9
  import { EngineEvents } from '../EngineEvents.js';
@@ -30,6 +30,9 @@ export class VideoRenderManager {
30
30
  * @param {boolean} [options.enableOIDN=true] - Run OIDN denoiser per frame
31
31
  * @param {number} [options.speed=1] - Playback speed multiplier (maps video time to animation time)
32
32
  * @param {number} [options.totalFrames] - Override total frame count (for looped animations)
33
+ * @param {number} [options.startFrame=0] - First frame to render: a resumed export skips those it has
34
+ * @param {import('./timeline/TimelineManager.js').TimelineManager} [options.timeline] - Keyframes to
35
+ * play in video time; without a clip the video lasts as long as they do
33
36
  * @param {Function} [options.onFrame] - async (ImageBitmap, frameIndex, totalFrames) => void
34
37
  * @param {Function} [options.onProgress] - ({ frame, totalFrames, percent }) => void
35
38
  * @param {Function} [options.onComplete] - (success: boolean) => void
@@ -42,6 +45,8 @@ export class VideoRenderManager {
42
45
  speed = 1,
43
46
  samplesPerFrame = PRODUCTION_RENDER_CONFIG.maxSamples,
44
47
  enableOIDN = true,
48
+ timeline = null,
49
+ startFrame = 0,
45
50
  onFrame,
46
51
  onProgress,
47
52
  onComplete,
@@ -49,32 +54,32 @@ export class VideoRenderManager {
49
54
 
50
55
  const app = this._app;
51
56
 
52
- if ( ! app.animationManager?.hasAnimations ) {
57
+ // Keyframes alone make a video too.
58
+ const clip = app.animationManager?.hasAnimations ? app.animationManager.clips[ clipIndex ] : null;
59
+ if ( ! clip && ! timeline?.animates ) {
53
60
 
54
- console.warn( 'VideoRenderManager: No animation clips available' );
61
+ console.warn( app.animationManager?.hasAnimations ? `VideoRenderManager: Invalid clip index ${clipIndex}` : 'VideoRenderManager: No animation clips or keyframes' );
55
62
  onComplete?.( false );
56
63
  return;
57
64
 
58
65
  }
59
66
 
60
- const clip = app.animationManager.clips[ clipIndex ];
61
- if ( ! clip ) {
67
+ const effectiveDuration = clip ? clip.duration / ( speed || 1 ) : timeline.duration;
68
+ const totalFrames = options.totalFrames || Math.ceil( effectiveDuration * fps );
69
+ const frameDuration = 1 / fps;
70
+ if ( ! ( totalFrames > 0 ) ) {
62
71
 
63
- console.warn( `VideoRenderManager: Invalid clip index ${clipIndex}` );
72
+ console.warn( 'VideoRenderManager: No frames to render' );
64
73
  onComplete?.( false );
65
74
  return;
66
75
 
67
76
  }
68
77
 
69
- const effectiveDuration = clip.duration / ( speed || 1 );
70
- const totalFrames = options.totalFrames || Math.ceil( effectiveDuration * fps );
71
- const frameDuration = 1 / fps;
72
-
73
78
  this._cancelled = false;
74
79
  this._rendering = true;
75
80
 
76
81
  // Save current engine state
77
- const savedState = this._saveState();
82
+ const savedState = this._saveState( { view: !! timeline } );
78
83
 
79
84
  // Stop the rAF loop — we drive rendering manually
80
85
  app.stopAnimation();
@@ -91,7 +96,7 @@ export class VideoRenderManager {
91
96
 
92
97
  try {
93
98
 
94
- for ( let i = 0; i < totalFrames; i ++ ) {
99
+ for ( let i = Math.max( 0, startFrame ); i < totalFrames; i ++ ) {
95
100
 
96
101
  if ( this._cancelled ) break;
97
102
 
@@ -99,7 +104,16 @@ export class VideoRenderManager {
99
104
  const animationTime = i * frameDuration * speed;
100
105
 
101
106
  // 1. Seek animation to frame time
102
- const positions = app.animationManager.seekTo( animationTime, clipIndex );
107
+ const positions = clip ? app.animationManager.seekTo( animationTime, clipIndex ) : null;
108
+
109
+ // After the clip, which moves a followed model camera.
110
+ if ( timeline?.seek( i * frameDuration ) ) {
111
+
112
+ // A moving camera changes what is under the focus point.
113
+ app.cameraManager.resetAutoFocus();
114
+ app.cameraManager.updateAutoFocus();
115
+
116
+ }
103
117
 
104
118
  // 2. Refit BVH with deformed positions (also calls reset())
105
119
  if ( positions ) {
@@ -268,16 +282,21 @@ export class VideoRenderManager {
268
282
 
269
283
  /**
270
284
  * Save engine state that we'll modify during video render.
285
+ * @param {Object} options
286
+ * @param {boolean} options.view - a timeline will move the camera, and auto-focus with it
271
287
  * @private
272
288
  */
273
- _saveState() {
289
+ _saveState( { view } ) {
274
290
 
275
291
  const app = this._app;
276
292
  const effective = app.settings.getEffective();
293
+ const keys = Object.keys( modePresetSettings( PRODUCTION_RENDER_CONFIG ) );
294
+ if ( view ) keys.push( 'focusDistance' );
277
295
 
278
296
  return {
297
+ view: view ? app.cameraManager.captureView() : null,
279
298
  // Everything configureForMode( 'production' ) overwrites, with who set it.
280
- settings: Object.keys( modePresetSettings( PRODUCTION_RENDER_CONFIG ) ).map( key => [ key, effective[ key ] ] ),
299
+ settings: keys.map( key => [ key, effective[ key ] ] ),
281
300
  renderMode: app.stages.pathTracer?.renderMode?.value,
282
301
  controlsEnabled: app.cameraManager.controls?.enabled,
283
302
  oidnEnabled: app.denoisingManager?.finalDenoise,
@@ -321,6 +340,8 @@ export class VideoRenderManager {
321
340
 
322
341
  app.pauseRendering = state.pauseRendering ?? false;
323
342
 
343
+ if ( state.view ) app.cameraManager.applyPose( state.view );
344
+
324
345
  // configureForMode( 'production' ) suspended the live-view refresh and nothing here switches
325
346
  // back, so the viewport would stay un-denoised until the host changed tab.
326
347
  app.denoisingManager?.setCadenceSuspended( false );
@@ -0,0 +1,77 @@
1
+ import { OrthographicCamera, PerspectiveCamera } from 'three';
2
+
3
+ /**
4
+ * The view's camera: a perspective camera that can also project orthographically.
5
+ *
6
+ * It switches in place, so everything holding it (the controls, picking, the gizmo, the overlays
7
+ * and the path tracer) follows. three.js reads the camera's type from the two flags below, and an
8
+ * orthographic camera's frustum from `top`, `bottom`, `left` and `right`.
9
+ */
10
+ export class ViewCamera extends PerspectiveCamera {
11
+
12
+ constructor( fov, aspect, near, far ) {
13
+
14
+ super( fov, aspect, near, far );
15
+
16
+ this.orthographic = false;
17
+ /** Half the view's height in world units while orthographic, before `zoom`. */
18
+ this.orthoHalfHeight = 1;
19
+
20
+ }
21
+
22
+ get isPerspectiveCamera() {
23
+
24
+ return ! this.orthographic;
25
+
26
+ }
27
+
28
+ // PerspectiveCamera's constructor assigns the flag.
29
+ set isPerspectiveCamera( value ) {}
30
+
31
+ get isOrthographicCamera() {
32
+
33
+ return this.orthographic;
34
+
35
+ }
36
+
37
+ get top() {
38
+
39
+ return this.orthoHalfHeight;
40
+
41
+ }
42
+
43
+ get bottom() {
44
+
45
+ return - this.orthoHalfHeight;
46
+
47
+ }
48
+
49
+ get right() {
50
+
51
+ return this.orthoHalfHeight * this.aspect;
52
+
53
+ }
54
+
55
+ get left() {
56
+
57
+ return - this.orthoHalfHeight * this.aspect;
58
+
59
+ }
60
+
61
+ updateProjectionMatrix() {
62
+
63
+ if ( this.orthographic ) OrthographicCamera.prototype.updateProjectionMatrix.call( this );
64
+ else super.updateProjectionMatrix();
65
+
66
+ }
67
+
68
+ copy( source, recursive ) {
69
+
70
+ super.copy( source, recursive );
71
+ this.orthographic = source.orthographic === true;
72
+ this.orthoHalfHeight = source.orthoHalfHeight ?? this.orthoHalfHeight;
73
+ return this;
74
+
75
+ }
76
+
77
+ }