@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
JavaScript
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