@giro3d/giro3d
Version:
A JS/WebGL framework for 3D geospatial data visualization
1,347 lines (1,118 loc) • 44.6 kB
text/typescript
/*
* Copyright (c) 2015-2018, IGN France.
* Copyright (c) 2018-2026, Giro3D team.
* SPDX-License-Identifier: MIT
*/
import type {
ColorRepresentation,
IUniform,
Light,
Side,
Texture,
TextureDataType,
WebGLProgramParametersWithUniforms,
WebGLRenderer,
} from 'three';
import {
Color,
GLSL3,
NoBlending,
NormalBlending,
RGBAFormat,
ShaderMaterial,
Uniform,
UniformsLib,
UnsignedByteType,
Vector2,
Vector3,
Vector4,
} from 'three';
import type ColorimetryOptions from '../core/ColorimetryOptions';
import type ColorMapMode from '../core/ColorMapMode';
import type ContourLineOptions from '../core/ContourLineOptions';
import type ElevationRange from '../core/ElevationRange';
import type Extent from '../core/geographic/Extent';
import type GraticuleOptions from '../core/GraticuleOptions';
import type ColorLayer from '../core/layer/ColorLayer';
import type ElevationLayer from '../core/layer/ElevationLayer';
import type Layer from '../core/layer/Layer';
import type { TextureAndPitch } from '../core/layer/Layer';
import type MaskLayer from '../core/layer/MaskLayer';
import type { MaskMode } from '../core/layer/MaskLayer';
import type MemoryUsage from '../core/MemoryUsage';
import type TerrainOptions from '../core/TerrainOptions';
import type MapLightingOptions from '../entities/MapLightingOptions';
import type { AtlasInfo, LayerAtlasInfo } from './AtlasBuilder';
import type ColorMapAtlas from './ColorMapAtlas';
import BlendingMode from '../core/layer/BlendingMode';
import { type GetMemoryUsageContext } from '../core/MemoryUsage';
import OffsetScale from '../core/OffsetScale';
import Rect from '../core/Rect';
import { MapLightingMode } from '../entities/MapLightingOptions';
import Capabilities from '../utils/Capabilities';
import { getColor } from '../utils/predicates';
import TextureGenerator from '../utils/TextureGenerator';
import { nonNull } from '../utils/tsutils';
import AtlasBuilder from './AtlasBuilder';
import WebGLComposer from './composition/WebGLComposer';
import EmptyTexture from './EmptyTexture';
import MaterialUtils from './MaterialUtils';
import MemoryTracker from './MemoryTracker';
import RenderingState from './RenderingState';
import TileFS from './shader/TileFS.glsl';
import TileVS from './shader/TileVS.glsl';
const EMPTY_IMAGE_SIZE = 16;
const tmpDims = new Vector2();
const emptyTexture = new EmptyTexture();
const COLORMAP_DISABLED = 0;
const DISABLED_ELEVATION_RANGE = new Vector2(-999999, 999999);
class TextureInfo {
public readonly layer: ColorLayer;
public originalOffsetScale: OffsetScale;
public offsetScale: OffsetScale;
public texture: Texture;
public opacity: number;
public visible: boolean;
public color: Color;
public elevationRange?: Vector2;
public brightnessContrastSaturation: Vector3;
public constructor(layer: ColorLayer) {
this.layer = layer;
this.opacity = layer.opacity;
this.visible = layer.visible;
this.offsetScale = new OffsetScale(0, 0, 0, 0);
this.originalOffsetScale = new OffsetScale(0, 0, 0, 0);
this.texture = emptyTexture;
this.color = new Color(1, 1, 1);
this.brightnessContrastSaturation = new Vector3(0, 1, 1);
}
public get mode(): MaskMode {
return (this.layer as MaskLayer).maskMode ?? 0;
}
}
export const DEFAULT_OUTLINE_COLOR = 'red';
export const DEFAULT_HILLSHADING_INTENSITY = 1;
export const DEFAULT_HILLSHADING_ZFACTOR = 1;
export const DEFAULT_AZIMUTH = 135;
export const DEFAULT_ZENITH = 45;
export const DEFAULT_GRATICULE_COLOR = new Color(0, 0, 0);
export const DEFAULT_GRATICULE_STEP = 500; // meters
export const DEFAULT_GRATICULE_THICKNESS = 1;
export const DEFAULT_SUN_DIRECTION = new Vector3(1, 0, 0);
function drawImageOnAtlas(
width: number,
height: number,
composer: WebGLComposer,
atlasInfo: LayerAtlasInfo,
texture: Texture,
): void {
const dx = atlasInfo.x;
const dy = atlasInfo.y + nonNull(atlasInfo.offset);
const dw = width;
const dh = height;
const rect = new Rect(dx, dx + dw, dy, dy + dh);
composer.draw(texture, rect);
}
function updateOffsetScale(
imageSize: Vector2,
atlas: LayerAtlasInfo,
originalOffsetScale: OffsetScale,
width: number,
height: number,
target: OffsetScale,
): void {
if (originalOffsetScale.z === 0 || originalOffsetScale.w === 0) {
target.set(0, 0, 0, 0);
return;
}
// compute offset / scale
const xRatio = imageSize.width / width;
const yRatio = imageSize.height / height;
target.set(
atlas.x / width + originalOffsetScale.x * xRatio,
(atlas.y + nonNull(atlas.offset)) / height + originalOffsetScale.y * yRatio,
originalOffsetScale.z * xRatio,
originalOffsetScale.w * yRatio,
);
}
function repeat<T extends object>(value: T, count: number): T[] {
const result: T[] = new Array(count);
for (let i = 0; i < count; i++) {
result[i] = { ...value };
}
return result;
}
export interface MaterialOptions {
/**
* Discards no-data pixels.
*/
discardNoData: boolean;
/**
* Geometric terrain options.
*/
terrain: Required<TerrainOptions>;
/**
* Colorimetry options for the entire material.
*/
colorimetry: Required<ColorimetryOptions>;
/**
* The sidedness.
*/
side: Side;
/**
* Contour lines options.
*/
contourLines: Required<ContourLineOptions>;
/**
* Lighting options.
*/
lighting: Required<MapLightingOptions>;
/**
* Graticule options.
*/
graticule: Required<GraticuleOptions>;
/**
* The elevation range.
*/
elevationRange: { min: number; max: number } | null;
/**
* The colormap atlas.
*/
colorMapAtlas: ColorMapAtlas | null;
/**
* The background color.
*/
backgroundColor: Color;
/**
* The background opacity.
*/
backgroundOpacity: number;
/**
* Show the outlines of tile meshes.
*/
showTileOutlines: boolean;
/**
* The tile outline color.
* @defaultValue {@link DEFAULT_OUTLINE_COLOR}
*/
tileOutlineColor: Color;
/**
* Force using texture atlases even when not required by WebGL limitations.
*/
forceTextureAtlases: boolean;
/**
* Displays the collider meshes used for raycast.
*/
showColliderMeshes: boolean;
/**
* Displays the bounding boxes of tiles.
*/
showBoundingBoxes: boolean;
/**
* Displays the bounding spheres of tiles.
*/
showBoundingSpheres: boolean;
helperColor: ColorRepresentation;
depthTest: boolean;
}
enum InternalShadingMode {
Disabled = 0,
Simple = 1,
Realistic = 2,
}
function mapLightingMode(input: MapLightingOptions): InternalShadingMode {
if (input.enabled !== true) {
return InternalShadingMode.Disabled;
}
if (input.mode === MapLightingMode.LightBased) {
return InternalShadingMode.Realistic;
}
return InternalShadingMode.Simple;
}
interface HillshadingUniform {
mode: InternalShadingMode;
intensity: number;
zFactor: number;
zenith: number;
azimuth: number;
}
interface ContourLineUniform {
thickness: number;
primaryInterval: number;
secondaryInterval: number;
color: Vector4;
}
interface GraticuleUniform {
thickness: number;
/** xOffset, yOffset, xStep, yStep */
position: Vector4;
color: Vector4;
}
interface LayerUniform {
offsetScale: Vector4;
color: Vector4;
textureSize: Vector2;
elevationRange: Vector2;
brightnessContrastSaturation: Vector3;
mode: 0 | MaskMode;
blendingMode: BlendingMode;
}
interface NeighbourUniform {
offsetScale: Vector4 | null;
diffLevel: number;
}
interface ColorMapUniform {
mode: ColorMapMode | 0;
min: number;
max: number;
offset: number;
}
interface Defines extends Record<string, unknown> {
ENABLE_CONTOUR_LINES?: 1;
STITCHING?: 1;
TERRAIN_DEFORMATION?: 1;
DISCARD_NODATA_ELEVATION?: 1;
ENABLE_ELEVATION_RANGE?: 1;
ELEVATION_LAYER?: 1;
ENABLE_LAYER_MASKS?: 1;
ENABLE_OUTLINES?: 1;
APPLY_SHADING_ON_COLORLAYERS?: 1;
ENABLE_GRATICULE?: 1;
USE_ATLAS_TEXTURE?: 1;
/** Normal color rendering */
COLOR_RENDER?: 1;
/** For depth-based effects, such as shadow maps for directional lights */
DEPTH_RENDER?: 1;
/** For distance-based effects, such as shadow maps for point lights */
DISTANCE_RENDER?: 1;
/** The number of _visible_ color layers */
/**
* The z coordinate of vertices is reset before computing terrain
*/
GLOBE?: 1;
/**
* The number of _visible_ color layers
*/
VISIBLE_COLOR_LAYER_COUNT: number;
ENABLE_SKIRTS?: 1;
}
type ThreeUniforms = typeof UniformsLib.common & typeof UniformsLib.fog & typeof UniformsLib.lights;
interface Uniforms extends ThreeUniforms, Record<string, IUniform> {
// The id of the tile encoded into a single float
uuid: IUniform<number>;
// Lighting & shading
hillshading: IUniform<HillshadingUniform>;
renderingState: IUniform<RenderingState>;
segments: IUniform<number>;
extent: IUniform<Vector4>;
tileDimensions: IUniform<Vector2>;
neighbours: IUniform<NeighbourUniform[]>;
neighbourTextures: IUniform<(Texture | null)[]>;
elevationRange: IUniform<Vector2>;
baseTextureSize: IUniform<Vector2>;
graticule: IUniform<GraticuleUniform>;
contourLines: IUniform<ContourLineUniform>;
backgroundColor: IUniform<Vector4>;
tileOutlineColor: IUniform<Color>;
brightnessContrastSaturation: IUniform<Vector3>;
colorMapAtlas: IUniform<Texture | null>;
layersColorMaps: IUniform<ColorMapUniform[]>;
elevationColorMap: IUniform<ColorMapUniform>;
elevationScaling: IUniform<number>;
elevationTexture: IUniform<Texture | null>;
atlasTexture: IUniform<Texture | null>;
colorTextures: IUniform<Texture[]>;
layers: IUniform<LayerUniform[]>;
elevationLayer: IUniform<LayerUniform>;
// For distance-based rendering (point light shadow maps)
referencePosition: IUniform<Vector3>;
nearDistance: IUniform<number>;
farDistance: IUniform<number>;
// Skirts related uniforms
// The skirt elevation, in CRS units (might be negative)
skirtElevation: IUniform<number>;
// The start and end index of vertices located at the bottom of the skirt
skirtVertexRange: IUniform<Vector2>;
}
class LayeredMaterial extends ShaderMaterial implements MemoryUsage {
public readonly isMemoryUsage = true as const;
// Used for point-light shadow maps
public light?: Light;
private readonly _getIndexFn: (arg0: Layer) => number;
private readonly _renderer: WebGLRenderer;
private readonly _colorLayers: ColorLayer[] = [];
private readonly _atlasInfo: AtlasInfo;
private readonly _forceTextureAtlas: boolean;
private readonly _maxTextureImageUnits: number;
private readonly _textureSize: Vector2;
private readonly _texturesInfo: {
color: {
infos: TextureInfo[];
atlasTexture: Texture | null;
};
elevation: {
offsetScale: OffsetScale;
texture: Texture | null;
};
};
private _elevationLayer: ElevationLayer | null = null;
private _mustUpdateUniforms = true;
private _needsSorting = true;
private _needsAtlasRepaint = false;
private _composer: WebGLComposer | null = null;
private _colorMapAtlas: ColorMapAtlas | null = null;
private _composerDataType: TextureDataType = UnsignedByteType;
public override readonly uniforms: Uniforms;
public override readonly defines: Defines = {
VISIBLE_COLOR_LAYER_COUNT: 0,
};
private _options?: MaterialOptions;
public getMemoryUsage(context: GetMemoryUsageContext): void {
// We only consider textures that this material owns. That excludes layer textures.
const atlas = this._texturesInfo.color.atlasTexture;
if (atlas) {
TextureGenerator.getMemoryUsage(context, atlas);
}
}
public constructor(params: {
/** the material options. */
options: MaterialOptions;
/** the WebGL renderer. */
renderer: WebGLRenderer;
/** The number of maximum texture units in fragment shaders */
maxTextureImageUnits: number;
/** The function to help sorting color layers. */
getIndexFn: (arg0: Layer) => number;
/** The texture data type to be used for the atlas texture. */
textureDataType: TextureDataType;
hasElevationLayer: boolean;
tileDimensions: Vector2;
extent: Extent;
textureSize: Vector2;
isGlobe: boolean;
}) {
super({ clipping: true, glslVersion: GLSL3 });
this._atlasInfo = { maxX: 0, maxY: 0, atlas: null };
this._textureSize = params.textureSize;
this.fog = true;
this._maxTextureImageUnits = params.maxTextureImageUnits;
this._getIndexFn = params.getIndexFn;
const options = params.options;
MaterialUtils.setDefine(this, 'USE_ATLAS_TEXTURE', false);
MaterialUtils.setDefine(this, 'STITCHING', options.terrain.stitching);
MaterialUtils.setDefine(this, 'GLOBE', params.isGlobe);
MaterialUtils.setDefine(this, 'TERRAIN_DEFORMATION', options.terrain.enabled);
MaterialUtils.setDefine(this, 'DISCARD_NODATA_ELEVATION', options.discardNoData);
MaterialUtils.setDefine(this, 'ENABLE_ELEVATION_RANGE', options.elevationRange != null);
MaterialUtils.setDefineValue(this, 'VISIBLE_COLOR_LAYER_COUNT', 0);
MaterialUtils.setDefine(this, 'COLOR_RENDER', true);
this.fragmentShader = TileFS;
this.vertexShader = TileVS;
this._texturesInfo = {
color: {
infos: [],
atlasTexture: null,
},
elevation: {
offsetScale: new OffsetScale(0, 0, 0, 0),
texture: null,
},
};
this.side = options.side;
this.lights = true;
this._renderer = params.renderer;
this._forceTextureAtlas = options.forceTextureAtlases ?? false;
this._composerDataType = params.textureDataType;
this._colorMapAtlas = options.colorMapAtlas ?? null;
const elevationRange = options.elevationRange
? new Vector2(options.elevationRange.min, options.elevationRange.max)
: DISABLED_ELEVATION_RANGE;
const elevInfo = this._texturesInfo.elevation;
const extent = params.extent;
const { width, height } = extent.dimensions(tmpDims);
this.uniforms = {
// Automatically updated by THREE.js
...UniformsLib.common,
...UniformsLib.lights,
...UniformsLib.fog,
// Uniforms for point light shadow maps
referencePosition: new Uniform(new Vector3()),
nearDistance: new Uniform(1),
farDistance: new Uniform(1000),
uuid: new Uniform(0),
baseTextureSize: new Uniform(this._textureSize),
hillshading: new Uniform<HillshadingUniform>({
mode: mapLightingMode(options.lighting),
zenith: DEFAULT_ZENITH,
azimuth: DEFAULT_AZIMUTH,
intensity: DEFAULT_HILLSHADING_INTENSITY,
zFactor: DEFAULT_HILLSHADING_ZFACTOR,
}),
renderingState: new Uniform(RenderingState.FINAL),
extent: new Uniform(new Vector4(extent.minX, extent.minY, width, height)),
tileDimensions: new Uniform(params.tileDimensions),
segments: new Uniform(options.terrain.segments ?? 8),
neighbours: new Uniform(
repeat<NeighbourUniform>(
{
diffLevel: 0,
offsetScale: null,
},
8,
),
),
neighbourTextures: new Uniform([null, null, null, null, null, null, null, null]),
elevationRange: new Uniform(elevationRange),
graticule: new Uniform<GraticuleUniform>({
color: new Vector4(0, 0, 0, 1),
thickness: DEFAULT_GRATICULE_THICKNESS,
position: new Vector4(0, 0, DEFAULT_GRATICULE_STEP, DEFAULT_GRATICULE_STEP),
}),
contourLines: new Uniform({
thickness: 1,
primaryInterval: 100,
secondaryInterval: 20,
color: new Vector4(0, 0, 0, 1),
}),
backgroundColor: new Uniform(new Vector4()),
tileOutlineColor: new Uniform(new Color(DEFAULT_OUTLINE_COLOR)),
brightnessContrastSaturation: new Uniform(new Vector3(0, 1, 1)),
colorMapAtlas: new Uniform(null),
layersColorMaps: new Uniform([]),
elevationColorMap: new Uniform<ColorMapUniform>({
mode: 0,
offset: 0,
max: 0,
min: 0,
}),
elevationScaling: new Uniform(1),
elevationTexture: new Uniform(elevInfo.texture),
atlasTexture: new Uniform(this._texturesInfo.color.atlasTexture),
colorTextures: new Uniform([]),
// Describe the properties of each color layer (offsetScale, color...).
layers: new Uniform([]),
elevationLayer: new Uniform<LayerUniform>({
brightnessContrastSaturation: new Vector3(0, 1, 1),
color: new Vector4(0, 0, 0, 0),
elevationRange: new Vector2(0, 0),
offsetScale: new OffsetScale(0, 0, 0, 0),
textureSize: new Vector2(0, 0),
blendingMode: BlendingMode.None,
mode: 0,
}),
skirtVertexRange: new Uniform(new Vector2(0, 0)),
skirtElevation: new Uniform(0),
};
this.uniformsNeedUpdate = true;
this.update(options);
MemoryTracker.track(this, 'LayeredMaterial');
}
/**
* @param v - The number of segments.
*/
public set segments(v: number) {
this.uniforms.segments.value = v;
}
public updateNeighbour(
neighbour: number,
diffLevel: number,
offsetScale: OffsetScale,
texture: Texture | null,
): void {
this.uniforms.neighbours.value[neighbour].diffLevel = diffLevel;
this.uniforms.neighbours.value[neighbour].offsetScale = offsetScale;
this.uniforms.neighbourTextures.value[neighbour] = texture;
}
public setElevationScaling(scaling: number): void {
this.uniforms.elevationScaling.value = scaling;
}
public override onBeforeCompile(parameters: WebGLProgramParametersWithUniforms): void {
// This is a workaround due to a limitation in three.js, documented
// here: https://github.com/mrdoob/three.js/issues/28020
// Normally, we would not have to do this and let the loop unrolling do its job.
// However, in our case, the loop end index is not an integer, but a define.
// We have to patch the fragment shader ourselves because three.js will not do it
// before the loop is unrolled, leading to a compilation error.
parameters.fragmentShader = parameters.fragmentShader.replaceAll(
'COLOR_LAYERS_LOOP_END',
`${this.defines.VISIBLE_COLOR_LAYER_COUNT}`,
);
}
private updateColorLayerUniforms(): void {
const useAtlas = this.defines.USE_ATLAS_TEXTURE === 1;
this.sortLayersIfNecessary();
if (this._mustUpdateUniforms) {
const layersUniform: LayerUniform[] = [];
const infos = this._texturesInfo.color.infos;
const textureUniforms = this.uniforms.colorTextures.value;
textureUniforms.length = 0;
for (const info of infos) {
const layer = info.layer;
// Ignore non-visible layers
if (!layer.visible) {
continue;
}
// If we use an atlas, the offset/scale is different.
const offsetScale = useAtlas ? info.offsetScale : info.originalOffsetScale;
const tex = info.texture;
let textureSize = new Vector2(0, 0);
const image = tex.image;
if (image != null) {
textureSize = new Vector2(image.width, image.height);
}
const rgb = info.color;
const a = info.visible ? info.opacity : 0;
const color = new Vector4(rgb.r, rgb.g, rgb.b, a);
const elevationRange = info.elevationRange || DISABLED_ELEVATION_RANGE;
const uniform: LayerUniform = {
offsetScale,
color,
textureSize,
elevationRange,
mode: info.mode,
blendingMode: layer.blendingMode,
brightnessContrastSaturation: info.brightnessContrastSaturation,
};
layersUniform.push(uniform);
if (!useAtlas) {
textureUniforms.push(tex);
}
}
this.uniforms.layers.value = layersUniform;
}
}
public override dispose(): void {
this.dispatchEvent({
type: 'dispose',
});
for (const layer of this._colorLayers) {
const index = this.indexOfColorLayer(layer);
if (index === -1) {
continue;
}
delete this._texturesInfo.color.infos[index];
}
this._colorLayers.length = 0;
this._composer?.dispose();
this._texturesInfo.color.atlasTexture?.dispose();
}
public getColorTexture(layer: ColorLayer): Texture | null {
const index = this.indexOfColorLayer(layer);
if (index === -1) {
return null;
}
return this._texturesInfo.color.infos[index].texture;
}
private countIndividualTextures(): { totalTextureUnits: number; visibleColorLayers: number } {
let totalTextureUnits = 0;
if (this._elevationLayer) {
totalTextureUnits++;
if (this.defines.STITCHING) {
// We use 8 neighbour textures for stit-ching
totalTextureUnits += 8;
}
}
if (this._colorMapAtlas) {
totalTextureUnits++;
}
const visibleColorLayers = this.getVisibleColorLayerCount();
// Count only visible color layers
totalTextureUnits += visibleColorLayers;
return { totalTextureUnits, visibleColorLayers };
}
public override onBeforeRender(): void {
this.updateOpacityParameters(this.opacity);
if (this.defines.USE_ATLAS_TEXTURE && this._needsAtlasRepaint) {
this.repaintAtlas();
this._needsAtlasRepaint = false;
}
this.updateColorWrite();
this.updateColorLayerUniforms();
this.updateColorMaps();
}
/**
* Determine if this material should write to the color buffer.
*/
private updateColorWrite(): void {
if (this._texturesInfo.elevation.texture == null && this.defines.DISCARD_NODATA_ELEVATION) {
// No elevation texture means that every single fragment will be discarded,
// which is an illegal operation in WebGL (raising warnings).
this.colorWrite = false;
} else {
this.colorWrite = true;
}
}
public repaintAtlas(): void {
this.rebuildAtlasIfNecessary();
const composer = nonNull(this._composer);
composer.clear();
// Redraw all visible color layers on the canvas
for (const l of this._colorLayers) {
if (!l.visible) {
continue;
}
const idx = this.indexOfColorLayer(l);
const atlas = nonNull(this._atlasInfo.atlas)[l.id];
const layerTexture = this._texturesInfo.color.infos[idx].texture;
const w = layerTexture?.image?.width ?? EMPTY_IMAGE_SIZE;
const h = layerTexture?.image?.height ?? EMPTY_IMAGE_SIZE;
updateOffsetScale(
new Vector2(w, h),
atlas,
this._texturesInfo.color.infos[idx].originalOffsetScale,
this.composerWidth,
this.composerHeight,
this._texturesInfo.color.infos[idx].offsetScale,
);
if (layerTexture != null) {
drawImageOnAtlas(w, h, nonNull(composer), atlas, layerTexture);
}
}
const rendered = composer.render();
rendered.name = 'LayeredMaterial - Atlas';
MemoryTracker.track(rendered, rendered.name);
// Even though we asked the composer to reuse the same texture, sometimes it has
// to recreate a new texture when some parameters change, such as pixel format.
if (rendered.uuid !== this._texturesInfo.color.atlasTexture?.uuid) {
this.rebuildAtlasTexture(rendered);
}
this.uniforms.atlasTexture.value = this._texturesInfo.color.atlasTexture;
}
public setColorTextures(layer: ColorLayer, textureAndPitch: TextureAndPitch): void {
const index = this.indexOfColorLayer(layer);
if (index < 0) {
this.pushColorLayer(layer);
}
const { pitch, texture } = textureAndPitch;
this._texturesInfo.color.infos[index].originalOffsetScale.copy(pitch);
this._texturesInfo.color.infos[index].texture = texture;
const currentSize = TextureGenerator.getBytesPerChannel(this._composerDataType);
const textureSize = TextureGenerator.getBytesPerChannel(texture.type);
if (textureSize > currentSize) {
// The new layer uses a bigger data type, we need to recreate the atlas
this._composerDataType = texture.type;
}
this._needsAtlasRepaint = true;
}
public pushElevationLayer(layer: ElevationLayer): void {
this._elevationLayer = layer;
}
public removeElevationLayer(): void {
this._elevationLayer = null;
this.uniforms.elevationTexture.value = null;
this._texturesInfo.elevation.texture = null;
MaterialUtils.setDefine(this, 'ELEVATION_LAYER', false);
}
public setElevationTexture(
layer: ElevationLayer,
{ texture, pitch }: { texture: Texture; pitch: OffsetScale },
): void {
this._elevationLayer = layer;
MaterialUtils.setDefine(this, 'ELEVATION_LAYER', true);
this.uniforms.elevationTexture.value = texture;
this._texturesInfo.elevation.texture = texture;
this._texturesInfo.elevation.offsetScale.copy(pitch);
const uniform = this.uniforms.elevationLayer.value;
uniform.offsetScale = pitch;
uniform.textureSize = new Vector2(texture.image.width, texture.image.height);
uniform.color = new Vector4(1, 1, 1, 1);
uniform.brightnessContrastSaturation = new Vector3(1, 1, 1);
uniform.elevationRange = new Vector2();
this.updateColorMaps();
}
private rebuildAtlasInfo(): void {
const colorLayers = this._colorLayers;
// rebuild color textures atlas
// We use a margin to prevent atlas bleeding.
const margin = 1.1;
const { width, height } = this._textureSize;
const { atlas, maxX, maxY } = AtlasBuilder.pack(
Capabilities.getMaxTextureSize(),
colorLayers.map(l => ({
id: l.id,
size: new Vector2(
Math.round(width * l.resolutionFactor * margin),
Math.round(height * l.resolutionFactor * margin),
),
})),
this._atlasInfo.atlas,
);
this._atlasInfo.atlas = atlas;
this._atlasInfo.maxX = Math.max(this._atlasInfo.maxX, maxX);
this._atlasInfo.maxY = Math.max(this._atlasInfo.maxY, maxY);
}
public pushColorLayer(newLayer: ColorLayer): void {
if (this._colorLayers.includes(newLayer)) {
return;
}
this._colorLayers.push(newLayer);
const info = new TextureInfo(newLayer);
if (newLayer.type === 'MaskLayer') {
MaterialUtils.setDefine(this, 'ENABLE_LAYER_MASKS', true);
}
this.rebuildAtlasInfo();
// Optional feature: limit color layer display within an elevation range
if (newLayer.elevationRange != null) {
MaterialUtils.setDefine(this, 'ENABLE_ELEVATION_RANGE', true);
const { min, max } = newLayer.elevationRange;
info.elevationRange = new Vector2(min, max);
}
this._texturesInfo.color.infos.push(info);
this.updateColorLayerCount();
this.updateColorMaps();
this.needsUpdate = true;
}
private getVisibleColorLayerCount(): number {
let result = 0;
for (let i = 0; i < this._colorLayers.length; i++) {
const layer = this._colorLayers[i];
if (layer.visible) {
result++;
}
}
return result;
}
public reorderLayers(): void {
this._needsSorting = true;
}
private sortLayersIfNecessary(): void {
const idx = this._getIndexFn;
if (this._needsSorting) {
this._colorLayers.sort((a, b) => idx(a) - idx(b));
this._texturesInfo.color.infos.sort((a, b) => idx(a.layer) - idx(b.layer));
this._needsSorting = false;
}
}
public removeColorLayer(layer: ColorLayer): void {
const index = this.indexOfColorLayer(layer);
if (index === -1) {
return;
}
// NOTE: we cannot dispose the texture here, because it might be cached for later.
this._texturesInfo.color.infos.splice(index, 1);
this._colorLayers.splice(index, 1);
this.updateColorMaps();
this.rebuildAtlasInfo();
this.updateColorLayerCount();
}
/**
* Sets the colormap atlas.
*
* @param atlas - The atlas.
*/
public setColorMapAtlas(atlas: ColorMapAtlas | null): void {
this._colorMapAtlas = atlas;
}
private updateColorMaps(): void {
this.sortLayersIfNecessary();
const atlas = this._colorMapAtlas;
const elevationColorMap = this._elevationLayer?.colorMap;
const elevationUniform = this.uniforms.elevationColorMap;
if (elevationColorMap?.active === true) {
elevationUniform.value.mode = elevationColorMap?.mode ?? COLORMAP_DISABLED;
elevationUniform.value.min = elevationColorMap?.min ?? 0;
elevationUniform.value.max = elevationColorMap?.max ?? 0;
elevationUniform.value.offset = atlas?.getOffset(elevationColorMap) ?? 0;
} else {
elevationUniform.value.mode = COLORMAP_DISABLED;
elevationUniform.value.min = 0;
elevationUniform.value.max = 0;
}
const colorLayers = this._texturesInfo.color.infos;
const uniforms: ColorMapUniform[] = [];
for (let i = 0; i < colorLayers.length; i++) {
const texInfo = colorLayers[i];
if (!texInfo.layer.visible) {
continue;
}
const colorMap = texInfo.layer.colorMap;
const uniform: ColorMapUniform = {
mode: colorMap?.active === true ? colorMap.mode : COLORMAP_DISABLED,
min: colorMap?.min ?? 0,
max: colorMap?.max ?? 0,
offset: colorMap ? (atlas?.getOffset(colorMap) ?? 0) : 0,
};
uniforms.push(uniform);
}
this.uniforms.layersColorMaps = new Uniform(uniforms);
if (atlas?.texture) {
const luts = atlas.texture ?? null;
this.uniforms.colorMapAtlas.value = luts;
}
}
private updateGraticuleUniforms(opts: MaterialOptions): void {
const graticule = opts.graticule;
const enabled = graticule.enabled ?? false;
MaterialUtils.setDefine(this, 'ENABLE_GRATICULE', enabled);
if (enabled) {
const uniform = this.uniforms.graticule.value;
uniform.thickness = graticule.thickness;
uniform.position.set(
graticule.xOffset,
graticule.yOffset,
graticule.xStep,
graticule.yStep,
);
const rgb = getColor(graticule.color);
uniform.color.set(rgb.r, rgb.g, rgb.b, graticule.opacity ?? 0);
}
}
private updateContourLineUniforms(opts: MaterialOptions): void {
const contourLines = opts.contourLines;
if (contourLines.enabled) {
const c = getColor(contourLines.color);
const a = contourLines.opacity;
this.uniforms.contourLines.value = {
thickness: contourLines.thickness ?? 1,
primaryInterval: contourLines.interval ?? 100,
secondaryInterval: contourLines.secondaryInterval ?? 0,
color: new Vector4(c.r, c.g, c.b, a),
};
}
MaterialUtils.setDefine(this, 'ENABLE_CONTOUR_LINES', contourLines.enabled);
}
private updateColorUniforms(opts: MaterialOptions): void {
const a = opts.backgroundOpacity;
const c = opts.backgroundColor;
const vec4 = new Vector4(c.r, c.g, c.b, a);
this.uniforms.backgroundColor.value.copy(vec4);
const colorimetry = opts.colorimetry;
this.uniforms.brightnessContrastSaturation.value.set(
colorimetry.brightness,
colorimetry.contrast,
colorimetry.saturation,
);
}
private updateHillshadingUniforms(opts: MaterialOptions): void {
const params = opts.lighting;
MaterialUtils.setDefine(this, 'APPLY_SHADING_ON_COLORLAYERS', !params.elevationLayersOnly);
const uniform = this.uniforms.hillshading.value;
if (params.mode === MapLightingMode.Hillshade) {
uniform.zenith = params.hillshadeZenith ?? DEFAULT_ZENITH;
uniform.azimuth = params.hillshadeAzimuth ?? DEFAULT_AZIMUTH;
uniform.intensity = params.hillshadeIntensity ?? 1;
}
uniform.mode = mapLightingMode(params);
uniform.zFactor = params.zFactor ?? 1;
}
public update(opts?: MaterialOptions): boolean {
if (opts) {
this._options = opts;
this.depthTest = opts.depthTest;
if (this._colorMapAtlas) {
this.updateColorMaps();
}
this.updateColorUniforms(opts);
this.updateGraticuleUniforms(opts);
this.updateContourLineUniforms(opts);
this.updateHillshadingUniforms(opts);
if (opts.elevationRange) {
const { min, max } = opts.elevationRange;
this.uniforms.elevationRange.value.set(min, max);
}
MaterialUtils.setDefine(this, 'ELEVATION_LAYER', this._elevationLayer?.visible);
MaterialUtils.setDefine(this, 'ENABLE_OUTLINES', opts.showTileOutlines);
if (opts.showTileOutlines) {
this.uniforms.tileOutlineColor.value = getColor(opts.tileOutlineColor);
}
MaterialUtils.setDefine(this, 'DISCARD_NODATA_ELEVATION', opts.discardNoData);
MaterialUtils.setDefine(this, 'TERRAIN_DEFORMATION', opts.terrain.enabled);
MaterialUtils.setDefine(this, 'STITCHING', opts.terrain.stitching);
const newSide = opts.side;
if (this.side !== newSide) {
this.side = newSide;
this.needsUpdate = true;
}
}
if (this._colorLayers.length === 0) {
return true;
}
return this.rebuildAtlasIfNecessary();
}
private updateColorLayerCount(): void {
// If we have fewer textures than allowed by WebGL max texture units,
// then we can directly use those textures in the shader.
// Otherwise we have to reduce the number of color textures by aggregating
// them in a texture atlas. Note that doing so will have a performance cost,
// both increasing memory consumption and GPU time, since each color texture
// must rendered into the atlas.
const { totalTextureUnits, visibleColorLayers } = this.countIndividualTextures();
const shouldUseAtlas =
this._forceTextureAtlas || totalTextureUnits > this._maxTextureImageUnits;
MaterialUtils.setDefine(this, 'USE_ATLAS_TEXTURE', shouldUseAtlas);
// If the number of visible layers has changed, we need to repaint the
// atlas because it only shows visible layers.
if (MaterialUtils.setDefineValue(this, 'VISIBLE_COLOR_LAYER_COUNT', visibleColorLayers)) {
this._mustUpdateUniforms = true;
this._needsAtlasRepaint = true;
this.needsUpdate = true;
}
}
public override customProgramCacheKey(): string {
return (this.defines.VISIBLE_COLOR_LAYER_COUNT ?? 0).toString();
}
public createComposer(): WebGLComposer {
const newComposer = new WebGLComposer({
extent: new Rect(0, this._atlasInfo.maxX, 0, this._atlasInfo.maxY),
width: this._atlasInfo.maxX,
height: this._atlasInfo.maxY,
reuseTexture: true,
webGLRenderer: this._renderer,
pixelFormat: RGBAFormat,
textureDataType: this._composerDataType,
});
return newComposer;
}
private get composerWidth(): number {
return this._composer?.width ?? 0;
}
private get composerHeight(): number {
return this._composer?.height ?? 0;
}
public rebuildAtlasIfNecessary(): boolean {
if (
this._composer == null ||
this._atlasInfo.maxX > this.composerWidth ||
this._atlasInfo.maxY > this.composerHeight ||
this._composer.dataType !== this._composerDataType
) {
const newComposer = this.createComposer();
let newTexture: Texture | null = null;
const currentTexture = this._texturesInfo.color.atlasTexture;
if (this._composer && currentTexture && this.composerWidth > 0) {
// repaint the old canvas into the new one.
newComposer.draw(
currentTexture,
new Rect(0, this.composerWidth, 0, this.composerHeight),
);
newTexture = newComposer.render();
}
this._composer?.dispose();
currentTexture?.dispose();
this._composer = newComposer;
const atlases = nonNull(this._atlasInfo.atlas);
for (let i = 0; i < this._colorLayers.length; i++) {
const layer = this._colorLayers[i];
const atlas = atlases[layer.id];
const pitch = this._texturesInfo.color.infos[i].originalOffsetScale;
const texture = this._texturesInfo.color.infos[i].texture;
// compute offset / scale
const w = texture?.image?.width ?? EMPTY_IMAGE_SIZE;
const h = texture?.image?.height ?? EMPTY_IMAGE_SIZE;
const xRatio = w / this.composerWidth;
const yRatio = h / this.composerHeight;
this._texturesInfo.color.infos[i].offsetScale = new OffsetScale(
atlas.x / this.composerWidth + pitch.x * xRatio,
(atlas.y + nonNull(atlas.offset)) / this.composerHeight + pitch.y * yRatio,
pitch.z * xRatio,
pitch.w * yRatio,
);
}
this.rebuildAtlasTexture(newTexture);
}
return this.composerWidth > 0;
}
private rebuildAtlasTexture(newTexture: Texture | null): void {
if (newTexture) {
newTexture.name = 'LayeredMaterial - Atlas';
}
this._texturesInfo.color.atlasTexture?.dispose();
this._texturesInfo.color.atlasTexture = newTexture;
this.uniforms.atlasTexture.value = this._texturesInfo.color.atlasTexture;
}
public changeState(state: RenderingState): void {
if (this.uniforms.renderingState.value === state) {
return;
}
this.uniforms.renderingState.value = state;
this.updateOpacityParameters(this.opacity);
this.updateBlendingMode();
this.needsUpdate = true;
}
private updateBlendingMode(): void {
const state = this.uniforms.renderingState.value;
if (state === RenderingState.FINAL) {
const background = this._options?.backgroundOpacity ?? 1;
this.transparent = this.opacity < 1 || background < 1;
this.needsUpdate = true;
this.blending = NormalBlending;
} else {
// We cannot use alpha blending with custom rendering states because the alpha component
// of the fragment in those modes has nothing to do with transparency at all.
this.blending = NoBlending;
this.transparent = false;
this.needsUpdate = true;
}
}
public hasColorLayer(layer: ColorLayer): boolean {
return this.indexOfColorLayer(layer) !== -1;
}
public hasElevationLayer(layer: ElevationLayer): boolean {
return this._elevationLayer !== layer;
}
public indexOfColorLayer(layer: ColorLayer): number {
return this._colorLayers.indexOf(layer);
}
private updateOpacityParameters(opacity: number): void {
this.uniforms.opacity.value = opacity;
this.updateBlendingMode();
}
public setLayerOpacity(layer: ColorLayer, opacity: number): void {
const index = this.indexOfColorLayer(layer);
this._texturesInfo.color.infos[index].opacity = opacity;
this._mustUpdateUniforms = true;
}
public setLayerVisibility(layer: ColorLayer, visible: boolean): void {
const index = this.indexOfColorLayer(layer);
this._texturesInfo.color.infos[index].visible = visible;
this._mustUpdateUniforms = true;
this.needsUpdate = true;
this.reorderLayers();
this.updateColorLayerCount();
}
public setLayerElevationRange(layer: ColorLayer, range: ElevationRange | null): void {
if (range != null) {
MaterialUtils.setDefine(this, 'ENABLE_ELEVATION_RANGE', true);
}
const index = this.indexOfColorLayer(layer);
const value = range ? new Vector2(range.min, range.max) : DISABLED_ELEVATION_RANGE;
this._texturesInfo.color.infos[index].elevationRange = value;
this._mustUpdateUniforms = true;
}
public setColorimetry(
layer: ColorLayer,
brightness: number,
contrast: number,
saturation: number,
): void {
const index = this.indexOfColorLayer(layer);
this._texturesInfo.color.infos[index].brightnessContrastSaturation.set(
brightness,
contrast,
saturation,
);
this._mustUpdateUniforms = true;
}
public getElevationTexture(): Texture | null {
return this._texturesInfo.elevation.texture;
}
public getElevationOffsetScale(): OffsetScale {
return this._texturesInfo.elevation.offsetScale;
}
/** @internal */
public getElevationLayer(): ElevationLayer | null {
return this._elevationLayer;
}
/**
* Gets the number of layers on this material.
*
* @returns The number of layers present on this material.
*/
public getLayerCount(): number {
return (this._elevationLayer ? 1 : 0) + this._colorLayers.length;
}
public setUuid(uuid: number): void {
this.uniforms.uuid.value = uuid;
}
}
export default LayeredMaterial;