@giro3d/giro3d
Version:
A JS/WebGL framework for 3D geospatial data visualization
1,339 lines (1,143 loc) • 41.9 kB
text/typescript
import {
Color,
EventDispatcher,
LinearFilter,
MathUtils,
Vector2,
type ColorRepresentation,
type Material,
type Object3D,
type Object3DEventMap,
type PixelFormat,
type RenderTargetOptions,
type Texture,
type TextureDataType,
type WebGLRenderTarget,
} from 'three';
import MemoryTracker from '../../renderer/MemoryTracker';
import type RenderingContextHandler from '../../renderer/RenderingContextHandler';
import { GlobalRenderTargetPool } from '../../renderer/RenderTargetPool';
import ImageSource, { type ImageResult } from '../../sources/ImageSource';
import PromiseUtils, { PromiseStatus } from '../../utils/PromiseUtils';
import TextureGenerator from '../../utils/TextureGenerator';
import { nonNull } from '../../utils/tsutils';
import type ColorMap from '../ColorMap';
import type Context from '../Context';
import type Disposable from '../Disposable';
import type ElevationRange from '../ElevationRange';
import type Extent from '../geographic/Extent';
import type Instance from '../Instance';
import type MemoryUsage from '../MemoryUsage';
import { type GetMemoryUsageContext } from '../MemoryUsage';
import type OffsetScale from '../OffsetScale';
import OperationCounter from '../OperationCounter';
import type Progress from '../Progress';
import type RequestQueue from '../RequestQueue';
import { DefaultQueue } from '../RequestQueue';
import type ColorLayer from './ColorLayer';
import Interpretation from './Interpretation';
import LayerComposer from './LayerComposer';
import type NoDataOptions from './NoDataOptions';
export interface TextureAndPitch {
texture: Texture;
pitch: OffsetScale;
}
const tmpDims = new Vector2();
/**
* Events for nodes.
*/
export interface LayerNodeEventMap extends Object3DEventMap {
dispose: unknown;
'visibility-changed': unknown;
}
/**
* A node material.
*/
export interface LayerNodeMaterial extends Material {
setColorTextures(layer: ColorLayer, textureAndPitch: TextureAndPitch): void;
setLayerVisibility(layer: ColorLayer, visible: boolean): void;
setLayerOpacity(layer: ColorLayer, opacity: number): void;
setLayerElevationRange(layer: ColorLayer, range: ElevationRange | null): void;
setColorimetry(
layer: ColorLayer,
brightness: number,
contrast: number,
saturation: number,
): void;
hasColorLayer(layer: ColorLayer): boolean;
indexOfColorLayer(layer: ColorLayer): number;
removeColorLayer(layer: ColorLayer): void;
pushColorLayer(layer: ColorLayer, extent: Extent): void;
}
/**
* Represents an object that can be painted by this layer.
* Nodes might be map tiles or anything else that matches the interface definition.
*/
export interface LayerNode extends Object3D<LayerNodeEventMap> {
/**
* Is this node disposed ?
*/
disposed: boolean;
/**
* The node material.
*/
material: LayerNodeMaterial;
/**
* The node texture size, in pixels.
*/
textureSize: Vector2;
/**
* Gets whether this node can accept a color layer texture.
*/
canProcessColorLayer(): boolean;
/**
* The node's extent.
*/
getExtent(): Extent;
/**
* The LOD or depth level of this node in the hierarchy (the root node is level zero).
*/
level: number;
}
enum TargetState {
Pending = 0,
Processing = 1,
Complete = 2,
}
function shouldCancel(node: LayerNode): boolean {
if (node.disposed) {
return true;
}
if (node.parent == null || node.material == null) {
return true;
}
return !node.material.visible;
}
export class Target implements MemoryUsage {
readonly isMemoryUsage = true as const;
node: LayerNode;
pitch: OffsetScale;
extent: Extent;
width: number;
height: number;
renderTarget: WebGLRenderTarget | null = null;
imageIds: Set<string>;
controller: AbortController;
state: TargetState;
geometryExtent: Extent;
paintCount = 0;
private _disposed = false;
private _onVisibilityChanged: () => void;
isDisposed() {
return this.node.disposed || this._disposed;
}
getMemoryUsage(context: GetMemoryUsageContext) {
if (this.renderTarget) {
return TextureGenerator.getMemoryUsage(context, this.renderTarget);
}
}
constructor(options: {
node: LayerNode;
extent: Extent;
geometryExtent: Extent;
pitch: OffsetScale;
width: number;
height: number;
}) {
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();
}
private 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();
}
}
type FetchImagesOptions = {
/** The request extent. */
extent: Extent;
/** The request width, in pixels. */
width: number;
/** The request height, in pixels. */
height: number;
/** The target of the images. */
target: Target;
};
export interface LayerEvents {
/**
* Fires when layer visibility changes.
*/
'visible-property-changed': { visible: boolean };
}
export interface LayerOptions {
/**
* An optional name for this layer.
*/
name?: string;
/**
* The source of the layer.
*/
source: ImageSource;
/**
* The optional extent to use for this layer. If none is provided, then the extent from the
* source is used instead. The layer will not be visible outside this extent.
*/
extent?: Extent;
/**
* How to interpret the pixel data of the source.
*/
interpretation?: Interpretation;
/**
* Displays the border of source images.
*/
showTileBorders?: boolean;
/**
* Displays empty textures as colored rectangles.
*/
showEmptyTextures?: boolean;
/**
* How to treat no-data values.
*/
noDataOptions?: NoDataOptions;
/**
* Enables min/max computation of source images. Mainly used for elevation data.
*/
computeMinMax?: boolean;
/**
* The optional color map to use.
*/
colorMap?: ColorMap;
/**
* Enables or disable preloading of low resolution fallback images. Those fallback images
* are used when no data is available yet on a particular region of the layer.
*/
preloadImages?: boolean;
/**
* The optional background color of the layer.
*/
backgroundColor?: ColorRepresentation;
/**
* The resolution factor applied to textures generated by this layer, compared to the pixel size
* of the targets. Default is `1`. A value greater than one will create textures with a higher
* resolution than what is asked by the targets. For example, if a map tile has a texture size
* of 256\*256, and a layer has a resolution factor of 2, the generated textures will have a
* size of 512\*512 pixels.
*/
resolutionFactor?: number;
}
export type LayerUserData = Record<string, unknown>;
const nodesToDelete: LayerNode[] = [];
/**
* 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.
*/
abstract class Layer<
TEvents extends LayerEvents = LayerEvents,
TUserData extends LayerUserData = LayerUserData,
>
extends EventDispatcher<TEvents & LayerEvents>
implements Progress, MemoryUsage, RenderingContextHandler, Disposable
{
readonly isMemoryUsage = true as const;
/**
* Optional name of this layer.
*/
readonly name: string | undefined;
/**
* The unique identifier of this layer.
*/
readonly id: string;
/**
* Read-only flag to check if a given object is of type Layer.
*/
readonly isLayer: boolean = true;
type: string;
readonly interpretation: Interpretation;
readonly showTileBorders: boolean;
readonly showEmptyTextures: boolean;
readonly noDataOptions: NoDataOptions;
readonly computeMinMax: boolean;
private _visible: boolean;
/** The colormap of this layer */
readonly colorMap: ColorMap | null = null;
/** The extent of this layer */
readonly extent: Extent | null = null;
/** The source of this layer */
readonly source: ImageSource;
/** @internal */
protected _composer: LayerComposer | null = null;
private readonly _targets: Map<number, Target>;
private readonly _filter: (id: string) => boolean;
/** @internal */
protected readonly _queue: RequestQueue;
private readonly _opCounter: OperationCounter;
private _sortedTargets: Target[] | null = null;
private _instance: Instance | null = null;
private readonly _createReadableTextures: boolean;
private readonly _preloadImages: boolean;
private _fallbackImagesPromise: Promise<void> | null;
/** The resolution factor applied to the textures generated by this layer. */
readonly resolutionFactor: number;
private _preprocessOnce: Promise<this> | null = null;
private _onNodeDisposed: (options: { target: LayerNode }) => void;
private _ready = false;
backgroundColor: Color;
/**
* An object that can be used to store custom data about the {@link Layer}.
*/
readonly userData: TUserData;
/**
* Disables automatic updates of this layer. Useful for debugging purposes.
*/
frozen = false;
get ready() {
return this._ready;
}
getMemoryUsage(context: GetMemoryUsageContext) {
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: LayerOptions) {
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: string) => !nonNull(this._composer).has(imageId);
this._queue = DefaultQueue;
this._opCounter = new OperationCounter();
this._sortedTargets = null;
}
private shouldCancelRequest(node: LayerNode) {
return shouldCancel(node);
}
private onSourceUpdated(extent?: Extent) {
this.clear(extent);
}
onRenderingContextLost(): void {
/* Nothing to do */
}
onRenderingContextRestored(): void {
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?: 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: {
/**
* The instance to associate this layer.
* Once set, the layer cannot be used with any other instance.
*/
instance: Instance;
}): Promise<this> {
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;
}
protected get instance(): 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.
*/
private 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.
*/
public getExtent(): Extent | undefined {
// 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 as PromiseFulfilledResult<ImageResult>).value;
this.addToComposer(image, true);
}
}
await this.onInitialized();
}
protected onTextureCreated(texture: Texture): void {
// Interpretation color space have a higher precedence.
texture.colorSpace = this.interpretation.colorSpace ?? this.source.colorSpace;
}
private addToComposer(image: ImageResult, alwaysVisible: boolean) {
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.
*/
protected async onInitialized() {
// Implemented in derived classes.
}
private fetchImagesSync(options: FetchImagesOptions): void {
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() as ImageResult;
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.
*/
private async fetchImages(options: FetchImagesOptions): Promise<void> {
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 as () => Promise<ImageResult>,
priority,
shouldExecute,
})
.then((image: ImageResult) => {
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: LayerNode) {
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);
}
}
protected adjustExtent(extent: Extent): 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.
*/
protected adjustExtentAndPixelSize(
originalExtent: Extent,
originalWidth: number,
originalHeight: number,
): { extent: Extent; width: number; height: number } {
// 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 margin = 0.05;
const pixelMargin = 4;
const marginExtent = originalExtent.withRelativeMargin(margin);
// Should we crop the extent ?
const adjustedExtent = this.adjustExtent(marginExtent);
const width = originalWidth + pixelMargin * 2;
const height = originalHeight + pixelMargin * 2;
return { extent: adjustedExtent, width, height };
}
/**
* @returns Targets sorted by extent dimension.
*/
private getSortedTargets(): Target[] {
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.
*/
private getLoadedAncestor(target: Target): Target | null {
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.
*/
protected applyDefaultTexture(target: 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: LayerNode): { state: string; imageCount: number; paintCount: number } {
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.
*/
private processTarget(target: 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);
}
}
private paintTarget(target: 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
*/
public update(context: Context, node: LayerNode): void {
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: 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);
}
protected abstract canFetchImages(target: Target): boolean;
/**
* @param extent - The extent to test.
* @returns `true` if this layer contains the specified extent, `false` otherwise.
*/
public contains(extent: Extent): boolean {
const customExtent = this.extent;
if (customExtent) {
if (!customExtent.intersectsExtent(extent)) {
return false;
}
}
return this.source.contains(extent);
}
abstract getRenderTargetPixelFormat(): PixelFormat;
abstract getRenderTargetDataType(): TextureDataType;
/**
* @param target - The render target to release.
*/
private releaseRenderTarget(target: WebGLRenderTarget | null) {
if (!target) {
return;
}
GlobalRenderTargetPool.release(target, this.instance.renderer);
}
/**
* @param width - Width
* @param height - Height
* @returns The render target.
*/
private acquireRenderTarget(width: number, height: number): WebGLRenderTarget {
const type = this.getRenderTargetDataType();
const filter = TextureGenerator.getCompatibleTextureFilter(
LinearFilter,
type,
this.instance.renderer,
);
const options: RenderTargetOptions = {
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;
}
protected 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(): Readonly<LayerComposer | null> {
return this._composer;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected updateMaterial(material: Material) {
// Implemented in derived classes
}
protected abstract applyTextureToNode(
texture: TextureAndPitch,
target: Target,
isLastRender: boolean,
): void;
protected abstract applyEmptyTextureToNode(target: Target): void;
/**
* Disposes the layer. This releases all resources held by this layer.
*/
public dispose(): void {
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: unknown): obj is Layer {
return typeof obj === 'object' && (obj as Layer)?.isLayer;
}
export default Layer;