From 39c0bf444f40827a68cd7f21cbc544d1cdf6b01c Mon Sep 17 00:00:00 2001 From: Kirill Osipov Date: Fri, 5 Jun 2026 01:20:24 +0200 Subject: [PATCH 1/3] Add Spark splat dynamic lighting and line-based point helper --- .../js/loaders/SparkGaussianSplatLoader.ts | 2 +- .../helper/light/VolumePointLightHelper.js | 33 +- .../editor-oss/src/render/EffectRenderer.js | 8 + .../src/render/SparkLightingBridge.ts | 546 ++++++++++++++++++ 4 files changed, 585 insertions(+), 4 deletions(-) create mode 100644 client/packages/editor-oss/src/render/SparkLightingBridge.ts diff --git a/client/packages/editor-oss/src/assets/js/loaders/SparkGaussianSplatLoader.ts b/client/packages/editor-oss/src/assets/js/loaders/SparkGaussianSplatLoader.ts index 18f8202f..6c8b732c 100644 --- a/client/packages/editor-oss/src/assets/js/loaders/SparkGaussianSplatLoader.ts +++ b/client/packages/editor-oss/src/assets/js/loaders/SparkGaussianSplatLoader.ts @@ -49,7 +49,7 @@ class SparkGaussianSplatLoader extends BaseLoader { async load(url: string, options?: SparkGaussianSplatLoaderOptions): Promise { const resolvedUrl = this.resolveUrl(url); const packedSplats = await this.getPackedSplats(resolvedUrl, options); - const mesh = new SplatMesh({ packedSplats }); + const mesh = new SplatMesh({ packedSplats, editable: true }); await mesh.initialized; mesh.userData.type = 'GaussianSplat'; diff --git a/client/packages/editor-oss/src/helper/light/VolumePointLightHelper.js b/client/packages/editor-oss/src/helper/light/VolumePointLightHelper.js index 41f63ab5..ed0aa832 100644 --- a/client/packages/editor-oss/src/helper/light/VolumePointLightHelper.js +++ b/client/packages/editor-oss/src/helper/light/VolumePointLightHelper.js @@ -7,9 +7,23 @@ import * as THREE from "three"; -class VolumePointLightHelper extends THREE.PointLightHelper { +class VolumePointLightHelper extends THREE.LineSegments { constructor(light, sphereSize, color) { - super(light, sphereSize, color); + const helperGeometry = new THREE.EdgesGeometry( + new THREE.SphereGeometry( sphereSize, 4, 2 ), + ); + const helperMaterial = new THREE.LineBasicMaterial({ + fog: false, + toneMapped: false, + }); + + super(helperGeometry, helperMaterial); + + this.light = light; + this.color = color; + this.type = "PointLightHelper"; + this.matrix = this.light.matrixWorld; + this.matrixAutoUpdate = false; var geometry = new THREE.SphereGeometry(2, 4, 2); var material = new THREE.MeshBasicMaterial({ @@ -20,6 +34,8 @@ class VolumePointLightHelper extends THREE.PointLightHelper { this.picker = new THREE.Mesh(geometry, material); this.picker.name = "picker"; this.add(this.picker); + + this.update(); } raycast(raycaster, intersects) { @@ -40,7 +56,18 @@ class VolumePointLightHelper extends THREE.PointLightHelper { } delete this.picker; - super.dispose(); + this.geometry.dispose(); + this.material.dispose(); + } + + update() { + this.light.updateWorldMatrix(true, false); + + if (this.color !== undefined) { + this.material.color.set(this.color); + } else { + this.material.color.copy(this.light.color); + } } } diff --git a/client/packages/editor-oss/src/render/EffectRenderer.js b/client/packages/editor-oss/src/render/EffectRenderer.js index 0b711d53..d3fa6fa2 100644 --- a/client/packages/editor-oss/src/render/EffectRenderer.js +++ b/client/packages/editor-oss/src/render/EffectRenderer.js @@ -40,6 +40,7 @@ import {patchPassNode} from "./postprocessing/patchPassNode"; import {patchShadowNode} from "./postprocessing/patchShadowNode"; import {outline} from "./postprocessing/SharedDepthOutlineNode"; import {disposeSparkComposite, ensureSparkComposite} from "./SparkCompositeBridge"; +import {createSparkSceneLightingBridge} from "./SparkLightingBridge"; // TODO(@stem/editor-oss migration): these subsystems still live in // @web-shared. They will move into editor-oss in a follow-up sub-step; the // @web-shared alias is allowed during the migration window. @@ -171,6 +172,7 @@ class EffectRenderer extends BaseRenderer { this.height = 0; this.pixelRatio = 1; this.sparkComposite = null; + this.sparkLighting = null; } /** @@ -209,6 +211,8 @@ class EffectRenderer extends BaseRenderer { // Initialize cached canvas size synchronously before the first render const canvas = this.renderer && this.renderer.domElement ? this.renderer.domElement : renderer?.domElement; this.sparkComposite = ensureSparkComposite(scene, renderer, helperRoot || scene); + this.sparkLighting?.dispose(); + this.sparkLighting = createSparkSceneLightingBridge(scene); try { const splatSettings = this.scene?.userData?.rendering?.splat || {}; if (typeof this.sparkComposite?.setSparkOptions === "function") { @@ -1449,6 +1453,8 @@ class EffectRenderer extends BaseRenderer { * Render */ render() { + this.sparkLighting?.update(); + if (!this.ready || !this.renderPipeline) { // If background is set, we need to clear buffers manually if (this.scene.background) { @@ -1652,6 +1658,8 @@ class EffectRenderer extends BaseRenderer { this.scene = null; this.camera = null; + this.sparkLighting?.dispose(); + this.sparkLighting = null; disposeSparkComposite(this.sparkComposite); this.sparkComposite = null; this.renderer = null; diff --git a/client/packages/editor-oss/src/render/SparkLightingBridge.ts b/client/packages/editor-oss/src/render/SparkLightingBridge.ts new file mode 100644 index 00000000..d0315e4e --- /dev/null +++ b/client/packages/editor-oss/src/render/SparkLightingBridge.ts @@ -0,0 +1,546 @@ +import { + SplatEdit, + SplatEditRgbaBlendMode, + SplatEditSdf, + SplatEditSdfType, +} from '@querielo/spark'; +import { + AmbientLight, + Color, + DirectionalLight, + HemisphereLight, + Light, + Object3D, + PointLight, + Quaternion, + Scene, + SpotLight, + Vector3, +} from 'three'; + +const SPARK_LIGHTING_ROOT_NAME = '__SparkDynamicLighting'; +const DEFAULT_MAX_LIGHTS = 16; +const DEFAULT_POINT_RADIUS = 5; +const DEFAULT_POINT_INTENSITY_SCALE = 0.008; +const DEFAULT_SPOT_INTENSITY_SCALE = 0.02; +const DEFAULT_GLOBAL_INTENSITY_SCALE = 0.08; +const DEFAULT_AMBIENT_INTENSITY_SCALE = 0.1; +const DEFAULT_MAX_OPACITY = 1; +const DEFAULT_SDF_SMOOTH = 0.1; +const DEFAULT_SOFT_EDGE = 1.4; +const DEFAULT_INCLUDE_AMBIENT = false; +const DEFAULT_INCLUDE_DIRECTIONAL = false; +const DEFAULT_SCAN_INTERVAL_MS = 250; +const SPOT_CONE_FORWARD = new Vector3(0, 0, -1); + +type SparkLightingOptions = { + enabled?: boolean; + maxLights?: number; + defaultRadius?: number; + radiusMultiplier?: number; + intensityScale?: number; + pointIntensityScale?: number; + spotIntensityScale?: number; + globalIntensityScale?: number; + ambientIntensityScale?: number; + maxOpacity?: number; + sdfSmooth?: number; + softEdge?: number; + includeAmbient?: boolean; + includeDirectional?: boolean; + scanIntervalMs?: number; +}; + +type SparkLightOverrides = { + enabled?: boolean; + angle?: number; + radius?: number; + opacity?: number; + intensityScale?: number; + maxOpacity?: number; + color?: Color | string | number; + sdfSmooth?: number; + softEdge?: number; +}; + +type LightUserData = Record & { + sparkLighting?: SparkLightOverrides; + sparkSplatLightRadius?: number; + sparkSplatLightOpacity?: number; +}; + +type ManagedSparkLight = { + edit: SplatEdit; + sdf: SplatEditSdf; + singleSdfs: SplatEditSdf[]; + spotSdfs: SplatEditSdf[] | null; + rangeSdf: SplatEditSdf | null; +}; + +type ResolvedSparkLightingSettings = Required; + +const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); + +const readNumber = (value: unknown, fallback: number, min = -Infinity, max = Infinity): number => { + if (!isFiniteNumber(value)) { + return fallback; + } + + return Math.min(max, Math.max(min, value)); +}; + +const readBoolean = (value: unknown, fallback: boolean): boolean => { + return typeof value === 'boolean' ? value : fallback; +}; + +const isVisibleInScene = (object: Object3D): boolean => { + let current: Object3D | null = object; + while (current) { + if (!current.visible) { + return false; + } + current = current.parent; + } + return true; +}; + +const isVisibleGaussianSplat = (object: Object3D): boolean => { + return object.userData?.__isGaussianSplat === true + || object.userData?.gaussianSplatFormat + || object.userData?.type === 'GaussianSplat' + || object.type === 'SplatMesh'; +}; + +const resolveSceneSettings = (scene: Scene): ResolvedSparkLightingSettings => { + const splatSettings = scene.userData?.rendering?.splat; + const rawLightingSettings = ( + splatSettings && typeof splatSettings === 'object' && 'lighting' in splatSettings + ? (splatSettings as {lighting?: SparkLightingOptions}).lighting + : undefined + ) ?? {}; + const lightingSettings = typeof rawLightingSettings === 'object' && rawLightingSettings !== null + ? rawLightingSettings + : {}; + + return { + enabled: lightingSettings.enabled !== false, + maxLights: Math.floor(readNumber(lightingSettings.maxLights, DEFAULT_MAX_LIGHTS, 0, 64)), + defaultRadius: readNumber(lightingSettings.defaultRadius, DEFAULT_POINT_RADIUS, 0.01, 1000), + radiusMultiplier: readNumber(lightingSettings.radiusMultiplier, 1, 0.01, 100), + intensityScale: readNumber(lightingSettings.intensityScale, DEFAULT_POINT_INTENSITY_SCALE, 0, 10), + pointIntensityScale: readNumber( + lightingSettings.pointIntensityScale, + readNumber(lightingSettings.intensityScale, DEFAULT_POINT_INTENSITY_SCALE, 0, 10), + 0, + 10, + ), + spotIntensityScale: readNumber( + lightingSettings.spotIntensityScale, + readNumber(lightingSettings.intensityScale, DEFAULT_SPOT_INTENSITY_SCALE, 0, 10), + 0, + 10, + ), + globalIntensityScale: readNumber( + lightingSettings.globalIntensityScale, + DEFAULT_GLOBAL_INTENSITY_SCALE, + 0, + 10, + ), + ambientIntensityScale: readNumber( + lightingSettings.ambientIntensityScale, + DEFAULT_AMBIENT_INTENSITY_SCALE, + 0, + 10, + ), + maxOpacity: readNumber(lightingSettings.maxOpacity, DEFAULT_MAX_OPACITY, 0, 10), + sdfSmooth: readNumber(lightingSettings.sdfSmooth, DEFAULT_SDF_SMOOTH, 0, 100), + softEdge: readNumber(lightingSettings.softEdge, DEFAULT_SOFT_EDGE, 0, 1000), + includeAmbient: readBoolean(lightingSettings.includeAmbient, DEFAULT_INCLUDE_AMBIENT), + includeDirectional: readBoolean(lightingSettings.includeDirectional, DEFAULT_INCLUDE_DIRECTIONAL), + scanIntervalMs: readNumber(lightingSettings.scanIntervalMs, DEFAULT_SCAN_INTERVAL_MS, 0, 10000), + }; +}; + +const getLightOverrides = (light: Light): SparkLightOverrides => { + const lightUserData = light.userData as LightUserData | undefined; + return lightUserData?.sparkLighting && typeof lightUserData.sparkLighting === 'object' + ? lightUserData.sparkLighting + : {}; +}; + +const getLightColor = (light: Light, overrides: SparkLightOverrides, targetColor: Color): Color => { + targetColor.copy(light.color); + + if (light instanceof HemisphereLight) { + targetColor.lerp(light.groundColor, 0.5); + } + + if (overrides.color instanceof Color) { + targetColor.copy(overrides.color); + } else if (typeof overrides.color === 'string' || typeof overrides.color === 'number') { + targetColor.set(overrides.color); + } + + return targetColor; +}; + +const isGlobalLight = (light: Light): boolean => { + return light instanceof AmbientLight || light instanceof HemisphereLight || light instanceof DirectionalLight; +}; + +const isSupportedLight = ( + light: Light, + settings: ResolvedSparkLightingSettings, + overrides: SparkLightOverrides, +): boolean => { + if (light instanceof AmbientLight || light instanceof HemisphereLight) { + return overrides.enabled === true || settings.includeAmbient; + } + + if (light instanceof DirectionalLight) { + return overrides.enabled === true || settings.includeDirectional; + } + + return light instanceof PointLight || light instanceof SpotLight || light.isLight === true; +}; + +const getLightRadius = ( + light: Light, + settings: ResolvedSparkLightingSettings, + overrides: SparkLightOverrides, +): number => { + const lightUserData = light.userData as LightUserData | undefined; + const configuredRadius = overrides.radius ?? lightUserData?.sparkSplatLightRadius; + if (isFiniteNumber(configuredRadius)) { + return Math.max(0.01, configuredRadius); + } + + const distance = light instanceof PointLight || light instanceof SpotLight ? light.distance : 0; + const baseRadius = distance > 0 ? distance : settings.defaultRadius * Math.max(1, Math.sqrt(light.intensity)); + return Math.max(0.01, baseRadius * settings.radiusMultiplier); +}; + +const getSpotConeRadius = (light: SpotLight, overrides: SparkLightOverrides): number => { + const angle = readNumber(overrides.angle, light.angle, 0.001, Math.PI / 2); + return (4 * angle) / Math.PI; +}; + +const getPointFalloffRadius = (lightRadius: number): number => { + return Math.max(0.01, lightRadius * 0.5); +}; + +const getPointFalloffSoftEdge = (lightRadius: number): number => { + return Math.max(0.01, lightRadius); +}; + +const getLightStrength = ( + light: Light, + settings: ResolvedSparkLightingSettings, + overrides: SparkLightOverrides, +): number => { + const lightUserData = light.userData as LightUserData | undefined; + const maxOpacity = readNumber(overrides.maxOpacity, settings.maxOpacity, 0, 10); + const configuredOpacity = overrides.opacity ?? lightUserData?.sparkSplatLightOpacity; + if (isFiniteNumber(configuredOpacity)) { + return Math.min(maxOpacity, Math.max(0, configuredOpacity)); + } + + const fallbackScale = light instanceof AmbientLight || light instanceof HemisphereLight + ? settings.ambientIntensityScale + : isGlobalLight(light) + ? settings.globalIntensityScale + : light instanceof SpotLight + ? settings.spotIntensityScale + : light instanceof PointLight + ? settings.pointIntensityScale + : settings.intensityScale; + const intensityScale = readNumber(overrides.intensityScale, fallbackScale, 0, 10); + return Math.min(maxOpacity, Math.max(0, light.intensity * intensityScale)); +}; + +export class SparkSceneLightingBridge { + private readonly scene: Scene; + private readonly root: Object3D; + private readonly lightEntries = new Map(); + private readonly discoveredLights: Light[] = []; + private readonly activeLightIds = new Set(); + private hasVisibleSplat = false; + private readonly worldPosition = new Vector3(); + private readonly localPosition = new Vector3(); + private readonly targetWorldPosition = new Vector3(); + private readonly targetLocalPosition = new Vector3(); + private readonly spotDirection = new Vector3(); + private readonly worldQuaternion = new Quaternion(); + private readonly lightColor = new Color(); + private lastScanTime = -Infinity; + + constructor(scene: Scene, parent?: Object3D | null) { + this.scene = scene; + const existingRoot = scene.getObjectByName(SPARK_LIGHTING_ROOT_NAME); + this.root = existingRoot ?? new Object3D(); + this.root.name = SPARK_LIGHTING_ROOT_NAME; + this.root.userData.isRuntimeOnly = true; + this.root.userData.sparkLighting = true; + this.root.clear(); + + const targetParent = parent ?? scene; + if (this.root.parent !== targetParent) { + targetParent.add(this.root); + } + } + + update(): void { + const settings = resolveSceneSettings(this.scene); + if (!settings.enabled || settings.maxLights === 0) { + this.root.visible = false; + return; + } + + this.root.visible = true; + this.root.updateWorldMatrix(true, false); + + const now = globalThis.performance?.now() ?? Date.now(); + if (now - this.lastScanTime >= settings.scanIntervalMs) { + this.scanLights(); + this.lastScanTime = now; + } + + if (!this.hasVisibleSplat) { + this.root.visible = false; + for (const [lightId, managedLight] of this.lightEntries) { + this.removeManagedLight(lightId, managedLight); + } + return; + } + + this.activeLightIds.clear(); + let activeLightCount = 0; + + for (const light of this.discoveredLights) { + if (activeLightCount >= settings.maxLights) { + break; + } + + if (light.parent === null || !isVisibleInScene(light)) { + continue; + } + + const overrides = getLightOverrides(light); + if (light.userData?.isRuntimeOnly === true && overrides.enabled !== true) { + continue; + } + + if (overrides.enabled === false || !isSupportedLight(light, settings, overrides) || light.intensity <= 0) { + continue; + } + + const strength = getLightStrength(light, settings, overrides); + if (strength <= 0) { + continue; + } + + const managedLight = this.ensureManagedLight(light.uuid); + this.syncManagedLight(managedLight, light, settings, overrides, strength); + this.activeLightIds.add(light.uuid); + activeLightCount += 1; + } + + for (const [lightId, managedLight] of this.lightEntries) { + if (!this.activeLightIds.has(lightId)) { + this.removeManagedLight(lightId, managedLight); + } + } + } + + dispose(): void { + for (const [lightId, managedLight] of this.lightEntries) { + this.removeManagedLight(lightId, managedLight); + } + this.lightEntries.clear(); + this.discoveredLights.length = 0; + this.activeLightIds.clear(); + this.root.removeFromParent(); + } + + private scanLights(): void { + this.discoveredLights.length = 0; + this.hasVisibleSplat = false; + this.scene.traverseVisible((object) => { + if (object instanceof Light) { + this.discoveredLights.push(object); + } + + if (!this.hasVisibleSplat && isVisibleGaussianSplat(object)) { + this.hasVisibleSplat = true; + } + }); + } + + private ensureManagedLight(lightId: string): ManagedSparkLight { + const existingEntry = this.lightEntries.get(lightId); + if (existingEntry) { + return existingEntry; + } + + const sdf = new SplatEditSdf({ + type: SplatEditSdfType.SPHERE, + color: new Color(1, 1, 1), + radius: DEFAULT_POINT_RADIUS, + opacity: 0, + }); + sdf.name = `${SPARK_LIGHTING_ROOT_NAME}:Sdf:${lightId}`; + sdf.userData.isRuntimeOnly = true; + sdf.userData.sparkLighting = true; + + const singleSdfs = [sdf]; + + const edit = new SplatEdit({ + name: `${SPARK_LIGHTING_ROOT_NAME}:Edit:${lightId}`, + rgbaBlendMode: SplatEditRgbaBlendMode.ADD_RGBA, + sdfSmooth: DEFAULT_SDF_SMOOTH, + softEdge: DEFAULT_SOFT_EDGE, + sdfs: singleSdfs, + }); + edit.userData.isRuntimeOnly = true; + edit.userData.sparkLighting = true; + edit.add(sdf); + this.root.add(edit); + + const managedLight = {edit, sdf, singleSdfs, spotSdfs: null, rangeSdf: null}; + this.lightEntries.set(lightId, managedLight); + return managedLight; + } + + private removeManagedLight(lightId: string, managedLight: ManagedSparkLight): void { + managedLight.edit.sdfs = []; + managedLight.sdf.removeFromParent(); + managedLight.rangeSdf?.removeFromParent(); + managedLight.edit.removeFromParent(); + this.lightEntries.delete(lightId); + } + + private syncSdfColor(sdf: SplatEditSdf, light: Light, overrides: SparkLightOverrides, strength: number): void { + sdf.opacity = isFiniteNumber(overrides.opacity) ? Math.min(1, Math.max(0, overrides.opacity)) : 0; + sdf.color + .copy(getLightColor(light, overrides, this.lightColor)) + .multiplyScalar(strength); + } + + private ensureRangeSdf(managedLight: ManagedSparkLight, lightId: string): SplatEditSdf { + if (managedLight.rangeSdf) { + return managedLight.rangeSdf; + } + + const rangeSdf = new SplatEditSdf({ + type: SplatEditSdfType.SPHERE, + color: new Color(1, 1, 1), + radius: DEFAULT_POINT_RADIUS, + opacity: 0, + }); + rangeSdf.name = `${SPARK_LIGHTING_ROOT_NAME}:RangeSdf:${lightId}`; + rangeSdf.userData.isRuntimeOnly = true; + rangeSdf.userData.sparkLighting = true; + managedLight.rangeSdf = rangeSdf; + managedLight.spotSdfs = [managedLight.sdf, rangeSdf]; + managedLight.edit.add(rangeSdf); + return rangeSdf; + } + + private syncSpotLight( + managedLight: ManagedSparkLight, + light: SpotLight, + settings: ResolvedSparkLightingSettings, + overrides: SparkLightOverrides, + strength: number, + ): void { + light.getWorldPosition(this.worldPosition); + light.target.getWorldPosition(this.targetWorldPosition); + if (this.worldPosition.distanceToSquared(this.targetWorldPosition) < 0.000001) { + light.getWorldQuaternion(this.worldQuaternion); + this.spotDirection.set(0, 0, -1).applyQuaternion(this.worldQuaternion); + this.targetWorldPosition.copy(this.worldPosition).add(this.spotDirection); + } + + const rangeSdf = this.ensureRangeSdf(managedLight, light.uuid); + this.localPosition.copy(this.worldPosition); + this.root.worldToLocal(this.localPosition); + + managedLight.edit.invert = true; + managedLight.edit.sdfs = managedLight.spotSdfs; + + managedLight.sdf.visible = true; + managedLight.sdf.type = SplatEditSdfType.INFINITE_CONE; + managedLight.sdf.invert = true; + managedLight.sdf.position.copy(this.localPosition); + this.targetLocalPosition.copy(this.targetWorldPosition); + this.root.worldToLocal(this.targetLocalPosition); + this.spotDirection.subVectors(this.targetLocalPosition, this.localPosition); + if (this.spotDirection.lengthSq() > 0) { + managedLight.sdf.quaternion.setFromUnitVectors(SPOT_CONE_FORWARD, this.spotDirection.normalize()); + } else { + managedLight.sdf.quaternion.identity(); + } + managedLight.sdf.radius = getSpotConeRadius(light, overrides); + this.syncSdfColor(managedLight.sdf, light, overrides, strength); + + rangeSdf.visible = true; + rangeSdf.type = SplatEditSdfType.SPHERE; + rangeSdf.invert = true; + rangeSdf.position.copy(this.localPosition); + rangeSdf.quaternion.identity(); + rangeSdf.radius = getLightRadius(light, settings, overrides); + this.syncSdfColor(rangeSdf, light, overrides, strength); + } + + private syncManagedLight( + managedLight: ManagedSparkLight, + light: Light, + settings: ResolvedSparkLightingSettings, + overrides: SparkLightOverrides, + strength: number, + ): void { + const editSdfSmooth = readNumber(overrides.sdfSmooth, settings.sdfSmooth, 0, 100); + const editSoftEdge = readNumber(overrides.softEdge, settings.softEdge, 0, 1000); + managedLight.edit.sdfSmooth = editSdfSmooth; + managedLight.edit.softEdge = editSoftEdge; + managedLight.edit.invert = false; + managedLight.edit.sdfs = managedLight.singleSdfs; + if (managedLight.rangeSdf) { + managedLight.rangeSdf.visible = false; + managedLight.rangeSdf.invert = false; + } + + managedLight.sdf.visible = true; + managedLight.sdf.invert = false; + this.syncSdfColor(managedLight.sdf, light, overrides, strength); + + if (isGlobalLight(light)) { + managedLight.sdf.type = SplatEditSdfType.ALL; + managedLight.sdf.radius = 0; + managedLight.sdf.position.set(0, 0, 0); + return; + } + + if (light instanceof SpotLight) { + this.syncSpotLight(managedLight, light, settings, overrides, strength); + return; + } + + light.getWorldPosition(this.worldPosition); + this.localPosition.copy(this.worldPosition); + this.root.worldToLocal(this.localPosition); + managedLight.sdf.type = SplatEditSdfType.SPHERE; + managedLight.sdf.position.copy(this.localPosition); + const pointRadius = getLightRadius(light, settings, overrides); + if (light instanceof PointLight && !isFiniteNumber(overrides.softEdge)) { + managedLight.edit.softEdge = getPointFalloffSoftEdge(pointRadius); + managedLight.sdf.radius = getPointFalloffRadius(pointRadius); + } else { + managedLight.sdf.radius = pointRadius; + } + } +} + +export const createSparkSceneLightingBridge = (scene: Scene, parent?: Object3D | null): SparkSceneLightingBridge => { + return new SparkSceneLightingBridge(scene, parent); +}; From 6906b72c7a1245ee6ec1ae16503a4f2b359bec0a Mon Sep 17 00:00:00 2001 From: Kirill Osipov Date: Fri, 5 Jun 2026 14:37:23 +0200 Subject: [PATCH 2/3] Fix skinned mesh WebGL/WebGPU bridge --- .../src/render/SparkCompositeBridge.ts | 324 +++++++++++++++++- 1 file changed, 322 insertions(+), 2 deletions(-) diff --git a/client/packages/editor-oss/src/render/SparkCompositeBridge.ts b/client/packages/editor-oss/src/render/SparkCompositeBridge.ts index 16ece7c5..3679e602 100644 --- a/client/packages/editor-oss/src/render/SparkCompositeBridge.ts +++ b/client/packages/editor-oss/src/render/SparkCompositeBridge.ts @@ -1,14 +1,331 @@ import { SparkWebGpuRenderer } from '@querielo/spark'; -import { Object3D, Scene } from 'three'; +import { Camera, Float32BufferAttribute, Material, MeshDepthMaterial, Object3D, Scene, SkinnedMesh, WebGLRenderer } from 'three'; +import type { BufferAttribute, InterleavedBufferAttribute, Skeleton } from 'three'; import type { WebGPURenderer } from 'three/webgpu'; const SPARK_COMPOSITE_NAME = '__SparkWebGpuRenderer'; +const SPARK_SKINNED_DEPTH_PATCHED = Symbol('sparkSkinnedDepthPatched'); +const SPARK_SKINNED_DEPTH_MATERIAL = Symbol('sparkSkinnedDepthMaterial'); + +type SparkRendererLike = Object3D & { + render: (scene: Scene, camera: Camera) => unknown; +}; + +type SparkCompositeRuntime = Object3D & { + [SPARK_SKINNED_DEPTH_PATCHED]?: boolean; + [SPARK_SKINNED_DEPTH_MATERIAL]?: MeshDepthMaterial; + prepareComposite?: (renderer: WebGPURenderer, scene: Scene, camera: Camera) => unknown; + spark?: SparkRendererLike; + webglRenderer?: WebGLRenderer; +}; + +type SkeletonState = { + boneMatrices: Float32Array | null; + previousBoneMatrices: Float32Array | null; + boneTexture: Skeleton['boneTexture']; +}; + +type SkinIndexAttribute = BufferAttribute | InterleavedBufferAttribute; + +type SkinIndexAttributeSnapshot = { + geometry: SkinnedMesh['geometry']; + attribute: SkinIndexAttribute; +}; + +const webGLSkinIndexAttributeCache = new WeakMap(); const findSparkComposite = (scene: Scene): SparkWebGpuRenderer | null => { const existing = scene.getObjectByName(SPARK_COMPOSITE_NAME); return existing instanceof SparkWebGpuRenderer ? existing : null; }; +const isSkinnedMesh = (object: Object3D): object is SkinnedMesh => { + return (object as SkinnedMesh).isSkinnedMesh === true; +}; + +const getMaterials = (material: SkinnedMesh['material']): Material[] => { + return Array.isArray(material) ? material : [material]; +}; + +const writesDepth = (mesh: SkinnedMesh): boolean => { + return getMaterials(mesh.material).some(material => material.visible && material.depthWrite); +}; + +const isEffectivelyVisible = (object: Object3D, root: Scene): boolean => { + let current: Object3D | null = object; + + while (current) { + if (!current.visible) { + return false; + } + + if (current === root) { + return true; + } + + current = current.parent; + } + + return false; +}; + +const collectSkinnedDepthMeshes = (scene: Scene): SkinnedMesh[] => { + const meshes: SkinnedMesh[] = []; + + scene.traverse(object => { + if (isSkinnedMesh(object) && isEffectivelyVisible(object, scene) && writesDepth(object)) { + meshes.push(object); + } + }); + + return meshes; +}; + +const snapshotSkeletons = (meshes: SkinnedMesh[]): Map => { + const snapshots = new Map(); + + for (const mesh of meshes) { + const skeleton = mesh.skeleton; + if (skeleton && !snapshots.has(skeleton)) { + snapshots.set(skeleton, { + boneMatrices: skeleton.boneMatrices, + previousBoneMatrices: skeleton.previousBoneMatrices, + boneTexture: skeleton.boneTexture, + }); + } + } + + return snapshots; +}; + +const compactSkeletonMatrices = ( + source: Float32Array | null | undefined, + fallback: Float32Array | null | undefined, + compactLength: number, +): Float32Array | null => { + const sourceMatrices = source ?? fallback ?? null; + + if (!sourceMatrices) { + return null; + } + + if (fallback && fallback.length === compactLength) { + if (sourceMatrices !== fallback) { + fallback.set(sourceMatrices.subarray(0, compactLength)); + } + return fallback; + } + + const compactMatrices = new Float32Array(compactLength); + compactMatrices.set(sourceMatrices.subarray(0, Math.min(sourceMatrices.length, compactLength))); + return compactMatrices; +}; + +const restoreSkeletons = (snapshots: Map) => { + for (const [skeleton, snapshot] of snapshots) { + const compactLength = skeleton.bones.length * 16; + const currentBoneTexture = skeleton.boneTexture; + + skeleton.boneMatrices = compactSkeletonMatrices( + skeleton.boneMatrices, + snapshot.boneMatrices, + compactLength, + ); + skeleton.previousBoneMatrices = compactSkeletonMatrices( + skeleton.previousBoneMatrices, + snapshot.previousBoneMatrices, + compactLength, + ); + + if (currentBoneTexture && currentBoneTexture !== snapshot.boneTexture) { + currentBoneTexture.dispose(); + } + + if (snapshot.boneTexture) { + snapshot.boneTexture.dispose(); + } + + // WebGLRenderer creates a padded boneTexture/boneMatrices pair. The + // surrounding WebGPU renderer expects compact bone matrix buffers. + skeleton.boneTexture = null; + } +}; + +const getWebGLSkinIndexAttribute = (attribute: SkinIndexAttribute): BufferAttribute => { + const cached = webGLSkinIndexAttributeCache.get(attribute); + if (cached) { + return cached; + } + + const itemSize = attribute.itemSize; + const array = new Float32Array(attribute.count * itemSize); + + for (let index = 0; index < attribute.count; index++) { + for (let component = 0; component < itemSize; component++) { + array[index * itemSize + component] = attribute.getComponent(index, component); + } + } + + const webGLAttribute = new Float32BufferAttribute(array, itemSize, false); + webGLAttribute.name = attribute.name; + webGLSkinIndexAttributeCache.set(attribute, webGLAttribute); + + return webGLAttribute; +}; + +const swapWebGLSkinIndexAttributes = (meshes: SkinnedMesh[]): SkinIndexAttributeSnapshot[] => { + const snapshots: SkinIndexAttributeSnapshot[] = []; + const seenGeometries = new Set(); + + for (const mesh of meshes) { + const geometry = mesh.geometry; + const skinIndex = geometry?.getAttribute('skinIndex') as SkinIndexAttribute | undefined; + + if (!geometry || !skinIndex || seenGeometries.has(geometry)) { + continue; + } + + seenGeometries.add(geometry); + snapshots.push({ geometry, attribute: skinIndex }); + geometry.setAttribute('skinIndex', getWebGLSkinIndexAttribute(skinIndex)); + } + + return snapshots; +}; + +const restoreSkinIndexAttributes = (snapshots: SkinIndexAttributeSnapshot[]) => { + for (const snapshot of snapshots) { + snapshot.geometry.setAttribute('skinIndex', snapshot.attribute); + } +}; + +const renderSkinnedDepth = ( + composite: SparkCompositeRuntime, + scene: Scene, + camera: Camera, + skinnedMeshes: SkinnedMesh[], + depthMaterial: MeshDepthMaterial, +) => { + const renderer = composite.webglRenderer; + if (!renderer || skinnedMeshes.length === 0) { + return; + } + + const previousOverrideMaterial = scene.overrideMaterial; + const skeletonSnapshots = snapshotSkeletons(skinnedMeshes); + const skinIndexAttributeSnapshots = swapWebGLSkinIndexAttributes(skinnedMeshes); + const visibilitySnapshots: Array<{ object: Object3D; visible: boolean }> = []; + const meshMaterialSnapshots: Array<{ mesh: SkinnedMesh; material: SkinnedMesh['material'] }> = []; + const depthMaterialSnapshots = new Map(); + + const setVisible = (object: Object3D | null | undefined, visible: boolean) => { + if (!object) { + return; + } + + visibilitySnapshots.push({ object, visible: object.visible }); + object.visible = visible; + }; + + const prepareDepthMaterial = (material: Material) => { + if (!depthMaterialSnapshots.has(material)) { + depthMaterialSnapshots.set(material, { + colorWrite: material.colorWrite, + depthWrite: material.depthWrite, + depthTest: material.depthTest, + visible: material.visible, + }); + } + + material.colorWrite = false; + material.depthWrite = true; + material.depthTest = true; + material.visible = true; + }; + + try { + renderer.resetState(); + scene.overrideMaterial = null; + setVisible(composite, false); + setVisible(composite.spark, false); + + for (const mesh of skinnedMeshes) { + const meshDepthMaterial = mesh.customDepthMaterial ?? depthMaterial; + + setVisible(mesh, true); + prepareDepthMaterial(meshDepthMaterial); + meshMaterialSnapshots.push({ mesh, material: mesh.material }); + mesh.material = meshDepthMaterial; + } + + renderer.render(scene, camera); + } finally { + restoreSkinIndexAttributes(skinIndexAttributeSnapshots); + restoreSkeletons(skeletonSnapshots); + renderer.resetState(); + + for (let i = meshMaterialSnapshots.length - 1; i >= 0; i--) { + const { mesh, material } = meshMaterialSnapshots[i]!; + mesh.material = material; + } + + for (const [material, snapshot] of depthMaterialSnapshots) { + material.colorWrite = snapshot.colorWrite; + material.depthWrite = snapshot.depthWrite; + material.depthTest = snapshot.depthTest; + material.visible = snapshot.visible; + } + + for (let i = visibilitySnapshots.length - 1; i >= 0; i--) { + const { object, visible } = visibilitySnapshots[i]!; + object.visible = visible; + } + + scene.overrideMaterial = previousOverrideMaterial; + } +}; + +const patchSparkSkinnedDepth = (composite: SparkWebGpuRenderer) => { + const runtime = composite as unknown as SparkCompositeRuntime; + + if (runtime[SPARK_SKINNED_DEPTH_PATCHED]) { + return; + } + + if (typeof runtime.prepareComposite !== 'function') { + return; + } + + const originalPrepareComposite = runtime.prepareComposite; + const depthMaterial = new MeshDepthMaterial(); + depthMaterial.colorWrite = false; + depthMaterial.depthTest = true; + depthMaterial.depthWrite = true; + + runtime[SPARK_SKINNED_DEPTH_MATERIAL] = depthMaterial; + runtime.prepareComposite = function prepareCompositeWithSkinnedDepth(renderer, scene, camera) { + const spark = runtime.spark; + const skinnedDepthMeshes = collectSkinnedDepthMeshes(scene); + + if (!spark || skinnedDepthMeshes.length === 0) { + return originalPrepareComposite.call(this, renderer, scene, camera); + } + + const originalSparkRender = spark.render; + spark.render = function renderSparkWithSkinnedDepth(sparkScene, sparkCamera) { + renderSkinnedDepth(runtime, sparkScene, sparkCamera, skinnedDepthMeshes, depthMaterial); + return originalSparkRender.call(this, sparkScene, sparkCamera); + }; + + try { + return originalPrepareComposite.call(this, renderer, scene, camera); + } finally { + spark.render = originalSparkRender; + } + }; + runtime[SPARK_SKINNED_DEPTH_PATCHED] = true; +}; + export const ensureSparkComposite = ( scene: Scene, renderer: WebGPURenderer, @@ -20,10 +337,12 @@ export const ensureSparkComposite = ( if (existing.parent !== targetParent) { targetParent.add(existing); } + patchSparkSkinnedDepth(existing); return existing; } const composite = new SparkWebGpuRenderer({ renderer }); + patchSparkSkinnedDepth(composite); composite.name = SPARK_COMPOSITE_NAME; composite.userData.isRuntimeOnly = true; composite.userData.sparkComposite = true; @@ -38,5 +357,6 @@ export const disposeSparkComposite = (composite: SparkWebGpuRenderer | null | un } composite.removeFromParent(); + (composite as unknown as SparkCompositeRuntime)[SPARK_SKINNED_DEPTH_MATERIAL]?.dispose(); composite.dispose(); -}; \ No newline at end of file +}; From df608c50790399f1724b0810e8f5002c9221c78b Mon Sep 17 00:00:00 2001 From: Kirill Osipov Date: Fri, 5 Jun 2026 14:41:33 +0200 Subject: [PATCH 3/3] Fix skinned mesh WebGL/WebGPU bridge --- bun.lock | 3 + .../packages/editor-oss/src/EngineRuntime.ts | 2 +- .../src/assets/js/animations/poseFit.ts | 1 - .../packages/editor-oss/src/editor/Editor.ts | 36 +++- .../ModelUpload/utils/ModelPreviewRenderer.ts | 19 +- .../ModelUpload/utils/render.worker.ts | 190 ++++++++++-------- package.json | 3 + patches/@querielo%2Fspark@2.0.0.patch | 26 +++ vite.config.ts | 4 +- 9 files changed, 190 insertions(+), 94 deletions(-) create mode 100644 patches/@querielo%2Fspark@2.0.0.patch diff --git a/bun.lock b/bun.lock index 637769c7..1377c5b3 100644 --- a/bun.lock +++ b/bun.lock @@ -168,6 +168,9 @@ }, }, }, + "patchedDependencies": { + "@querielo/spark@2.0.0": "patches/@querielo%2Fspark@2.0.0.patch", + }, "packages": { "@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="], diff --git a/client/packages/editor-oss/src/EngineRuntime.ts b/client/packages/editor-oss/src/EngineRuntime.ts index 8a9c3050..a5a32875 100644 --- a/client/packages/editor-oss/src/EngineRuntime.ts +++ b/client/packages/editor-oss/src/EngineRuntime.ts @@ -1357,7 +1357,7 @@ export class EngineRuntime extends AppRuntime implements RuntimeContext { } private createWebGPURenderer(overrideForceWebGL?: boolean): WebGPURenderer { - const forceWebGL = overrideForceWebGL ?? (this.editor?.scene?.userData?.rendering?.forceWebGL || false); + const forceWebGL = !true; const useTransparentCanvas = !!( this.editor?.scene?.userData?.cesium?.enabled || this.scene?.userData?.cesium?.enabled ); diff --git a/client/packages/editor-oss/src/assets/js/animations/poseFit.ts b/client/packages/editor-oss/src/assets/js/animations/poseFit.ts index 6ebeb08f..7f27b201 100644 --- a/client/packages/editor-oss/src/assets/js/animations/poseFit.ts +++ b/client/packages/editor-oss/src/assets/js/animations/poseFit.ts @@ -743,7 +743,6 @@ export function applyPoseToSkeleton(skeleton: THREE.Skeleton, pose: PoseTargets) } skeleton.calculateInverses(); - skeleton.computeBoneTexture?.(); console.debug("[poseFit] applied pose to skeleton", { appliedPairs: applied, diff --git a/client/packages/editor-oss/src/editor/Editor.ts b/client/packages/editor-oss/src/editor/Editor.ts index 26b3752e..fc5cc5be 100644 --- a/client/packages/editor-oss/src/editor/Editor.ts +++ b/client/packages/editor-oss/src/editor/Editor.ts @@ -3458,7 +3458,12 @@ class Editor { getSelectionBoundaryObject(obj: THREE.Object3D): THREE.Object3D { const billboardRoot = this.getBillboardSelectionRoot(obj); - return billboardRoot || obj; + if (billboardRoot) { + return billboardRoot; + } + + const skinnedMeshRoot = this.getSkinnedMeshSelectionRoot(obj); + return skinnedMeshRoot || obj; } getBillboardSelectionRoot(obj: THREE.Object3D): THREE.Object3D | null { @@ -3474,6 +3479,35 @@ class Editor { return null; } + getSkinnedMeshSelectionRoot(obj: THREE.Object3D): THREE.Object3D | null { + const skinnedMesh = (obj as THREE.SkinnedMesh).isSkinnedMesh ? obj as THREE.SkinnedMesh : null; + const skeletonRoot = skinnedMesh?.skeleton?.bones[0]; + + if (!skinnedMesh || !skeletonRoot) { + return null; + } + + const skeletonAncestors = new Set(); + let current: THREE.Object3D | null = skeletonRoot; + + while (current) { + skeletonAncestors.add(current); + current = current.parent; + } + + current = skinnedMesh.parent; + while (current && current.type !== "Scene") { + if (skeletonAncestors.has(current)) { + return current; + } + current = current.parent; + } + + return skinnedMesh.parent && skinnedMesh.parent.type !== "Scene" + ? skinnedMesh.parent + : null; + } + // Clear selection helper clearSelection() { if (Array.isArray(this.selected)) { diff --git a/client/packages/editor-oss/src/editor/assets/v2/LeftPanel/MainTabs/AssetsTab/ModelUpload/utils/ModelPreviewRenderer.ts b/client/packages/editor-oss/src/editor/assets/v2/LeftPanel/MainTabs/AssetsTab/ModelUpload/utils/ModelPreviewRenderer.ts index 35d75013..e9d891b0 100644 --- a/client/packages/editor-oss/src/editor/assets/v2/LeftPanel/MainTabs/AssetsTab/ModelUpload/utils/ModelPreviewRenderer.ts +++ b/client/packages/editor-oss/src/editor/assets/v2/LeftPanel/MainTabs/AssetsTab/ModelUpload/utils/ModelPreviewRenderer.ts @@ -29,6 +29,10 @@ import { getObjectBoundingBox, isGaussianSplatObject } from '@stem/editor-oss/mo import { disposeSparkComposite, ensureSparkComposite } from '../../../../../../../../render/SparkCompositeBridge'; import { positionCameraForModel } from "../../../../../utils/positionCameraForModel"; +const PREVIEW_AMBIENT_INTENSITY = 1.8; +const PREVIEW_DIRECTIONAL_INTENSITY = 0.6; +const PREVIEW_ENVIRONMENT_INTENSITY = 0.45; + export class ModelPreviewRenderer { renderer: WebGPURenderer; scene: Scene; @@ -71,11 +75,11 @@ export class ModelPreviewRenderer { this.scene = new Scene(); this.scene.name = "ModelPreviewScene"; - const light = new AmbientLight(0xffffff, 5); + const light = new AmbientLight(0xffffff, PREVIEW_AMBIENT_INTENSITY); light.name = "AutoLight"; this.scene.add(light); - this.directionalLight = new DirectionalLight(0xffffff, 0.8); + this.directionalLight = new DirectionalLight(0xffffff, PREVIEW_DIRECTIONAL_INTENSITY); this.directionalLight.position.set(5, 10, 7.5); this.scene.add(this.directionalLight); @@ -84,7 +88,7 @@ export class ModelPreviewRenderer { (loadedTexture: Texture) => { loadedTexture.mapping = EquirectangularReflectionMapping; this.scene.environment = loadedTexture; - this.scene.environmentIntensity = 1.0; + this.scene.environmentIntensity = PREVIEW_ENVIRONMENT_INTENSITY; }, undefined, (error: unknown) => { @@ -152,7 +156,7 @@ export class ModelPreviewRenderer { depthMaterial, }; - this.sparkComposite = ensureSparkComposite(this.scene, this.renderer); + this.sparkComposite = null; } async init() { @@ -180,6 +184,12 @@ export class ModelPreviewRenderer { this.scene.add(this.model); this.model.updateMatrixWorld(true); this.isGaussianSplatModel = isGaussianSplatObject(this.model); + if (this.isGaussianSplatModel) { + this.sparkComposite = ensureSparkComposite(this.scene, this.renderer); + } else if (this.sparkComposite) { + disposeSparkComposite(this.sparkComposite); + this.sparkComposite = null; + } this.cameraWarmupFramesRemaining = this.isGaussianSplatModel ? 45 : 0; positionCameraForModel(this.model, this.camera, this.controls); @@ -309,6 +319,7 @@ export class ModelPreviewRenderer { this.model = undefined; } disposeSparkComposite(this.sparkComposite); + this.sparkComposite = null; this.renderer.dispose(); this.scene.clear(); this.controls.dispose(); diff --git a/client/packages/editor-oss/src/editor/assets/v2/LeftPanel/MainTabs/AssetsTab/ModelUpload/utils/render.worker.ts b/client/packages/editor-oss/src/editor/assets/v2/LeftPanel/MainTabs/AssetsTab/ModelUpload/utils/render.worker.ts index 6ecd473e..39565b14 100644 --- a/client/packages/editor-oss/src/editor/assets/v2/LeftPanel/MainTabs/AssetsTab/ModelUpload/utils/render.worker.ts +++ b/client/packages/editor-oss/src/editor/assets/v2/LeftPanel/MainTabs/AssetsTab/ModelUpload/utils/render.worker.ts @@ -1,7 +1,3 @@ -import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; - -import { ModelPreviewRenderer } from './ModelPreviewRenderer'; - /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ @@ -60,7 +56,7 @@ function dispatchEventPolyfill(target: any, event: any) { const targetListeners = eventRegistry.get(obj); // Ensure array exists before spreading const listenersList = targetListeners ? targetListeners[event.type] : undefined; - + if (listenersList) { const listeners = [...listenersList]; for (const l of listeners) { @@ -79,54 +75,91 @@ function dispatchEventPolyfill(target: any, event: any) { // Polyfills for Worker Environment self.window = self; -// Patch self/window addEventListener -const originalAdd = self.addEventListener; -const originalRemove = self.removeEventListener; +function createFakeElement(ownerDocument?: any) { + const el = { + style: {}, + nodeType: 1, + ownerDocument, + setAttribute: () => { }, + append: (..._nodes: any[]) => _nodes.at(-1), + appendChild: (node: T) => node, + removeChild: (child: T) => child, + addEventListener: (type: string, listener: any) => addListener(el, type, listener), + removeEventListener: (type: string, listener: any) => removeListener(el, type, listener), + querySelector: () => null, + querySelectorAll: () => [], + } as unknown as any; + return el; +} self.addEventListener = (type: string, listener: any, options?: any) => { + void options; addListener(self, type, listener); }; self.removeEventListener = (type: string, listener: any, options?: any) => { + void options; removeListener(self, type, listener); }; -self.document = { - createElement: (_name: string) => { - void _name; - // Return a dummy element that supports events - const el = { - style: {}, - nodeType: 1, - setAttribute: () => { }, - addEventListener: (t: string, l: any) => addListener(el, t, l), - removeEventListener: (t: string, l: any) => removeListener(el, t, l), - } as unknown as any; - return el; - }, - createElementNS: (_ns: string, _name: string) => { - void _ns; - void _name; - const el = { - style: {}, - nodeType: 1, - setAttribute: () => { }, - addEventListener: (t: string, l: any) => addListener(el, t, l), - removeEventListener: (t: string, l: any) => removeListener(el, t, l), - } as unknown as any; - return el; - }, - body: { - appendChild: (node: T) => node, - removeChild: (child: T) => child, - } as unknown as any, - documentElement: { - style: {} as unknown as any, - } as unknown as any, - addEventListener: (type: string, listener: any) => addListener(self.document, type, listener), - removeEventListener: (type: string, listener: any) => removeListener(self.document, type, listener), -} as unknown as any; +self.document = {} as unknown as any; +self.document.createElement = (_name: string) => { + void _name; + return createFakeElement(self.document); +}; +self.document.createElementNS = (_ns: string, _name: string) => { + void _ns; + void _name; + return createFakeElement(self.document); +}; +self.document.querySelector = () => null; +self.document.querySelectorAll = () => []; +self.document.body = createFakeElement(self.document); +self.document.head = createFakeElement(self.document); +self.document.documentElement = createFakeElement(self.document); +self.document.addEventListener = (type: string, listener: any) => addListener(self.document, type, listener); +self.document.removeEventListener = (type: string, listener: any) => removeListener(self.document, type, listener); + +let renderer: any; +let rendererReady = false; +let pendingModelPayload: ArrayBuffer | undefined; + +async function updateModelPayload(payload: ArrayBuffer) { + if (!renderer) return; + + const { GLTFLoader } = await import('three/examples/jsm/loaders/GLTFLoader.js'); + const loader = new GLTFLoader(); -let renderer: ModelPreviewRenderer | undefined; + try { + loader.parse( + payload, + '', + (gltf) => { + renderer?.updateModel(gltf.scene); + }, + (error) => { + const payloadType = payload ? payload.constructor?.name ?? typeof payload : typeof payload; + const sizeInfo = + payload instanceof ArrayBuffer + ? `, byteLength=${payload.byteLength}` + : ''; + console.error( + `Worker: Failed to parse GLB (payloadType=${payloadType}${sizeInfo})`, + error, + ); + }, + ); + } catch (err) { + const payloadType = payload ? payload.constructor?.name ?? typeof payload : typeof payload; + const sizeInfo = + payload instanceof ArrayBuffer + ? `, byteLength=${payload.byteLength}` + : ''; + console.error( + `Worker: Failed to parse model (payloadType=${payloadType}${sizeInfo})`, + err, + ); + } +} self.onmessage = async (e: MessageEvent) => { const { type, payload } = e.data; @@ -183,56 +216,39 @@ self.onmessage = async (e: MessageEvent) => { } // Hook into our event system - offscreenCanvas.addEventListener = (type: string, listener: any) => { - addListener(offscreenCanvas, type, listener); + offscreenCanvas.addEventListener = (eventType: string, listener: any) => { + addListener(offscreenCanvas, eventType, listener); }; - offscreenCanvas.removeEventListener = (type: string, listener: any) => { - removeListener(offscreenCanvas, type, listener); + offscreenCanvas.removeEventListener = (eventType: string, listener: any) => { + removeListener(offscreenCanvas, eventType, listener); }; (offscreenCanvas as any).dispatchEvent = (event: any) => { return dispatchEventPolyfill(offscreenCanvas, event); }; + const { ModelPreviewRenderer } = await import('./ModelPreviewRenderer'); + rendererReady = false; renderer = new ModelPreviewRenderer(canvas, width as number, height as number, pixelRatio as number); await renderer.init(); + rendererReady = true; + + if (pendingModelPayload) { + const modelPayload = pendingModelPayload; + pendingModelPayload = undefined; + await updateModelPayload(modelPayload); + } break; } case 'updateModel': { - if (!renderer) return; - - const loader = new GLTFLoader(); - try { - // We use the full payload now, relying on patched ImageLoader - loader.parse( - payload as ArrayBuffer, // ArrayBuffer - '', // path - (gltf) => { - renderer?.updateModel(gltf.scene); - }, - (error) => { - const payloadType = payload ? payload.constructor?.name ?? typeof payload : typeof payload; - const sizeInfo = - payload instanceof ArrayBuffer - ? `, byteLength=${payload.byteLength}` - : ''; - console.error( - `Worker: Failed to parse GLB (payloadType=${payloadType}${sizeInfo})`, - error, - ); - }, - ); - } catch (err) { - const payloadType = payload ? payload.constructor?.name ?? typeof payload : typeof payload; - const sizeInfo = - payload instanceof ArrayBuffer - ? `, byteLength=${payload.byteLength}` - : ''; - console.error( - `Worker: Failed to parse model (payloadType=${payloadType}${sizeInfo})`, - err, - ); + if (!rendererReady) { + if (payload instanceof ArrayBuffer) { + pendingModelPayload = payload; + } + return; } + + await updateModelPayload(payload as ArrayBuffer); break; } @@ -246,19 +262,21 @@ self.onmessage = async (e: MessageEvent) => { case 'dispose': { renderer?.dispose(); renderer = undefined; + rendererReady = false; + pendingModelPayload = undefined; break; } case 'event': { if (!renderer) return; const { eventCopy } = payload; - - eventCopy.preventDefault = () => {}; + + eventCopy.preventDefault = () => { }; eventCopy.stopPropagation = () => { eventCopy.cancelBubble = true; }; - + // Fix target to be the canvas - eventCopy.target = renderer.renderer.domElement; - + eventCopy.target = renderer.renderer.domElement; + // Dispatch with bubbling (renderer.renderer.domElement as any).dispatchEvent(eventCopy); break; diff --git a/package.json b/package.json index 3baa11de..13403cb6 100644 --- a/package.json +++ b/package.json @@ -213,5 +213,8 @@ "vite-plugin-node-polyfills": "^0.25.0", "vite-raw-plugin": "^1.0.2", "vitest": "^4.1.6" + }, + "patchedDependencies": { + "@querielo/spark@2.0.0": "patches/@querielo%2Fspark@2.0.0.patch" } } diff --git a/patches/@querielo%2Fspark@2.0.0.patch b/patches/@querielo%2Fspark@2.0.0.patch new file mode 100644 index 00000000..926ed757 --- /dev/null +++ b/patches/@querielo%2Fspark@2.0.0.patch @@ -0,0 +1,26 @@ +diff --git a/dist/spark.cjs.js b/dist/spark.cjs.js +index 4ab8da5494a00c820eb75af186e81d46a4563eec..ba55b29878e410568395076655dc50a0fd994788 100644 +--- a/dist/spark.cjs.js ++++ b/dist/spark.cjs.js +@@ -16789,7 +16789,7 @@ function restoreHidden(hidden) { + } + } + function isRenderableObject(object) { +- return object instanceof THREE__namespace.Mesh || object instanceof THREE__namespace.Line || object instanceof THREE__namespace.Points || object instanceof THREE__namespace.Sprite; ++ return object.isMesh === true || object.isSkinnedMesh === true || object.isInstancedMesh === true || object.isLine === true || object.isPoints === true || object.isSprite === true; + } + function getObjectMaterials(object) { + const material = object.material; +diff --git a/dist/spark.module.js b/dist/spark.module.js +index 93a778fc0aae9f4f2599635ce2fe890e84413cbc..f6ec3821f3b0ed82b60333816007e17d1bb6cb4e 100644 +--- a/dist/spark.module.js ++++ b/dist/spark.module.js +@@ -16770,7 +16770,7 @@ function restoreHidden(hidden) { + } + } + function isRenderableObject(object) { +- return object instanceof THREE.Mesh || object instanceof THREE.Line || object instanceof THREE.Points || object instanceof THREE.Sprite; ++ return object.isMesh === true || object.isSkinnedMesh === true || object.isInstancedMesh === true || object.isLine === true || object.isPoints === true || object.isSprite === true; + } + function getObjectMaterials(object) { + const material = object.material; diff --git a/vite.config.ts b/vite.config.ts index f26e4dac..336b01ac 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -15,6 +15,8 @@ const CESIUM_PUBLIC_PATH = "/cesium"; const CESIUM_BUILD_DIR = resolve(__dirname, "node_modules/cesium/Build/Cesium"); const CESIUM_OUTPUT_DIR = resolve(__dirname, "build/public/cesium"); const WEB_BUILD_PUBLIC_DIR = resolve(__dirname, "build/public"); +const reactRefreshInclude = /\.[jt]sx$/; +const reactRefreshExclude = [/\/node_modules\//, /\.worker\.[tj]sx?$/]; const MIME_TYPES: Record = { ".css": "text/css; charset=utf-8", ".gif": "image/gif", @@ -578,7 +580,7 @@ export default async ({ mode }) => { nodePolyfillsWithoutDeprecatedEsbuild(), imagetools(), glsl(), - react(), + react({ include: reactRefreshInclude, exclude: reactRefreshExclude }), raw({ fileRegex: /.(txt|fs|vs)$/, }),