mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
## Summary
Adds `HDRIManager` to load `.hdr/.exr` files as equirectangular
environment maps via **three.js** `RGBELoader/EXRLoader`
- Uploads HDRI files to the server via `/upload/image` API so they
persist across page reloads
- Restores HDRI state (enabled, **intensity**, **background**) from node
properties on reload
- Auto-enables "**Show as Background**" on successful upload for
immediate visual feedback
- Hides standard directional lights when HDRI is active; restores them
when disabled
- Hides the Light Intensity control while HDRI is active (lights have no
effect when HDRI overrides scene lighting)
- Limits HDRI availability to PBR-capable formats (.gltf, .glb, .fbx,
.obj); automatically disables when switching to an incompatible model
- Adds intensity slider and "**Show as Background**" toggle to the HDRI
panel
## How to Use HDRI Environment Lighting
1. Load a 3D model using a Load3D or Load3DViewer node (supported
formats: .gltf, .glb, .fbx, .obj)
2. Open the control panel → go to the Light tab
3. Click the globe icon to open the **HDRI panel**
4. Click Upload HDRI and select a` .hdr` or `.exr` file
5. The environment lighting applies automatically — the scene background
also updates to preview the panorama
6. Use the intensity slider to adjust the strength of the environment
lighting
7. Toggle Show as Background to show or hide the HDRI panorama behind
the model
## Screenshots
https://github.com/user-attachments/assets/1ec56ef0-853e-452f-ae2b-2474c9d0d781
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10818-feat-load3d-add-optional-HDRI-environment-lighting-to-3D-preview-nodes-3366d73d365081ea8c7ad9226b8b1e2f)
by [Unito](https://www.unito.io)
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> **Medium Risk**
> Adds new HDRI loading/rendering path and persists new
`LightConfig.hdri` state, touching Three.js rendering, file uploads, and
node property restoration. Risk is moderate due to new async flows and
potential compatibility/performance issues with model switching and
renderer settings.
>
> **Overview**
> Adds optional **HDRI environment lighting** to Load3D previews,
including a new `HDRIManager` that loads `.hdr`/`.exr` files into
Three.js environment/background and exposes controls for enable/disable,
background display, and intensity.
>
> Extends `LightConfig` with an `hdri` block that is persisted on nodes
and restored on reload; `useLoad3d` now uploads HDRI files, loads them
into `Load3d`, maps scene light intensity to HDRI intensity, and
auto-disables HDRI when the current model format doesn’t support it.
>
> Updates the UI to include embedded HDRI controls under the Light panel
(with dismissable overlays and icon updates), adjusts light intensity
behavior when HDRI is active, and adds tests/strings/utilities
(`getFilenameExtension`, `mapSceneLightIntensityToHdri`, new constants)
to support the feature.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
b12c9722dc. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
---------
Co-authored-by: Terry Jia <terryjia88@gmail.com>
275 lines
7.3 KiB
TypeScript
275 lines
7.3 KiB
TypeScript
import Load3d from '@/extensions/core/load3d/Load3d'
|
|
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
|
import type {
|
|
CameraConfig,
|
|
CameraState,
|
|
HDRIConfig,
|
|
LightConfig,
|
|
ModelConfig,
|
|
SceneConfig
|
|
} from '@/extensions/core/load3d/interfaces'
|
|
import type { Dictionary } from '@/lib/litegraph/src/interfaces'
|
|
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
|
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
|
import { api } from '@/scripts/api'
|
|
|
|
type Load3DConfigurationSettings = {
|
|
loadFolder: string
|
|
modelWidget: IBaseWidget
|
|
cameraState?: CameraState
|
|
width?: IBaseWidget
|
|
height?: IBaseWidget
|
|
bgImagePath?: string
|
|
}
|
|
|
|
class Load3DConfiguration {
|
|
constructor(
|
|
private load3d: Load3d,
|
|
private properties?: Dictionary<NodeProperty | undefined>
|
|
) {}
|
|
|
|
configureForSaveMesh(loadFolder: 'input' | 'output', filePath: string) {
|
|
this.setupModelHandlingForSaveMesh(filePath, loadFolder)
|
|
this.setupDefaultProperties()
|
|
}
|
|
|
|
configure(setting: Load3DConfigurationSettings) {
|
|
this.setupModelHandling(
|
|
setting.modelWidget,
|
|
setting.loadFolder,
|
|
setting.cameraState
|
|
)
|
|
this.setupTargetSize(setting.width, setting.height)
|
|
this.setupDefaultProperties(setting.bgImagePath)
|
|
}
|
|
|
|
private setupTargetSize(width?: IBaseWidget, height?: IBaseWidget) {
|
|
if (width && height) {
|
|
this.load3d.setTargetSize(width.value as number, height.value as number)
|
|
|
|
width.callback = (value: number) => {
|
|
this.load3d.setTargetSize(value, height.value as number)
|
|
}
|
|
|
|
height.callback = (value: number) => {
|
|
this.load3d.setTargetSize(width.value as number, value)
|
|
}
|
|
}
|
|
}
|
|
|
|
private setupModelHandlingForSaveMesh(filePath: string, loadFolder: string) {
|
|
const onModelWidgetUpdate = this.createModelUpdateHandler(loadFolder)
|
|
|
|
if (filePath) {
|
|
onModelWidgetUpdate(filePath)
|
|
}
|
|
}
|
|
|
|
private setupModelHandling(
|
|
modelWidget: IBaseWidget,
|
|
loadFolder: string,
|
|
cameraState?: CameraState
|
|
) {
|
|
const onModelWidgetUpdate = this.createModelUpdateHandler(
|
|
loadFolder,
|
|
cameraState
|
|
)
|
|
if (modelWidget.value) {
|
|
onModelWidgetUpdate(modelWidget.value)
|
|
}
|
|
|
|
const originalCallback = modelWidget.callback
|
|
|
|
let currentValue = modelWidget.value
|
|
Object.defineProperty(modelWidget, 'value', {
|
|
get() {
|
|
return currentValue
|
|
},
|
|
set(newValue) {
|
|
currentValue = newValue
|
|
if (modelWidget.callback && newValue !== undefined && newValue !== '') {
|
|
modelWidget.callback(newValue)
|
|
}
|
|
},
|
|
enumerable: true,
|
|
configurable: true
|
|
})
|
|
|
|
modelWidget.callback = (value: string | number | boolean | object) => {
|
|
onModelWidgetUpdate(value)
|
|
|
|
if (originalCallback) {
|
|
originalCallback(value)
|
|
}
|
|
}
|
|
}
|
|
|
|
private setupDefaultProperties(bgImagePath?: string) {
|
|
const sceneConfig = this.loadSceneConfig()
|
|
this.applySceneConfig(sceneConfig, bgImagePath)
|
|
|
|
const cameraConfig = this.loadCameraConfig()
|
|
this.applyCameraConfig(cameraConfig)
|
|
|
|
const lightConfig = this.loadLightConfig()
|
|
this.applyLightConfig(lightConfig)
|
|
if (lightConfig.hdri) this.applyHDRISettings(lightConfig.hdri)
|
|
}
|
|
|
|
private loadSceneConfig(): SceneConfig {
|
|
if (this.properties && 'Scene Config' in this.properties) {
|
|
return this.properties['Scene Config'] as SceneConfig
|
|
}
|
|
|
|
return {
|
|
showGrid: useSettingStore().get('Comfy.Load3D.ShowGrid'),
|
|
backgroundColor:
|
|
'#' + useSettingStore().get('Comfy.Load3D.BackgroundColor'),
|
|
backgroundImage: ''
|
|
} as SceneConfig
|
|
}
|
|
|
|
private loadCameraConfig(): CameraConfig {
|
|
if (this.properties && 'Camera Config' in this.properties) {
|
|
return this.properties['Camera Config'] as CameraConfig
|
|
}
|
|
|
|
return {
|
|
cameraType: useSettingStore().get('Comfy.Load3D.CameraType'),
|
|
fov: 35
|
|
} as CameraConfig
|
|
}
|
|
|
|
private loadLightConfig(): LightConfig {
|
|
const hdriDefaults: HDRIConfig = {
|
|
enabled: false,
|
|
hdriPath: '',
|
|
showAsBackground: false,
|
|
intensity: 1
|
|
}
|
|
|
|
if (this.properties && 'Light Config' in this.properties) {
|
|
const saved = this.properties['Light Config'] as Partial<LightConfig>
|
|
return {
|
|
intensity:
|
|
saved.intensity ??
|
|
(useSettingStore().get('Comfy.Load3D.LightIntensity') as number),
|
|
hdri: { ...hdriDefaults, ...(saved.hdri ?? {}) }
|
|
}
|
|
}
|
|
|
|
return {
|
|
intensity: useSettingStore().get('Comfy.Load3D.LightIntensity') as number,
|
|
hdri: hdriDefaults
|
|
}
|
|
}
|
|
|
|
private loadModelConfig(): ModelConfig {
|
|
if (this.properties && 'Model Config' in this.properties) {
|
|
return this.properties['Model Config'] as ModelConfig
|
|
}
|
|
|
|
return {
|
|
upDirection: 'original',
|
|
materialMode: 'original',
|
|
showSkeleton: false
|
|
}
|
|
}
|
|
|
|
private applySceneConfig(config: SceneConfig, bgImagePath?: string) {
|
|
this.load3d.toggleGrid(config.showGrid)
|
|
this.load3d.setBackgroundColor(config.backgroundColor)
|
|
if (config.backgroundImage) {
|
|
if (bgImagePath && bgImagePath != config.backgroundImage) {
|
|
return
|
|
}
|
|
|
|
void this.load3d.setBackgroundImage(config.backgroundImage)
|
|
|
|
if (config.backgroundRenderMode) {
|
|
this.load3d.setBackgroundRenderMode(config.backgroundRenderMode)
|
|
}
|
|
}
|
|
}
|
|
|
|
private applyCameraConfig(config: CameraConfig) {
|
|
this.load3d.toggleCamera(config.cameraType)
|
|
this.load3d.setFOV(config.fov)
|
|
|
|
if (config.state) {
|
|
this.load3d.setCameraState(config.state)
|
|
}
|
|
}
|
|
|
|
private applyLightConfig(config: LightConfig) {
|
|
this.load3d.setLightIntensity(config.intensity)
|
|
}
|
|
|
|
private applyHDRISettings(config: HDRIConfig) {
|
|
if (!config.hdriPath) return
|
|
this.load3d.setHDRIIntensity(config.intensity)
|
|
this.load3d.setHDRIAsBackground(config.showAsBackground)
|
|
if (config.enabled) {
|
|
this.load3d.setHDRIEnabled(true)
|
|
}
|
|
}
|
|
|
|
private applyModelConfig(config: ModelConfig) {
|
|
this.load3d.setUpDirection(config.upDirection)
|
|
this.load3d.setMaterialMode(config.materialMode)
|
|
}
|
|
|
|
private createModelUpdateHandler(
|
|
loadFolder: string,
|
|
cameraState?: CameraState
|
|
) {
|
|
let isFirstLoad = true
|
|
return async (value: string | number | boolean | object) => {
|
|
if (!value) return
|
|
|
|
const filename = value as string
|
|
|
|
this.setResourceFolder(filename)
|
|
|
|
const modelUrl = api.apiURL(
|
|
Load3dUtils.getResourceURL(
|
|
...Load3dUtils.splitFilePath(filename),
|
|
loadFolder
|
|
)
|
|
)
|
|
|
|
await this.load3d.loadModel(modelUrl, filename)
|
|
|
|
const modelConfig = this.loadModelConfig()
|
|
this.applyModelConfig(modelConfig)
|
|
|
|
if (isFirstLoad && cameraState) {
|
|
try {
|
|
this.load3d.setCameraState(cameraState)
|
|
} catch (error) {
|
|
console.warn('Failed to restore camera state:', error)
|
|
}
|
|
isFirstLoad = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private setResourceFolder(filename: string): void {
|
|
const pathParts = filename.split('/').filter((part) => part.trim())
|
|
|
|
if (pathParts.length <= 2) {
|
|
return
|
|
}
|
|
|
|
const subfolderParts = pathParts.slice(1, -1)
|
|
const subfolder = subfolderParts.join('/')
|
|
|
|
if (subfolder && this.properties) {
|
|
this.properties['Resource Folder'] = subfolder
|
|
}
|
|
}
|
|
}
|
|
|
|
export default Load3DConfiguration
|