From 8d8ae7dfc00a3209b2c3ec4eab6de9dfc5be3fe7 Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Mon, 13 Jul 2026 09:50:30 -0400 Subject: [PATCH] feat: share one WebGL context across all 3D views (#13547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Browsers cap live WebGL contexts per page, so each Load3D node owning its own renderer broke once enough 3D nodes were added. All 3D views now share a single offscreen WebGLRenderer: each view renders into the bottom-left region of the shared drawing buffer and blits the frame into its own plain 2D canvas, which has no context limit. - Add sharedWebGLRenderer (refcounted singleton, grow-only buffer) and RendererView (per-view canvas + per-view renderer state + blit) - Per-view renderer state (tone mapping, color space, clear color) is applied before each view renders; HDRIManager and SceneModelManager write to it instead of the renderer - OrbitControls/TransformControls bind to the node container explicitly - ViewHelperManager renders the axes helper itself so it can position and scale independently of renderer DOM layout - RecordingManager records from the view canvas; capture renders at exact size on the shared buffer as before - On last view release the context is force-lost and webglcontextlost is dispatched synchronously so context-loss handlers still run ## Screenshots (if applicable) before image after 屏幕截图 2026-07-09 135025 image --- src/composables/useLoad3d.test.ts | 6 +- src/composables/useLoad3d.ts | 2 +- .../core/load3d/ControlsManager.test.ts | 47 ++---- src/extensions/core/load3d/ControlsManager.ts | 5 +- .../core/load3d/GizmoManager.test.ts | 8 +- src/extensions/core/load3d/GizmoManager.ts | 8 +- .../core/load3d/HDRIManager.test.ts | 9 +- src/extensions/core/load3d/HDRIManager.ts | 15 +- src/extensions/core/load3d/Load3d.test.ts | 104 ++++++++----- src/extensions/core/load3d/Load3d.ts | 6 +- .../core/load3d/RecordingManager.test.ts | 10 +- .../core/load3d/RecordingManager.ts | 8 +- .../core/load3d/SceneManager.test.ts | 52 ++++++- src/extensions/core/load3d/SceneManager.ts | 24 ++- .../core/load3d/SceneModelManager.test.ts | 30 ++-- .../core/load3d/SceneModelManager.ts | 12 +- .../core/load3d/ViewHelperManager.ts | 25 ++++ src/extensions/core/load3d/Viewport3d.test.ts | 10 +- src/extensions/core/load3d/Viewport3d.ts | 97 ++++++------ .../core/load3d/createLoad3d.test.ts | 15 +- src/extensions/core/load3d/createLoad3d.ts | 41 ++--- src/renderer/three/RendererView.test.ts | 126 ++++++++++++++++ src/renderer/three/RendererView.ts | 88 +++++++++++ .../three/sharedWebGLRenderer.test.ts | 140 ++++++++++++++++++ src/renderer/three/sharedWebGLRenderer.ts | 78 ++++++++++ 25 files changed, 748 insertions(+), 218 deletions(-) create mode 100644 src/renderer/three/RendererView.test.ts create mode 100644 src/renderer/three/RendererView.ts create mode 100644 src/renderer/three/sharedWebGLRenderer.test.ts create mode 100644 src/renderer/three/sharedWebGLRenderer.ts diff --git a/src/composables/useLoad3d.test.ts b/src/composables/useLoad3d.test.ts index 2477a441fe..bdc7f33659 100644 --- a/src/composables/useLoad3d.test.ts +++ b/src/composables/useLoad3d.test.ts @@ -214,9 +214,7 @@ describe('useLoad3d', () => { getModelInfo: vi.fn().mockReturnValue(null), captureThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,test'), setAnimationTime: vi.fn(), - renderer: { - domElement: mockCanvas - } as Partial as Load3d['renderer'] + domElement: mockCanvas } vi.mocked(Load3d).mockImplementation(function (this: Load3d) { @@ -303,7 +301,7 @@ describe('useLoad3d', () => { mockNode.flags.collapsed = true mockNode.onDrawBackground?.({} as CanvasRenderingContext2D) - expect(mockLoad3d.renderer!.domElement.hidden).toBe(true) + expect(mockLoad3d.domElement!.hidden).toBe(true) }) it('should initialize without loading model (model loading is handled by Load3DConfiguration)', async () => { diff --git a/src/composables/useLoad3d.ts b/src/composables/useLoad3d.ts index e46009e857..beb15264b4 100644 --- a/src/composables/useLoad3d.ts +++ b/src/composables/useLoad3d.ts @@ -244,7 +244,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { node.onDrawBackground, function (this: LGraphNode) { if (load3d) { - load3d.renderer.domElement.hidden = this.flags.collapsed ?? false + load3d.domElement.hidden = this.flags.collapsed ?? false } } ) diff --git a/src/extensions/core/load3d/ControlsManager.test.ts b/src/extensions/core/load3d/ControlsManager.test.ts index 21791a124f..c324ba7812 100644 --- a/src/extensions/core/load3d/ControlsManager.test.ts +++ b/src/extensions/core/load3d/ControlsManager.test.ts @@ -43,13 +43,8 @@ function makeMockEventManager() { } satisfies EventManagerInterface } -function makeRenderer(opts: { withParent?: boolean } = {}) { - const canvas = document.createElement('canvas') - if (opts.withParent) { - const parent = document.createElement('div') - parent.appendChild(canvas) - } - return { domElement: canvas } as unknown as THREE.WebGLRenderer +function makeElement() { + return document.createElement('div') } describe('ControlsManager', () => { @@ -64,33 +59,19 @@ describe('ControlsManager', () => { }) describe('construction', () => { - it('attaches OrbitControls to the canvas parent when one exists', () => { - const renderer = makeRenderer({ withParent: true }) + it('attaches OrbitControls to the interaction element', () => { + const element = makeElement() - manager = new ControlsManager(renderer, camera, events) + manager = new ControlsManager(element, camera, events) - expect(mockOrbitControls).toHaveBeenCalledWith( - camera, - renderer.domElement.parentElement - ) + expect(mockOrbitControls).toHaveBeenCalledWith(camera, element) expect(manager.controls.enableDamping).toBe(true) }) - - it('falls back to the canvas itself when there is no parent', () => { - const renderer = makeRenderer({ withParent: false }) - - manager = new ControlsManager(renderer, camera, events) - - expect(mockOrbitControls).toHaveBeenCalledWith( - camera, - renderer.domElement - ) - }) }) describe('init', () => { it('emits cameraChanged with a perspective state when the controls fire end', () => { - manager = new ControlsManager(makeRenderer(), camera, events) + manager = new ControlsManager(makeElement(), camera, events) camera.position.set(1, 2, 3) camera.zoom = 1.25 manager.controls.target.set(4, 5, 6) @@ -109,7 +90,7 @@ describe('ControlsManager', () => { it('reports orthographic camera type when initialized with one', () => { const ortho = new THREE.OrthographicCamera() ortho.zoom = 0.5 - manager = new ControlsManager(makeRenderer(), ortho, events) + manager = new ControlsManager(makeElement(), ortho, events) manager.init() ;(manager.controls as unknown as { fire(e: string): void }).fire('end') @@ -123,7 +104,7 @@ describe('ControlsManager', () => { describe('updateCamera', () => { it('rebinds controls to the new camera, copies position from the previous one, and preserves the target', () => { - manager = new ControlsManager(makeRenderer(), camera, events) + manager = new ControlsManager(makeElement(), camera, events) camera.position.set(7, 8, 9) manager.controls.target.set(1, 1, 1) @@ -139,7 +120,7 @@ describe('ControlsManager', () => { describe('update / reset', () => { it('update delegates to controls.update', () => { - manager = new ControlsManager(makeRenderer(), camera, events) + manager = new ControlsManager(makeElement(), camera, events) manager.update() @@ -147,7 +128,7 @@ describe('ControlsManager', () => { }) it('reset clears the target back to the origin and refreshes', () => { - manager = new ControlsManager(makeRenderer(), camera, events) + manager = new ControlsManager(makeElement(), camera, events) manager.controls.target.set(5, 6, 7) manager.reset() @@ -159,7 +140,7 @@ describe('ControlsManager', () => { describe('dispose', () => { it('disposes the underlying OrbitControls', () => { - manager = new ControlsManager(makeRenderer(), camera, events) + manager = new ControlsManager(makeElement(), camera, events) manager.dispose() @@ -169,7 +150,7 @@ describe('ControlsManager', () => { describe('detach / attach', () => { it('detach disables OrbitControls interaction', () => { - manager = new ControlsManager(makeRenderer(), camera, events) + manager = new ControlsManager(makeElement(), camera, events) expect(manager.controls.enabled).toBe(true) manager.detach() @@ -178,7 +159,7 @@ describe('ControlsManager', () => { }) it('attach re-enables OrbitControls interaction', () => { - manager = new ControlsManager(makeRenderer(), camera, events) + manager = new ControlsManager(makeElement(), camera, events) manager.detach() manager.attach() diff --git a/src/extensions/core/load3d/ControlsManager.ts b/src/extensions/core/load3d/ControlsManager.ts index d5da3f950e..498ef84718 100644 --- a/src/extensions/core/load3d/ControlsManager.ts +++ b/src/extensions/core/load3d/ControlsManager.ts @@ -12,15 +12,14 @@ export class ControlsManager implements ControlsManagerInterface { private camera: THREE.Camera constructor( - renderer: THREE.WebGLRenderer, + interactionElement: HTMLElement, camera: THREE.Camera, eventManager: EventManagerInterface ) { this.eventManager = eventManager this.camera = camera - const container = renderer.domElement.parentElement || renderer.domElement - this.controls = new OrbitControls(camera, container) + this.controls = new OrbitControls(camera, interactionElement) this.controls.enableDamping = true } diff --git a/src/extensions/core/load3d/GizmoManager.test.ts b/src/extensions/core/load3d/GizmoManager.test.ts index 9f5174eb21..d9c5a6c8b1 100644 --- a/src/extensions/core/load3d/GizmoManager.test.ts +++ b/src/extensions/core/load3d/GizmoManager.test.ts @@ -55,7 +55,7 @@ function makeMockOrbitControls() { describe('GizmoManager', () => { let scene: THREE.Scene - let renderer: THREE.WebGLRenderer + let interactionElement: HTMLElement let camera: THREE.PerspectiveCamera let orbitControls: ReturnType let manager: GizmoManager @@ -66,9 +66,7 @@ describe('GizmoManager', () => { vi.clearAllMocks() scene = new THREE.Scene() - renderer = { - domElement: document.createElement('canvas') - } as unknown as THREE.WebGLRenderer + interactionElement = document.createElement('div') camera = new THREE.PerspectiveCamera() orbitControls = makeMockOrbitControls() onTransformChange = vi.fn() @@ -80,7 +78,7 @@ describe('GizmoManager', () => { manager = new GizmoManager( scene, - renderer, + interactionElement, orbitControls, () => camera, onTransformChange diff --git a/src/extensions/core/load3d/GizmoManager.ts b/src/extensions/core/load3d/GizmoManager.ts index e20c3ea0be..42e9c4cdcc 100644 --- a/src/extensions/core/load3d/GizmoManager.ts +++ b/src/extensions/core/load3d/GizmoManager.ts @@ -15,19 +15,19 @@ export class GizmoManager { private activeCamera: THREE.Camera private mode: GizmoMode = 'translate' private scene: THREE.Scene - private renderer: THREE.WebGLRenderer + private interactionElement: HTMLElement private orbitControls: OrbitControls private onTransformChange?: () => void constructor( scene: THREE.Scene, - renderer: THREE.WebGLRenderer, + interactionElement: HTMLElement, orbitControls: OrbitControls, getActiveCamera: () => THREE.Camera, onTransformChange?: () => void ) { this.scene = scene - this.renderer = renderer + this.interactionElement = interactionElement this.orbitControls = orbitControls this.activeCamera = getActiveCamera() this.onTransformChange = onTransformChange @@ -36,7 +36,7 @@ export class GizmoManager { init(): void { this.transformControls = new TransformControls( this.activeCamera, - this.renderer.domElement + this.interactionElement ) this.transformControls.addEventListener('dragging-changed', (event) => { diff --git a/src/extensions/core/load3d/HDRIManager.test.ts b/src/extensions/core/load3d/HDRIManager.test.ts index 39b0fd1f71..db5a7f1f2b 100644 --- a/src/extensions/core/load3d/HDRIManager.test.ts +++ b/src/extensions/core/load3d/HDRIManager.test.ts @@ -1,6 +1,8 @@ import * as THREE from 'three' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createRendererViewState } from '@/renderer/three/sharedWebGLRenderer' + import { HDRIManager } from './HDRIManager' import Load3dUtils from './Load3dUtils' @@ -76,7 +78,12 @@ describe('HDRIManager', () => { dispose: vi.fn() }) - manager = new HDRIManager(scene, {} as THREE.WebGLRenderer, eventManager) + manager = new HDRIManager( + scene, + {} as THREE.WebGLRenderer, + createRendererViewState(), + eventManager + ) }) afterEach(() => { diff --git a/src/extensions/core/load3d/HDRIManager.ts b/src/extensions/core/load3d/HDRIManager.ts index 7e27fbce32..9cc63f07c4 100644 --- a/src/extensions/core/load3d/HDRIManager.ts +++ b/src/extensions/core/load3d/HDRIManager.ts @@ -2,12 +2,14 @@ import * as THREE from 'three' import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader' import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader' +import type { RendererViewState } from '@/renderer/three/sharedWebGLRenderer' + import Load3dUtils from './Load3dUtils' import type { EventManagerInterface } from './interfaces' export class HDRIManager { private scene: THREE.Scene - private renderer: THREE.WebGLRenderer + private viewState: RendererViewState private pmremGenerator: THREE.PMREMGenerator private eventManager: EventManagerInterface @@ -33,10 +35,11 @@ export class HDRIManager { constructor( scene: THREE.Scene, renderer: THREE.WebGLRenderer, + viewState: RendererViewState, eventManager: EventManagerInterface ) { this.scene = scene - this.renderer = renderer + this.viewState = viewState this.pmremGenerator = new THREE.PMREMGenerator(renderer) this.pmremGenerator.compileEquirectangularShader() this.eventManager = eventManager @@ -101,8 +104,8 @@ export class HDRIManager { this.scene.environment = envMap this.scene.environmentIntensity = this._intensity this.scene.background = this._showAsBackground ? this.hdriTexture : null - this.renderer.toneMapping = THREE.ACESFilmicToneMapping - this.renderer.toneMappingExposure = 1.0 + this.viewState.toneMapping = THREE.ACESFilmicToneMapping + this.viewState.toneMappingExposure = 1.0 this.eventManager.emitEvent('hdriChange', { enabled: this._isEnabled, showAsBackground: this._showAsBackground @@ -114,8 +117,8 @@ export class HDRIManager { if (this.scene.background === this.hdriTexture) { this.scene.background = null } - this.renderer.toneMapping = THREE.NoToneMapping - this.renderer.toneMappingExposure = 1.0 + this.viewState.toneMapping = THREE.NoToneMapping + this.viewState.toneMappingExposure = 1.0 this.eventManager.emitEvent('hdriChange', { enabled: false, showAsBackground: this._showAsBackground diff --git a/src/extensions/core/load3d/Load3d.test.ts b/src/extensions/core/load3d/Load3d.test.ts index db6d8c3643..5f5908428c 100644 --- a/src/extensions/core/load3d/Load3d.test.ts +++ b/src/extensions/core/load3d/Load3d.test.ts @@ -334,7 +334,7 @@ describe('Load3d', () => { const sceneResize = vi.fn() Object.assign(ctx.load3d, { - renderer: { domElement: canvas, setSize, setPixelRatio: vi.fn() }, + view: { canvas, setSize }, targetWidth: 400, targetHeight: 200, targetAspectRatio: 2, @@ -361,26 +361,21 @@ describe('Load3d', () => { const updateAspectRatio = vi.fn() const renderBackground = vi.fn() - const canvas = document.createElement('canvas') - Object.defineProperty(canvas, 'clientWidth', { - value: 800, - configurable: true - }) - Object.defineProperty(canvas, 'clientHeight', { - value: 600, - configurable: true - }) const scene = {} as THREE.Scene Object.assign(ctx.load3d, { - renderer: { - domElement: canvas, - setViewport, - setScissor, - setScissorTest, - setClearColor, - clear, - render + view: { + width: 800, + height: 600, + state: { clearColor: new THREE.Color(0x000000), clearAlpha: 0 }, + renderer: { + setViewport, + setScissor, + setScissorTest, + setClearColor, + clear, + render + } }, targetWidth: 400, targetHeight: 200, @@ -415,7 +410,7 @@ describe('Load3d', () => { }) Object.assign(ctx.load3d, { - renderer: { domElement: canvas }, + view: { canvas }, targetWidth: 400, targetHeight: 200, targetAspectRatio: 2, @@ -438,7 +433,7 @@ describe('Load3d', () => { expect(args[3]).toBe(400) }) - it('handleResize calls setPixelRatio with the value returned by getZoomScaleCallback', () => { + it('handleResize scales the view size by getZoomScaleCallback', () => { delete (ctx.load3d as { handleResize?: unknown }).handleResize const parent = document.createElement('div') @@ -453,10 +448,10 @@ describe('Load3d', () => { const canvas = document.createElement('canvas') parent.appendChild(canvas) - const setPixelRatio = vi.fn() + const setSize = vi.fn() Object.assign(ctx.load3d, { - renderer: { domElement: canvas, setSize: vi.fn(), setPixelRatio }, + view: { canvas, setSize }, getZoomScaleCallback: () => 2.5, targetWidth: 0, targetHeight: 0, @@ -467,10 +462,10 @@ describe('Load3d', () => { ctx.load3d.handleResize() - expect(setPixelRatio).toHaveBeenCalledWith(2.5) + expect(setSize).toHaveBeenCalledWith(1000, 1000) }) - it('handleResize defaults to pixelRatio 1 when no getZoomScaleCallback is provided', () => { + it('handleResize caps the zoom scale at 3', () => { delete (ctx.load3d as { handleResize?: unknown }).handleResize const parent = document.createElement('div') @@ -485,10 +480,42 @@ describe('Load3d', () => { const canvas = document.createElement('canvas') parent.appendChild(canvas) - const setPixelRatio = vi.fn() + const setSize = vi.fn() Object.assign(ctx.load3d, { - renderer: { domElement: canvas, setSize: vi.fn(), setPixelRatio }, + view: { canvas, setSize }, + getZoomScaleCallback: () => 10, + targetWidth: 0, + targetHeight: 0, + isViewerMode: false, + cameraManager: { ...ctx.cameraManager, handleResize: vi.fn() }, + sceneManager: { ...ctx.sceneManager, handleResize: vi.fn() } + }) + + ctx.load3d.handleResize() + + expect(setSize).toHaveBeenCalledWith(1200, 1200) + }) + + it('handleResize defaults to scale 1 when no getZoomScaleCallback is provided', () => { + delete (ctx.load3d as { handleResize?: unknown }).handleResize + + const parent = document.createElement('div') + Object.defineProperty(parent, 'clientWidth', { + value: 400, + configurable: true + }) + Object.defineProperty(parent, 'clientHeight', { + value: 400, + configurable: true + }) + const canvas = document.createElement('canvas') + parent.appendChild(canvas) + + const setSize = vi.fn() + + Object.assign(ctx.load3d, { + view: { canvas, setSize }, getZoomScaleCallback: undefined, targetWidth: 0, targetHeight: 0, @@ -499,7 +526,7 @@ describe('Load3d', () => { ctx.load3d.handleResize() - expect(setPixelRatio).toHaveBeenCalledWith(1) + expect(setSize).toHaveBeenCalledWith(400, 400) }) }) @@ -510,7 +537,8 @@ describe('Load3d', () => { const viewHelperRender = vi.fn() const controlsUpdate = vi.fn() const renderMainScene = vi.fn() - const resetViewport = vi.fn() + const beginRender = vi.fn() + const blit = vi.fn() Object.assign(ctx.load3d, { STATUS_MOUSE_ON_NODE: true, @@ -525,13 +553,16 @@ describe('Load3d', () => { }, viewHelperManager: { update: viewHelperUpdate, - viewHelper: { render: viewHelperRender } + render: viewHelperRender }, controlsManager: { update: controlsUpdate }, recordingManager: { getIsRecording: vi.fn(() => false) }, renderMainScene, - resetViewport, - renderer: {} + view: { + beginRender, + blit, + renderer: { setScissorTest: vi.fn() } + } }) ;(ctx.load3d as unknown as { startAnimation(): void }).startAnimation() @@ -546,9 +577,10 @@ describe('Load3d', () => { expect(animationUpdate).toHaveBeenCalledOnce() expect(viewHelperUpdate).toHaveBeenCalledOnce() expect(controlsUpdate).toHaveBeenCalledOnce() + expect(beginRender).toHaveBeenCalledOnce() expect(renderMainScene).toHaveBeenCalledOnce() - expect(resetViewport).toHaveBeenCalledOnce() expect(viewHelperRender).toHaveBeenCalledOnce() + expect(blit).toHaveBeenCalledOnce() // Cancel the queued rAF so the test doesn't leak frames. loop.stop() @@ -560,12 +592,10 @@ describe('Load3d', () => { Object.assign(ctx.load3d, { renderLoop: { stop }, - resizeObserver: null, contextMenuAbortController: null, - renderer: { - forceContextLoss: vi.fn(), - dispose: vi.fn(), - domElement: canvas + view: { + canvas, + dispose: vi.fn() }, sceneManager: { ...ctx.sceneManager, dispose: vi.fn() }, cameraManager: { ...ctx.cameraManager, dispose: vi.fn() }, diff --git a/src/extensions/core/load3d/Load3d.ts b/src/extensions/core/load3d/Load3d.ts index 71b0b0625d..cf48f03ba9 100644 --- a/src/extensions/core/load3d/Load3d.ts +++ b/src/extensions/core/load3d/Load3d.ts @@ -67,7 +67,7 @@ class Load3d extends Viewport3d { private hasLoadedModel: boolean = false constructor( - container: Element | HTMLElement, + container: HTMLElement, deps: Load3dDeps, options: Load3DOptions = {} ) { @@ -255,8 +255,8 @@ class Load3d extends Viewport3d { this.sceneManager.backgroundTexture && this.sceneManager.backgroundMesh ) { - const containerWidth = this.renderer.domElement.clientWidth - const containerHeight = this.renderer.domElement.clientHeight + const containerWidth = this.domElement.clientWidth + const containerHeight = this.domElement.clientHeight if (this.shouldMaintainAspectRatio()) { const { width, height } = computeLetterboxedViewport( diff --git a/src/extensions/core/load3d/RecordingManager.test.ts b/src/extensions/core/load3d/RecordingManager.test.ts index 06d6508144..a5b0960888 100644 --- a/src/extensions/core/load3d/RecordingManager.test.ts +++ b/src/extensions/core/load3d/RecordingManager.test.ts @@ -64,16 +64,16 @@ function makeStream(): MediaStream { } as unknown as MediaStream } -function makeRenderer(): THREE.WebGLRenderer { +function makeSourceCanvas(): HTMLCanvasElement { const canvas = document.createElement('canvas') canvas.width = 800 canvas.height = 600 - return { domElement: canvas } as unknown as THREE.WebGLRenderer + return canvas } describe('RecordingManager', () => { let scene: THREE.Scene - let renderer: THREE.WebGLRenderer + let sourceCanvas: HTMLCanvasElement let events: ReturnType let manager: RecordingManager let rafSpy: ReturnType @@ -104,9 +104,9 @@ describe('RecordingManager', () => { vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}) scene = new THREE.Scene() - renderer = makeRenderer() + sourceCanvas = makeSourceCanvas() events = makeMockEventManager() - manager = new RecordingManager(scene, renderer, events) + manager = new RecordingManager(scene, sourceCanvas, events) }) afterEach(() => { diff --git a/src/extensions/core/load3d/RecordingManager.ts b/src/extensions/core/load3d/RecordingManager.ts index f5416c59d6..a5ae79cdf4 100644 --- a/src/extensions/core/load3d/RecordingManager.ts +++ b/src/extensions/core/load3d/RecordingManager.ts @@ -11,7 +11,7 @@ export class RecordingManager { private recordingStream: MediaStream | null = null private recordingIndicator: THREE.Sprite | null = null private scene: THREE.Scene - private renderer: THREE.WebGLRenderer + private sourceCanvas: HTMLCanvasElement private eventManager: EventManagerInterface private recordingStartTime: number = 0 private recordingDuration: number = 0 @@ -21,11 +21,11 @@ export class RecordingManager { constructor( scene: THREE.Scene, - renderer: THREE.WebGLRenderer, + sourceCanvas: HTMLCanvasElement, eventManager: EventManagerInterface ) { this.scene = scene - this.renderer = renderer + this.sourceCanvas = sourceCanvas this.eventManager = eventManager this.setupRecordingIndicator() } @@ -61,7 +61,7 @@ export class RecordingManager { } try { - const sourceCanvas = this.renderer.domElement + const sourceCanvas = this.sourceCanvas const sourceWidth = sourceCanvas.width const sourceHeight = sourceCanvas.height diff --git a/src/extensions/core/load3d/SceneManager.test.ts b/src/extensions/core/load3d/SceneManager.test.ts index 382f81e16d..563a700ed8 100644 --- a/src/extensions/core/load3d/SceneManager.test.ts +++ b/src/extensions/core/load3d/SceneManager.test.ts @@ -3,6 +3,9 @@ import * as THREE from 'three' import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RendererView } from '@/renderer/three/RendererView' +import { createRendererViewState } from '@/renderer/three/sharedWebGLRenderer' + import type { EventManagerInterface } from './interfaces' import Load3dUtils from './Load3dUtils' import { SceneManager } from './SceneManager' @@ -65,6 +68,32 @@ function makeMockEventManager() { } satisfies EventManagerInterface } +function makeView( + renderer: THREE.WebGLRenderer, + width = 400, + height = 300 +): RendererView { + const canvas = document.createElement('canvas') + Object.defineProperty(canvas, 'clientWidth', { + configurable: true, + value: width + }) + Object.defineProperty(canvas, 'clientHeight', { + configurable: true, + value: height + }) + return { + renderer, + canvas, + state: createRendererViewState(), + width, + height, + beginRender: vi.fn(), + blit: vi.fn(), + setSize: vi.fn() + } as unknown as RendererView +} + function makeRenderer() { const canvas = document.createElement('canvas') Object.defineProperty(canvas, 'clientWidth', { @@ -120,7 +149,7 @@ describe('SceneManager', () => { camera = new THREE.PerspectiveCamera() events = makeMockEventManager() manager = new SceneManager( - renderer, + makeView(renderer), () => camera, () => ({}) as unknown as OrbitControls, events @@ -592,20 +621,22 @@ describe('SceneManager', () => { function makeSceneManager( pixelRatio = 1, - cameraOverride?: THREE.PerspectiveCamera | THREE.OrthographicCamera + cameraOverride?: THREE.PerspectiveCamera | THREE.OrthographicCamera, + viewSize?: { width: number; height: number } ) { const renderer = makeMockRenderer(pixelRatio) + const view = makeView(renderer, viewSize?.width, viewSize?.height) const camera = cameraOverride ?? new THREE.PerspectiveCamera() const eventManager = makeMockEventManager() const manager = new SceneManager( - renderer, + view, () => camera, vi.fn() as unknown as () => InstanceType< typeof import('three/examples/jsm/controls/OrbitControls').OrbitControls >, eventManager ) - return { manager, renderer, camera, eventManager } + return { manager, renderer, view, camera, eventManager } } describe('SceneManager.captureScene', () => { @@ -648,6 +679,19 @@ describe('SceneManager.captureScene', () => { expect(calls.at(-1)).toEqual([400, 300]) }) + it('restores the view state first and resizes the background to the view size, not the shared buffer size', async () => { + const { manager, view } = makeSceneManager(1, undefined, { + width: 640, + height: 480 + }) + const handleResize = vi.spyOn(manager, 'handleResize') + + await manager.captureScene(1920, 1080) + + expect(view.beginRender).toHaveBeenCalledOnce() + expect(handleResize).toHaveBeenCalledWith(640, 480) + }) + it('restores perspective camera aspect after capture', async () => { const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000) const { manager } = makeSceneManager(1, camera) diff --git a/src/extensions/core/load3d/SceneManager.ts b/src/extensions/core/load3d/SceneManager.ts index 243ca72a8d..4b669b0d02 100644 --- a/src/extensions/core/load3d/SceneManager.ts +++ b/src/extensions/core/load3d/SceneManager.ts @@ -2,6 +2,8 @@ import { SparkRenderer } from '@sparkjsdev/spark' import * as THREE from 'three' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' +import type { RendererView } from '@/renderer/three/RendererView' + import Load3dUtils from './Load3dUtils' import { type BackgroundRenderModeType, @@ -38,16 +40,18 @@ export class SceneManager implements SceneManagerInterface { private eventManager: EventManagerInterface private renderer: THREE.WebGLRenderer + private view: RendererView private getActiveCamera: () => THREE.Camera constructor( - renderer: THREE.WebGLRenderer, + view: RendererView, getActiveCamera: () => THREE.Camera, _getControls: () => OrbitControls, eventManager: EventManagerInterface ) { - this.renderer = renderer + this.view = view + this.renderer = view.renderer this.eventManager = eventManager this.scene = new THREE.Scene() @@ -65,7 +69,7 @@ export class SceneManager implements SceneManagerInterface { // forceRender directly into onDirty caused a per-frame render-setDirty // cascade that made splats visibly "balloon" during camera interaction. this.sparkRenderer = new SparkRenderer({ - renderer, + renderer: view.renderer, onDirty: () => { const resolve = this.nextSparkDirtyResolve this.nextSparkDirtyResolve = null @@ -104,7 +108,8 @@ export class SceneManager implements SceneManagerInterface { this.backgroundMesh.position.set(0, 0, 0) this.backgroundScene.add(this.backgroundMesh) - this.renderer.setClearColor(0x000000, 0) + this.view.state.clearColor.set(0x000000) + this.view.state.clearAlpha = 0 } init(): void {} @@ -250,8 +255,8 @@ export class SceneManager implements SceneManagerInterface { this.updateBackgroundSize( this.backgroundTexture, this.backgroundMesh, - this.renderer.domElement.clientWidth, - this.renderer.domElement.clientHeight + this.view.canvas.clientWidth, + this.view.canvas.clientHeight ) } @@ -369,6 +374,8 @@ export class SceneManager implements SceneManagerInterface { width: number, height: number ): Promise<{ scene: string; mask: string; normal: string }> { + this.view.beginRender() + const originalSize = new THREE.Vector2() this.renderer.getSize(originalSize) const originalPixelRatio = this.renderer.getPixelRatio() @@ -495,7 +502,10 @@ export class SceneManager implements SceneManagerInterface { this.renderer.setPixelRatio(originalPixelRatio) this.renderer.setSize(originalSize.x, originalSize.y) this.renderer.outputColorSpace = originalOutputColorSpace - this.handleResize(originalSize.x, originalSize.y) + this.handleResize( + this.view.canvas.clientWidth, + this.view.canvas.clientHeight + ) } } diff --git a/src/extensions/core/load3d/SceneModelManager.test.ts b/src/extensions/core/load3d/SceneModelManager.test.ts index 62442caeb5..4fc08b2b95 100644 --- a/src/extensions/core/load3d/SceneModelManager.test.ts +++ b/src/extensions/core/load3d/SceneModelManager.test.ts @@ -2,18 +2,13 @@ import { SparkRenderer } from '@sparkjsdev/spark' import * as THREE from 'three' import { describe, expect, it, vi } from 'vitest' +import { createRendererViewState } from '@/renderer/three/sharedWebGLRenderer' + import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter' import type { ModelAdapterCapabilities } from './ModelAdapter' import { SceneModelManager } from './SceneModelManager' import type { EventManagerInterface } from './interfaces' -function createMockRenderer(): THREE.WebGLRenderer { - return { - outputColorSpace: THREE.SRGBColorSpace, - dispose: vi.fn() - } as unknown as THREE.WebGLRenderer -} - function createMockEventManager(): EventManagerInterface { return { addEventListener: vi.fn(), @@ -30,7 +25,7 @@ function createManager( } = {} ) { const scene = overrides.scene ?? new THREE.Scene() - const renderer = createMockRenderer() + const viewState = createRendererViewState() const eventManager = overrides.eventManager ?? createMockEventManager() const camera = new THREE.PerspectiveCamera() const getActiveCamera = () => camera @@ -43,7 +38,7 @@ function createManager( const manager = new SceneModelManager( scene, - renderer, + viewState, eventManager, getActiveCamera, setupCamera, @@ -54,7 +49,7 @@ function createManager( return { manager, scene, - renderer, + viewState, eventManager, camera, setupCamera, @@ -67,7 +62,6 @@ function createManagerWithPose(opts: { pose: { size: THREE.Vector3; center: THREE.Vector3 } | null }) { const scene = new THREE.Scene() - const renderer = createMockRenderer() const eventManager = createMockEventManager() const camera = new THREE.PerspectiveCamera() const setupCamera = vi.fn() @@ -79,7 +73,7 @@ function createManagerWithPose(opts: { const manager = new SceneModelManager( scene, - renderer, + createRendererViewState(), eventManager, () => camera, setupCamera, @@ -474,7 +468,7 @@ describe('SceneModelManager', () => { }) it('switches to depth material', async () => { - const { manager, renderer } = createManager() + const { manager, viewState } = createManager() const model = createMeshModel() await manager.setupModel(model) @@ -482,7 +476,7 @@ describe('SceneModelManager', () => { const mesh = model.children[0] as THREE.Mesh expect(mesh.material).toBeInstanceOf(THREE.MeshDepthMaterial) - expect(renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace) + expect(viewState.outputColorSpace).toBe(THREE.LinearSRGBColorSpace) }) it('restores original material when switching back', async () => { @@ -513,16 +507,16 @@ describe('SceneModelManager', () => { expect((mesh.material as THREE.MeshStandardMaterial).map).toBe(texture) }) - it('sets renderer color space to SRGB for non-depth modes', async () => { - const { manager, renderer } = createManager() + it('sets view color space to SRGB for non-depth modes', async () => { + const { manager, viewState } = createManager() const model = createMeshModel() await manager.setupModel(model) manager.setMaterialMode('depth') - expect(renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace) + expect(viewState.outputColorSpace).toBe(THREE.LinearSRGBColorSpace) manager.setMaterialMode('normal') - expect(renderer.outputColorSpace).toBe(THREE.SRGBColorSpace) + expect(viewState.outputColorSpace).toBe(THREE.SRGBColorSpace) }) it('delegates to handlePLYModeSwitch for BufferGeometry original model', async () => { diff --git a/src/extensions/core/load3d/SceneModelManager.ts b/src/extensions/core/load3d/SceneModelManager.ts index 0cb04c85f5..4c41f81760 100644 --- a/src/extensions/core/load3d/SceneModelManager.ts +++ b/src/extensions/core/load3d/SceneModelManager.ts @@ -2,6 +2,8 @@ import { SparkRenderer } from '@sparkjsdev/spark' import * as THREE from 'three' import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader' +import type { RendererViewState } from '@/renderer/three/sharedWebGLRenderer' + import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter' import type { ModelAdapterCapabilities } from './ModelAdapter' import { buildPointCloudForMaterialMode } from './PointCloudModelAdapter' @@ -38,7 +40,7 @@ export class SceneModelManager implements ModelManagerInterface { showSkeleton: boolean = false private scene: THREE.Scene - private renderer: THREE.WebGLRenderer + private viewState: RendererViewState private eventManager: EventManagerInterface private activeCamera: THREE.Camera private setupCamera: (size: THREE.Vector3, center: THREE.Vector3) => void @@ -53,7 +55,7 @@ export class SceneModelManager implements ModelManagerInterface { constructor( scene: THREE.Scene, - renderer: THREE.WebGLRenderer, + viewState: RendererViewState, eventManager: EventManagerInterface, getActiveCamera: () => THREE.Camera, setupCamera: (size: THREE.Vector3, center: THREE.Vector3) => void, @@ -69,7 +71,7 @@ export class SceneModelManager implements ModelManagerInterface { } | null = () => null ) { this.scene = scene - this.renderer = renderer + this.viewState = viewState this.eventManager = eventManager this.activeCamera = getActiveCamera() this.setupCamera = setupCamera @@ -234,9 +236,9 @@ export class SceneModelManager implements ModelManagerInterface { } if (mode === 'depth') { - this.renderer.outputColorSpace = THREE.LinearSRGBColorSpace + this.viewState.outputColorSpace = THREE.LinearSRGBColorSpace } else { - this.renderer.outputColorSpace = THREE.SRGBColorSpace + this.viewState.outputColorSpace = THREE.SRGBColorSpace } if (this.currentModel) { diff --git a/src/extensions/core/load3d/ViewHelperManager.ts b/src/extensions/core/load3d/ViewHelperManager.ts index f7824f1685..f9af740f03 100644 --- a/src/extensions/core/load3d/ViewHelperManager.ts +++ b/src/extensions/core/load3d/ViewHelperManager.ts @@ -15,6 +15,16 @@ export class ViewHelperManager implements ViewHelperManagerInterface { private getControls: () => OrbitControls private eventManager: EventManagerInterface + private readonly helperCamera = new THREE.OrthographicCamera( + -2, + 2, + 2, + -2, + 0, + 4 + ) + private readonly savedViewport = new THREE.Vector4() + constructor( _renderer: THREE.WebGLRenderer, getActiveCamera: () => THREE.Camera, @@ -24,10 +34,25 @@ export class ViewHelperManager implements ViewHelperManagerInterface { this.getActiveCamera = getActiveCamera this.getControls = getControls this.eventManager = eventManager + this.helperCamera.position.set(0, 0, 2) } init(): void {} + render(renderer: THREE.WebGLRenderer, size: number): void { + const helper = this.viewHelper + if (!helper.isViewHelper) return + + helper.quaternion.copy(this.getActiveCamera().quaternion).invert() + helper.updateMatrixWorld() + + renderer.clearDepth() + renderer.getViewport(this.savedViewport) + renderer.setViewport(0, 0, size, size) + renderer.render(helper, this.helperCamera) + renderer.setViewport(this.savedViewport) + } + dispose(): void { if (this.viewHelper) { this.viewHelper.dispose() diff --git a/src/extensions/core/load3d/Viewport3d.test.ts b/src/extensions/core/load3d/Viewport3d.test.ts index f9299eb658..cbf1bbb058 100644 --- a/src/extensions/core/load3d/Viewport3d.test.ts +++ b/src/extensions/core/load3d/Viewport3d.test.ts @@ -394,14 +394,10 @@ describe('Viewport3d', () => { initialRenderTimer: null, startAnimation: vi.fn(), renderLoop: { stop: vi.fn() }, - resizeObserver: null, disposeContextMenuGuard: null, - renderer: { - forceContextLoss: vi.fn(), - dispose: vi.fn(), - domElement: Object.assign(document.createElement('canvas'), { - remove: vi.fn() - }) + view: { + canvas: document.createElement('canvas'), + dispose: vi.fn() }, disposeManagers: vi.fn() }) diff --git a/src/extensions/core/load3d/Viewport3d.ts b/src/extensions/core/load3d/Viewport3d.ts index 45d2bcc9dc..50d8884cc6 100644 --- a/src/extensions/core/load3d/Viewport3d.ts +++ b/src/extensions/core/load3d/Viewport3d.ts @@ -1,6 +1,6 @@ import * as THREE from 'three' -import { WebGLViewport } from '@/renderer/three/WebGLViewport' +import type { RendererView } from '@/renderer/three/RendererView' import type { CameraManager } from './CameraManager' import type { ControlsManager } from './ControlsManager' @@ -19,8 +19,10 @@ import type { RenderLoopHandle } from './load3dRenderLoop' import { startRenderLoop } from './load3dRenderLoop' import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport' +const VIEW_HELPER_SIZE = 128 + export type Viewport3dDeps = { - renderer: THREE.WebGLRenderer + view: RendererView eventManager: EventManager sceneManager: SceneManager cameraManager: CameraManager @@ -29,7 +31,8 @@ export type Viewport3dDeps = { viewHelperManager: ViewHelperManager } -export class Viewport3d extends WebGLViewport { +export class Viewport3d { + protected readonly view: RendererView protected clock: THREE.Clock private renderLoop: RenderLoopHandle | null = null private onContextMenuCallback?: (event: MouseEvent) => void @@ -57,13 +60,14 @@ export class Viewport3d extends WebGLViewport { private externalActiveCamera: THREE.Camera | null = null private overlay: SceneOverlay | null = null private initialRenderTimer: ReturnType | null = null + private viewPixelScale = 1 constructor( - container: Element | HTMLElement, + container: HTMLElement, deps: Viewport3dDeps, options: Load3DOptions = {} ) { - super(deps.renderer) + this.view = deps.view this.clock = new THREE.Clock() this.isViewerMode = options.isViewerMode || false this.onContextMenuCallback = options.onContextMenu @@ -94,7 +98,15 @@ export class Viewport3d extends WebGLViewport { this.STATUS_MOUSE_ON_VIEWER = false this.initContextMenu() - this.observeResize(container, () => this.handleResize()) + this.view.observeResize(container, () => this.handleResize()) + } + + get renderer(): THREE.WebGLRenderer { + return this.view.renderer + } + + get domElement(): HTMLCanvasElement { + return this.view.canvas } start(): void { @@ -120,7 +132,7 @@ export class Viewport3d extends WebGLViewport { private initContextMenu(): void { this.disposeContextMenuGuard = attachContextMenuGuard( - this.renderer.domElement, + this.view.canvas, (event) => this.onContextMenuCallback?.(event), { isDisabled: () => this.isViewerMode } ) @@ -159,17 +171,23 @@ export class Viewport3d extends WebGLViewport { forceRender(): void { const delta = this.clock.getDelta() this.tickPerFrame(delta) - - this.renderMainScene() - this.resetViewport() - - if (this.viewHelperManager.viewHelper.render) { - this.viewHelperManager.viewHelper.render(this.renderer) - } - + this.renderView() this.INITIAL_RENDER_DONE = true } + private renderView(): void { + this.view.beginRender() + this.renderMainScene() + + this.renderer.setScissorTest(false) + this.viewHelperManager.render( + this.renderer, + VIEW_HELPER_SIZE * this.viewPixelScale + ) + + this.view.blit() + } + protected tickPerFrame(delta: number): void { this.overlay?.update?.(delta) this.viewHelperManager.update(delta) @@ -219,8 +237,8 @@ export class Viewport3d extends WebGLViewport { } renderMainScene(): void { - const containerWidth = this.renderer.domElement.clientWidth - const containerHeight = this.renderer.domElement.clientHeight + const viewWidth = this.view.width + const viewHeight = this.view.height if (this.getDimensionsCallback) { const dims = this.getDimensionsCallback() @@ -229,15 +247,16 @@ export class Viewport3d extends WebGLViewport { } } + this.renderer.setViewport(0, 0, viewWidth, viewHeight) + this.renderer.setScissor(0, 0, viewWidth, viewHeight) + this.renderer.setScissorTest(true) + if (this.shouldMaintainAspectRatio()) { const { offsetX, offsetY, width, height } = computeLetterboxedViewport( - { width: containerWidth, height: containerHeight }, + { width: viewWidth, height: viewHeight }, this.targetAspectRatio ) - this.renderer.setViewport(0, 0, containerWidth, containerHeight) - this.renderer.setScissor(0, 0, containerWidth, containerHeight) - this.renderer.setScissorTest(true) this.renderer.setClearColor(0x0a0a0a) this.renderer.clear() @@ -246,34 +265,23 @@ export class Viewport3d extends WebGLViewport { this.cameraManager.updateAspectRatio(width / height) } else { - this.renderer.setViewport(0, 0, containerWidth, containerHeight) - this.renderer.setScissor(0, 0, containerWidth, containerHeight) - this.renderer.setScissorTest(true) + this.renderer.setClearColor( + this.view.state.clearColor, + this.view.state.clearAlpha + ) + this.renderer.clear() } this.sceneManager.renderBackground() this.renderer.render(this.sceneManager.scene, this.getRenderCamera()) } - resetViewport(): void { - const width = this.renderer.domElement.clientWidth - const height = this.renderer.domElement.clientHeight - - this.renderer.setViewport(0, 0, width, height) - this.renderer.setScissor(0, 0, width, height) - this.renderer.setScissorTest(false) - } - protected startAnimation(): void { this.renderLoop = startRenderLoop({ tick: () => { const delta = this.clock.getDelta() this.tickPerFrame(delta) - this.renderMainScene() - this.resetViewport() - if (this.viewHelperManager.viewHelper.render) { - this.viewHelperManager.viewHelper.render(this.renderer) - } + this.renderView() }, isActive: () => this.isActive() }) @@ -346,7 +354,7 @@ export class Viewport3d extends WebGLViewport { } handleResize(): void { - const parentElement = this.renderer?.domElement?.parentElement + const parentElement = this.view.canvas.parentElement if (!parentElement) { console.warn('Parent element not found') @@ -357,7 +365,7 @@ export class Viewport3d extends WebGLViewport { const containerHeight = parentElement.clientHeight const zoomScale = this.getZoomScaleCallback?.() ?? 1 - this.renderer.setPixelRatio(Math.min(zoomScale, 3)) + this.viewPixelScale = Math.min(zoomScale, 3) if (this.getDimensionsCallback) { const dims = this.getDimensionsCallback() @@ -366,17 +374,20 @@ export class Viewport3d extends WebGLViewport { } } + this.view.setSize( + containerWidth * this.viewPixelScale, + containerHeight * this.viewPixelScale + ) + if (this.shouldMaintainAspectRatio()) { const { width, height } = computeLetterboxedViewport( { width: containerWidth, height: containerHeight }, this.targetAspectRatio ) - this.renderer.setSize(containerWidth, containerHeight) this.cameraManager.handleResize(width, height) this.sceneManager.handleResize(width, height) } else { - this.renderer.setSize(containerWidth, containerHeight) this.cameraManager.handleResize(containerWidth, containerHeight) this.sceneManager.handleResize(containerWidth, containerHeight) } @@ -398,7 +409,7 @@ export class Viewport3d extends WebGLViewport { this.disposeManagers() - this.disposeRenderer() + this.view.dispose() } protected disposeManagers(): void { diff --git a/src/extensions/core/load3d/createLoad3d.test.ts b/src/extensions/core/load3d/createLoad3d.test.ts index d173773ebc..7a7f965972 100644 --- a/src/extensions/core/load3d/createLoad3d.test.ts +++ b/src/extensions/core/load3d/createLoad3d.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter' import type { ModelAdapter, ModelAdapterCapabilities } from './ModelAdapter' @@ -20,7 +20,14 @@ vi.mock('three', async () => { rendererCtor(opts) } setSize() {} + setPixelRatio() {} setClearColor() {} + getSize(target: { set(x: number, y: number): unknown }) { + target.set(300, 300) + return target + } + forceContextLoss() {} + dispose() {} } } }) @@ -163,6 +170,12 @@ function makeAdapter(overrides: Partial = {}): ModelAdapter { } describe('createLoad3d', () => { + beforeEach(() => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + drawImage: vi.fn() + } as unknown as ReturnType) + }) + it('constructs the renderer with alpha + antialias and appends it to the container', () => { rendererCtor.mockClear() const container = createContainer() diff --git a/src/extensions/core/load3d/createLoad3d.ts b/src/extensions/core/load3d/createLoad3d.ts index 3f2d1d31c6..cde7c755a1 100644 --- a/src/extensions/core/load3d/createLoad3d.ts +++ b/src/extensions/core/load3d/createLoad3d.ts @@ -1,4 +1,6 @@ -import * as THREE from 'three' +import type * as THREE from 'three' + +import { RendererView } from '@/renderer/three/RendererView' import { AnimationManager } from './AnimationManager' import { CameraManager } from './CameraManager' @@ -17,25 +19,9 @@ import { SceneModelManager } from './SceneModelManager' import { ViewHelperManager } from './ViewHelperManager' import type { Load3DOptions } from './interfaces' -function createRenderer(container: Element | HTMLElement): THREE.WebGLRenderer { - const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true }) - renderer.setSize(300, 300) - renderer.setClearColor(0x282828) - renderer.autoClear = false - renderer.outputColorSpace = THREE.SRGBColorSpace - renderer.domElement.classList.add( - 'absolute', - 'inset-0', - 'h-full', - 'w-full', - 'outline-none' - ) - container.appendChild(renderer.domElement) - return renderer -} - -function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps { - const renderer = createRenderer(container) +function buildLoad3dDeps(container: HTMLElement): Load3dDeps { + const view = new RendererView(container) + const renderer = view.renderer const eventManager = new EventManager() // Shared mutable handle: LoaderManager writes the active adapter on each // load; SceneModelManager reads it for capability/bounds/dispose lookups @@ -50,7 +36,7 @@ function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps { const getControls = () => controlsManager.controls const sceneManager = new SceneManager( - renderer, + view, getActiveCamera, getControls, eventManager @@ -58,7 +44,7 @@ function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps { cameraManager = new CameraManager(renderer, eventManager) controlsManager = new ControlsManager( - renderer, + container, cameraManager.activeCamera, eventManager ) @@ -68,6 +54,7 @@ function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps { const hdriManager = new HDRIManager( sceneManager.scene, renderer, + view.state, eventManager ) const viewHelperManager = new ViewHelperManager( @@ -79,7 +66,7 @@ function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps { const modelManager = new SceneModelManager( sceneManager.scene, - renderer, + view.state, eventManager, getActiveCamera, (size, center) => cameraManager.setupForModel(size, center), @@ -98,14 +85,14 @@ function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps { ) const recordingManager = new RecordingManager( sceneManager.scene, - renderer, + view.canvas, eventManager ) const animationManager = new AnimationManager(eventManager) gizmoManager = new GizmoManager( sceneManager.scene, - renderer, + container, controlsManager.controls, getActiveCamera, () => { @@ -119,7 +106,7 @@ function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps { ) return { - renderer, + view, eventManager, sceneManager, cameraManager, @@ -137,7 +124,7 @@ function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps { } export function createLoad3d( - container: Element | HTMLElement, + container: HTMLElement, options?: Load3DOptions ): Load3d { return new Load3d(container, buildLoad3dDeps(container), options) diff --git a/src/renderer/three/RendererView.test.ts b/src/renderer/three/RendererView.test.ts new file mode 100644 index 0000000000..35cda4e934 --- /dev/null +++ b/src/renderer/three/RendererView.test.ts @@ -0,0 +1,126 @@ +import * as THREE from 'three' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { RendererView } from './RendererView' + +vi.mock('three', async (importOriginal) => { + const actual = await importOriginal() + class WebGLRenderer { + domElement = document.createElement('canvas') + autoClear = true + outputColorSpace = '' + toneMapping = 0 + toneMappingExposure = 1 + setSize(width: number, height: number) { + this.domElement.width = width + this.domElement.height = height + } + getSize(target: { set(x: number, y: number): unknown }) { + target.set(this.domElement.width, this.domElement.height) + return target + } + setPixelRatio() {} + setClearColor = vi.fn() + forceContextLoss = vi.fn() + dispose = vi.fn() + } + return { ...actual, WebGLRenderer } +}) + +const drawImage = vi.fn() + +beforeEach(() => { + drawImage.mockClear() + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + drawImage, + globalCompositeOperation: 'source-over' + } as unknown as ReturnType) +}) + +describe('RendererView', () => { + it('appends its own canvas to the container', () => { + const container = document.createElement('div') + + const view = new RendererView(container) + + expect(view.canvas.parentElement).toBe(container) + view.dispose() + expect(view.canvas.parentElement).toBeNull() + }) + + it('setSize resizes the view canvas and grows the shared buffer to fit', () => { + const container = document.createElement('div') + const view = new RendererView(container) + + view.setSize(640.4, 480.6) + + expect(view.width).toBe(640) + expect(view.height).toBe(481) + expect(view.canvas.width).toBe(640) + expect(view.canvas.height).toBe(481) + expect(view.renderer.domElement.width).toBeGreaterThanOrEqual(640) + expect(view.renderer.domElement.height).toBeGreaterThanOrEqual(481) + + view.dispose() + }) + + it('setSize clamps degenerate sizes to 1 pixel', () => { + const container = document.createElement('div') + const view = new RendererView(container) + + view.setSize(0, -5) + + expect(view.width).toBe(1) + expect(view.height).toBe(1) + + view.dispose() + }) + + it('beginRender applies the view state and regrows a shrunken shared buffer', () => { + const container = document.createElement('div') + const view = new RendererView(container) + view.setSize(400, 300) + view.state.toneMapping = THREE.ACESFilmicToneMapping + view.state.toneMappingExposure = 0.5 + view.state.outputColorSpace = THREE.LinearSRGBColorSpace + view.state.clearAlpha = 0.25 + view.renderer.setSize(50, 40) + + view.beginRender() + + expect(view.renderer.domElement.width).toBeGreaterThanOrEqual(400) + expect(view.renderer.domElement.height).toBeGreaterThanOrEqual(300) + expect(view.renderer.toneMapping).toBe(THREE.ACESFilmicToneMapping) + expect(view.renderer.toneMappingExposure).toBe(0.5) + expect(view.renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace) + expect(view.renderer.setClearColor).toHaveBeenCalledWith( + view.state.clearColor, + 0.25 + ) + + view.dispose() + }) + + it('blit copies the bottom-left region of the shared buffer into the view canvas', () => { + const container = document.createElement('div') + const view = new RendererView(container) + view.renderer.setSize(1000, 800) + view.setSize(400, 300) + + view.blit() + + expect(drawImage).toHaveBeenCalledWith( + view.renderer.domElement, + 0, + 500, + 400, + 300, + 0, + 0, + 400, + 300 + ) + + view.dispose() + }) +}) diff --git a/src/renderer/three/RendererView.ts b/src/renderer/three/RendererView.ts new file mode 100644 index 0000000000..5c3ebc9833 --- /dev/null +++ b/src/renderer/three/RendererView.ts @@ -0,0 +1,88 @@ +import type * as THREE from 'three' + +import type { + RendererViewState, + SharedRendererHandle +} from './sharedWebGLRenderer' +import { + acquireSharedRenderer, + applyRendererViewState, + createRendererViewState, + ensureRendererSize +} from './sharedWebGLRenderer' + +export class RendererView { + readonly renderer: THREE.WebGLRenderer + readonly canvas: HTMLCanvasElement + readonly state: RendererViewState = createRendererViewState() + + width = 1 + height = 1 + + private readonly context: CanvasRenderingContext2D + private readonly handle: SharedRendererHandle + private resizeObserver: ResizeObserver | null = null + + constructor(container: HTMLElement) { + this.canvas = document.createElement('canvas') + this.canvas.classList.add( + 'absolute', + 'inset-0', + 'h-full', + 'w-full', + 'outline-none' + ) + const context = this.canvas.getContext('2d') + if (!context) { + throw new Error('Failed to create 2D context for 3D view') + } + this.context = context + + this.handle = acquireSharedRenderer() + this.renderer = this.handle.renderer + container.appendChild(this.canvas) + } + + setSize(width: number, height: number): void { + this.width = Math.max(1, Math.round(width)) + this.height = Math.max(1, Math.round(height)) + if (this.canvas.width !== this.width) this.canvas.width = this.width + if (this.canvas.height !== this.height) this.canvas.height = this.height + ensureRendererSize(this.renderer, this.width, this.height) + } + + beginRender(): void { + ensureRendererSize(this.renderer, this.width, this.height) + applyRendererViewState(this.renderer, this.state) + } + + blit(): void { + const source = this.renderer.domElement + this.context.globalCompositeOperation = 'copy' + this.context.drawImage( + source, + 0, + source.height - this.height, + this.width, + this.height, + 0, + 0, + this.width, + this.height + ) + } + + observeResize(target: Element, onResize: () => void): void { + if (typeof ResizeObserver === 'undefined') return + this.resizeObserver?.disconnect() + this.resizeObserver = new ResizeObserver(() => onResize()) + this.resizeObserver.observe(target) + } + + dispose(): void { + this.resizeObserver?.disconnect() + this.resizeObserver = null + this.canvas.remove() + this.handle.release() + } +} diff --git a/src/renderer/three/sharedWebGLRenderer.test.ts b/src/renderer/three/sharedWebGLRenderer.test.ts new file mode 100644 index 0000000000..af7235265c --- /dev/null +++ b/src/renderer/three/sharedWebGLRenderer.test.ts @@ -0,0 +1,140 @@ +import * as THREE from 'three' +import { describe, expect, it, vi } from 'vitest' + +import { + acquireSharedRenderer, + applyRendererViewState, + createRendererViewState, + ensureRendererSize +} from './sharedWebGLRenderer' + +const { rendererCtor, forceContextLoss, dispose } = vi.hoisted(() => ({ + rendererCtor: vi.fn(), + forceContextLoss: vi.fn(), + dispose: vi.fn() +})) + +vi.mock('three', async (importOriginal) => { + const actual = await importOriginal() + class WebGLRenderer { + domElement = document.createElement('canvas') + autoClear = true + outputColorSpace = '' + toneMapping = 0 + toneMappingExposure = 1 + width = 0 + height = 0 + constructor(opts: unknown) { + rendererCtor(opts) + } + setSize(width: number, height: number) { + this.width = width + this.height = height + } + getSize(target: THREE.Vector2) { + target.set(this.width, this.height) + return target + } + setPixelRatio() {} + setClearColor = vi.fn() + forceContextLoss = forceContextLoss + dispose = dispose + } + return { ...actual, WebGLRenderer } +}) + +describe('acquireSharedRenderer', () => { + it('returns the same renderer for concurrent views and disposes after the last release', () => { + rendererCtor.mockClear() + forceContextLoss.mockClear() + dispose.mockClear() + + const first = acquireSharedRenderer() + const second = acquireSharedRenderer() + + expect(second.renderer).toBe(first.renderer) + expect(rendererCtor).toHaveBeenCalledTimes(1) + + first.release() + expect(dispose).not.toHaveBeenCalled() + + const disposedWhenContextLostFired: boolean[] = [] + second.renderer.domElement.addEventListener('webglcontextlost', () => { + disposedWhenContextLostFired.push(dispose.mock.calls.length > 0) + }) + + second.release() + expect(forceContextLoss).toHaveBeenCalledTimes(1) + expect(dispose).toHaveBeenCalledTimes(1) + expect(disposedWhenContextLostFired).toEqual([false]) + }) + + it('creates a fresh renderer after the previous one was torn down', () => { + rendererCtor.mockClear() + + const first = acquireSharedRenderer() + first.release() + + const second = acquireSharedRenderer() + + expect(rendererCtor).toHaveBeenCalledTimes(2) + expect(second.renderer).not.toBe(first.renderer) + second.release() + }) + + it('ignores double release from the same handle', () => { + dispose.mockClear() + + const first = acquireSharedRenderer() + const second = acquireSharedRenderer() + + first.release() + first.release() + + expect(dispose).not.toHaveBeenCalled() + second.release() + expect(dispose).toHaveBeenCalledTimes(1) + }) +}) + +describe('ensureRendererSize', () => { + it('grows the drawing buffer per-axis but never shrinks it', () => { + const handle = acquireSharedRenderer() + const renderer = handle.renderer + renderer.setSize(300, 300) + + ensureRendererSize(renderer, 500, 200) + expect(renderer.getSize(new THREE.Vector2())).toEqual( + new THREE.Vector2(500, 300) + ) + + ensureRendererSize(renderer, 400, 250) + expect(renderer.getSize(new THREE.Vector2())).toEqual( + new THREE.Vector2(500, 300) + ) + + handle.release() + }) +}) + +describe('applyRendererViewState', () => { + it('writes the per-view state onto the renderer', () => { + const handle = acquireSharedRenderer() + const renderer = handle.renderer + const state = createRendererViewState() + state.toneMapping = THREE.ACESFilmicToneMapping + state.toneMappingExposure = 0.5 + state.outputColorSpace = THREE.LinearSRGBColorSpace + state.clearColor.set(0x123456) + state.clearAlpha = 0.25 + + applyRendererViewState(renderer, state) + + expect(renderer.toneMapping).toBe(THREE.ACESFilmicToneMapping) + expect(renderer.toneMappingExposure).toBe(0.5) + expect(renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace) + expect(renderer.setClearColor).toHaveBeenCalledWith(state.clearColor, 0.25) + + handle.release() + }) +}) diff --git a/src/renderer/three/sharedWebGLRenderer.ts b/src/renderer/three/sharedWebGLRenderer.ts new file mode 100644 index 0000000000..e39b8c4727 --- /dev/null +++ b/src/renderer/three/sharedWebGLRenderer.ts @@ -0,0 +1,78 @@ +import * as THREE from 'three' + +export type RendererViewState = { + toneMapping: THREE.ToneMapping + toneMappingExposure: number + outputColorSpace: THREE.ColorSpace + clearColor: THREE.Color + clearAlpha: number +} + +export function createRendererViewState(): RendererViewState { + return { + toneMapping: THREE.NoToneMapping, + toneMappingExposure: 1.0, + outputColorSpace: THREE.SRGBColorSpace, + clearColor: new THREE.Color(0x000000), + clearAlpha: 0 + } +} + +export function applyRendererViewState( + renderer: THREE.WebGLRenderer, + state: RendererViewState +): void { + renderer.toneMapping = state.toneMapping + renderer.toneMappingExposure = state.toneMappingExposure + renderer.outputColorSpace = state.outputColorSpace + renderer.setClearColor(state.clearColor, state.clearAlpha) +} + +export type SharedRendererHandle = { + renderer: THREE.WebGLRenderer + release: () => void +} + +let sharedRenderer: THREE.WebGLRenderer | null = null +let viewCount = 0 + +function createSharedRenderer(): THREE.WebGLRenderer { + const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true }) + renderer.setPixelRatio(1) + renderer.setSize(300, 300) + renderer.autoClear = false + renderer.outputColorSpace = THREE.SRGBColorSpace + return renderer +} + +export function acquireSharedRenderer(): SharedRendererHandle { + sharedRenderer ??= createSharedRenderer() + viewCount++ + const renderer = sharedRenderer + let released = false + return { + renderer, + release() { + if (released) return + released = true + viewCount-- + if (viewCount > 0 || sharedRenderer !== renderer) return + sharedRenderer = null + renderer.forceContextLoss() + renderer.domElement.dispatchEvent( + new Event('webglcontextlost', { bubbles: true, cancelable: true }) + ) + renderer.dispose() + } + } +} + +export function ensureRendererSize( + renderer: THREE.WebGLRenderer, + width: number, + height: number +): void { + const size = renderer.getSize(new THREE.Vector2()) + if (size.width >= width && size.height >= height) return + renderer.setSize(Math.max(size.width, width), Math.max(size.height, height)) +}