mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 14:16:00 +00:00
## Summary
Preview 3D and Animation nodes were stuck at the LOD from initial page
load because CSS `scale3d` transforms don't affect
`clientWidth`/`clientHeight` — `handleResize()` always received
layout-space dimensions regardless of zoom level. This fix passes
`ds.scale` as the renderer pixel ratio so the 3D scene renders at the
correct visual resolution when the graph is zoomed in or out.
## Changes
- **What**: In `Load3d.handleResize()`, call
`renderer.setPixelRatio(ds.scale)` before `setSize` so pixel density
scales with canvas zoom. A `getZoomScale` callback is threaded through
`Load3DOptions` → `Load3d` constructor → `handleResize`. In `useLoad3d`,
a watcher on `canvasStore.appScalePercentage` triggers `handleResize`
whenever the zoom level changes.
- **What**: Fix `SceneManager.captureScene()` to save and restore the
renderer's logical size and pixel ratio around capture, so exact-pixel
output is unaffected by the current zoom state.
## Review Focus
- `handleResize` now calls `setPixelRatio` before `setSize`. Three.js
renders at `logicalWidth × pixelRatio` physical pixels while CSS
displays it at `logicalWidth` CSS pixels — this is the standard pattern
for HiDPI but here used to match the visual zoom level.
- `captureScene` must reset `pixelRatio` to 1 so `setSize(w, h)`
produces exactly `w×h` pixel output. It saves and restores both logical
size and pixel ratio via `renderer.getSize()` /
`renderer.getPixelRatio()`.
- The zoom watcher is guarded with `getActivePinia()` to avoid errors in
unit tests and non-Pinia contexts.
## Test
before
https://github.com/user-attachments/assets/9778ad54-7cb2-4fdc-b200-65a683ee8e4d
after
https://github.com/user-attachments/assets/acfaaf7a-43c7-495f-b352-5dd2cdaa94db
## Analysis Report
https://linear.app/comfyorg/issue/FE-401/bug-preview-3d-and-animation-nodes-lod-stuck-at-initial-page-load
## More
- Add `debounce` and pixel ratio limit
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> **Medium Risk**
> Medium risk because it changes core `Load3d.handleResize()` rendering
behavior (pixel ratio/LOD) and adds a debounced zoom-driven resize
watcher, which could affect performance or visual output across all
Load3D nodes. Capture logic is also refactored to manipulate renderer
size/pixel ratio and camera params, so regressions would show up in
thumbnails/exports.
>
> **Overview**
> Fixes Load3D LOD/render sharpness when the graph canvas is zoomed by
threading a new `getZoomScale` option from `useLoad3d` into `Load3d` and
using it to call `renderer.setPixelRatio()` (clamped) during
`handleResize()`.
>
> Adds a debounced watcher on `canvasStore.appScalePercentage` to
trigger `handleResize()` on zoom changes, and updates
`SceneManager.captureScene()` to temporarily force pixel ratio 1 and
restore renderer size/pixel ratio and camera settings after capture.
Coverage is expanded with new Playwright smoke coverage plus unit tests
for zoom propagation, debouncing, pixel ratio behavior, and capture
state restoration.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
261940d111. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11734-fix-load3d-update-renderer-pixel-ratio-on-canvas-zoom-to-fix-LOD-resolution-3516d73d365081e6b3d4cdd05f516489)
by [Unito](https://www.unito.io)
471 lines
14 KiB
TypeScript
471 lines
14 KiB
TypeScript
import * as THREE from 'three'
|
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
|
|
|
import Load3dUtils from './Load3dUtils'
|
|
import {
|
|
type BackgroundRenderModeType,
|
|
type EventManagerInterface,
|
|
type SceneManagerInterface
|
|
} from './interfaces'
|
|
|
|
export class SceneManager implements SceneManagerInterface {
|
|
scene!: THREE.Scene
|
|
gridHelper: THREE.GridHelper
|
|
|
|
backgroundScene!: THREE.Scene
|
|
backgroundCamera: THREE.OrthographicCamera
|
|
backgroundMesh: THREE.Mesh | null = null
|
|
backgroundTexture: THREE.Texture | null = null
|
|
|
|
backgroundRenderMode: 'tiled' | 'panorama' = 'tiled'
|
|
|
|
backgroundColorMaterial: THREE.MeshBasicMaterial | null = null
|
|
currentBackgroundType: 'color' | 'image' = 'color'
|
|
currentBackgroundColor: string = '#282828'
|
|
|
|
private eventManager: EventManagerInterface
|
|
private renderer: THREE.WebGLRenderer
|
|
|
|
private getActiveCamera: () => THREE.Camera
|
|
|
|
constructor(
|
|
renderer: THREE.WebGLRenderer,
|
|
getActiveCamera: () => THREE.Camera,
|
|
_getControls: () => OrbitControls,
|
|
eventManager: EventManagerInterface
|
|
) {
|
|
this.renderer = renderer
|
|
this.eventManager = eventManager
|
|
this.scene = new THREE.Scene()
|
|
|
|
this.scene.name = 'MainScene'
|
|
|
|
this.getActiveCamera = getActiveCamera
|
|
|
|
this.gridHelper = new THREE.GridHelper(20, 20)
|
|
this.gridHelper.position.set(0, 0, 0)
|
|
this.scene.add(this.gridHelper)
|
|
|
|
this.backgroundScene = new THREE.Scene()
|
|
this.backgroundScene.name = 'BackgroundScene'
|
|
this.backgroundCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, -1, 1)
|
|
|
|
this.initBackgroundScene()
|
|
}
|
|
|
|
private initBackgroundScene(): void {
|
|
const planeGeometry = new THREE.PlaneGeometry(2, 2)
|
|
|
|
this.backgroundColorMaterial = new THREE.MeshBasicMaterial({
|
|
color: new THREE.Color(this.currentBackgroundColor),
|
|
transparent: false,
|
|
depthWrite: false,
|
|
depthTest: false,
|
|
side: THREE.DoubleSide
|
|
})
|
|
|
|
this.backgroundMesh = new THREE.Mesh(
|
|
planeGeometry,
|
|
this.backgroundColorMaterial
|
|
)
|
|
this.backgroundMesh.position.set(0, 0, 0)
|
|
this.backgroundScene.add(this.backgroundMesh)
|
|
|
|
this.renderer.setClearColor(0x000000, 0)
|
|
}
|
|
|
|
init(): void {}
|
|
|
|
dispose(): void {
|
|
if (this.backgroundTexture) {
|
|
this.backgroundTexture.dispose()
|
|
}
|
|
|
|
if (this.backgroundColorMaterial) {
|
|
this.backgroundColorMaterial.dispose()
|
|
}
|
|
|
|
if (this.backgroundMesh) {
|
|
this.backgroundMesh.geometry.dispose()
|
|
if (this.backgroundMesh.material instanceof THREE.Material) {
|
|
this.backgroundMesh.material.dispose()
|
|
}
|
|
}
|
|
|
|
if (this.scene.background) {
|
|
this.scene.background = null
|
|
}
|
|
|
|
this.backgroundScene.clear()
|
|
|
|
this.scene.clear()
|
|
}
|
|
|
|
toggleGrid(showGrid: boolean): void {
|
|
if (this.gridHelper) {
|
|
this.gridHelper.visible = showGrid
|
|
}
|
|
|
|
this.eventManager.emitEvent('showGridChange', showGrid)
|
|
}
|
|
|
|
setBackgroundColor(color: string): void {
|
|
this.currentBackgroundColor = color
|
|
this.currentBackgroundType = 'color'
|
|
|
|
if (this.scene.background instanceof THREE.Texture) {
|
|
this.scene.background = null
|
|
}
|
|
|
|
if (this.backgroundRenderMode === 'panorama') {
|
|
this.backgroundRenderMode = 'tiled'
|
|
this.eventManager.emitEvent('backgroundRenderModeChange', 'tiled')
|
|
}
|
|
|
|
if (!this.backgroundMesh || !this.backgroundColorMaterial) {
|
|
this.initBackgroundScene()
|
|
}
|
|
|
|
this.backgroundColorMaterial!.color.set(color)
|
|
this.backgroundColorMaterial!.map = null
|
|
this.backgroundColorMaterial!.transparent = false
|
|
this.backgroundColorMaterial!.needsUpdate = true
|
|
|
|
if (this.backgroundMesh) {
|
|
this.backgroundMesh.material = this.backgroundColorMaterial!
|
|
}
|
|
|
|
if (this.backgroundTexture) {
|
|
this.backgroundTexture.dispose()
|
|
this.backgroundTexture = null
|
|
}
|
|
|
|
this.eventManager.emitEvent('backgroundColorChange', color)
|
|
}
|
|
|
|
async setBackgroundImage(uploadPath: string): Promise<void> {
|
|
if (uploadPath === '') {
|
|
this.setBackgroundColor(this.currentBackgroundColor)
|
|
|
|
return
|
|
}
|
|
|
|
this.eventManager.emitEvent('backgroundImageLoadingStart', null)
|
|
|
|
let type = 'input'
|
|
let pathParts = Load3dUtils.splitFilePath(uploadPath)
|
|
let subfolder = pathParts[0]
|
|
let filename = pathParts[1]
|
|
|
|
if (subfolder === 'temp') {
|
|
type = 'temp'
|
|
pathParts = ['', filename]
|
|
} else if (subfolder === 'output') {
|
|
type = 'output'
|
|
pathParts = ['', filename]
|
|
}
|
|
|
|
let imageUrl = Load3dUtils.getResourceURL(...pathParts, type)
|
|
|
|
if (!imageUrl.startsWith('/api')) {
|
|
imageUrl = '/api' + imageUrl
|
|
}
|
|
|
|
try {
|
|
const textureLoader = new THREE.TextureLoader()
|
|
const texture = await new Promise<THREE.Texture>((resolve, reject) => {
|
|
textureLoader.load(imageUrl, resolve, undefined, reject)
|
|
})
|
|
|
|
if (this.backgroundTexture) {
|
|
this.backgroundTexture.dispose()
|
|
}
|
|
|
|
texture.colorSpace = THREE.SRGBColorSpace
|
|
|
|
this.backgroundTexture = texture
|
|
this.currentBackgroundType = 'image'
|
|
|
|
if (this.backgroundRenderMode === 'panorama') {
|
|
texture.mapping = THREE.EquirectangularReflectionMapping
|
|
this.scene.background = texture
|
|
} else {
|
|
if (!this.backgroundMesh) {
|
|
this.initBackgroundScene()
|
|
}
|
|
|
|
const imageMaterial = new THREE.MeshBasicMaterial({
|
|
map: texture,
|
|
transparent: true,
|
|
depthWrite: false,
|
|
depthTest: false,
|
|
side: THREE.DoubleSide
|
|
})
|
|
|
|
if (this.backgroundMesh) {
|
|
if (
|
|
this.backgroundMesh.material !== this.backgroundColorMaterial &&
|
|
this.backgroundMesh.material instanceof THREE.Material
|
|
) {
|
|
this.backgroundMesh.material.dispose()
|
|
}
|
|
|
|
this.backgroundMesh.material = imageMaterial
|
|
this.backgroundMesh.position.set(0, 0, 0)
|
|
}
|
|
|
|
this.updateBackgroundSize(
|
|
this.backgroundTexture,
|
|
this.backgroundMesh,
|
|
this.renderer.domElement.clientWidth,
|
|
this.renderer.domElement.clientHeight
|
|
)
|
|
}
|
|
|
|
this.eventManager.emitEvent('backgroundImageChange', uploadPath)
|
|
this.eventManager.emitEvent('backgroundImageLoadingEnd', null)
|
|
} catch (error) {
|
|
this.eventManager.emitEvent('backgroundImageLoadingEnd', null)
|
|
console.error('Error loading background image:', error)
|
|
this.setBackgroundColor(this.currentBackgroundColor)
|
|
}
|
|
}
|
|
|
|
removeBackgroundImage(): void {
|
|
this.setBackgroundColor(this.currentBackgroundColor)
|
|
this.eventManager.emitEvent('backgroundImageLoadingEnd', null)
|
|
}
|
|
|
|
setBackgroundRenderMode(mode: BackgroundRenderModeType): void {
|
|
if (this.backgroundRenderMode === mode) return
|
|
|
|
this.backgroundRenderMode = mode
|
|
|
|
if (this.currentBackgroundType === 'image' && this.backgroundTexture) {
|
|
try {
|
|
if (mode === 'panorama') {
|
|
this.backgroundTexture.mapping =
|
|
THREE.EquirectangularReflectionMapping
|
|
this.scene.background = this.backgroundTexture
|
|
} else {
|
|
this.scene.background = null
|
|
if (
|
|
this.backgroundMesh &&
|
|
this.backgroundMesh.material instanceof THREE.MeshBasicMaterial
|
|
) {
|
|
this.backgroundMesh.material.map = this.backgroundTexture
|
|
this.backgroundMesh.material.needsUpdate = true
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error set background render mode:', error)
|
|
}
|
|
}
|
|
|
|
this.eventManager.emitEvent('backgroundRenderModeChange', mode)
|
|
}
|
|
|
|
updateBackgroundSize(
|
|
backgroundTexture: THREE.Texture | null,
|
|
backgroundMesh: THREE.Mesh | null,
|
|
targetWidth: number,
|
|
targetHeight: number
|
|
): void {
|
|
if (!backgroundTexture || !backgroundMesh) return
|
|
|
|
const material = backgroundMesh.material as THREE.MeshBasicMaterial
|
|
|
|
if (!material.map) return
|
|
|
|
const imageAspect =
|
|
backgroundTexture.image.width / backgroundTexture.image.height
|
|
const targetAspect = targetWidth / targetHeight
|
|
|
|
if (imageAspect > targetAspect) {
|
|
backgroundMesh.scale.set(imageAspect / targetAspect, 1, 1)
|
|
} else {
|
|
backgroundMesh.scale.set(1, targetAspect / imageAspect, 1)
|
|
}
|
|
|
|
material.needsUpdate = true
|
|
}
|
|
|
|
handleResize(width: number, height: number): void {
|
|
if (
|
|
this.backgroundTexture &&
|
|
this.backgroundMesh &&
|
|
this.currentBackgroundType === 'image'
|
|
) {
|
|
this.updateBackgroundSize(
|
|
this.backgroundTexture,
|
|
this.backgroundMesh,
|
|
width,
|
|
height
|
|
)
|
|
}
|
|
}
|
|
|
|
renderBackground(): void {
|
|
if (
|
|
(this.backgroundRenderMode === 'tiled' ||
|
|
this.currentBackgroundType === 'color') &&
|
|
this.backgroundMesh
|
|
) {
|
|
const currentToneMapping = this.renderer.toneMapping
|
|
const currentExposure = this.renderer.toneMappingExposure
|
|
|
|
this.renderer.toneMapping = THREE.NoToneMapping
|
|
this.renderer.render(this.backgroundScene, this.backgroundCamera)
|
|
|
|
this.renderer.toneMapping = currentToneMapping
|
|
this.renderer.toneMappingExposure = currentExposure
|
|
}
|
|
}
|
|
|
|
getCurrentBackgroundInfo(): { type: 'color' | 'image'; value: string } {
|
|
return {
|
|
type: this.currentBackgroundType,
|
|
value:
|
|
this.currentBackgroundType === 'color'
|
|
? this.currentBackgroundColor
|
|
: ''
|
|
}
|
|
}
|
|
|
|
async captureScene(
|
|
width: number,
|
|
height: number
|
|
): Promise<{ scene: string; mask: string; normal: string }> {
|
|
const originalSize = new THREE.Vector2()
|
|
this.renderer.getSize(originalSize)
|
|
const originalPixelRatio = this.renderer.getPixelRatio()
|
|
const originalClearColor = this.renderer.getClearColor(new THREE.Color())
|
|
const originalClearAlpha = this.renderer.getClearAlpha()
|
|
const originalOutputColorSpace = this.renderer.outputColorSpace
|
|
|
|
const activeCamera = this.getActiveCamera()
|
|
const savedCameraParams =
|
|
activeCamera instanceof THREE.PerspectiveCamera
|
|
? { type: 'perspective' as const, aspect: activeCamera.aspect }
|
|
: {
|
|
type: 'orthographic' as const,
|
|
left: (activeCamera as THREE.OrthographicCamera).left,
|
|
right: (activeCamera as THREE.OrthographicCamera).right,
|
|
top: (activeCamera as THREE.OrthographicCamera).top,
|
|
bottom: (activeCamera as THREE.OrthographicCamera).bottom
|
|
}
|
|
|
|
const originalMaterials = new Map<
|
|
THREE.Mesh,
|
|
THREE.Material | THREE.Material[]
|
|
>()
|
|
const tempMaterials: THREE.MeshNormalMaterial[] = []
|
|
const gridVisible = this.gridHelper.visible
|
|
|
|
try {
|
|
// Capture at exactly the requested pixel dimensions, independent of
|
|
// the current zoom-driven pixel ratio.
|
|
this.renderer.setPixelRatio(1)
|
|
this.renderer.setSize(width, height)
|
|
|
|
if (activeCamera instanceof THREE.PerspectiveCamera) {
|
|
activeCamera.aspect = width / height
|
|
activeCamera.updateProjectionMatrix()
|
|
} else {
|
|
const orthographicCamera = activeCamera as THREE.OrthographicCamera
|
|
|
|
const frustumSize = 10
|
|
const aspect = width / height
|
|
|
|
orthographicCamera.left = (-frustumSize * aspect) / 2
|
|
orthographicCamera.right = (frustumSize * aspect) / 2
|
|
orthographicCamera.top = frustumSize / 2
|
|
orthographicCamera.bottom = -frustumSize / 2
|
|
|
|
orthographicCamera.updateProjectionMatrix()
|
|
}
|
|
|
|
if (
|
|
this.backgroundTexture &&
|
|
this.backgroundMesh &&
|
|
this.currentBackgroundType === 'image'
|
|
) {
|
|
this.updateBackgroundSize(
|
|
this.backgroundTexture,
|
|
this.backgroundMesh,
|
|
width,
|
|
height
|
|
)
|
|
}
|
|
|
|
this.renderer.clear()
|
|
this.renderBackground()
|
|
this.renderer.render(this.scene, activeCamera)
|
|
const sceneData = this.renderer.domElement.toDataURL('image/png')
|
|
|
|
this.renderer.setClearColor(0x000000, 0)
|
|
this.renderer.clear()
|
|
this.renderer.render(this.scene, activeCamera)
|
|
const maskData = this.renderer.domElement.toDataURL('image/png')
|
|
|
|
this.scene.traverse((child) => {
|
|
if (child instanceof THREE.Mesh) {
|
|
originalMaterials.set(child, child.material)
|
|
|
|
const tempMaterial = new THREE.MeshNormalMaterial({
|
|
flatShading: false,
|
|
side: THREE.DoubleSide,
|
|
normalScale: new THREE.Vector2(1, 1)
|
|
})
|
|
tempMaterials.push(tempMaterial)
|
|
child.material = tempMaterial
|
|
}
|
|
})
|
|
|
|
this.gridHelper.visible = false
|
|
|
|
this.renderer.setClearColor(0x000000, 1)
|
|
this.renderer.clear()
|
|
this.renderer.render(this.scene, activeCamera)
|
|
const normalData = this.renderer.domElement.toDataURL('image/png')
|
|
|
|
this.renderer.setClearColor(0xffffff, 1)
|
|
this.renderer.clear()
|
|
|
|
return { scene: sceneData, mask: maskData, normal: normalData }
|
|
} finally {
|
|
this.scene.traverse((child) => {
|
|
if (child instanceof THREE.Mesh) {
|
|
const originalMaterial = originalMaterials.get(child)
|
|
if (originalMaterial) {
|
|
child.material = originalMaterial
|
|
}
|
|
}
|
|
})
|
|
for (const mat of tempMaterials) {
|
|
mat.dispose()
|
|
}
|
|
this.gridHelper.visible = gridVisible
|
|
if (savedCameraParams.type === 'perspective') {
|
|
const persp = activeCamera as THREE.PerspectiveCamera
|
|
persp.aspect = savedCameraParams.aspect
|
|
persp.updateProjectionMatrix()
|
|
} else {
|
|
const ortho = activeCamera as THREE.OrthographicCamera
|
|
ortho.left = savedCameraParams.left
|
|
ortho.right = savedCameraParams.right
|
|
ortho.top = savedCameraParams.top
|
|
ortho.bottom = savedCameraParams.bottom
|
|
ortho.updateProjectionMatrix()
|
|
}
|
|
this.renderer.setClearColor(originalClearColor, originalClearAlpha)
|
|
this.renderer.setPixelRatio(originalPixelRatio)
|
|
this.renderer.setSize(originalSize.x, originalSize.y)
|
|
this.renderer.outputColorSpace = originalOutputColorSpace
|
|
this.handleResize(originalSize.x, originalSize.y)
|
|
}
|
|
}
|
|
|
|
reset(): void {}
|
|
}
|