@giro3d/giro3d
Version:
A JS/WebGL framework for 3D geospatial data visualization
1,009 lines (953 loc) • 30 kB
JavaScript
import { Color, EventDispatcher, LinearFilter, MathUtils, Vector2 } from 'three';
import MemoryTracker from '../../renderer/MemoryTracker';
import { GlobalRenderTargetPool } from '../../renderer/RenderTargetPool';
import ImageSource from '../../sources/ImageSource';
import PromiseUtils, { PromiseStatus } from '../../utils/PromiseUtils';
import TextureGenerator from '../../utils/TextureGenerator';
import { nonNull } from '../../utils/tsutils';
import OperationCounter from '../OperationCounter';
import { DefaultQueue } from '../RequestQueue';
import Interpretation from './Interpretation';
import LayerComposer from './LayerComposer';
const tmpDims = new Vector2();
/**
* Events for nodes.
*/
/**
* A node material.
*/
/**
* Represents an object that can be painted by this layer.
* Nodes might be map tiles or anything else that matches the interface definition.
*/
var TargetState = /*#__PURE__*/function (TargetState) {
TargetState[TargetState["Pending"] = 0] = "Pending";
TargetState[TargetState["Processing"] = 1] = "Processing";
TargetState[TargetState["Complete"] = 2] = "Complete";
return TargetState;
}(TargetState || {});
function shouldCancel(node) {
if (node.disposed) {
return true;
}
if (node.parent == null || node.material == null) {
return true;
}
return !node.material.visible;
}
export class Target {
isMemoryUsage = true;
renderTarget = null;
paintCount = 0;
_disposed = false;
isDisposed() {
return this.node.disposed || this._disposed;
}
getMemoryUsage(context) {
if (this.renderTarget) {
return TextureGenerator.getMemoryUsage(context, this.renderTarget);
}
}
constructor(options) {
this.node = options.node;
this.pitch = options.pitch;
this.extent = options.extent;
this.geometryExtent = options.geometryExtent;
this.width = options.width;
this.height = options.height;
this.imageIds = new Set();
this.controller = new AbortController();
this.state = TargetState.Pending;
this._onVisibilityChanged = this.onVisibilityChanged.bind(this);
this.node.addEventListener('visibility-changed', this._onVisibilityChanged);
}
dispose() {
this._disposed = true;
this.node.removeEventListener('visibility-changed', this._onVisibilityChanged);
this.abort();
}
onVisibilityChanged() {
if (shouldCancel(this.node)) {
// If the node became invisible before we could complete the processing, cancel it.
if (this.state !== TargetState.Complete) {
this.abort();
this.state = TargetState.Pending;
}
}
}
reset() {
this.abort();
this.state = TargetState.Pending;
this.imageIds.clear();
}
abort() {
this.controller.abort(PromiseUtils.abortError());
this.controller = new AbortController();
}
abortAndThrow() {
const signal = this.controller.signal;
this.abort();
signal.throwIfAborted();
}
}
const nodesToDelete = [];
/**
* Base class of layers. Layers are components of maps or any compatible entity.
*
* The same layer can be added to multiple entities. Don't forget to call {@link dispose} when the
* layer should be destroyed, as removing a layer from an entity will not release memory associated
* with the layer (such as textures).
*
* ## Layer nodes
*
* Layers generate textures to be applied to {@link LayerNode | nodes}. Nodes might be map tiles, point
* cloud tiles or any object that matches the definition of the interface.
*
* ## Types of layers
*
* `Layer` is an abstract class. See subclasses for specific information. Main subclasses:
*
* - `ColorLayer` for color information, such as satellite imagery, vector data, etc.
* - `ElevationLayer` for elevation and terrain data.
* - `MaskLayer`: a special kind of layer that applies a mask on its host map.
*
* ## The `userData` property
*
* The `userData` property can be used to attach custom data to the layer, in a type safe manner.
* It is recommended to use this property instead of attaching arbitrary properties to the object:
*
* ```ts
* type MyCustomUserData = {
* creationDate: Date;
* owner: string;
* };
* const newLayer = new ColorLayer<MyCustomUserData>({ ... });
*
* newLayer.userData.creationDate = Date.now();
* newLayer.userData.owner = 'John Doe';
* ```
*
* ## Reprojection capabilities
*
* When the {@link source} of the layer has a different coordinate system (CRS) than the instance,
* the images from the source will be reprojected to the instance CRS.
*
* Note that doing so will have a performance cost in both CPU and memory.
*
* ```js
* // Add and create a new Layer to an existing map.
* const newLayer = new ColorLayer({ ... });
*
* await map.addLayer(newLayer);
*
* // Change layer's visibilty
* newLayer.visible = false;
* instance.notifyChange(); // update instance
*
* // Change layer's opacity
* newLayer.opacity = 0.5;
* instance.notifyChange(); // update instance
*
* // Listen to properties
* newLayer.addEventListener('visible-property-changed', (event) => console.log(event));
* ```
* @typeParam TEvents - The event map of the layer.
* @typeParam TUserData - The type of the `userData` property.
*/
class Layer extends EventDispatcher {
isMemoryUsage = true;
/**
* Optional name of this layer.
*/
/**
* The unique identifier of this layer.
*/
/**
* Read-only flag to check if a given object is of type Layer.
*/
isLayer = true;
/** The colormap of this layer */
colorMap = null;
/** The extent of this layer */
extent = null;
/** The source of this layer */
/** @internal */
_composer = null;
/** @internal */
_sortedTargets = null;
_instance = null;
/** The resolution factor applied to the textures generated by this layer. */
_preprocessOnce = null;
_ready = false;
/**
* An object that can be used to store custom data about the {@link Layer}.
*/
/**
* Disables automatic updates of this layer. Useful for debugging purposes.
*/
frozen = false;
get ready() {
return this._ready;
}
getMemoryUsage(context) {
this._targets.forEach(target => target.getMemoryUsage(context));
if (this.composer) {
this.composer.getMemoryUsage(context);
}
this.source.getMemoryUsage(context);
}
/**
* Creates a layer.
*
* @param options - The layer options.
*/
constructor(options) {
super();
this.name = options.name;
// @ts-expect-error {} is not assignable to TUserData in the case when the initial
// value is not provided. However, we have no way to initialize the userData to a
// correct default value. Instead of assigning to null/undefined, the compromise is
// to assign to the empty object.
this.userData = {};
this._onNodeDisposed = e => this.unregisterNode(e.target);
// We need a globally unique ID for this layer, to avoid collisions in the request queue.
this.id = MathUtils.generateUUID();
this.type = 'Layer';
this.interpretation = options.interpretation ?? Interpretation.Raw;
this.showTileBorders = options.showTileBorders ?? false;
this.showEmptyTextures = options.showEmptyTextures ?? false;
this._preloadImages = options.preloadImages ?? false;
this._fallbackImagesPromise = null;
this.noDataOptions = options.noDataOptions ?? {
replaceNoData: false
};
this.computeMinMax = options.computeMinMax ?? false;
this._createReadableTextures = this.computeMinMax != null && this.computeMinMax !== false;
this._visible = true;
this.colorMap = options.colorMap ?? null;
this.extent = options.extent ?? null;
this.resolutionFactor = options.resolutionFactor ?? 1;
if (options.source == null || !(options.source instanceof ImageSource)) {
throw new Error('missing or invalid source');
}
this.source = options.source;
this.source.addEventListener('updated', ({
extent
}) => this.onSourceUpdated(extent));
this.backgroundColor = new Color(options.backgroundColor);
this._targets = new Map();
// We only fetch images that we don't already have.
this._filter = imageId => !nonNull(this._composer).has(imageId);
this._queue = DefaultQueue;
this._opCounter = new OperationCounter();
this._sortedTargets = null;
}
shouldCancelRequest(node) {
return shouldCancel(node);
}
onSourceUpdated(extent) {
this.clear(extent);
}
onRenderingContextLost() {
/* Nothing to do */
}
onRenderingContextRestored() {
this.clear();
}
/**
* Resets all render targets to a blank state and repaint all the targets.
* @param extent - An optional extent to limit the region to clear.
*/
clear(extent) {
if (!this.ready) {
return;
}
nonNull(this._composer).clear(extent);
this._fallbackImagesPromise = null;
const reset = () => {
for (const target of this._targets.values()) {
if (!extent || extent.intersectsExtent(target.extent)) {
target.reset();
}
}
this.instance.notifyChange(this, {
immediate: true
});
};
if (this._preloadImages) {
this.loadFallbackImages().then(reset);
} else {
reset();
}
}
/**
* Gets or sets the visibility of this layer.
*/
get visible() {
return this._visible;
}
set visible(v) {
if (this._visible !== v) {
this._visible = v;
this.dispatchEvent({
type: 'visible-property-changed',
visible: v
});
this._targets.forEach(t => this.updateMaterial(t.node.material));
}
}
get loading() {
return this._opCounter.loading;
}
get progress() {
return this._opCounter.progress;
}
/**
* Initializes this layer. Note: this method is automatically called when the layer is added
* to an entity.
*
* @param options - Initialization options.
* @returns A promise that resolves when the initialization is complete.
* @internal
*/
initialize(options) {
const {
instance
} = options;
if (this._instance != null && instance !== this._instance) {
throw new Error('This layer has already been initialized for another instance.');
}
this._instance = instance;
if (this.extent && this.extent.crs !== instance.referenceCrs) {
throw new Error(`the extent of the layer was defined in a different CRS (${this.extent.crs}) than the instance's (${instance.referenceCrs}). Please convert the extent to the instance CRS before creating the layer.`);
}
if (!this._preprocessOnce) {
this._preprocessOnce = this.initializeOnce().then(() => {
this._ready = true;
return this;
});
}
return this._preprocessOnce;
}
get instance() {
return nonNull(this._instance, 'This layer is not initialized');
}
/**
* Perform the initialization. This should be called exactly once in the lifetime of the layer.
*/
async initializeOnce() {
this._opCounter.increment();
const targetProjection = this.instance.referenceCrs;
await this.source.initialize({
targetProjection
});
this._composer = new LayerComposer({
renderer: this.instance.renderer,
showImageOutlines: this.showTileBorders,
showEmptyTextures: this.showEmptyTextures,
extent: this.extent ?? undefined,
computeMinMax: this.computeMinMax,
sourceCrs: this.source.getCrs(),
targetCrs: targetProjection,
interpretation: this.interpretation,
fillNoData: this.noDataOptions.replaceNoData,
fillNoDataAlphaReplacement: this.noDataOptions.alpha,
fillNoDataRadius: this.noDataOptions.maxSearchDistance,
textureDataType: this.getRenderTargetDataType(),
pixelFormat: this.getRenderTargetPixelFormat()
});
if (this._preloadImages) {
await this.loadFallbackImages();
}
this.instance.notifyChange(this);
this._opCounter.decrement();
return this;
}
/**
* Returns the final extent of this layer. If this layer has its own extent defined,
* this will be used.
* Otherwise, will return the source extent (if any).
* May return undefined if not pre-processed yet.
*
* @returns The layer final extent.
*/
getExtent() {
// The layer extent takes precedence over the source extent,
// since it maye be used for some cropping effect.
return this.extent ?? this.source.getExtent()?.clone()?.as(this.instance.referenceCrs);
}
async loadFallbackImagesInternal() {
const extent = this.getExtent();
// If neither the source nor the layer are able to provide an extent,
// we cannot reliably fetch fallback images.
if (!extent) {
return;
}
const width = 512 * this.resolutionFactor;
const dims = extent.dimensions();
const height = width * (dims.y / dims.x);
const extentAsSourceCrs = extent.clone().as(this.source.getCrs());
const requests = this.source.getImages({
id: 'background',
extent: extentAsSourceCrs,
width,
height,
createReadableTextures: this._createReadableTextures
});
const promises = requests.map(img => img.request());
this._opCounter.increment();
const results = await Promise.allSettled(promises);
this._opCounter.decrement();
for (const result of results) {
if (result.status === PromiseStatus.Fullfilled) {
const image = result.value;
this.addToComposer(image, true);
}
}
await this.onInitialized();
}
onTextureCreated(texture) {
// Interpretation color space have a higher precedence.
texture.colorSpace = this.interpretation.colorSpace ?? this.source.colorSpace;
}
addToComposer(image, alwaysVisible) {
this.onTextureCreated(image.texture);
nonNull(this._composer).add({
alwaysVisible,
// Ensures background images are never deleted
flipY: this.source.flipY,
...image
});
}
async loadFallbackImages() {
if (!this._preloadImages) {
return;
}
if (!this._fallbackImagesPromise) {
// Let's fetch a low resolution image to fill tiles until we have a better resolution.
this._fallbackImagesPromise = this.loadFallbackImagesInternal();
}
await this._fallbackImagesPromise;
}
/**
* Called when the layer has finished initializing.
*/
async onInitialized() {
// Implemented in derived classes.
}
fetchImagesSync(options) {
const {
extent,
width,
height,
target
} = options;
const node = target.node;
const results = this.source.getImages({
id: `${target.node.id}`,
extent: extent.clone().as(this.source.getCrs()),
width,
height,
signal: target.controller.signal,
createReadableTextures: this._createReadableTextures
});
if (results.length === 0) {
// No new image to generate
return;
}
// Register the ids on the tile
results.forEach(r => {
target.imageIds.add(r.id);
});
if (this.shouldCancelRequest(node)) {
target.abortAndThrow();
}
const composer = nonNull(this._composer);
for (const {
id,
request
} of results) {
if (request == null || composer.has(id)) {
continue;
}
try {
const image = request();
this.addToComposer(image, false);
if (!this.shouldCancelRequest(node)) {
composer.lock(id, node.id);
}
} catch (e) {
if (e instanceof Error && e.name !== 'AbortError') {
console.error(e);
}
}
}
}
/**
* @param options - Options.
* @returns A promise that is settled when all images have been fetched.
*/
async fetchImages(options) {
const {
extent,
width,
height,
target
} = options;
const node = target.node;
const results = this.source.getImages({
id: `${target.node.id}`,
extent: extent.clone().as(this.source.getCrs()),
width,
height,
signal: target.controller.signal,
createReadableTextures: this._createReadableTextures
});
if (results.length === 0) {
// No new image to generate
return;
}
// Register the ids on the tile
results.forEach(r => {
target.imageIds.add(r.id);
});
if (this.shouldCancelRequest(node)) {
target.abortAndThrow();
}
const allImages = [];
const composer = nonNull(this._composer);
for (const {
id,
request
} of results) {
if (request == null || composer.has(id)) {
continue;
}
// More recent requests should be served first.
const priority = performance.now();
const shouldExecute = () => node.visible && this._filter(id);
this._opCounter.increment();
const requestId = `${this.id}-${id}`;
const p = this._queue.enqueue({
id: requestId,
request: request,
priority,
shouldExecute
}).then(image => {
this.addToComposer(image, false);
if (!this.shouldCancelRequest(node)) {
composer.lock(id, node.id);
}
}).catch(e => {
if (e.name !== 'AbortError') {
console.error(e);
}
}).finally(() => {
this._opCounter.decrement();
});
allImages.push(p);
}
await Promise.allSettled(allImages);
}
/**
* Removes the node from this layer.
*
* @param node - The disposed node.
*/
unregisterNode(node) {
const id = node.id;
const target = this._targets.get(id);
if (target) {
this.releaseRenderTarget(target.renderTarget);
this._targets.delete(id);
nonNull(this._composer).unlock(target.imageIds, id);
target.dispose();
this._sortedTargets = null;
node.removeEventListener('dispose', this._onNodeDisposed);
}
}
adjustExtent(extent) {
return extent;
}
/**
* Adjusts the extent to avoid visual artifacts.
*
* @param originalExtent - The original extent.
* @param originalWidth - The width, in pixels, of the original extent.
* @param originalHeight - The height, in pixels, of the original extent.
* @returns And object containing the adjusted extent, as well as adjusted pixel size.
*/
adjustExtentAndPixelSize(originalExtent, originalWidth, originalHeight) {
// This feature only makes sense if both the source and instance have the same CRS,
// meaning that pixels can be aligned
if (this.source.getCrs() === this.instance.referenceCrs) {
// Let's ask the source if it can help us have a pixel-perfect extent
const sourceAdjusted = this.source.adjustExtentAndPixelSize(originalExtent, originalWidth, originalHeight, 2);
if (sourceAdjusted) {
return sourceAdjusted;
}
}
// Tough luck, the source does not implement this feature. Let's use a default
// implementation: add a 5% margin to eliminate visual artifacts at the edges of tiles,
// such as color bleeding in atlas textures and hillshading issues with elevation data.
const pixelMargin = 4;
const marginExtent = originalExtent.withRelativeMargin(0.05);
// Should we crop the extent ?
const adjustedExtent = this.adjustExtent(marginExtent);
return {
extent: adjustedExtent,
width: originalWidth + pixelMargin * 2,
height: originalHeight + pixelMargin * 2
};
}
/**
* @returns Targets sorted by extent dimension.
*/
getSortedTargets() {
if (this._sortedTargets == null) {
this._sortedTargets = Array.from(this._targets.values()).sort((a, b) => {
const ax = a.extent.dimensions(tmpDims).x;
const bx = b.extent.dimensions(tmpDims).x;
return ax - bx;
});
}
return this._sortedTargets;
}
/**
* Returns the first ancestor that is completely loaded, or null if not found.
* @param target - The target.
* @returns The smallest target that still contains this extent.
*/
getLoadedAncestor(target) {
const extent = target.geometryExtent;
const targets = this.getSortedTargets();
for (const t of targets) {
const otherExtent = t.geometryExtent;
if (t !== target && extent.isInside(otherExtent, 0.00000001) && t.state === TargetState.Complete && t.renderTarget != null) {
return t;
}
}
return null;
}
/**
* @param target - The target.
*/
applyDefaultTexture(target) {
if (target.isDisposed()) {
return;
}
const parent = this.getLoadedAncestor(target);
const renderTarget = nonNull(target.renderTarget);
const composer = nonNull(this._composer);
if (parent) {
const parentRenderTarget = nonNull(parent.renderTarget);
const img = {
texture: parentRenderTarget.texture,
extent: parent.extent
};
// Inherit parent's texture by copying the data of the parent into the child.
composer.copy({
source: [img],
dest: renderTarget,
targetExtent: target.extent
});
} else {
// We didn't find any parent nor child, use whatever is present in the composer.
composer.render({
extent: target.extent,
width: target.width,
height: target.height,
target: renderTarget,
imageIds: target.imageIds,
isFallbackMode: true
});
}
const texture = renderTarget.texture;
this.applyTextureToNode({
texture,
pitch: target.pitch
}, target, false);
// Ensure that the material is up to date with the default texture
this.updateMaterial(target.node.material);
this.instance.notifyChange(this);
target.paintCount++;
}
/**
* @internal
*/
getInfo(node) {
const target = this._targets.get(node.id);
if (target) {
return {
state: TargetState[target.state],
imageCount: target.imageIds.size,
paintCount: target.paintCount
};
}
return {
state: 'unknown',
imageCount: -1,
paintCount: -1
};
}
/**
* Processes the target once, fetching all images relevant for this target,
* then paints those images to the target's texture.
*
* @param target - The target to paint.
*/
processTarget(target) {
if (target.state !== TargetState.Pending) {
return;
}
const signal = target.controller.signal;
if (signal.aborted) {
target.state = TargetState.Pending;
return;
}
const extent = target.extent;
const width = target.width;
const height = target.height;
// Fetch adequate images from the source...
const isContained = this.contains(extent);
if (isContained) {
if (!target.renderTarget) {
target.renderTarget = this.acquireRenderTarget(width, height);
// If the source is not synchronous, we need a default texture
// to avoid seeing a blank texture on the tile.
if (!this.source.synchronous) {
this.applyDefaultTexture(target);
}
}
if (!this.canFetchImages(target)) {
return;
}
target.state = TargetState.Processing;
// If the source is synchronous, the whole pipeline is also synchronous.
if (this.source.synchronous) {
try {
this.fetchImagesSync({
extent,
width,
height,
target
});
this.paintTarget(target);
} catch (e) {
console.error(e);
target.state = TargetState.Pending;
}
} else {
this.fetchImages({
extent,
width,
height,
target
}).then(() => {
this.paintTarget(target);
}).catch(err => {
// Abort errors are perfectly normal, so we don't need to log them.
// However any other error implies an abnormal termination of the processing.
if (err.name !== 'AbortError') {
console.error(err);
target.state = TargetState.Complete;
} else {
target.state = TargetState.Pending;
}
});
}
} else {
// The layer does not overlap with this tile, let's apply an empty texture.
target.state = TargetState.Complete;
this.applyEmptyTextureToNode(target);
}
}
paintTarget(target) {
if (target.isDisposed()) {
return;
}
const extent = target.extent;
const width = target.width;
const height = target.height;
const pitch = target.pitch;
const {
isLastRender
} = nonNull(this._composer).render({
extent,
width,
height,
target: nonNull(target.renderTarget),
imageIds: target.imageIds
});
if (isLastRender) {
target.state = TargetState.Complete;
} else {
target.state = TargetState.Pending;
}
target.paintCount++;
const texture = nonNull(target.renderTarget).texture;
this.applyTextureToNode({
texture,
pitch
}, target, isLastRender);
this.instance.notifyChange(this);
}
/**
* Updates the provided node with content from this layer.
*
* @param context - the context
* @param node - the node to update
*/
update(context, node) {
if (!this.ready || !this.visible) {
return;
}
const {
material
} = node;
if (node.parent == null || material == null) {
return;
}
// Node is hidden, no need to update it
if (!node.material.visible) {
return;
}
let target;
// First time we encounter this node
if (!this._targets.has(node.id)) {
const originalExtent = node.getExtent().clone();
const textureSize = node.textureSize;
// The texture that will be painted onto this node will not have the exact extent of
// this node, to avoid problems caused by pixels sitting on the edge of the tile.
const {
extent,
width,
height
} = this.adjustExtentAndPixelSize(originalExtent, Math.round(textureSize.x * this.resolutionFactor), Math.round(textureSize.y * this.resolutionFactor));
const pitch = originalExtent.offsetToParent(extent);
target = new Target({
node,
extent,
pitch,
width: Math.round(width),
height: Math.round(height),
geometryExtent: originalExtent
});
this._targets.set(node.id, target);
this._sortedTargets = null;
// Since the node does not own the texture for this layer, we need to be
// notified whenever it is disposed so we can in turn dispose the texture.
node.addEventListener('dispose', this._onNodeDisposed);
} else {
target = nonNull(this._targets.get(node.id));
}
if (target.isDisposed()) {
return;
}
this.updateMaterial(material);
// An update is pending / or impossible -> abort
if (this.frozen || !this.visible) {
return;
}
// Repaint the target if necessary.
this.processTarget(target);
}
/**
* @param extent - The extent to test.
* @returns `true` if this layer contains the specified extent, `false` otherwise.
*/
contains(extent) {
const customExtent = this.extent;
if (customExtent) {
if (!customExtent.intersectsExtent(extent)) {
return false;
}
}
return this.source.contains(extent);
}
/**
* @param target - The render target to release.
*/
releaseRenderTarget(target) {
if (!target) {
return;
}
GlobalRenderTargetPool.release(target, this.instance.renderer);
}
/**
* @param width - Width
* @param height - Height
* @returns The render target.
*/
acquireRenderTarget(width, height) {
const type = this.getRenderTargetDataType();
const filter = TextureGenerator.getCompatibleTextureFilter(LinearFilter, type, this.instance.renderer);
const options = {
format: this.getRenderTargetPixelFormat(),
magFilter: filter,
minFilter: filter,
type,
depthBuffer: false,
generateMipmaps: false
};
const result = GlobalRenderTargetPool.acquire(this.instance.renderer, width, height, options);
result.texture.name = `Layer "${this.id} - WebGLRenderTarget`;
MemoryTracker.track(result, `Layer "${this.id} - WebGLRenderTarget`);
return result;
}
deleteUnusedTargets() {
nodesToDelete.length = 0;
const sorted = this.getSortedTargets();
// Let's start from the smallest tiles (i.e with the highest resolution) first.
for (const target of sorted) {
// Is this target invisible ? We can only unload invisible targets.
// Note that we never delete root nodes so that we can always have some fallback data
if (!target.node.material.visible) {
const level = target.node.level;
// Can we unload it ?
// - We don't unload root nodes (level = 0)
// - We also don't unload nodes every 3 levels
// - We also don't unload nodes that do not have any loaded ancestor,
// to avoid sudden blank tiles.
if (level > 0 && level % 3 !== 0 && this.getLoadedAncestor(target)) {
nodesToDelete.push(target.node);
}
}
}
for (const node of nodesToDelete) {
this.unregisterNode(node);
}
}
postUpdate() {
this.deleteUnusedTargets();
this._composer?.postUpdate();
}
/**
* @internal
*/
get composer() {
return this._composer;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
updateMaterial() {
// Implemented in derived classes
}
/**
* Disposes the layer. This releases all resources held by this layer.
*/
dispose() {
this.source.dispose();
this._composer?.dispose();
for (const target of this._targets.values()) {
target.abort();
this.unregisterNode(target.node);
}
}
}
/**
* Returns `true` if the given object is a {@link Layer}.
*/
export function isLayer(obj) {
return typeof obj === 'object' && obj?.isLayer;
}
export default Layer;