UNPKG

@babylonjs/viewer

Version:

The Babylon Viewer aims to simplify a specific but common Babylon.js use case: loading, viewing, and interacting with a 3D model.

1,045 lines (1,043 loc) 287 kB
import '@babylonjs/core/Misc/symbolMetadataPolyfill.js'; import { AbortError } from '@babylonjs/core/Misc/error.js'; import { AsyncLock } from '@babylonjs/core/Misc/asyncLock.js'; import { Logger } from '@babylonjs/core/Misc/logger.js'; import { Observable } from '@babylonjs/core/Misc/observable.js'; import { ArcRotateCamera, ComputeAlpha, ComputeBeta } from '@babylonjs/core/Cameras/arcRotateCamera.js'; import { Constants } from '@babylonjs/core/Engines/constants.js'; import { PointerEventTypes } from '@babylonjs/core/Events/pointerEvents.js'; import { DirectionalLight } from '@babylonjs/core/Lights/directionalLight.js'; import { HemisphericLight } from '@babylonjs/core/Lights/hemisphericLight.js'; import { LoadAssetContainerAsync } from '@babylonjs/core/Loading/sceneLoader.js'; import { BackgroundMaterial } from '@babylonjs/core/Materials/Background/backgroundMaterial.js'; import { ImageProcessingConfiguration } from '@babylonjs/core/Materials/imageProcessingConfiguration.js'; import { Texture } from '@babylonjs/core/Materials/Textures/texture.js'; import { Color3, Color4 } from '@babylonjs/core/Maths/math.color.js'; import { Clamp, Lerp } from '@babylonjs/core/Maths/math.scalar.functions.js'; import { Vector3, Matrix, Vector2 } from '@babylonjs/core/Maths/math.vector.js'; import { Viewport } from '@babylonjs/core/Maths/math.viewport.js'; import { GetHotSpotToRef } from '@babylonjs/core/Meshes/abstractMesh.hotSpot.js'; import { CreateBox } from '@babylonjs/core/Meshes/Builders/boxBuilder.js'; import { IsGaussianSplattingClassName } from '@babylonjs/core/Meshes/GaussianSplatting/gaussianSplattingMesh.pure.js'; import { Mesh } from '@babylonjs/core/Meshes/mesh.js'; import { RemoveUnreferencedVerticesData, computeMaxExtents } from '@babylonjs/core/Meshes/meshUtils.js'; import { BuildTuple } from '@babylonjs/core/Misc/arrayTools.js'; import { deepMerge } from '@babylonjs/core/Misc/deepMerger.js'; import { Lazy } from '@babylonjs/core/Misc/lazy.js'; import { SceneOptimizerOptions, HardwareScalingOptimization, SceneOptimizer } from '@babylonjs/core/Misc/sceneOptimizer.js'; import { SnapshotRenderingHelper } from '@babylonjs/core/Misc/snapshotRenderingHelper.js'; import { _RetryWithInterval } from '@babylonjs/core/Misc/timingTools.js'; import { GetExtensionFromUrl } from '@babylonjs/core/Misc/urlTools.js'; import { Scene } from '@babylonjs/core/scene.js'; import { registerBuiltInLoaders } from '@babylonjs/loaders/dynamic.js'; import { Deferred } from '@babylonjs/core/Misc/deferred.js'; /** * Throws if any of the supplied abort signals is aborted. * @param abortSignals The signals to check. */ function throwIfAborted(...abortSignals) { for (const signal of abortSignals) { signal?.throwIfAborted(); } } /** * Fire-and-forget wrapper for a promise that logs non-abort errors. * @param promise The promise to observe. */ function observePromise(promise) { // eslint-disable-next-line @typescript-eslint/no-floating-promises (async () => { try { await promise; } catch (error) { if (!(error instanceof AbortError)) { Logger.Error(error); } } })(); } const shadowQualityOptions = ["none", "normal", "high"]; const toneMappingOptions = ["none", "standard", "aces", "neutral"]; /** * Checks if the given value is a valid tone mapping option. * @param value The value to check. * @returns True if the value is a valid tone mapping option, otherwise false. */ function IsToneMapping(value) { return toneMappingOptions.includes(value); } /** * Checks if the given value is a valid shadow quality option. * @param value The value to check. * @returns True if the value is a valid shadow quality option, otherwise false. */ function IsShadowQuality(value) { return shadowQualityOptions.includes(value); } /** * Provides the result of a hot spot query. */ class ViewerHotSpotResult { constructor() { /** * 2D canvas position in pixels. */ this.screenPosition = [NaN, NaN]; /** * 3D world coordinates. */ this.worldPosition = [NaN, NaN, NaN]; /** * Visibility range is [-1..1]. A value of 0 means camera eye is on the plane. */ this.visibility = NaN; } } /** * The default values for {@link ViewerBaseOptions}. Used as fallbacks by both the full Viewer * and the Lite Viewer; each re-exports this as its own `DefaultViewerOptions`. */ const DefaultViewerBaseOptions = { clearColor: [0, 0, 0, 0], autoSuspendRendering: true, environmentConfig: { intensity: 1, blur: 0.3, rotation: 0, }, environmentLighting: "auto", environmentSkybox: "none", cameraAutoOrbit: { enabled: false, delay: 2000, speed: 0.05, }, animationAutoPlay: false, animationSpeed: 1, shadowConfig: { quality: "none", }, postProcessing: { toneMapping: "neutral", contrast: 1, exposure: 1, ssao: "auto", }, useRightHandedSystem: false, useOpenPBR: false, }; /** * @internal * Orbit angle (radians) applied to the arc-rotate camera on every automatic reframe (model load * and animation switch). Shared by the full Viewer and ViewerLite so both frame to the same default * viewpoint. Exported from `viewerBase` for internal sharing only — intentionally not re-exported * from the package index, so it is not part of the public API. */ const FramingCameraAlpha = Math.PI / 2; /** * @internal * Elevation angle (radians) applied to the arc-rotate camera on every automatic reframe. Shared by * the full Viewer and ViewerLite. Exported from `viewerBase` for internal sharing only — * intentionally not re-exported from the package index, so it is not part of the public API. */ const FramingCameraBeta = Math.PI / 2.4; /** * Common base for the full Babylon.js {@link Viewer} and the lite Viewer. * * Encapsulates the pieces that are identical between both engine backends: * - The 18 public observables exposed by the viewer surface area * - In-flight load operation tracking (used to compute aggregate `loadingProgress`) * - The `_throwIfDisposedOrAborted` helper used at the start of every async operation * - The disposed flag and observable teardown in `dispose()` * * Subclasses are responsible for everything engine-specific (scene/engine creation, * model + environment loading orchestration, camera, post-processing, shadows, etc.) * and for declaring `implements IViewer` themselves so the public API contract is * verified at the leaf class level. */ class ViewerBase { constructor() { // ── Observables ── // Concrete `Observable<T>` is exposed publicly (it satisfies `IReadonlyObservable<T>` // for the `IViewer` interface contract). Subclasses notify directly via `this.onXxx.notifyObservers()`. /** * Fired when the environment has changed. */ this.onEnvironmentChanged = new Observable(); /** * Fired when the environment configuration has changed. */ this.onEnvironmentConfigurationChanged = new Observable(); /** * Fired when an error occurs while loading the environment. */ this.onEnvironmentError = new Observable(); /** * Fired when the shadows configuration changes. */ this.onShadowsConfigurationChanged = new Observable(); /** * Fired when the post processing state changes. */ this.onPostProcessingChanged = new Observable(); /** * Fired when a model is loaded into the viewer (or unloaded from the viewer). * @remarks * The event argument is the source that was loaded, or null if no model is loaded. */ this.onModelChanged = new Observable(); /** * Fired when an error occurs while loading a model. */ this.onModelError = new Observable(); /** * Fired when progress changes on loading activity. */ this.onLoadingProgressChanged = new Observable(); /** * Fired when the camera auto orbit state changes. */ this.onCameraAutoOrbitChanged = new Observable(); /** * Fired when the selected animation changes. */ this.onSelectedAnimationChanged = new Observable(); /** * Fired when the animation speed changes. */ this.onAnimationSpeedChanged = new Observable(); /** * Fired when the selected animation is playing or paused. */ this.onIsAnimationPlayingChanged = new Observable(); /** * Fired when the current point on the selected animation timeline changes. */ this.onAnimationProgressChanged = new Observable(); /** * Fired when the selected material variant changes. */ this.onSelectedMaterialVariantChanged = new Observable(); /** * Fired when the hot spots object changes to a complete new object instance. */ this.onHotSpotsChanged = new Observable(); /** * Fired when the cameras as hot spots property changes. */ this.onCamerasAsHotSpotsChanged = new Observable(); /** * Fired after each frame is rendered. */ this.onAfterRenderObservable = new Observable(); /** * Fired when the clear color changes. */ this.onClearColorChanged = new Observable(); /** * @internal Tracks in-flight load operations (model + environment + shadows) so that * `loadingProgress` can return either an aggregate progress number, `true` (indeterminate), * or `false` (no operations in flight). */ this._loadOperations = new Set(); /** @internal True after `dispose()` has been called. */ this._isDisposed = false; // ────────────────────────────────────────────────────────────────────────── // Environment loading orchestration // ────────────────────────────────────────────────────────────────────────── /** Lock guarding lighting-side environment loads. */ this._loadEnvironmentLightingLock = new AsyncLock(); /** Abort controller for the currently in-flight lighting-side load (null when none). */ this._loadEnvironmentLightingAbortController = null; /** Lock guarding skybox-side environment loads. */ this._loadEnvironmentSkyboxLock = new AsyncLock(); /** Abort controller for the currently in-flight skybox-side load (null when none). */ this._loadEnvironmentSkyboxAbortController = null; // ── Environment configuration (intensity / blur / rotation) ── /** @internal Current environment intensity. Initialized from options in subclass constructors. */ this._environmentIntensity = DefaultViewerBaseOptions.environmentConfig.intensity; /** @internal Current environment skybox blur. Initialized from options in subclass constructors. */ this._environmentBlur = DefaultViewerBaseOptions.environmentConfig.blur; /** @internal Current environment rotation in radians. Initialized from options in subclass constructors. */ this._environmentRotation = DefaultViewerBaseOptions.environmentConfig.rotation; // ── Camera auto-orbit ── /** @internal Initialized from options in subclass constructors. */ this._autoOrbitEnabled = DefaultViewerBaseOptions.cameraAutoOrbit.enabled; /** @internal Initialized from options in subclass constructors. */ this._autoOrbitSpeed = DefaultViewerBaseOptions.cameraAutoOrbit.speed; /** @internal Initialized from options in subclass constructors. */ this._autoOrbitDelay = DefaultViewerBaseOptions.cameraAutoOrbit.delay; // ── Clear color ── /** * @internal The current scene clear color, stored as a stable mutable record so consumers * holding a reference returned by the `clearColor` getter see updates from the setter and from * `reset("environment")`. The setter mutates this object in-place rather than replacing it. */ this._clearColor = { r: 0, g: 0, b: 0, a: 0 }; // ── Hot spots ── /** @internal Pure state — no engine state. Subclasses initialize via the public `hotSpots` setter in their constructor body. */ this._hotSpots = {}; // ────────────────────────────────────────────────────────────────────────── // Model loading orchestration // ────────────────────────────────────────────────────────────────────────── /** Lock guarding model loads (and resets). */ this._loadModelLock = new AsyncLock(); /** Abort controller for the currently in-flight model load (null when none). */ this._loadModelAbortController = null; // ────────────────────────────────────────────────────────────────────────── // Shadow update orchestration // ────────────────────────────────────────────────────────────────────────── /** Lock guarding shadow updates. */ this._updateShadowsLock = new AsyncLock(); /** Abort controller for the currently in-flight shadow update (null when none). */ this._shadowsAbortController = null; /** * @internal The currently committed shadow quality. Subclasses initialize this from their * options in their constructor and read it in their `_loadModelImpl` etc. The base class * commits a new value here only after `_updateShadowsImpl` succeeds, so failed/aborted * shadow updates don't leave this field out of sync with engine state. */ this._shadowQuality = DefaultViewerBaseOptions.shadowConfig.quality; } /** * The current loading progress. False when no load is in flight, true when at least one * load is in flight with indeterminate progress, or a number between 0 and 1 representing * the average of all in-flight loads' progress. */ get loadingProgress() { if (this._loadOperations.size > 0) { let totalProgress = 0; for (const operation of this._loadOperations) { if (operation.progress == null) { return true; } totalProgress += operation.progress; } return totalProgress / this._loadOperations.size; } return false; } /** * Begin tracking a new load operation. Subclasses call this at the start of an async load * and dispose the returned handle when it completes (or fails). The handle exposes a * `progress` setter that, when assigned, fires `onLoadingProgressChanged`. * @returns A handle that can be disposed when the operation completes; the `progress` setter * updates the aggregate `loadingProgress` as the operation runs. */ _beginLoadOperation() { // eslint-disable-next-line @typescript-eslint/no-this-alias const viewer = this; let progress = null; const loadOperation = { get progress() { return progress; }, set progress(value) { progress = value; viewer.onLoadingProgressChanged.notifyObservers(); }, dispose: () => { viewer._loadOperations.delete(loadOperation); viewer.onLoadingProgressChanged.notifyObservers(); }, }; this._loadOperations.add(loadOperation); this.onLoadingProgressChanged.notifyObservers(); return loadOperation; } /** * @internal Throws if the viewer has been disposed or any of the supplied abort signals * are aborted. Used at the start of every async operation to bail out early. * @param abortSignals Optional abort signals to check. */ _throwIfDisposedOrAborted(...abortSignals) { if (this._isDisposed) { throw new Error("Viewer is disposed."); } throwIfAborted(...abortSignals); } /** * @internal The abort signal of the currently in-flight lighting-side load, or `undefined` if * none. Subclasses can use this in `_throwIfDisposedOrAborted` to bail out of dependent async * work (shadows, post-processing, etc.) when the user starts a new lighting load. */ get _loadEnvironmentLightingAbortSignal() { return this._loadEnvironmentLightingAbortController?.signal; } /** * @internal The abort signal of the currently in-flight skybox-side load, or `undefined` if * none. Subclasses can use this in `_throwIfDisposedOrAborted` to bail out of dependent async * work (shadows, post-processing, etc.) when the user starts a new skybox load. */ get _loadEnvironmentSkyboxAbortSignal() { return this._loadEnvironmentSkyboxAbortController?.signal; } /** * Loads an environment from the specified URL. The lighting and skybox sides have * independent locks and abort controllers so concurrent requests for one side don't * cancel an in-flight load for the other. * @param url The URL of the environment to load. * @param options Selects which sides to update (defaults to both) and forwards engine-specific extras. * @param abortSignal Optional signal that can be used to abort the load externally. * @returns A promise that resolves when the environment has finished loading. */ async loadEnvironment(url, options, abortSignal) { return await this._updateEnvironment(url, options, abortSignal); } /** * Removes the loaded environment. By default removes both lighting and skybox; pass `options` * to remove only one side. Subclasses (notably the full Viewer) may override to add backend-specific * fallback behavior such as substituting a default environment for lighting when the scene contains * PBR materials. * @param options Selects which sides to remove (defaults to both). * @param abortSignal Optional signal that can be used to abort the operation externally. * @returns A promise that resolves when the environment has finished resetting. */ async resetEnvironment(options, abortSignal) { return await this._updateEnvironment(undefined, options, abortSignal); } /** * @internal Internal helper exposing the dual-lock orchestration with a nullable URL. * Used by the public `loadEnvironment` (with a string URL) and by subclass `resetEnvironment` * implementations (which pass `undefined` to clear or `"auto"` to load defaults). * * Subclasses should NOT override this — override the abstract `_loadEnvironmentImpl` instead. */ async _updateEnvironment(url, options, abortSignal) { this._throwIfDisposedOrAborted(abortSignal); // Default semantics: omitting `options` updates both; passing `options` updates only // the sides whose flag is truthy. `{ lighting: true }` alone updates only lighting, // `{ skybox: true }` alone updates only skybox. const updateLighting = options ? !!options.lighting : true; const updateSkybox = options ? !!options.skybox : true; if (!updateLighting && !updateSkybox) { return; } // Resolved options object — `lighting` and `skybox` are guaranteed booleans here, while // engine-specific extras (e.g. `extension`) are forwarded as-is. Passed to the impl so // it can use both the resolved flags and the original extras without duplicating the // default-resolution logic. const resolvedOptions = { ...options, lighting: updateLighting, skybox: updateSkybox }; const locks = []; const internalAbortControllers = []; if (updateLighting) { this._loadEnvironmentLightingAbortController?.abort(new AbortError("New environment lighting is being loaded before previous environment lighting finished loading.")); const lightingAbortController = (this._loadEnvironmentLightingAbortController = new AbortController()); locks.push(this._loadEnvironmentLightingLock); internalAbortControllers.push(lightingAbortController); } if (updateSkybox) { this._loadEnvironmentSkyboxAbortController?.abort(new AbortError("New environment skybox is being loaded before previous environment skybox finished loading.")); const skyboxAbortController = (this._loadEnvironmentSkyboxAbortController = new AbortController()); locks.push(this._loadEnvironmentSkyboxLock); internalAbortControllers.push(skyboxAbortController); } // Composite abort fires only when ALL relevant internal aborts fire — a skybox-only // re-request shouldn't cancel an in-progress lighting load when both were requested // together. const compositeAbortController = new AbortController(); const checkAllAborted = () => { if (internalAbortControllers.every((c) => c.signal.aborted)) { compositeAbortController.abort(new AbortError(internalAbortControllers.map((controller) => controller.signal.reason).join(" | "))); } }; for (const controller of internalAbortControllers) { controller.signal.addEventListener("abort", checkAllAborted); } try { await AsyncLock.LockAsync(async () => { throwIfAborted(abortSignal, compositeAbortController.signal); await this._loadEnvironmentImpl(url?.trim() ?? url, resolvedOptions, abortSignal, compositeAbortController.signal); }, locks); } finally { for (const controller of internalAbortControllers) { controller.signal.removeEventListener("abort", checkAllAborted); } } } get environmentConfig() { return { intensity: this._environmentIntensity, blur: this._environmentBlur, rotation: this._environmentRotation, }; } set environmentConfig(value) { if (value.blur !== undefined && value.blur !== this._environmentBlur) { this._environmentBlur = value.blur; this._applyEnvironmentBlur(); } if (value.intensity !== undefined && value.intensity !== this._environmentIntensity) { this._environmentIntensity = value.intensity; this._applyEnvironmentIntensity(); } if (value.rotation !== undefined && value.rotation !== this._environmentRotation) { this._environmentRotation = value.rotation; this._applyEnvironmentRotation(); } this.onEnvironmentConfigurationChanged.notifyObservers(); } get cameraAutoOrbit() { return { enabled: this._autoOrbitEnabled, speed: this._autoOrbitSpeed, delay: this._autoOrbitDelay, }; } set cameraAutoOrbit(value) { let changed = false; if (value.enabled !== undefined && value.enabled !== this._autoOrbitEnabled) { this._autoOrbitEnabled = value.enabled; this._applyCameraAutoOrbitEnabled(); changed = true; } if (value.speed !== undefined && value.speed !== this._autoOrbitSpeed) { this._autoOrbitSpeed = value.speed; this._applyCameraAutoOrbitSpeed(); changed = true; } if (value.delay !== undefined && value.delay !== this._autoOrbitDelay) { this._autoOrbitDelay = value.delay; this._applyCameraAutoOrbitDelay(); changed = true; } if (changed) { this.onCameraAutoOrbitChanged.notifyObservers(); } } /** * The viewer clear color (e.g. background). */ get clearColor() { return this._clearColor; } set clearColor(value) { this._clearColor.r = value.r; this._clearColor.g = value.g; this._clearColor.b = value.b; this._clearColor.a = value.a; this._applyClearColor(); this.onClearColorChanged.notifyObservers(); } /** * The set of defined hotspots. */ get hotSpots() { return this._hotSpots; } set hotSpots(value) { this._hotSpots = value; this.onHotSpotsChanged.notifyObservers(); } /** * @internal The abort signal of the currently in-flight model load, or `undefined` if none. * Subclasses can use this in `_throwIfDisposedOrAborted` to bail out of dependent async work * (shadows, environment fallback, etc.) when the user starts a new model load. */ get _loadModelAbortSignal() { return this._loadModelAbortController?.signal; } /** * Loads a 3D model from the specified source. * @param source The source of the model to load. * @param options Engine-specific options for loading the model. * @param abortSignal Optional signal that can be used to abort the load externally. * @returns A promise that resolves when the model has finished loading. */ async loadModel(source, options, abortSignal) { return await this._updateModel(source, options, abortSignal); } /** * Unloads the current 3D model if one is loaded. * @param abortSignal Optional signal that can be used to abort the reset. * @returns A promise that resolves when the current model has been unloaded. */ async resetModel(abortSignal) { return await this._updateModel(undefined, undefined, abortSignal); } /** * @internal Internal helper exposing the model load orchestration with `source: undefined` meaning * "unload the current model". Subclasses should NOT override this — override `_loadModelImpl` instead. */ async _updateModel(source, options, abortSignal) { this._throwIfDisposedOrAborted(abortSignal); this._loadModelAbortController?.abort(new AbortError("New model is being loaded before previous model finished loading.")); const internalAbortController = (this._loadModelAbortController = new AbortController()); await this._loadModelLock.lockAsync(async () => { throwIfAborted(abortSignal, internalAbortController.signal); await this._loadModelImpl(source, options, abortSignal, internalAbortController.signal); }); // Post-lock follow-up (e.g. operations that need other locks). Skip if a newer model load // has already aborted us — the new load owns the post-load work for its own state. if (!internalAbortController.signal.aborted) { await this._afterLoadModel(source, options, abortSignal, internalAbortController.signal); } } /** * @internal Optional post-lock hook invoked AFTER the model load lock is released, allowing * subclasses to do follow-up work that needs other locks (e.g. environment fallback). The base * skips this hook if the load was superseded by a newer one before we got here. * * Implementations that await additional work should re-check `internalAbortSignal.aborted` * after each await to avoid acting on stale state (a newer load may have started during the * await window). * * Default: no-op. */ async _afterLoadModel( // eslint-disable-next-line @typescript-eslint/no-unused-vars source, // eslint-disable-next-line @typescript-eslint/no-unused-vars options, // eslint-disable-next-line @typescript-eslint/no-unused-vars abortSignal, // eslint-disable-next-line @typescript-eslint/no-unused-vars internalAbortSignal) { // Default: no-op. } /** * @internal The abort signal of the currently in-flight shadow update, or `undefined` if none. * Subclasses can use this in `_throwIfDisposedOrAborted` to bail out of dependent async work * when the user starts a new shadow update. */ get _shadowsAbortSignal() { return this._shadowsAbortController?.signal; } /** * Gets the current shadow configuration. */ get shadowConfig() { return { quality: this._shadowQuality }; } /** * Updates the shadow configuration. Skips work if the requested value matches the currently * committed one. Subclasses can override this to validate the requested quality (e.g. throw on * unsupported combinations) before delegating to `super.updateShadows(value, abortSignal)`. * @param value The new shadow configuration. * @param abortSignal Optional signal that can be used to abort the update externally. * @returns A promise that resolves when the shadow update completes. */ async updateShadows(value, abortSignal) { if (value.quality === undefined || value.quality === this._shadowQuality) { return; } // Commit `_shadowQuality` BEFORE running the impl: the engine-specific shadow setup (and // other handlers that may fire while shadows are being reconfigured, e.g. environment // rotation) read `this._shadowQuality` as the target value, so it needs to be visible // during the impl. On failure/abort we roll back below so the public `shadowConfig` stays // in sync with the actually-applied engine state and a retry with the same quality isn't // short-circuited by the early-return above. const previousQuality = this._shadowQuality; this._shadowQuality = value.quality; try { await this._updateShadows(this._shadowQuality, abortSignal); } catch (error) { // Only roll back if a newer `updateShadows` hasn't already committed its own value on // top of ours; otherwise we'd clobber the newer caller's intent. if (this._shadowQuality === value.quality) { this._shadowQuality = previousQuality; } throw error; } this.onShadowsConfigurationChanged.notifyObservers(); } /** * Runs the engine-specific shadow update at the given quality under the shared lock, with * abort-prev semantics. Subclasses should call this (rather than `_updateShadowsImpl` directly) * when they need to re-run the shadow setup (e.g. after a model change or environment change). * The public `updateShadows` also routes through this helper. * @param quality The shadow quality to apply. Defaults to the currently committed quality * (`this._shadowQuality`), which is the right choice for re-running shadow setup without * changing the committed quality. The public `updateShadows` passes a resolved new quality. * @param abortSignal Optional external abort signal. * @returns A promise that resolves when the shadow update completes. */ async _updateShadows(quality = this._shadowQuality, abortSignal) { this._throwIfDisposedOrAborted(abortSignal); this._shadowsAbortController?.abort(new AbortError("Shadows quality is being changed before previous shadows finished initializing.")); const internalAbortController = (this._shadowsAbortController = new AbortController()); await this._updateShadowsLock.lockAsync(async () => { throwIfAborted(abortSignal, internalAbortController.signal); await this._updateShadowsImpl(quality, abortSignal, internalAbortController.signal); }); } /** * Disposes the viewer and releases shared resources (observables, disposed flag). * Subclasses MUST override this method to dispose their own engine-specific state * (engine, scene, abort controllers, models, etc.) and call `super.dispose()` last * so observable consumers see the engine-specific notifications before observables clear. * * Subclasses should also early-return if `_isDisposed` is already true. */ dispose() { this._loadEnvironmentLightingAbortController?.abort(new AbortError("The viewer is being disposed.")); this._loadEnvironmentLightingAbortController = null; this._loadEnvironmentSkyboxAbortController?.abort(new AbortError("The viewer is being disposed.")); this._loadEnvironmentSkyboxAbortController = null; this._loadModelAbortController?.abort(new AbortError("The viewer is being disposed.")); this._loadModelAbortController = null; this._shadowsAbortController?.abort(new AbortError("The viewer is being disposed.")); this._shadowsAbortController = null; this.onEnvironmentChanged.clear(); this.onEnvironmentConfigurationChanged.clear(); this.onEnvironmentError.clear(); this.onShadowsConfigurationChanged.clear(); this.onPostProcessingChanged.clear(); this.onModelChanged.clear(); this.onModelError.clear(); this.onLoadingProgressChanged.clear(); this.onCameraAutoOrbitChanged.clear(); this.onSelectedAnimationChanged.clear(); this.onAnimationSpeedChanged.clear(); this.onIsAnimationPlayingChanged.clear(); this.onAnimationProgressChanged.clear(); this.onSelectedMaterialVariantChanged.clear(); this.onHotSpotsChanged.clear(); this.onCamerasAsHotSpotsChanged.clear(); this.onAfterRenderObservable.clear(); this.onClearColorChanged.clear(); this._isDisposed = true; } // ── Reset orchestration ── /** * Resets the viewer to its initial state based on the options passed in to the constructor. * @param flags The flags that specify which parts of the viewer to reset. If no flags are provided, all parts will be reset. * - "source": Reset the loaded model. * - "environment": Reset environment related state. * - "shadow": Reset shadow related state. * - "animation": Reset animation related state. * - "camera": Reset camera related state. * - "post-processing": Reset post-processing related state. * - "material-variant": Reset material variant related state. */ reset(...flags) { this._reset(true, ...flags); } /** * @internal * Orchestrates the reset operation in canonical flag order. The {@link interpolate} parameter is * forwarded to per-flag hooks (currently only `_resetCamera`) so internal callers can reset * without camera animation. */ _reset(interpolate, ...flags) { const all = flags.length === 0; if (all || flags.includes("source")) { this._resetModel(); } if (all || flags.includes("environment")) { this._resetEnvironment(); } if (all || flags.includes("shadow")) { this._resetShadows(); } if (all || flags.includes("animation")) { this._resetAnimation(); } if (all || flags.includes("camera")) { this._resetCamera(interpolate); } if (all || flags.includes("post-processing")) { this._resetPostProcessing(); } if (all || flags.includes("material-variant")) { this._resetMaterialVariant(); } } /** * @internal Resets the loaded model to the source specified at construction (or no model if no source was specified). */ _resetModel() { observePromise(this._updateModel(this._options?.source)); } /** * @internal Resets the shadow configuration to the value specified at construction. */ _resetShadows() { observePromise(this.updateShadows({ quality: this._options?.shadowConfig?.quality ?? DefaultViewerBaseOptions.shadowConfig.quality })); } /** * @internal Resets the selected material variant to the value specified at construction (or null if not specified). */ _resetMaterialVariant() { this.selectedMaterialVariant = this._options?.selectedMaterialVariant ?? null; } } // eslint-disable-next-line @typescript-eslint/promise-function-async const LazySSAODependenciesPromise = new Lazy(() => Promise.all([ import('@babylonjs/core/PostProcesses/RenderPipeline/Pipelines/ssao2RenderingPipeline.js'), import('@babylonjs/core/Rendering/prePassRendererSceneComponent.js'), import('@babylonjs/core/Rendering/geometryBufferRendererSceneComponent.js'), import('@babylonjs/core/Engines/Extensions/engine.multiRender.js'), import('@babylonjs/core/Engines/WebGPU/Extensions/engine.multiRender.js'), ])); const WebGPUSnapshotRenderingLoggingEnabled = false; // Logger.LogLevels = Logger.AllLogLevel; // TODO: Consider moving this to core after the 9.0 release. async function WhenNext(observable, abortSignal) { return await new Promise((resolve, reject) => { if (abortSignal.aborted) { reject(new AbortError("Aborted")); return; } const observer = observable.addOnce((payload) => { abortSignal.removeEventListener("abort", onAbort); resolve(payload); }); const onAbort = () => { observer.remove(); reject(new AbortError("Aborted")); }; abortSignal.addEventListener("abort", onAbort, { once: true }); }); } function IsGaussianSplattingMesh(mesh) { const className = mesh.getClassName(); return IsGaussianSplattingClassName(className) || className === "GaussianSplattingPartProxyMesh"; } function IsPBRMaterial(material) { const cn = material.getClassName(); return cn.startsWith("PBR") || cn === "OpenPBRMaterial"; } async function createCubeTexture(url, scene, extension) { extension = extension ?? GetExtensionFromUrl(url); const instantiateTexture = await (async () => { if (extension === ".hdr") { const { HDRCubeTexture } = await import('@babylonjs/core/Materials/Textures/hdrCubeTexture.js'); return () => new HDRCubeTexture(url, scene, 256, false, true, false, true, undefined, undefined, undefined, true, true); } else { const { CubeTexture } = await import('@babylonjs/core/Materials/Textures/cubeTexture.js'); return () => new CubeTexture(url, scene, null, false, null, null, null, undefined, true, extension, true); } })(); const originalUseDelayedTextureLoading = scene.useDelayedTextureLoading; try { scene.useDelayedTextureLoading = false; return instantiateTexture(); } finally { scene.useDelayedTextureLoading = originalUseDelayedTextureLoading; } } function createSkybox(scene, camera, reflectionTexture, blur) { const originalBlockMaterialDirtyMechanism = scene.blockMaterialDirtyMechanism; scene.blockMaterialDirtyMechanism = true; try { const hdrSkybox = CreateBox("hdrSkyBox", { sideOrientation: Mesh.BACKSIDE }, scene); const hdrSkyboxMaterial = new BackgroundMaterial("skyBox", scene); // Use the default image processing configuration on the skybox (e.g. don't apply tone mapping, contrast, or exposure). hdrSkyboxMaterial.imageProcessingConfiguration = new ImageProcessingConfiguration(); hdrSkyboxMaterial.reflectionTexture = reflectionTexture; reflectionTexture.coordinatesMode = Texture.SKYBOX_MODE; hdrSkyboxMaterial.reflectionBlur = blur; hdrSkybox.material = hdrSkyboxMaterial; hdrSkybox.isPickable = false; hdrSkybox.infiniteDistance = true; hdrSkybox.applyFog = false; updateSkybox(hdrSkybox, camera); return hdrSkybox; } finally { scene.blockMaterialDirtyMechanism = originalBlockMaterialDirtyMechanism; } } function updateSkybox(skybox, camera) { skybox?.scaling.setAll((camera.maxZ - camera.minZ) / 2); } function computeModelsMaxExtents(models) { return models.flatMap((model) => { return computeMaxExtents(model.assetContainer.meshes, model.assetContainer.animationGroups[model.selectedAnimation]); }); } function reduceMeshesExtendsToBoundingInfo(maxExtents) { if (maxExtents.length === 0) { return null; } const min = new Vector3(Math.min(...maxExtents.map((e) => e.minimum.x)), Math.min(...maxExtents.map((e) => e.minimum.y)), Math.min(...maxExtents.map((e) => e.minimum.z))); const max = new Vector3(Math.max(...maxExtents.map((e) => e.maximum.x)), Math.max(...maxExtents.map((e) => e.maximum.y)), Math.max(...maxExtents.map((e) => e.maximum.z))); const size = max.subtract(min); const center = min.add(size.scale(0.5)); return { extents: { min: min.asArray(), max: max.asArray(), }, size: size.asArray(), center: center.asArray(), }; } /** * Adjusts the light's target direction to ensure it's not too flat and points downwards. * @param targetDirection The target direction of the light. * @returns The adjusted target direction of the light. */ function adjustLightTargetDirection(targetDirection) { const lightSteepnessThreshold = -0.01; // threshold to trigger steepness adjustment const lightSteepnessFactor = 10; // the factor to multiply Y by if it's too flat const minLightDirectionY = -0.05; // the minimum steepness for light direction Y const adjustedDirection = targetDirection.clone(); // ensure light points downwards if (adjustedDirection.y > 0) { adjustedDirection.y *= -1; } // if light is too flat (pointing almost horizontally or very slightly down), make it steeper if (adjustedDirection.y > lightSteepnessThreshold) { adjustedDirection.y = Math.min(adjustedDirection.y * lightSteepnessFactor, minLightDirectionY); } return adjustedDirection; } /** * Compute the bounding info for the models by computing their maximum extents, size, and center considering animation, skeleton, and morph targets. * @param models The models to consider when computing the bounding info * @returns The computed bounding info for the models or null */ function computeModelsBoundingInfos(models) { const maxExtents = computeModelsMaxExtents(models); return reduceMeshesExtendsToBoundingInfo(maxExtents); } /** * Generates a HotSpot from a camera by computing its spherical coordinates (alpha, beta, radius) relative to a target point. * * The target point is determined using the camera's forward ray: * - If the ray intersects with a mesh in the model, the intersection point is used as the target. * - If no intersection is found, a fallback target is calculated by projecting the distance * between the camera and the model's center along the camera's forward direction. * * @param model The reference model used to determine the target point. * @param camera The camera from which the HotSpot is generated. * @returns A HotSpot object. */ async function CreateHotSpotFromCamera(model, camera) { await import('@babylonjs/core/Culling/ray.js'); const scene = model.assetContainer.scene; const ray = camera.getForwardRay(100, camera.getWorldMatrix(), camera.globalPosition); // Set starting point to camera global position const camGlobalPos = camera.globalPosition.clone(); // Target let radius = 0.0001; // Just to avoid division by zero const targetPoint = Vector3.Zero(); const pickingInfo = scene.pickWithRay(ray, (mesh) => model.assetContainer.meshes.includes(mesh)); if (pickingInfo && pickingInfo.hit) { targetPoint.copyFrom(pickingInfo.pickedPoint); // Use intersection point as target } else { const worldBounds = model.getWorldBounds(); const centerArray = worldBounds ? worldBounds.center : [0, 0, 0]; const distancePoint = Vector3.FromArray(centerArray); const direction = ray.direction.clone(); targetPoint.copyFrom(camGlobalPos); radius = Vector3.Distance(camGlobalPos, distancePoint); direction.scaleAndAddToRef(radius, targetPoint); // Compute fallback target } const computationVector = Vector3.Zero(); camGlobalPos.subtractToRef(targetPoint, computationVector); // Radius if (pickingInfo && pickingInfo.hit) { radius = computationVector.length(); } // Alpha and Beta const alpha = ComputeAlpha(computationVector); const beta = ComputeBeta(computationVector.y, radius); const targetArray = targetPoint.asArray(); return { type: "world", position: targetArray, normal: targetArray, cameraOrbit: [alpha, beta, radius] }; } /** * The default options for the Viewer. */ const DefaultViewerOptions = DefaultViewerBaseOptions; /** * Provides an experience for viewing a single 3D model. * @remarks * The Viewer is not tied to a specific UI framework and can be used with Babylon.js in a browser or with Babylon Native. */ class Viewer extends ViewerBase { /** * Gets or sets the clear color (background color) of the viewer. */ /** @internal */ _applyClearColor() { this._scene.clearColor.r = this._clearColor.r; this._scene.clearColor.g = this._clearColor.g; this._scene.clearColor.b = this._clearColor.b; this._scene.clearColor.a = this._clearColor.a; this._markSceneMutated(); } /** * True if a model is currently loaded. */ get isModelLoaded() { return this._activeModelBacking !== null; } /** * Called whenever the filtered object lists managed by an active frame graph are recomputed. * Each entry in the array corresponds to the filter at the same index passed to * {@link _setActiveFrameGraph}. Called once immediately when _setActiveFrameGraph is called, * and again after every model change. Override in a subclass to wire ObjectList input blocks * or perform other per-model setup without managing observer lifetimes manually. * @param _objectLists The filtered lists of meshes corresponding to the filters passed to _setActiveFrameGraph. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars _onObjectListsUpdated(_objectLists) { } constructor(_engine, _options) { super(); this._engine = _engine; this._options = _options; /** * When enabled, the Viewer will emit additional diagnostic logs to the console. */ this.showDebugLogs = false; this._snapshotHelper = null; // Lazily created, viewer-scoped "clay" material assigned to loaded meshes that have no material // (e.g. an OBJ with no MTL, or an STL). It is created on first use and disposed with the scene. // We cache the promise (not the material) so that concurrent callers are de-duplicated and only // a single material is ever created, without relying on any external lock. this._defaultMaterialPromise = null; this._renderedLastFrame = null; this._isIdle = false; this._sceneOptimizer = null; this._tempVectors = BuildTuple(4, Vector3.Zero); this._meshDataCache = new Map(); this._beforeRenderObserver = null; this._renderLoopController = null; this._loadedModelsBacking = []; this._activeModelBacking = null; this._environmentSkyboxMode = "none"; this._environmentLightingMode = "none"; this._skybox = null; this._skyboxTexture = null; this._reflectionTexture = null; this._light = null; this._ssaoOption = this._options?.postProcessing?.ssao ?? DefaultViewerOptions.postProcessing.ssao; this._ssaoPipeline = null; this._autoSuspendRendering = this._options?.autoSuspendRendering ?? DefaultViewerOptions.autoSuspendRendering; this._sceneMutated = false; this._suspendRenderCount = 0; this._camerasAsHotSpotsAbortController = null; this._updateSSAOLock = new AsyncLock(); this._ssaoAbortController = null; this._activeAnimationObservers = []; this._animationSpeed = this._options?.animationSpeed ?? DefaultViewerOptions.animationSpeed; this._camerasAsHotSpots = false; this._shadowState = {}; this._iblShadowsAnimationObserver = null; this._objectListFilters = []; this._objectListModelChangedObserver = null; if (this._options?.shadowConfig?.quality === "high" && this._options?.postProcessing?.ssao === "enabled") { throw new Error("High quality shadows are not compatible with SSAO. Please choose either high quality shadows or SSAO."); } this._shadowQuality = this._options?.shadowConfig?.quality ?? DefaultViewerOptions.shadowConfig.quality; this._environmentIntensity = this._options?.environmentConfig?.intensity ?? DefaultViewerOptions.environmentConfig.intensity; this._environmentBlur = this._options?.environmentConfig?.blur ?? DefaultViewerOptions.environmentConfig.blur; this._environmentRotation = this._options?.environmentConfig?.rotation ?? DefaultViewerOptions.environmentConfig.rotation; if (this._options?.hotSpots) { this.hotSpots = this._options.hotSpots; } this._defaultHardwareScalingLevel = this._lastHardwareScalingLevel = this._engine.getHardwareScalingLevel(); { const scene = new Scene(this._engine); scene.useRightHandedSystem = this._options?.useRightHandedSystem ?? DefaultViewerOptions.useRightHandedSystem; // Deduce tone mapping, contrast, and exposure from the scene (so the viewer stays in sync if anything mutates these values directly on the scene). this._toneMappingEnabled = scene.imageProcessingConfiguration.toneMappingEnabled; thi