mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-14 03:07:57 +00:00
## 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" />
141 lines
3.9 KiB
TypeScript
141 lines
3.9 KiB
TypeScript
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()
|
|
})
|
|
})
|