UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

588 lines (475 loc) • 15.4 kB
import { BvhClient } from "../../../../core/bvh2/bvh3/BvhClient.js"; import { combine_hash } from "../../../../core/collection/array/combine_hash.js"; import List from '../../../../core/collection/list/List.js'; import { aabb3_array_compute_from_sphere } from "../../../../core/geom/3d/aabb/aabb3_array_compute_from_sphere.js"; import Vector1 from "../../../../core/geom/Vector1.js"; import { interpolate_irradiance_linear } from "../../../../core/math/physics/irradiance/interpolate_irradiance_linear.js"; import { interpolate_irradiance_lograrithmic } from "../../../../core/math/physics/irradiance/interpolate_irradiance_lograrithmic.js"; import { interpolate_irradiance_smith } from "../../../../core/math/physics/irradiance/interpolate_irradiance_smith.js"; import { objectKeyByValue } from "../../../../core/model/object/objectKeyByValue.js"; import { computeHashFloat } from "../../../../core/primitives/numbers/computeHashFloat.js"; import { number_compare_ascending } from "../../../../core/primitives/numbers/number_compare_ascending.js"; import { compareStrings } from "../../../../core/primitives/strings/compareStrings.js"; import { computeStringHash } from "../../../../core/primitives/strings/computeStringHash.js"; import { SoundAttenuationFunction } from "./SoundAttenuationFunction.js"; import { SoundEmitterFlags } from "./SoundEmitterFlags.js"; import { SoundPanningModelType } from "./SoundPanningModelType.js"; import { SoundTrack } from "./SoundTrack.js"; const DEFAULT_DISTANCE_MIN = 1; const DEFAULT_DISTANCE_MAX = 10000; const DEFAULT_CHANNEL = null; /** * @class */ export class SoundEmitter { /** * @readonly * @type {List<SoundTrack>} */ tracks = new List(); /** * * @type {String|SoundEmitterChannels|null} */ channel = DEFAULT_CHANNEL; /** * * @type {number} * @private */ __distanceMin = 1; /** * * @type {number} * @private */ __distanceMax = 10000; /** * @deprecated * @type {number} * @private */ __distanceRolloff = 1; /** * TODO add to binary serialization * @type {SoundPanningModelType} */ panningModel = SoundPanningModelType.HRTF; /** * Type of attenuation used for sound fall-off, this is only used if Attenuation flag is set * @type {SoundAttenuationFunction|number} */ attenuation = SoundAttenuationFunction.Smith; /** * * @type {number|SoundEmitterFlags} */ flags = 0; /** * @readonly */ nodes = { /** * @type {GainNode} */ volume: null, /** * @type {PannerNode} */ panner: null, /** * @type {GainNode} */ attenuation: null, /** * One of the other nodes, depending on the configuration * @type {AudioNode} */ endpoint: null }; /** * * @type {Vector1} */ volume = new Vector1(1); /** * @readonly * @type {BvhClient} */ bvh = new BvhClient(); /** * * @constructor */ constructor() { this.volume.onChanged.add((value) => { const volume_node = this.nodes.volume; if (volume_node !== null) { volume_node.gain.setValueAtTime(value, 0); } }); } /** * * @param {SoundEmitter} other * @returns number */ compare(other) { const tracks = this.tracks.compare(other.tracks); if (tracks !== 0) { return tracks; } const channel = compareStrings(this.channel, other.channel); if (channel !== 0) { return channel; } const distanceMin = number_compare_ascending(this.__distanceMin, other.__distanceMin); if (distanceMin !== 0) { return distanceMin; } const distanceMax = number_compare_ascending(this.__distanceMax, other.__distanceMax); if (distanceMax !== 0) { return distanceMax; } const panningModel = number_compare_ascending(this.panningModel, other.panningModel); if (panningModel !== 0) { return panningModel; } const attenuation = number_compare_ascending(this.attenuation, other.attenuation); if (attenuation !== 0) { return attenuation; } const flags = number_compare_ascending(this.flags, other.flags); if (flags !== 0) { return flags; } const volume = number_compare_ascending(this.volume.getValue(), other.volume.getValue()); if (volume !== 0) { return volume; } return 0; } /** * * @param {number|SoundEmitterFlags} flag * @returns {void} */ setFlag(flag) { this.flags |= flag; } /** * * @param {number|SoundEmitterFlags} flag * @returns {void} */ clearFlag(flag) { this.flags &= ~flag; } /** * * @param {number|SoundEmitterFlags} flag * @param {boolean} value */ writeFlag(flag, value) { if (value) { this.setFlag(flag); } else { this.clearFlag(flag); } } /** * * @param {number|SoundEmitterFlags} flag * @returns {boolean} */ getFlag(flag) { return (this.flags & flag) === flag; } /** * @deprecated * @param {boolean} v */ set isPositioned(v) { this.writeFlag(SoundEmitterFlags.Spatialization, v); } /** * @deprecated * @return {boolean} */ get isPositioned() { return this.getFlag(SoundEmitterFlags.Spatialization); } /** * @return {AudioNode} */ getTargetNode() { return this.nodes.endpoint; } /** * @returns {boolean} * @param {SoundEmitter} other */ equals(other) { return this.channel === other.channel && this.flags === other.flags && this.__distanceMin === other.__distanceMin && this.__distanceMax === other.__distanceMax && this.attenuation === other.attenuation && this.tracks.equals(other.tracks) ; } /** * * @return {number} */ hash() { return combine_hash( computeStringHash(this.channel), this.flags, computeHashFloat(this.__distanceMin), computeHashFloat(this.__distanceMax), this.attenuation, this.tracks.hash() ); } /** * * @param {AudioContext} ctx */ buildNodes(ctx) { const nodes = this.nodes; nodes.volume = ctx.createGain(); if (this.getFlag(SoundEmitterFlags.Attenuation)) { nodes.attenuation = ctx.createGain(); } if (this.getFlag(SoundEmitterFlags.Spatialization)) { nodes.panner = ctx.createPanner(); // if (this.panningModel === SoundPanningModelType.HRTF) { nodes.panner.panningModel = 'HRTF'; } else if (this.panningModel === SoundPanningModelType.EqualPower) { nodes.panner.panningModel = 'equalpower'; } else { console.error('Invalid value of panning model type:', this.panningModel); nodes.panner.panningModel = 'equalpower'; } // we set up distance model in the most efficient way, since we are ignoring it anyway and use custom distance model via a GainNode instead nodes.panner.distanceModel = 'linear'; nodes.panner.rolloffFactor = 0; nodes.panner.refDistance = this.distanceMin; nodes.panner.maxDistance = this.distanceMax; } //do wiring if (this.getFlag(SoundEmitterFlags.Attenuation | SoundEmitterFlags.Spatialization)) { nodes.attenuation.connect(nodes.panner); nodes.volume.connect(nodes.attenuation); nodes.endpoint = nodes.panner; } else if (this.getFlag(SoundEmitterFlags.Spatialization)) { nodes.volume.connect(nodes.panner); nodes.endpoint = nodes.panner; } else if (this.getFlag(SoundEmitterFlags.Attenuation)) { nodes.volume.connect(nodes.attenuation); nodes.endpoint = nodes.attenuation; } else { nodes.endpoint = nodes.volume; } nodes.volume.gain.setValueAtTime(this.volume.getValue(), 0); } /** * @private * @param {number} distance */ writeAttenuationVolume(distance) { let volume; const attenuation = this.attenuation; const distance_min = this.__distanceMin; const distance_max = this.__distanceMax; if (attenuation === SoundAttenuationFunction.Linear) { volume = interpolate_irradiance_linear(distance, distance_min, distance_max); } else if (attenuation === SoundAttenuationFunction.Logarithmic) { volume = interpolate_irradiance_lograrithmic(distance, distance_min, distance_max); } else if (attenuation === SoundAttenuationFunction.Smith) { volume = interpolate_irradiance_smith(distance, distance_min, distance_max); } else { //unsupported function, don't attenuate volume = 1; } /** * * @type {GainNode} */ const pv = this.nodes.attenuation; if (pv !== null) { pv.gain.setValueAtTime(volume, 0); } } /** * * @returns {number} */ get distanceMin() { return this.__distanceMin; } /** * @param {number} v */ set distanceMin(v) { this.__distanceMin = v; } /** * * @returns {number} */ get distanceMax() { return this.__distanceMax; } /** * @param {number} v */ set distanceMax(v) { this.__distanceMax = v; } /** * @deprecated * @returns {number} */ get distanceRolloff() { return this.__distanceRolloff; } /** * @deprecated * @param {number} v */ set distanceRolloff(v) { this.__distanceRolloff = v; const panner = this.nodes.panner; if (panner !== null) { panner.rolloffFactor = v; } } /** * * @param {number} x * @param {number} y * @param {number} z */ updatePosition(x, y, z) { if (this.getFlag(SoundEmitterFlags.Spatialization)) { //update position of the panner node const nodes = this.nodes; /** * * @type {PannerNode} */ const panner = nodes.panner; if (panner !== null) { panner.setPosition(x, y, z); } } const distanceMax = this.distanceMax; const bounds = this.bvh.bounds; aabb3_array_compute_from_sphere(bounds, 0, x, y, z, distanceMax); this.bvh.write_bounds(); } /** * * @param {number} [duration] fade duration in seconds * @param startAfter */ fadeOutAllTracks(duration = 1, startAfter = 0) { const tracks = this.tracks; const n = tracks.length; for (let i = 0; i < n; i++) { const soundTrack = tracks.get(i); soundTrack.setVolumeOverTime(0, duration, startAfter); } } stopAllTracks() { const tracks = this.tracks; const n = tracks.length; for (let i = 0; i < n; i++) { const soundTrack = tracks.get(i); soundTrack.playing = false; } } toJSON() { return { isPositioned: this.getFlag(SoundEmitterFlags.Spatialization), isAttenuated: this.getFlag(SoundEmitterFlags.Attenuation), channel: this.channel, volume: this.volume.toJSON(), tracks: this.tracks.toJSON(), distanceMin: this.distanceMin, distanceMax: this.distanceMax, attenuation: objectKeyByValue(SoundAttenuationFunction, this.attenuation), panningModel: objectKeyByValue(SoundPanningModelType, this.panningModel) }; } fromJSON(json) { if (json.isPositioned !== undefined) { this.writeFlag(SoundEmitterFlags.Spatialization, json.isPositioned); } else { this.clearFlag(SoundEmitterFlags.Spatialization); } if (json.channel !== undefined) { this.channel = json.channel; } else { this.channel = DEFAULT_CHANNEL; } if (json.volume !== undefined) { this.volume.fromJSON(json.volume); } if (typeof json.distanceMin === "number") { this.distanceMin = json.distanceMin; } else { this.distanceMin = DEFAULT_DISTANCE_MIN; } if (typeof json.distanceMax === "number") { this.distanceMax = json.distanceMax; } else { this.distanceMax = DEFAULT_DISTANCE_MAX; } if (typeof json.isAttenuated === "boolean") { this.writeFlag(SoundEmitterFlags.Attenuation, json.isAttenuated); } else { this.setFlag(SoundEmitterFlags.Attenuation); } if (typeof json.attenuation === "string") { const attenuation = SoundAttenuationFunction[json.attenuation]; if (attenuation === undefined) { throw new Error(`Unknown attenuation type '${json.attenuation}', valid types are: ${Object.keys(SoundAttenuationFunction)}`); } this.attenuation = attenuation; } else { this.attenuation = SoundAttenuationFunction.Linear; } //tracks if (json.tracks !== undefined) { this.tracks.fromJSON(json.tracks, SoundTrack); } if (json.panningModel === undefined) { this.panningModel = SoundPanningModelType.HRTF; } else { const model = SoundPanningModelType[json.panningModel]; if (model === undefined) { throw new Error(`Unknown panning model '${json.panningModel}', valid types are: ${Object.keys(SoundPanningModelType)}`); } this.panningModel = model; } } /** * * @param json * @returns {SoundEmitter} */ static fromJSON(json) { const result = new SoundEmitter(); result.fromJSON(json); return result; } } SoundEmitter.typeName = "SoundEmitter";