Files
ComfyUI_frontend/src/extensions/core/load3d/SceneManager.ts
Kelly Yang 7b59c561ff fix(load3d): update renderer pixel ratio on canvas zoom to fix LOD resolution (#11734)
## 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)
2026-05-04 20:25:55 -04:00

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 {}
}