mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-13 18:59:32 +00:00
feat: share one WebGL context across all 3D views (#13547)
## 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 <img width="2155" height="1527" alt="image" src="https://github.com/user-attachments/assets/35a7ab8a-28dd-41ba-8349-525bd2e97977" /> after <img width="918" height="430" alt="屏幕截图 2026-07-09 135025" src="https://github.com/user-attachments/assets/67328577-bd82-4494-921a-6d298442d081" /> <img width="1636" height="1509" alt="image" src="https://github.com/user-attachments/assets/a3be9215-7cd0-427c-8264-1fd964d3ace1" />
This commit is contained in:
@@ -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<Load3d['renderer']> 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 () => {
|
||||
|
||||
@@ -244,7 +244,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
node.onDrawBackground,
|
||||
function (this: LGraphNode) {
|
||||
if (load3d) {
|
||||
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
|
||||
load3d.domElement.hidden = this.flags.collapsed ?? false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof makeMockOrbitControls>
|
||||
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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<typeof makeMockEventManager>
|
||||
let manager: RecordingManager
|
||||
let rafSpy: ReturnType<typeof vi.spyOn>
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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<typeof setTimeout> | 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 {
|
||||
|
||||
@@ -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> = {}): ModelAdapter {
|
||||
}
|
||||
|
||||
describe('createLoad3d', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
drawImage: vi.fn()
|
||||
} as unknown as ReturnType<HTMLCanvasElement['getContext']>)
|
||||
})
|
||||
|
||||
it('constructs the renderer with alpha + antialias and appends it to the container', () => {
|
||||
rendererCtor.mockClear()
|
||||
const container = createContainer()
|
||||
|
||||
@@ -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)
|
||||
|
||||
126
src/renderer/three/RendererView.test.ts
Normal file
126
src/renderer/three/RendererView.test.ts
Normal file
@@ -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<typeof THREE>()
|
||||
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<HTMLCanvasElement['getContext']>)
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
88
src/renderer/three/RendererView.ts
Normal file
88
src/renderer/three/RendererView.ts
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
140
src/renderer/three/sharedWebGLRenderer.test.ts
Normal file
140
src/renderer/three/sharedWebGLRenderer.test.ts
Normal file
@@ -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<typeof THREE>()
|
||||
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()
|
||||
})
|
||||
})
|
||||
78
src/renderer/three/sharedWebGLRenderer.ts
Normal file
78
src/renderer/three/sharedWebGLRenderer.ts
Normal file
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user