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,261 lines (1,249 loc) 93.5 kB
import { T as ThinWebGPUEngine, a as WebGPUCacheRenderPipeline, b as WebGPUTextureHelper, c as WebGPUPerfCounter, d as WebGPURenderItemBeginOcclusionQuery, e as WebGPURenderItemEndOcclusionQuery, W as WebGPUEngine } from './webgpuEngine.pure-a3cJGBUn.esm.js'; import './clearQuad.vertex-Bsj0uOdp.esm.js'; import './clearQuad.fragment-Dz6oZlav.esm.js'; import { a7 as VertexBuffer, cH as GetTypeByteLength, bk as Buffer, O as Observable, V as Vector3, aD as Quaternion, M as Matrix, a as EngineStore, A as AbstractEngine, C as Constants, w as InternalTexture, y as Logger, cI as GetTypeForDepthTexture, cn as HasStencilAspect } from './index-MZPybX0H.esm.js'; import './instancesVertex-DMZ4QPzF.esm.js'; import './bakedVertexAnimation-v7FGdAXH.esm.js'; import './instancesDeclaration-BNVwxGTw.esm.js'; import './helperFunctions-CiGzvr6_.esm.js'; import './fresnelFunction-IWnpfsH_.esm.js'; import './meshUboDeclaration-CCr-qXSL.esm.js'; import './sceneUboDeclaration-F06dki1D.esm.js'; import './decalFragment-Bg57qmDF.esm.js'; import { R as RenderTargetWrapper } from './renderTargetWrapper-CR-wj86S.esm.js'; import { e as _SpatialAudioAttacherComponent, d as _WebAudioParameterComponent, f as _SpatialWebAudioUpdaterComponent, A as AbstractAudioNode } from './spatialWebAudioUpdaterComponent-jS7MKp73.esm.js'; import './sphericalPolynomial.pure-BJkHu_AG.esm.js'; import './textureLoaderManager-l5zAgBPe.esm.js'; /** This file must only contain pure code and pure imports */ let _Registered$a = false; /** * Register side effects for bufferAlign. * Safe to call multiple times; only the first call has an effect. */ function RegisterBufferAlign() { if (_Registered$a) { return; } _Registered$a = true; // eslint-disable-next-line @typescript-eslint/naming-convention const IsLittleEndian = (() => { const array = new Uint8Array(4); const view = new Uint32Array(array.buffer); return !!((view[0] = 1) & array[0]); })(); Object.defineProperty(VertexBuffer.prototype, "effectiveByteStride", { get: function () { return (this._alignedBuffer && this._alignedBuffer.byteStride) || this.byteStride; }, enumerable: true, configurable: true, }); Object.defineProperty(VertexBuffer.prototype, "effectiveByteOffset", { get: function () { return this._alignedBuffer ? 0 : this.byteOffset; }, enumerable: true, configurable: true, }); Object.defineProperty(VertexBuffer.prototype, "effectiveBuffer", { get: function () { return (this._alignedBuffer && this._alignedBuffer.getBuffer()) || this._buffer.getBuffer(); }, enumerable: true, configurable: true, }); VertexBuffer.prototype._rebuild = function () { this._buffer?._rebuild(); this._alignedBuffer?._rebuild(); }; VertexBuffer.prototype.dispose = function () { if (this._ownsBuffer) { this._buffer.dispose(); } this._alignedBuffer?.dispose(); this._alignedBuffer = undefined; this._isDisposed = true; }; VertexBuffer.prototype.getWrapperBuffer = function () { return this._alignedBuffer || this._buffer; }; VertexBuffer.prototype._alignBuffer = function () { const data = this._buffer.getData(); if (!this.engine._features.forceVertexBufferStrideAndOffsetMultiple4Bytes || (this.byteStride % 4 === 0 && this.byteOffset % 4 === 0) || !data) { return; } const typeByteLength = GetTypeByteLength(this.type); const alignedByteStride = (this.byteStride + 3) & -4; const alignedSize = alignedByteStride / typeByteLength; const totalVertices = this._maxVerticesCount; const totalByteLength = totalVertices * alignedByteStride; const totalLength = totalByteLength / typeByteLength; let sourceData; if (Array.isArray(data)) { const sourceDataAsFloat = new Float32Array(data); sourceData = new DataView(sourceDataAsFloat.buffer, sourceDataAsFloat.byteOffset, sourceDataAsFloat.byteLength); } else if (ArrayBuffer.isView(data)) { sourceData = new DataView(data.buffer, data.byteOffset, data.byteLength); } else { sourceData = new DataView(data, 0, data.byteLength); } let alignedData; if (this.type === VertexBuffer.BYTE) { alignedData = new Int8Array(totalLength); } else if (this.type === VertexBuffer.UNSIGNED_BYTE) { alignedData = new Uint8Array(totalLength); } else if (this.type === VertexBuffer.SHORT) { alignedData = new Int16Array(totalLength); } else if (this.type === VertexBuffer.UNSIGNED_SHORT) { alignedData = new Uint16Array(totalLength); } else if (this.type === VertexBuffer.HALF_FLOAT) { alignedData = new Uint16Array(totalLength); } else if (this.type === VertexBuffer.INT) { alignedData = new Int32Array(totalLength); } else if (this.type === VertexBuffer.UNSIGNED_INT) { alignedData = new Uint32Array(totalLength); } else { alignedData = new Float32Array(totalLength); } const numComponents = this.getSize(); let sourceOffset = this.byteOffset; for (let i = 0; i < totalVertices; ++i) { for (let j = 0; j < numComponents; ++j) { switch (this.type) { case VertexBuffer.BYTE: alignedData[i * alignedSize + j] = sourceData.getInt8(sourceOffset + j); break; case VertexBuffer.UNSIGNED_BYTE: alignedData[i * alignedSize + j] = sourceData.getUint8(sourceOffset + j); break; case VertexBuffer.SHORT: alignedData[i * alignedSize + j] = sourceData.getInt16(sourceOffset + j * 2, IsLittleEndian); break; case VertexBuffer.UNSIGNED_SHORT: alignedData[i * alignedSize + j] = sourceData.getUint16(sourceOffset + j * 2, IsLittleEndian); break; case VertexBuffer.HALF_FLOAT: alignedData[i * alignedSize + j] = sourceData.getUint16(sourceOffset + j * 2, IsLittleEndian); break; case VertexBuffer.INT: alignedData[i * alignedSize + j] = sourceData.getInt32(sourceOffset + j * 4, IsLittleEndian); break; case VertexBuffer.UNSIGNED_INT: alignedData[i * alignedSize + j] = sourceData.getUint32(sourceOffset + j * 4, IsLittleEndian); break; case VertexBuffer.FLOAT: alignedData[i * alignedSize + j] = sourceData.getFloat32(sourceOffset + j * 4, IsLittleEndian); break; } } sourceOffset += this.byteStride; } this._alignedBuffer?.dispose(); this._alignedBuffer = new Buffer(this.engine, alignedData, false, alignedByteStride, false, this.getIsInstanced(), true, this.instanceDivisor, (this._label ?? "VertexBuffer") + "_aligned"); }; } RegisterBufferAlign(); const Instances = []; /** * Observable that notifies when a new v2 audio engine instance has been created. * - Fires after the engine has been fully constructed and initialized (e.g. from {@link CreateAudioEngineAsync}), * so subclass state (audio context, listener, etc.) is guaranteed to be available to observers. */ new Observable(); /** * Abstract base class for v2 audio engines. * * A v2 audio engine based on the WebAudio API can be created with the {@link CreateAudioEngineAsync} function. */ class AudioEngineV2 { /** * The list of v2 audio engines that have been created and not yet disposed. * - Engines are added on construction and removed on {@link AudioEngineV2.dispose}. */ static get Instances() { return Instances; } /** * Observable that notifies when a top-level audio node (sound, sound source, bus, or main bus) is added to this engine. */ get onNodeAddedObservable() { return this._onNodeAddedObservable; } /** * Observable that notifies when a top-level audio node (sound, sound source, bus, or main bus) is removed from this engine. */ get onNodeRemovedObservable() { return this._onNodeRemovedObservable; } /** * Observable that notifies when this engine is disposed. * - Fires from {@link AudioEngineV2.dispose} after the engine has been removed from {@link AudioEngineV2.Instances}. */ get onDisposeObservable() { return this._onDisposeObservable; } constructor(options) { /** Not owned, but all items should be in `_nodes` container, too, which is owned. */ this._mainBuses = new Set(); this._sounds = new Set(); this._soundsArray = null; /** Owned top-level sound and bus nodes. */ this._nodes = new Set(); this._defaultMainBus = null; this._parameterRampDuration = 0.01; this._onNodeAddedObservable = new Observable(); this._onNodeRemovedObservable = new Observable(); this._onDisposeObservable = new Observable(); Instances.push(this); if (typeof options.parameterRampDuration === "number") { this.parameterRampDuration = options.parameterRampDuration; } // Intentionally do NOT notify {@link OnAudioEngineV2CreatedObservable} here: // - This base constructor runs before subclass fields (audio context, listener, ...) are initialized // and before any async {@link CreateAudioEngineAsync}-style setup completes, so observers would // see a partially constructed engine. // - Engine factory functions (e.g. {@link CreateAudioEngineAsync}) call `notifyObservers` themselves // once the engine is fully constructed and initialized. } /** * The default main bus that will be used for audio buses and sounds if their `outBus` option is not set. * @see {@link IAudioBusOptions.outBus} * @see {@link IAbstractSoundOptions.outBus} */ get defaultMainBus() { if (this._mainBuses.size === 0) { return null; } if (!this._defaultMainBus) { this._defaultMainBus = Array.from(this._mainBuses)[0]; } return this._defaultMainBus; } /** * The smoothing duration to use when changing audio parameters, in seconds. Defaults to `0.01` (10 milliseconds). */ get parameterRampDuration() { return this._parameterRampDuration; } set parameterRampDuration(value) { this._parameterRampDuration = Math.max(0, value); } /** * The list of static and streaming sounds created by the audio engine. */ get sounds() { if (!this._soundsArray) { this._soundsArray = Array.from(this._sounds); } return this._soundsArray; } /** * The list of top-level audio nodes (sounds, sound sources, buses, main buses) owned by the audio engine. */ get nodes() { return this._nodes; } /** * Releases associated resources. */ dispose() { if (Instances.includes(this)) { Instances.splice(Instances.indexOf(this), 1); } const nodeIt = this._nodes.values(); for (let next = nodeIt.next(); !next.done; next = nodeIt.next()) { next.value.dispose(); } this._mainBuses.clear(); this._nodes.clear(); this._sounds.clear(); this._disposeSoundsArray(); this._defaultMainBus = null; this._onDisposeObservable.notifyObservers(this); this._onDisposeObservable.clear(); this._onNodeAddedObservable.clear(); this._onNodeRemovedObservable.clear(); } /** * Unlocks the audio engine if it is locked. * - Note that the returned promise may already be resolved if the audio engine is already unlocked. * @returns A promise that is resolved when the audio engine is unlocked. */ // eslint-disable-next-line @typescript-eslint/promise-function-async, no-restricted-syntax unlockAsync() { return this.resumeAsync(); } _addMainBus(mainBus) { this._mainBuses.add(mainBus); this._addNode(mainBus); } _removeMainBus(mainBus) { this._mainBuses.delete(mainBus); this._defaultMainBus = null; this._removeNode(mainBus); } _addNode(node) { this._nodes.add(node); this._onNodeAddedObservable.notifyObservers(node); } _removeNode(node) { this._nodes.delete(node); this._onNodeRemovedObservable.notifyObservers(node); } _addSound(sound) { this._disposeSoundsArray(); this._sounds.add(sound); this._addNode(sound); } _removeSound(sound) { this._disposeSoundsArray(); this._sounds.delete(sound); this._removeNode(sound); } /** * Called when any sound's playback state changes (started, stopped, paused, resumed). * Override in platform-specific implementations to react to sound playback state changes. * @internal */ _onSoundPlaybackStateChanged() { // No-op base implementation. } _disposeSoundsArray() { if (this._soundsArray) { this._soundsArray.length = 0; this._soundsArray = null; } } } const _SpatialAudioListenerDefaults = { position: Vector3.Zero(), rotation: Vector3.Zero(), rotationQuaternion: new Quaternion(), }; /** * @param options The spatial audio listener options to check. * @returns `true` if spatial audio listener options are defined, otherwise `false`. */ function _HasSpatialAudioListenerOptions(options) { return (options.listenerEnabled || options.listenerMinUpdateTime !== undefined || options.listenerPosition !== undefined || options.listenerRotation !== undefined || options.listenerRotationQuaternion !== undefined); } /** * Abstract class representing the spatial audio `listener` property on an audio engine. * * @see {@link AudioEngineV2.listener} */ class AbstractSpatialAudioListener { } /** @internal */ class _SpatialAudioListener extends AbstractSpatialAudioListener { constructor() { super(); this._attacherComponent = null; this._attacherComponent = new _SpatialAudioAttacherComponent(this); } /** @internal */ get isAttached() { return this._attacherComponent !== null && this._attacherComponent.isAttached; } /** @internal */ get attachedNode() { return this._attacherComponent?.sceneNode ?? null; } /** * Attaches to a scene node. * * Detaches automatically before attaching to the given scene node. * If `sceneNode` is `null` it is the same as calling `detach()`. * * @param sceneNode The scene node to attach to, or `null` to detach. * @param useBoundingBox Whether to use the bounding box of the node for positioning. Defaults to `false`. * @param attachmentType Whether to attach to the node's position and/or rotation. Defaults to `PositionAndRotation`. */ attach(sceneNode, useBoundingBox = false, attachmentType = 3 /* SpatialAudioAttachmentType.PositionAndRotation */) { if (!this._attacherComponent) { this._attacherComponent = new _SpatialAudioAttacherComponent(this); } this._attacherComponent.attach(sceneNode, useBoundingBox, attachmentType); } /** * Detaches from the scene node if attached. */ detach() { this._attacherComponent?.detach(); } /** @internal */ dispose() { this._attacherComponent?.dispose(); this._attacherComponent = null; } /** @internal */ setOptions(options) { if (options.listenerMinUpdateTime !== undefined) { this.minUpdateTime = options.listenerMinUpdateTime; } if (options.listenerPosition) { this.position = options.listenerPosition.clone(); } if (options.listenerRotationQuaternion) { this.rotationQuaternion = options.listenerRotationQuaternion.clone(); } else if (options.listenerRotation) { this.rotation = options.listenerRotation.clone(); } else { this.rotationQuaternion = _SpatialAudioListenerDefaults.rotationQuaternion.clone(); } this.update(); } } const TmpMatrix = Matrix.Zero(); const TmpQuaternion = new Quaternion(); const TmpVector1 = Vector3.Zero(); const TmpVector2 = Vector3.Zero(); /** @internal */ function _CreateSpatialAudioListener(engine, autoUpdate, minUpdateTime) { const listener = engine._audioContext.listener; if (listener.forwardX && listener.forwardY && listener.forwardZ && listener.positionX && listener.positionY && listener.positionZ && listener.upX && listener.upY && listener.upZ) { return new _SpatialWebAudioListener(engine, autoUpdate, minUpdateTime); } else { return new _SpatialWebAudioListenerFallback(engine, autoUpdate, minUpdateTime); } } class _AbstractSpatialWebAudioListener extends _SpatialAudioListener { /** @internal */ constructor(engine, autoUpdate, minUpdateTime) { super(); this._lastPosition = Vector3.Zero(); this._lastRotation = Vector3.Zero(); this._lastRotationQuaternion = new Quaternion(); /** @internal */ this.position = Vector3.Zero(); /** @internal */ this.rotation = Vector3.Zero(); /** @internal */ this.rotationQuaternion = new Quaternion(); this._listener = engine._audioContext.listener; this.engine = engine; this._updaterComponent = new _SpatialWebAudioUpdaterComponent(this, autoUpdate, minUpdateTime); } /** @internal */ dispose() { super.dispose(); this._updaterComponent.dispose(); this._updaterComponent = null; } /** @internal */ get minUpdateTime() { return this._updaterComponent.minUpdateTime; } /** @internal */ set minUpdateTime(value) { this._updaterComponent.minUpdateTime = value; } /** @internal */ update() { if (this.isAttached) { this._attacherComponent?.update(); } else { this._updatePosition(); this._updateRotation(); } } _updatePosition() { if (this._lastPosition.equalsWithEpsilon(this.position)) { return; } this._setWebAudioPosition(this.position); this._lastPosition.copyFrom(this.position); } _updateRotation() { if (!this._lastRotationQuaternion.equalsWithEpsilon(this.rotationQuaternion)) { TmpQuaternion.copyFrom(this.rotationQuaternion); this._lastRotationQuaternion.copyFrom(this.rotationQuaternion); } else if (!this._lastRotation.equalsWithEpsilon(this.rotation)) { Quaternion.FromEulerAnglesToRef(this.rotation.x, this.rotation.y, this.rotation.z, TmpQuaternion); this._lastRotation.copyFrom(this.rotation); } else { return; } Matrix.FromQuaternionToRef(TmpQuaternion, TmpMatrix); // NB: The WebAudio API is right-handed. Vector3.TransformNormalToRef(Vector3.RightHandedForwardReadOnly, TmpMatrix, TmpVector1); Vector3.TransformNormalToRef(Vector3.Up(), TmpMatrix, TmpVector2); this._setWebAudioOrientation(TmpVector1, TmpVector2); } } /** * Full-featured spatial audio listener for the Web Audio API. * * Used in browsers that support the `forwardX/Y/Z`, `positionX/Y/Z`, and `upX/Y/Z` properties on the AudioContext listener. * * NB: Firefox falls back to using this implementation. * * @see _SpatialWebAudioListenerFallback for the implementation used if only `setPosition` and `setOrientation` are available. * * NB: This sub property is not backed by a sub node and all properties are set directly on the audio context listener. * * @internal */ class _SpatialWebAudioListener extends _AbstractSpatialWebAudioListener { constructor(engine, autoUpdate, minUpdateTime) { super(engine, autoUpdate, minUpdateTime); const listener = engine._audioContext.listener; this._forwardX = new _WebAudioParameterComponent(engine, listener.forwardX); this._forwardY = new _WebAudioParameterComponent(engine, listener.forwardY); this._forwardZ = new _WebAudioParameterComponent(engine, listener.forwardZ); this._positionX = new _WebAudioParameterComponent(engine, listener.positionX); this._positionY = new _WebAudioParameterComponent(engine, listener.positionY); this._positionZ = new _WebAudioParameterComponent(engine, listener.positionZ); this._upX = new _WebAudioParameterComponent(engine, listener.upX); this._upY = new _WebAudioParameterComponent(engine, listener.upY); this._upZ = new _WebAudioParameterComponent(engine, listener.upZ); } _setWebAudioPosition(position) { // If attached and there is a ramp in progress, we assume another update is coming soon that we can wait for. // We don't do this for unattached nodes because there may not be another update coming. if (this.isAttached && (this._positionX.isRamping || this._positionY.isRamping || this._positionZ.isRamping)) { return; } this._positionX.targetValue = position.x; this._positionY.targetValue = position.y; this._positionZ.targetValue = position.z; } _setWebAudioOrientation(forward, up) { // If attached and there is a ramp in progress, we assume another update is coming soon that we can wait for. // We don't do this for unattached nodes because there may not be another update coming. if (this.isAttached && (this._forwardX.isRamping || this._forwardY.isRamping || this._forwardZ.isRamping || this._upX.isRamping || this._upY.isRamping || this._upZ.isRamping)) { return; } this._forwardX.targetValue = forward.x; this._forwardY.targetValue = forward.y; this._forwardZ.targetValue = forward.z; this._upX.targetValue = up.x; this._upY.targetValue = up.y; this._upZ.targetValue = up.z; } } /** * Fallback spatial audio listener for the Web Audio API. * * Used in browsers that do not support the `forwardX/Y/Z`, `positionX/Y/Z`, and `upX/Y/Z` properties on the * AudioContext listener. * * @see _SpatialWebAudioListener for the implementation used if the `forwardX/Y/Z`, `positionX/Y/Z`, and `upX/Y/Z` * properties are available. * * NB: This sub property is not backed by a sub node and all properties are set directly on the audio context listener. * * @internal */ class _SpatialWebAudioListenerFallback extends _AbstractSpatialWebAudioListener { _setWebAudioPosition(position) { this._listener.setPosition(position.x, position.y, position.z); } _setWebAudioOrientation(forward, up) { this._listener.setOrientation(forward.x, forward.y, forward.z, up.x, up.y, up.z); } } /** * Abstract class for the main audio output node. * * A main audio output is the last audio node in the audio graph before the audio is sent to the speakers. * * @see {@link AudioEngineV2.mainOut} * @internal */ class _MainAudioOut extends AbstractAudioNode { constructor(engine) { super(engine, 1 /* AudioNodeType.HAS_INPUTS */); } } /** @internal */ class _WebAudioMainOut extends _MainAudioOut { /** @internal */ constructor(engine) { super(engine); this._setGainNode(new GainNode(engine._audioContext)); } /** @internal */ dispose() { super.dispose(); this._volume.dispose(); this._gainNode.disconnect(); this._destinationNode.disconnect(); } /** @internal */ get _inNode() { return this._gainNode; } set _inNode(value) { if (this._gainNode === value) { return; } this._setGainNode(value); } /** @internal */ get volume() { return this._volume.targetValue; } /** @internal */ set volume(value) { this._volume.targetValue = value; } get _destinationNode() { return this.engine._audioDestination; } /** @internal */ getClassName() { return "_WebAudioMainOut"; } /** @internal */ setVolume(value, options = null) { this._volume.setTargetValue(value, options); } _setGainNode(gainNode) { if (this._gainNode === gainNode) { return; } this._gainNode?.disconnect(); gainNode.connect(this._destinationNode); this._volume = new _WebAudioParameterComponent(this.engine, gainNode.gain); this._gainNode = gainNode; } } /** * Adds a UI button that starts the audio engine's underlying audio context when the user presses it. * @internal */ class _WebAudioUnmuteUI { /** @internal */ constructor(engine, parentElement) { this._button = null; this._enabled = true; this._style = null; this._onStateChanged = () => { if (!this._button) { return; } if (this._engine.state === "running") { this._hide(); } else { this._show(); } }; this._engine = engine; const parent = parentElement || EngineStore.LastCreatedEngine?.getInputElement()?.parentElement || document.body; const top = (parent?.offsetTop || 0) + 20; this._style = document.createElement("style"); this._style.appendChild(document.createTextNode(`.babylonUnmute{position:absolute;top:${top}px;margin-left:20px;height:40px;width:60px;background-color:rgba(51,51,51,0.7);background-image:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20version%3D%221.1%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2239%22%20height%3D%2232%22%20viewBox%3D%220%200%2039%2032%22%3E%3Cpath%20fill%3D%22white%22%20d%3D%22M9.625%2018.938l-0.031%200.016h-4.953q-0.016%200-0.031-0.016v-12.453q0-0.016%200.031-0.016h4.953q0.031%200%200.031%200.016v12.453zM12.125%207.688l8.719-8.703v27.453l-8.719-8.719-0.016-0.047v-9.938zM23.359%207.875l1.406-1.406%204.219%204.203%204.203-4.203%201.422%201.406-4.219%204.219%204.219%204.203-1.484%201.359-4.141-4.156-4.219%204.219-1.406-1.422%204.219-4.203z%22%3E%3C%2Fpath%3E%3C%2Fsvg%3E");background-size:80%;background-repeat:no-repeat;background-position:center;background-position-y:4px;border:none;outline:none;transition:transform 0.125s ease-out;cursor:pointer;z-index:9999;}.babylonUnmute:hover{transform:scale(1.05)}`)); document.head.appendChild(this._style); this._button = document.createElement("button"); this._button.className = "babylonUnmute"; this._button.id = "babylonUnmuteButton"; this._button.addEventListener("click", () => { // eslint-disable-next-line @typescript-eslint/no-floating-promises this._engine.unlockAsync(); }); parent.appendChild(this._button); this._engine.stateChangedObservable.add(this._onStateChanged); } /** @internal */ dispose() { this._button?.remove(); this._button = null; this._style?.remove(); this._style = null; this._engine.stateChangedObservable.removeCallback(this._onStateChanged); } /** @internal */ get enabled() { return this._enabled; } set enabled(value) { this._enabled = value; if (value) { if (this._engine.state !== "running") { this._show(); } } else { this._hide(); } } _show() { if (!this._button || !this._enabled) { return; } this._button.style.display = "block"; } _hide() { if (!this._button) { return; } this._button.style.display = "none"; } } const FormatMimeTypes = { aac: "audio/aac", ac3: "audio/ac3", flac: "audio/flac", m4a: "audio/mp4", mp3: 'audio/mpeg; codecs="mp3"', mp4: "audio/mp4", ogg: 'audio/ogg; codecs="vorbis"', wav: "audio/wav", webm: 'audio/webm; codecs="vorbis"', }; /** @internal */ class _WebAudioEngine extends AudioEngineV2 { /** @internal */ constructor(options = {}) { super(options); this._audioContextStarted = false; this._destinationNode = null; this._invalidFormats = new Set(); this._isUpdating = false; this._listener = null; this._listenerAutoUpdate = true; this._listenerMinUpdateTime = 0; this._pauseCalled = false; this._resumeOnInteraction = true; this._resumeOnPause = true; this._resumeOnPauseRetryInterval = 1000; this._resumeOnPauseTimerId = null; this._resumePromise = null; this._silentHtmlAudio = null; this._unmuteUI = null; this._updateObservable = null; this._validFormats = new Set(); this._volume = 1; /** @internal */ this._isUsingOfflineAudioContext = false; /** @internal */ this.isReadyPromise = new Promise((resolve) => { this._resolveIsReadyPromise = resolve; }); /** @internal */ this.stateChangedObservable = new Observable(); /** @internal */ this.userGestureObservable = new Observable(); this._initAudioContextAsync = async () => { this._audioContext.addEventListener("statechange", this._onAudioContextStateChange); this._mainOut = new _WebAudioMainOut(this); this._mainOut.volume = this._volume; await this.createMainBusAsync("default"); }; this._onAudioContextStateChange = () => { if (this.state === "running") { clearInterval(this._resumeOnPauseTimerId); this._audioContextStarted = true; this._resumePromise = null; } if (this.state === "suspended" || this.state === "interrupted") { if (this._audioContextStarted && this._resumeOnPause && !this._pauseCalled) { clearInterval(this._resumeOnPauseTimerId); this._resumeOnPauseTimerId = setInterval(() => { // eslint-disable-next-line @typescript-eslint/no-floating-promises this.resumeAsync(); }, this._resumeOnPauseRetryInterval); } } this.stateChangedObservable.notifyObservers(this.state); }; this._onUserGestureAsync = async () => { if (this._resumeOnInteraction) { await this._audioContext.resume(); } // On iOS the ringer switch must be turned on for WebAudio to play. // This gets WebAudio to play with the ringer switch turned off by playing an HTMLAudioElement. // The element is activated during a user gesture so it can be played/paused programmatically // later. It is immediately paused to avoid triggering iOS Safari's "now playing" detection, // which throttles the page to 30 FPS. if (!this._silentHtmlAudio) { this._silentHtmlAudio = document.createElement("audio"); const audio = this._silentHtmlAudio; audio.controls = false; audio.preload = "auto"; audio.loop = true; // Wave data for 0.0001 seconds of silence. audio.src = "data:audio/wav;base64,UklGRjAAAABXQVZFZm10IBAAAAABAAEAgLsAAAB3AQACABAAZGF0YQwAAAAAAAEA/v8CAP//AQA="; // Play briefly to activate the element, then immediately pause. // The rejection handler is intentionally empty — play() can reject if the // browser requires a more specific user gesture; this is non-fatal since // the audio context unlock is the primary goal, and the silent element is // only a supplementary iOS ringer-switch workaround. // eslint-disable-next-line github/no-then audio.play().then(() => audio.pause(), () => { }); } this.userGestureObservable.notifyObservers(); }; this._startUpdating = () => { if (this._isUpdating) { return; } this._isUpdating = true; if (this.state === "running") { this._update(); } else { const callback = () => { if (this.state === "running") { this._update(); this.stateChangedObservable.removeCallback(callback); } }; this.stateChangedObservable.add(callback); } }; this._update = () => { if (this._updateObservable?.hasObservers()) { this._updateObservable.notifyObservers(); requestAnimationFrame(this._update); } else { this._isUpdating = false; } }; if (typeof options.listenerAutoUpdate === "boolean") { this._listenerAutoUpdate = options.listenerAutoUpdate; } if (typeof options.listenerMinUpdateTime === "number") { this._listenerMinUpdateTime = options.listenerMinUpdateTime; } this._volume = options.volume ?? 1; if (options.audioContext) { this._isUsingOfflineAudioContext = options.audioContext instanceof OfflineAudioContext; this._audioContext = options.audioContext; } else { this._audioContext = new AudioContext(); } if (!options.disableDefaultUI) { this._unmuteUI = new _WebAudioUnmuteUI(this, options.defaultUIParentElement); } } /** @internal */ async _initAsync(options) { this._resumeOnInteraction = typeof options.resumeOnInteraction === "boolean" ? options.resumeOnInteraction : true; this._resumeOnPause = typeof options.resumeOnPause === "boolean" ? options.resumeOnPause : true; this._resumeOnPauseRetryInterval = options.resumeOnPauseRetryInterval ?? 1000; document.addEventListener("click", this._onUserGestureAsync); await this._initAudioContextAsync(); if (_HasSpatialAudioListenerOptions(options)) { this._listener = _CreateSpatialAudioListener(this, this._listenerAutoUpdate, this._listenerMinUpdateTime); this._listener.setOptions(options); } this._resolveIsReadyPromise(); } /** @internal */ get currentTime() { return this._audioContext.currentTime ?? 0; } /** @internal */ get _inNode() { return this._audioContext.destination; } /** @internal */ get mainOut() { return this._mainOut; } /** @internal */ get listener() { return this._listener ?? (this._listener = _CreateSpatialAudioListener(this, this._listenerAutoUpdate, this._listenerMinUpdateTime)); } /** @internal */ get state() { // Always return "running" for OfflineAudioContext so sound `play` calls work while the context is suspended. return this._isUsingOfflineAudioContext ? "running" : this._audioContext.state; } /** @internal */ get volume() { return this._volume; } /** @internal */ set volume(value) { if (this._volume === value) { return; } this._volume = value; if (this._mainOut) { this._mainOut.volume = value; } } /** * This property should only be used by the legacy audio engine. * @internal * */ get _audioDestination() { return this._destinationNode ? this._destinationNode : (this._destinationNode = this._audioContext.destination); } set _audioDestination(value) { this._destinationNode = value; } /** * This property should only be used by the legacy audio engine. * @internal */ get _unmuteUIEnabled() { return this._unmuteUI ? this._unmuteUI.enabled : false; } set _unmuteUIEnabled(value) { if (this._unmuteUI) { this._unmuteUI.enabled = value; } } /** @internal */ async createBusAsync(name, options = {}) { const module = await import('./webAudioBus-lW3wu8TI.esm.js'); const bus = new module._WebAudioBus(name, this, options); await bus._initAsync(options); return bus; } /** @internal */ async createMainBusAsync(name, options = {}) { const module = await import('./webAudioMainBus-Ombh60ye.esm.js'); const bus = new module._WebAudioMainBus(name, this); await bus._initAsync(options); return bus; } /** @internal */ async createMicrophoneSoundSourceAsync(name, options) { let mediaStream; try { mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch (e) { throw new Error("Unable to access microphone: " + e, { cause: e }); } return await this.createSoundSourceAsync(name, new MediaStreamAudioSourceNode(this._audioContext, { mediaStream }), { outBusAutoDefault: false, mediaStreamSinkEnabled: false, stopMediaStreamTracksOnDispose: true, ...options, }); } /** @internal */ async createSoundAsync(name, source, options = {}) { const module = await import('./webAudioStaticSound-CGPqNRqx.esm.js'); const sound = new module._WebAudioStaticSound(name, this, options); await sound._initAsync(source, options); return sound; } /** @internal */ async createSoundBufferAsync(source, options = {}) { const module = await import('./webAudioStaticSound-CGPqNRqx.esm.js'); const soundBuffer = new module._WebAudioStaticSoundBuffer(this); await soundBuffer._initAsync(source, options); return soundBuffer; } /** @internal */ async createSoundSourceAsync(name, source, options = {}) { const module = await import('./webAudioSoundSource-BpDGX_V9.esm.js'); const soundSource = new module._WebAudioSoundSource(name, source, this, options); await soundSource._initAsync(options); return soundSource; } /** @internal */ async createStreamingSoundAsync(name, source, options = {}) { const module = await import('./webAudioStreamingSound-DVbUdywu.esm.js'); const sound = new module._WebAudioStreamingSound(name, this, options); await sound._initAsync(source, options); return sound; } /** @internal */ dispose() { super.dispose(); this._listener?.dispose(); this._listener = null; // Note that OfflineAudioContext does not have a `close` method. if (this._audioContext.state !== "closed" && !this._isUsingOfflineAudioContext) { // eslint-disable-next-line @typescript-eslint/no-floating-promises this._audioContext.close(); } document.removeEventListener("click", this._onUserGestureAsync); this._audioContext.removeEventListener("statechange", this._onAudioContextStateChange); this._silentHtmlAudio?.remove(); this._updateObservable?.clear(); this._updateObservable = null; this._unmuteUI?.dispose(); this._unmuteUI = null; this.stateChangedObservable.clear(); } /** @internal */ flagInvalidFormat(format) { this._invalidFormats.add(format); } /** @internal */ isFormatValid(format) { if (this._validFormats.has(format)) { return true; } if (this._invalidFormats.has(format)) { return false; } const mimeType = FormatMimeTypes[format]; if (mimeType === undefined) { return false; } const audio = new Audio(); if (audio.canPlayType(mimeType) === "") { this._invalidFormats.add(format); return false; } this._validFormats.add(format); return true; } /** @internal */ async pauseAsync() { await this._audioContext.suspend(); this._pauseCalled = true; } /** @internal */ // eslint-disable-next-line @typescript-eslint/promise-function-async, no-restricted-syntax resumeAsync() { this._pauseCalled = false; if (this._resumePromise) { return this._resumePromise; } this._resumePromise = this._audioContext.resume(); this.stateChangedObservable.notifyObservers(this.state); return this._resumePromise; } /** @internal */ setVolume(value, options = null) { if (this._mainOut) { this._mainOut.setVolume(value, options); } else { throw new Error("Main output not initialized yet."); } } /** @internal */ _addMainBus(mainBus) { super._addMainBus(mainBus); } /** @internal */ _removeMainBus(mainBus) { super._removeMainBus(mainBus); } /** @internal */ _addNode(node) { super._addNode(node); } /** @internal */ _removeNode(node) { super._removeNode(node); } /** @internal */ _addSound(sound) { super._addSound(sound); } /** @internal */ _removeSound(sound) { super._removeSound(sound); } /** @internal */ _onSoundPlaybackStateChanged() { if (!this._silentHtmlAudio) { return; } const hasActiveSounds = this.sounds.some((s) => s.state === 3 /* SoundState.Started */ || s.state === 2 /* SoundState.Starting */ || s.state === 0 /* SoundState.Stopping */); if (hasActiveSounds && this._silentHtmlAudio.paused) { // Resume silent audio for iOS ringer switch workaround while sounds are playing. // Errors are safe to ignore — the silent audio element is a workaround, not a // user-facing sound, so a rejected play (e.g. missing user gesture) is harmless. // eslint-disable-next-line github/no-then void this._silentHtmlAudio.play().catch(() => { }); } else if (!hasActiveSounds && !this._silentHtmlAudio.paused) { // Pause silent audio when no sounds are playing to avoid triggering iOS Safari's // audio playback detection, which causes FPS throttling and shows a blue audio icon. this._silentHtmlAudio.pause(); } } /** @internal */ _addUpdateObserver(callback) { if (!this._updateObservable) { this._updateObservable = new Observable(); } this._updateObservable.add(callback); this._startUpdating(); } _removeUpdateObserver(callback) { if (this._updateObservable) { this._updateObservable.removeCallback(callback); } } } /** This file must only contain pure code and pure imports */ /** * This represents the default audio engine used in babylon. * It is responsible to play, synchronize and analyse sounds throughout the application. * @see https://doc.babylonjs.com/features/featuresDeepDive/audio/playingSoundsMusic */ class AudioEngine { /** * The master gain node defines the global audio volume of your audio engine. */ get masterGain() { return this._masterGain; } set masterGain(value) { this._masterGain = this._v2.mainOut._inNode = value; } /** * Defines if the audio engine relies on a custom unlocked button. * In this case, the embedded button will not be displayed. */ get useCustomUnlockedButton() { return this._useCustomUnlockedButton; } set useCustomUnlockedButton(value) { this._useCustomUnlockedButton = value; this._v2._unmuteUIEnabled = !value; } /** * Gets the current AudioContext if available. */ get audioContext() { if (this._v2.state === "running") { // Do not wait for the promise to unlock. // eslint-disable-next-line @typescript-eslint/no-floating-promises this._triggerRunningStateAsync(); } return this._v2._audioContext; } /** * Instantiates a new audio engine. * * @param hostElement defines the host element where to display the mute icon if necessary * @param audioContext defines the audio context to be used by the audio engine * @param audioDestination defines the audio destination node to be used by audio engine */ constructor(hostElement = null, audioContext = null, audioDestination = null) { this._tryToRun = false; this._useCustomUnlockedButton = false; /** * Gets whether the current host supports Web Audio and thus could create AudioContexts. */ this.canUseWebAudio = true; /** * Defines if Babylon should emit a warning if WebAudio is not supported. */ // eslint-disable-next-line @typescript-eslint/naming-convention this.WarnedWebAudioUnsupported = false; /** * Gets whether or not mp3 are supported by your browser. */ this.isMP3supported = false; /** * Gets whether or not ogg are supported by your browser. */ this.isOGGsupported = false; /** * Gets whether audio has been unlocked on the device. * Some Browsers have strong restrictions about Audio and won't autoplay unless * a user interaction has happened. */ this.unlocked = false; /** * Event raised when audio has been unlocked on the browser. */ this.onAudioUnlockedObservable = new Observable(); /** * Event raised when audio has been locked on the browser. */ this.onAudioLockedObservable = new Observable(); const v2 = new _WebAudioEngine({ audioContext: audioContext ? audioContext : undefined, defaultUIParentElement: hostElement?.parentElement ? hostElement.parentElement : undefined, }); // Historically the unmute button is disabled until a sound tries to play and can't, which results in a call // to `AudioEngine.lock()`, which is where the unmute button is enabled if no custom UI is requested. v2._unmuteUIEnabled = false; this._masterGain = new GainNode(v2._audioContext); v2._audioDestination = audioDestination; v2.stateChangedObservable.add((state) => { if (state === "running") { this.unlocked = true; this.onAudioUnlockedObservable.notifyObservers(this); } else { this.unlocked = false; this.onAudioLockedObservable.notifyObservers(this); } }); // eslint-disable-next-line @typescript-eslint/no-floating-promises, github/no-then v2._initAsync({ resumeOnInteraction: false }).then(() => { const mainBusOutNode = v2.defaultMainBus._outNode; if (mainBusOutNode) { mainBusOutNode.disconnect(v2.mainOut._inNode); mainBusOutNode.connect(this._masterGain); } v2.mainOut._inNode = this._masterGain; v2.stateChangedObservable.notifyObservers(v2.state); }); this.isMP3supported = v2.isFormatValid("mp3"); this.isOGGsupported = v2.isFormatValid("ogg"); this._v2 = v2; } /** * Flags the audio engine in Locked state. * This happens due to new browser policies preventing audio to autoplay. */ lock() { // eslint-disable-next-line @typescript-eslint/no-floating-promises this._v2._audioContext.suspend(); if (!this._useCustomUnlockedButton) { this._v2._unmuteUIEnabled = true; } } /** * Unlocks the audio engine once a user action has been done on the dom. * This is helpful to resume play once browser policies have been satisfied. */ unlock() { if (this._v2._audioContext?.state === "running") { if (!this.unlocked) { // Notify users that the audio stack is unlocked/unmuted this.unlocked = true; this.onAudioUnlockedObservable.notifyObservers(this); } return; } // eslint-disable-next-line @typescript-eslint/no-floating-promises this._triggerRunningStateAsync(); } /** @internal */ _resumeAudioContextOnStateChange() { this._v2._audioContext?.addEventListener("statechange", () => { if (this.unlocked && this._v2._audioContext?.state !== "running") { // eslint-disable-next-line @typescript-eslint/no-floating-promises this._resumeAudioContextAsync();