feat: use Web Worker for OBJ loading to prevent UI blocking (#7846)

## Summary

- Replace OBJLoader with OBJLoader2Parallel from wwobjloader2
- OBJ parsing now runs in a Web Worker, keeping UI responsive
- Add 100MB file size limit with user-friendly error message

reduce loading time for 97M obj from 9s to 3s

fix https://github.com/Comfy-Org/ComfyUI_frontend/issues/7843

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7846-feat-use-Web-Worker-for-OBJ-loading-to-prevent-UI-blocking-2df6d73d36508140bea3c87e83d11278)
by [Unito](https://www.unito.io)
This commit is contained in:
Terry Jia
2026-01-05 14:07:39 -05:00
committed by GitHub
parent 05028894e5
commit a13aa90875
7 changed files with 71 additions and 10 deletions

View File

@@ -187,6 +187,7 @@
"vue-i18n": "catalog:",
"vue-router": "catalog:",
"vuefire": "catalog:",
"wwobjloader2": "catalog:",
"yjs": "catalog:",
"zod": "catalog:",
"zod-validation-error": "catalog:"

32
pnpm-lock.yaml generated
View File

@@ -309,6 +309,9 @@ catalogs:
vuefire:
specifier: ^3.2.1
version: 3.2.1
wwobjloader2:
specifier: ^6.2.1
version: 6.2.1
yjs:
specifier: ^13.6.27
version: 13.6.27
@@ -497,6 +500,9 @@ importers:
vuefire:
specifier: 'catalog:'
version: 3.2.1(consola@3.4.2)(firebase@11.6.0)(vue@3.5.13(typescript@5.9.3))
wwobjloader2:
specifier: 'catalog:'
version: 6.2.1(three@0.170.0)
yjs:
specifier: 'catalog:'
version: 13.6.27
@@ -8225,6 +8231,19 @@ packages:
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
engines: {node: '>=18'}
wtd-core@3.0.0:
resolution: {integrity: sha512-LSPfAQ5ULSV5vPhipcjdQvV5xOx25QesYK23jwUOF99xOx6fuulk7CMQerERRwA4uoQooNmRd8AT6IPBwORlWQ==}
wtd-three-ext@3.0.0:
resolution: {integrity: sha512-PLZJipCAiinot8D1uB4A7+XHxPAYeZXDhczbbazK7pKdqpE77zMizQH4rSZsaNbzktgnIfpgK/ODqhJTdrUjUw==}
peerDependencies:
three: '>= 0.137.5 < 1'
wwobjloader2@6.2.1:
resolution: {integrity: sha512-/v/sfUX0PMQAI8souzCs6xsO9LR3RyL+ujnOiS/1pngUlakKyHYC5XMQvu77pTeWzY3rzNyt5Q/bg5O3RukA+g==}
peerDependencies:
three: '>= 0.137.5 < 1'
xdg-basedir@5.1.0:
resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==}
engines: {node: '>=12'}
@@ -17125,6 +17144,19 @@ snapshots:
dependencies:
is-wsl: 3.1.0
wtd-core@3.0.0: {}
wtd-three-ext@3.0.0(three@0.170.0):
dependencies:
three: 0.170.0
wtd-core: 3.0.0
wwobjloader2@6.2.1(three@0.170.0):
dependencies:
three: 0.170.0
wtd-core: 3.0.0
wtd-three-ext: 3.0.0(three@0.170.0)
xdg-basedir@5.1.0: {}
xml-name-validator@4.0.0: {}

View File

@@ -104,6 +104,7 @@ catalog:
vue-router: ^4.4.3
vue-tsc: ^3.2.1
vuefire: ^3.2.1
wwobjloader2: ^6.2.1
yjs: ^13.6.27
zod: ^3.23.8
zod-to-json-schema: ^3.24.1

View File

@@ -34,9 +34,26 @@ class Load3dUtils {
return await resp.json()
}
static readonly MAX_UPLOAD_SIZE_MB = 100
static async uploadFile(file: File, subfolder: string) {
let uploadPath
const fileSizeMB = file.size / 1024 / 1024
if (fileSizeMB > this.MAX_UPLOAD_SIZE_MB) {
const message = t('toastMessages.fileTooLarge', {
size: fileSizeMB.toFixed(1),
maxSize: this.MAX_UPLOAD_SIZE_MB
})
console.warn(
'[Load3D] uploadFile: file too large',
fileSizeMB.toFixed(2),
'MB'
)
useToastStore().addAlert(message)
return undefined
}
try {
const body = new FormData()
body.append('image', file)
@@ -61,7 +78,7 @@ class Load3dUtils {
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
}
} catch (error) {
console.error('Upload error:', error)
console.error('[Load3D] uploadFile: exception', error)
useToastStore().addAlert(
error instanceof Error
? error.message

View File

@@ -3,9 +3,10 @@ import * as THREE from 'three'
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { MtlObjBridge, OBJLoader2Parallel } from 'wwobjloader2'
import OBJLoader2WorkerUrl from 'wwobjloader2/worker?url'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -22,7 +23,7 @@ import { FastPLYLoader } from './loader/FastPLYLoader'
export class LoaderManager implements LoaderManagerInterface {
gltfLoader: GLTFLoader
objLoader: OBJLoader
objLoader: OBJLoader2Parallel
mtlLoader: MTLLoader
fbxLoader: FBXLoader
stlLoader: STLLoader
@@ -41,7 +42,12 @@ export class LoaderManager implements LoaderManagerInterface {
this.eventManager = eventManager
this.gltfLoader = new GLTFLoader()
this.objLoader = new OBJLoader()
this.objLoader = new OBJLoader2Parallel()
// Set worker URL for Vite compatibility
this.objLoader.setWorkerUrl(
true,
new URL(OBJLoader2WorkerUrl, import.meta.url)
)
this.mtlLoader = new MTLLoader()
this.fbxLoader = new FBXLoader()
this.stlLoader = new STLLoader()
@@ -173,7 +179,9 @@ export class LoaderManager implements LoaderManagerInterface {
const materials = await this.mtlLoader.loadAsync(mtlFileName)
materials.preload()
this.objLoader.setMaterials(materials)
const materialsFromMtl =
MtlObjBridge.addMaterialsFromMtlLoader(materials)
this.objLoader.setMaterials(materialsFromMtl)
} catch (e) {
console.log(
'No MTL file found or error loading it, continuing without materials'
@@ -181,8 +189,10 @@ export class LoaderManager implements LoaderManagerInterface {
}
}
this.objLoader.setPath(path)
model = await this.objLoader.loadAsync(filename)
// OBJLoader2Parallel uses Web Worker for parsing (non-blocking)
const objUrl = path + encodeURIComponent(filename)
model = await this.objLoader.loadAsync(objUrl)
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
this.modelManager.originalMaterials.set(child, child.material)
@@ -193,7 +203,6 @@ export class LoaderManager implements LoaderManagerInterface {
case 'gltf':
case 'glb':
this.gltfLoader.setPath(path)
const gltf = await this.gltfLoader.loadAsync(filename)
this.modelManager.setOriginalModel(gltf)

View File

@@ -4,8 +4,8 @@ import { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper'
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
import { type GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { type OBJLoader2Parallel } from 'wwobjloader2'
export type MaterialMode =
| 'original'
@@ -179,7 +179,7 @@ export interface ModelManagerInterface {
export interface LoaderManagerInterface {
gltfLoader: GLTFLoader
objLoader: OBJLoader
objLoader: OBJLoader2Parallel
mtlLoader: MTLLoader
fbxLoader: FBXLoader
stlLoader: STLLoader

View File

@@ -1702,6 +1702,7 @@
"pleaseSelectNodesToGroup": "Please select the nodes (or other groups) to create a group for",
"emptyCanvas": "Empty canvas",
"fileUploadFailed": "File upload failed",
"fileTooLarge": "File too large ({size} MB). Maximum supported size is {maxSize} MB",
"unableToGetModelFilePath": "Unable to get model file path",
"couldNotDetermineFileType": "Could not determine file type",
"errorLoadingModel": "Error loading model",