diff --git a/.cspell.json b/.cspell.json index 9466715bbed..00bff0daac8 100644 --- a/.cspell.json +++ b/.cspell.json @@ -62,6 +62,7 @@ "highp", "Hira", "Hoare", + "hypsometric", "ifdef", "ifdefs", "iframes", diff --git a/CHANGELOG.md b/CHANGELOG.md index 028add09415..54ef43dedce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## main ### ✨ Features and improvements - +- Add new `color-relief` layer type to render hypsometric tint from terrain-RGB tiles. ([#5742](https://github.com/maplibre/maplibre-gl-js/pull/5742)) - _...Add new stuff here..._ ### 🐞 Bug fixes diff --git a/docs/assets/examples/color-relief.png b/docs/assets/examples/color-relief.png new file mode 100644 index 00000000000..7340d6dae09 Binary files /dev/null and b/docs/assets/examples/color-relief.png differ diff --git a/src/render/draw_color_relief.ts b/src/render/draw_color_relief.ts new file mode 100644 index 00000000000..40c6022ee86 --- /dev/null +++ b/src/render/draw_color_relief.ts @@ -0,0 +1,99 @@ +import {Texture} from './texture'; +import type {StencilMode} from '../gl/stencil_mode'; +import {DepthMode} from '../gl/depth_mode'; +import {CullFaceMode} from '../gl/cull_face_mode'; +import {type ColorMode} from '../gl/color_mode'; +import { + colorReliefUniformValues +} from './program/color_relief_program'; + +import type {Painter, RenderOptions} from './painter'; +import type {SourceCache} from '../source/source_cache'; +import type {ColorReliefStyleLayer} from '../style/style_layer/color_relief_style_layer'; +import type {OverscaledTileID} from '../source/tile_id'; + +export function drawColorRelief(painter: Painter, sourceCache: SourceCache, layer: ColorReliefStyleLayer, tileIDs: Array, renderOptions: RenderOptions) { + if (painter.renderPass !== 'translucent') return; + if (!tileIDs.length) return; + + const {isRenderingToTexture} = renderOptions; + const projection = painter.style.projection; + const useSubdivision = projection.useSubdivision; + + const depthMode = painter.getDepthModeForSublayer(0, DepthMode.ReadOnly); + const colorMode = painter.colorModeForRenderPass(); + + // Globe (or any projection with subdivision) needs two-pass rendering to avoid artifacts when rendering texture tiles. + // See comments in draw_raster.ts for more details. + if (useSubdivision) { + // Two-pass rendering + const [stencilBorderless, stencilBorders, coords] = painter.stencilConfigForOverlapTwoPass(tileIDs); + renderColorRelief(painter, sourceCache, layer, coords, stencilBorderless, depthMode, colorMode, false, isRenderingToTexture); // draw without borders + renderColorRelief(painter, sourceCache, layer, coords, stencilBorders, depthMode, colorMode, true, isRenderingToTexture); // draw with borders + } else { + // Simple rendering + const [stencil, coords] = painter.getStencilConfigForOverlapAndUpdateStencilID(tileIDs); + renderColorRelief(painter, sourceCache, layer, coords, stencil, depthMode, colorMode, false, isRenderingToTexture); + } +} + +function renderColorRelief( + painter: Painter, + sourceCache: SourceCache, + layer: ColorReliefStyleLayer, + coords: Array, + stencilModes: {[_: number]: Readonly}, + depthMode: Readonly, + colorMode: Readonly, + useBorder: boolean, + isRenderingToTexture: boolean +) { + const projection = painter.style.projection; + const context = painter.context; + const transform = painter.transform; + const gl = context.gl; + const maxLength = Math.floor((gl.getParameter(gl.MAX_FRAGMENT_UNIFORM_VECTORS) - 3) / 2); + const colorRampLength = layer.getColorRamp(maxLength).colorStops.length; + const defines = [`#define NUM_ELEVATION_STOPS ${colorRampLength}`]; + const program = painter.useProgram('colorRelief', null, false, defines); + const align = !painter.options.moving; + + for (const coord of coords) { + const tile = sourceCache.getTile(coord); + const dem = tile.dem; + + if (!dem || !dem.data) { + continue; + } + + const textureStride = dem.stride; + + const pixelData = dem.getPixels(); + context.activeTexture.set(gl.TEXTURE0); + + context.pixelStoreUnpackPremultiplyAlpha.set(false); + tile.demTexture = tile.demTexture || painter.getTileTexture(textureStride); + if (tile.demTexture) { + const demTexture = tile.demTexture; + demTexture.update(pixelData, {premultiply: false}); + demTexture.bind(gl.LINEAR, gl.CLAMP_TO_EDGE); + } else { + tile.demTexture = new Texture(context, pixelData, gl.RGBA, {premultiply: false}); + tile.demTexture.bind(gl.LINEAR, gl.CLAMP_TO_EDGE); + } + + const mesh = projection.getMeshFromTileID(context, coord.canonical, useBorder, true, 'raster'); + + const terrainData = painter.style.map.terrain?.getTerrainData(coord); + + const projectionData = transform.getProjectionData({ + overscaledTileID: coord, + aligned: align, + applyGlobeMatrix: !isRenderingToTexture, + applyTerrainMatrix: true + }); + + program.draw(context, gl.TRIANGLES, depthMode, stencilModes[coord.overscaledZ], colorMode, CullFaceMode.backCCW, + colorReliefUniformValues(layer, tile.dem, maxLength), terrainData, projectionData, layer.id, mesh.vertexBuffer, mesh.indexBuffer, mesh.segments); + } +} diff --git a/src/render/painter.ts b/src/render/painter.ts index ebec4ec7588..bd8a981419b 100644 --- a/src/render/painter.ts +++ b/src/render/painter.ts @@ -25,6 +25,7 @@ import {drawLine} from './draw_line'; import {drawFill} from './draw_fill'; import {drawFillExtrusion} from './draw_fill_extrusion'; import {drawHillshade} from './draw_hillshade'; +import {drawColorRelief} from './draw_color_relief'; import {drawRaster} from './draw_raster'; import {drawBackground} from './draw_background'; import {drawDebug, drawDebugPadding, selectDebugSource} from './draw_debug'; @@ -56,6 +57,7 @@ import {isLineStyleLayer} from '../style/style_layer/line_style_layer'; import {isFillStyleLayer} from '../style/style_layer/fill_style_layer'; import {isFillExtrusionStyleLayer} from '../style/style_layer/fill_extrusion_style_layer'; import {isHillshadeStyleLayer} from '../style/style_layer/hillshade_style_layer'; +import {isColorReliefStyleLayer} from '../style/style_layer/color_relief_style_layer'; import {isRasterStyleLayer} from '../style/style_layer/raster_style_layer'; import {isBackgroundStyleLayer} from '../style/style_layer/background_style_layer'; import {isCustomStyleLayer} from '../style/style_layer/custom_style_layer'; @@ -671,6 +673,8 @@ export class Painter { drawFillExtrusion(painter, sourceCache, layer, coords, renderOptions); } else if (isHillshadeStyleLayer(layer)) { drawHillshade(painter, sourceCache, layer, coords, renderOptions); + } else if (isColorReliefStyleLayer(layer)) { + drawColorRelief(painter, sourceCache, layer, coords, renderOptions); } else if (isRasterStyleLayer(layer)) { drawRaster(painter, sourceCache, layer, coords, renderOptions); } else if (isBackgroundStyleLayer(layer)) { diff --git a/src/render/program/color_relief_program.ts b/src/render/program/color_relief_program.ts new file mode 100644 index 00000000000..20bcd4efeec --- /dev/null +++ b/src/render/program/color_relief_program.ts @@ -0,0 +1,53 @@ +import { + Uniform1i, + Uniform1f, + Uniform2f, + Uniform4f, + UniformFloatArray, + UniformColorArray +} from '../uniform_binding'; + +import type {Context} from '../../gl/context'; +import type {UniformValues, UniformLocations} from '../uniform_binding'; +import type {ColorReliefStyleLayer} from '../../style/style_layer/color_relief_style_layer'; +import type {DEMData} from '../../data/dem_data'; + +export type ColorReliefUniformsType = { + 'u_image': Uniform1i; + 'u_unpack': Uniform4f; + 'u_dimension': Uniform2f; + 'u_elevation_stops': UniformFloatArray; + 'u_color_stops': UniformColorArray; + 'u_opacity': Uniform1f; +}; + +const colorReliefUniforms = (context: Context, locations: UniformLocations): ColorReliefUniformsType => ({ + 'u_image': new Uniform1i(context, locations.u_image), + 'u_unpack': new Uniform4f(context, locations.u_unpack), + 'u_dimension': new Uniform2f(context, locations.u_dimension), + 'u_elevation_stops': new UniformFloatArray(context, locations.u_elevation_stops), + 'u_color_stops': new UniformColorArray(context, locations.u_color_stops), + 'u_opacity': new Uniform1f(context, locations.u_opacity) +}); + +const colorReliefUniformValues = ( + layer: ColorReliefStyleLayer, + dem: DEMData, + maxLength: number +): UniformValues => { + + const colorRamp = layer.getColorRamp(maxLength); + return { + 'u_image': 0, + 'u_unpack': dem.getUnpackVector(), + 'u_dimension': [dem.stride, dem.stride], + 'u_elevation_stops': colorRamp.elevationStops, + 'u_color_stops': colorRamp.colorStops, + 'u_opacity': layer.paint.get('color-relief-opacity') + }; +}; + +export { + colorReliefUniforms, + colorReliefUniformValues, +}; diff --git a/src/render/program/program_uniforms.ts b/src/render/program/program_uniforms.ts index d84722ebb4c..2584857580d 100644 --- a/src/render/program/program_uniforms.ts +++ b/src/render/program/program_uniforms.ts @@ -5,6 +5,7 @@ import {collisionUniforms, collisionCircleUniforms} from './collision_program'; import {debugUniforms} from './debug_program'; import {heatmapUniforms, heatmapTextureUniforms} from './heatmap_program'; import {hillshadeUniforms, hillshadePrepareUniforms} from './hillshade_program'; +import {colorReliefUniforms} from './color_relief_program'; import {lineUniforms, lineGradientUniforms, linePatternUniforms, lineSDFUniforms} from './line_program'; import {rasterUniforms} from './raster_program'; import {symbolIconUniforms, symbolSDFUniforms, symbolTextAndIconUniforms} from './symbol_program'; @@ -33,6 +34,7 @@ export const programUniforms = { heatmapTexture: heatmapTextureUniforms, hillshade: hillshadeUniforms, hillshadePrepare: hillshadePrepareUniforms, + colorRelief: colorReliefUniforms, line: lineUniforms, lineGradient: lineGradientUniforms, linePattern: linePatternUniforms, diff --git a/src/render/render_to_texture.ts b/src/render/render_to_texture.ts index b5b95a002ca..62c729cc572 100644 --- a/src/render/render_to_texture.ts +++ b/src/render/render_to_texture.ts @@ -18,7 +18,8 @@ const LAYERS: { [keyof in StyleLayer['type']]?: boolean } = { fill: true, line: true, raster: true, - hillshade: true + hillshade: true, + 'color-relief': true }; /** diff --git a/src/shaders/color_relief.fragment.glsl b/src/shaders/color_relief.fragment.glsl new file mode 100644 index 00000000000..23a30798dc8 --- /dev/null +++ b/src/shaders/color_relief.fragment.glsl @@ -0,0 +1,41 @@ +uniform sampler2D u_image; +uniform vec4 u_unpack; +uniform float u_elevation_stops[NUM_ELEVATION_STOPS]; +uniform vec4 u_color_stops[NUM_ELEVATION_STOPS]; +uniform float u_opacity; + +in vec2 v_pos; + +float getElevation(vec2 coord) { + // Convert encoded elevation value to meters + vec4 data = texture(u_image, coord) * 255.0; + data.a = -1.0; + return dot(data, u_unpack); +} + +void main() { + float el = getElevation(v_pos); + + // Binary search + int r = (NUM_ELEVATION_STOPS - 1); + int l = 0; + while(r - l > 1) + { + int m = (r + l) / 2; + if(el < u_elevation_stops[m]) + { + r = m; + } + else + { + l = m; + } + } + fragColor = u_opacity*mix(u_color_stops[l], + u_color_stops[r], + clamp((el - u_elevation_stops[l])/(u_elevation_stops[r]-u_elevation_stops[l]), 0.0, 1.0)); + +#ifdef OVERDRAW_INSPECTOR + fragColor = vec4(1.0); +#endif +} diff --git a/src/shaders/color_relief.vertex.glsl b/src/shaders/color_relief.vertex.glsl new file mode 100644 index 00000000000..83ffc659b85 --- /dev/null +++ b/src/shaders/color_relief.vertex.glsl @@ -0,0 +1,20 @@ +uniform vec2 u_dimension; + +in vec2 a_pos; + +out vec2 v_pos; + +void main() { + gl_Position = projectTile(a_pos, a_pos); + highp vec2 epsilon = 1.0 / u_dimension; + float scale = (u_dimension.x - 2.0) / u_dimension.x; + v_pos = (a_pos / 8192.0) * scale + epsilon; + // North pole + if (a_pos.y < -32767.5) { + v_pos.y = 0.0; + } + // South pole + if (a_pos.y > 32766.5) { + v_pos.y = 1.0; + } +} diff --git a/src/shaders/shaders.ts b/src/shaders/shaders.ts index d8ad62f0d3c..053cf057b8b 100644 --- a/src/shaders/shaders.ts +++ b/src/shaders/shaders.ts @@ -18,6 +18,8 @@ import collisionBoxFrag from './collision_box.fragment.glsl.g'; import collisionBoxVert from './collision_box.vertex.glsl.g'; import collisionCircleFrag from './collision_circle.fragment.glsl.g'; import collisionCircleVert from './collision_circle.vertex.glsl.g'; +import colorReliefFrag from './color_relief.fragment.glsl.g'; +import colorReliefVert from './color_relief.vertex.glsl.g'; import debugFrag from './debug.fragment.glsl.g'; import debugVert from './debug.vertex.glsl.g'; import depthVert from './depth.vertex.glsl.g'; @@ -87,6 +89,7 @@ export const shaders = { heatmapTexture: prepare(heatmapTextureFrag, heatmapTextureVert), collisionBox: prepare(collisionBoxFrag, collisionBoxVert), collisionCircle: prepare(collisionCircleFrag, collisionCircleVert), + colorRelief: prepare(colorReliefFrag, colorReliefVert), debug: prepare(debugFrag, debugVert), depth: prepare(clippingMaskFrag, depthVert), fill: prepare(fillFrag, fillVert), diff --git a/src/style/create_style_layer.ts b/src/style/create_style_layer.ts index 25905cad631..fb523213404 100644 --- a/src/style/create_style_layer.ts +++ b/src/style/create_style_layer.ts @@ -1,6 +1,7 @@ import {CircleStyleLayer} from './style_layer/circle_style_layer'; import {HeatmapStyleLayer} from './style_layer/heatmap_style_layer'; import {HillshadeStyleLayer} from './style_layer/hillshade_style_layer'; +import {ColorReliefStyleLayer} from './style_layer/color_relief_style_layer'; import {FillStyleLayer} from './style_layer/fill_style_layer'; import {FillExtrusionStyleLayer} from './style_layer/fill_extrusion_style_layer'; import {LineStyleLayer} from './style_layer/line_style_layer'; @@ -20,6 +21,8 @@ export function createStyleLayer(layer: LayerSpecification | CustomLayerInterfac return new BackgroundStyleLayer(layer); case 'circle': return new CircleStyleLayer(layer); + case 'color-relief': + return new ColorReliefStyleLayer(layer); case 'fill': return new FillStyleLayer(layer); case 'fill-extrusion': diff --git a/src/style/style_layer/color_relief_style_layer.test.ts b/src/style/style_layer/color_relief_style_layer.test.ts new file mode 100644 index 00000000000..23dba1d5518 --- /dev/null +++ b/src/style/style_layer/color_relief_style_layer.test.ts @@ -0,0 +1,120 @@ +import {describe, test, expect} from 'vitest'; +import {ColorReliefStyleLayer} from './color_relief_style_layer'; +import {Color, type LayerSpecification} from '@maplibre/maplibre-gl-style-spec'; +import {createStyleLayer} from '../create_style_layer'; +import {extend} from '../../util/util'; +import {type EvaluationParameters} from '../evaluation_parameters'; + +function createColorReliefLayerSpec(properties?: {paint: {'color-relief-opacity'?: number; 'color-relief-color'?: Array}}): LayerSpecification { + return extend({ + type: 'color-relief', + id: 'colorRelief', + source: 'colorReliefSource' + } as LayerSpecification, properties); +} + +describe('ColorReliefStyleLayer', () => { + + test('default', () => { + const layerSpec = createColorReliefLayerSpec(); + const layer = createStyleLayer(layerSpec); + expect(layer).toBeInstanceOf(ColorReliefStyleLayer); + const colorReliefStyleLayer = layer as ColorReliefStyleLayer; + expect(colorReliefStyleLayer.paint.get('color-relief-opacity')).toEqual(1); + const colorRamp = colorReliefStyleLayer.getColorRamp(256); + expect(colorRamp.elevationStops).toEqual([0,1]); + expect(colorRamp.colorStops).toEqual([Color.transparent,Color.transparent]); + }); + + test('parameters specified', () => { + const layerSpec = createColorReliefLayerSpec({ + paint: { + 'color-relief-opacity': 0.5, + 'color-relief-color': [ + 'interpolate', + ['linear'], + ['elevation'], + 0, '#000000', + 1000, '#ffffff' + ] + } + }); + const layer = createStyleLayer(layerSpec); + expect(layer).toBeInstanceOf(ColorReliefStyleLayer); + const colorReliefStyleLayer = layer as ColorReliefStyleLayer; + const colorRamp = colorReliefStyleLayer.getColorRamp(256); + expect(colorRamp.elevationStops).toEqual([0,1000]); + expect(colorRamp.colorStops).toEqual([Color.black,Color.white]); + + colorReliefStyleLayer.recalculate({zoom: 0, zoomHistory: {}} as EvaluationParameters, undefined); + expect(colorReliefStyleLayer.paint.get('color-relief-opacity')).toEqual(0.5); + }); + + test('single color', () => { + const layerSpec = createColorReliefLayerSpec({ + paint: { + 'color-relief-color': [ + 'interpolate', + ['linear'], + ['elevation'], + 0, '#ff0000' + ] + } + }); + const layer = createStyleLayer(layerSpec); + expect(layer).toBeInstanceOf(ColorReliefStyleLayer); + const colorReliefStyleLayer = layer as ColorReliefStyleLayer; + const colorRamp = colorReliefStyleLayer.getColorRamp(256); + expect(colorRamp.elevationStops).toEqual([0,1]); + expect(colorRamp.colorStops).toEqual([Color.red,Color.red]); + }); + + test('getColorRamp: no remapping', () => { + const layerSpec = createColorReliefLayerSpec({ + paint: { + 'color-relief-color': [ + 'interpolate', + ['linear'], + ['elevation'], + 0, '#000000', + 1000, '#ff0000', + 2000, '#ff0000', + 3000, '#ffffff' + ] + } + }); + const layer = createStyleLayer(layerSpec); + expect(layer).toBeInstanceOf(ColorReliefStyleLayer); + const colorReliefStyleLayer = layer as ColorReliefStyleLayer; + + const colorRamp = colorReliefStyleLayer.getColorRamp(4); + + expect(colorRamp.elevationStops).toEqual([0, 1000, 2000, 3000]); + expect(colorRamp.colorStops).toEqual([Color.black, Color.red, Color.red, Color.white]); + }); + + test('getColorRamp: with remapping', () => { + const layerSpec = createColorReliefLayerSpec({ + paint: { + 'color-relief-color': [ + 'interpolate', + ['linear'], + ['elevation'], + 0, '#000000', + 1000, '#ff0000', + 2000, '#ffffff', + 3000, '#000000', + 4000, '#ff0000' + ] + } + }); + const layer = createStyleLayer(layerSpec); + expect(layer).toBeInstanceOf(ColorReliefStyleLayer); + const colorReliefStyleLayer = layer as ColorReliefStyleLayer; + + const colorRamp = colorReliefStyleLayer.getColorRamp(4); + + expect(colorRamp.elevationStops).toEqual([0, 1000, 3000, 4000]); + expect(colorRamp.colorStops).toEqual([Color.black, Color.red, Color.black, Color.red]); + }); +}); diff --git a/src/style/style_layer/color_relief_style_layer.ts b/src/style/style_layer/color_relief_style_layer.ts new file mode 100644 index 00000000000..bccb91a7391 --- /dev/null +++ b/src/style/style_layer/color_relief_style_layer.ts @@ -0,0 +1,78 @@ +import {StyleLayer} from '../style_layer'; + +import properties, {type ColorReliefPaintPropsPossiblyEvaluated} from './color_relief_style_layer_properties.g'; +import {type Transitionable, type Transitioning, type PossiblyEvaluated} from '../properties'; + +import type {ColorReliefPaintProps} from './color_relief_style_layer_properties.g'; +import {Color, Interpolate, ZoomConstantExpression, type LayerSpecification, type EvaluationContext} from '@maplibre/maplibre-gl-style-spec'; +import {warnOnce} from '../../util/util'; + +export const isColorReliefStyleLayer = (layer: StyleLayer): layer is ColorReliefStyleLayer => layer.type === 'color-relief'; + +export type ColorRamp = {elevationStops: Array; colorStops: Array}; + +export class ColorReliefStyleLayer extends StyleLayer { + colorRamp: ColorRamp; + _transitionablePaint: Transitionable; + _transitioningPaint: Transitioning; + paint: PossiblyEvaluated; + + constructor(layer: LayerSpecification) { + super(layer, properties); + } + + _createColorRamp() : ColorRamp { + const colorRamp: ColorRamp = {elevationStops: [], colorStops: []}; + const expression = this._transitionablePaint._values['color-relief-color'].value.expression; + if (expression instanceof ZoomConstantExpression && expression._styleExpression.expression instanceof Interpolate) { + const interpolater = expression._styleExpression.expression; + colorRamp.elevationStops = interpolater.labels; + colorRamp.colorStops = []; + for (const label of colorRamp.elevationStops) { + colorRamp.colorStops.push(interpolater.evaluate({globals: {elevation: label}} as EvaluationContext)); + } + } + if (colorRamp.elevationStops.length < 1) + { + colorRamp.elevationStops = [0]; + colorRamp.colorStops = [Color.transparent]; + } + if (colorRamp.elevationStops.length < 2) + { + colorRamp.elevationStops.push(colorRamp.elevationStops[0] + 1); + colorRamp.colorStops.push(colorRamp.colorStops[0]); + } + return colorRamp; + } + + /** + * Get the color ramp, enforcing a maximum length for the vectors. This modifies the internal color ramp, + * so that the remapping is only performed once. + * + * @param maxLength - the maximum number of stops in the color ramp + * + * @return a `ColorRamp` object with no more than `maxLength` stops. + * + */ + getColorRamp(maxLength: number) : ColorRamp { + if (!this.colorRamp) { + this.colorRamp = this._createColorRamp(); + } + if (this.colorRamp.elevationStops.length > maxLength) { + const colorRamp: ColorRamp = {elevationStops: [], colorStops: []}; + const remapStepSize = (this.colorRamp.elevationStops.length - 1)/(maxLength - 1); + + for (let i = 0; i < this.colorRamp.elevationStops.length - 0.5; i += remapStepSize) { + colorRamp.elevationStops.push(this.colorRamp.elevationStops[Math.round(i)]); + colorRamp.colorStops.push(this.colorRamp.colorStops[Math.round(i)]); + } + warnOnce(`Too many colors in specification of ${this.id} color-relief layer, may not render properly.`); + this.colorRamp = colorRamp; + } + return this.colorRamp; + } + + hasOffscreenPass() { + return this.visibility !== 'none' && !!this.colorRamp; + } +} diff --git a/src/style/style_layer/typed_style_layer.ts b/src/style/style_layer/typed_style_layer.ts index a340484e7e6..dc45b20dfd3 100644 --- a/src/style/style_layer/typed_style_layer.ts +++ b/src/style/style_layer/typed_style_layer.ts @@ -3,7 +3,8 @@ import type {FillStyleLayer} from './fill_style_layer'; import type {FillExtrusionStyleLayer} from './fill_extrusion_style_layer'; import type {HeatmapStyleLayer} from './heatmap_style_layer'; import type {HillshadeStyleLayer} from './hillshade_style_layer'; +import type {ColorReliefStyleLayer} from './color_relief_style_layer'; import type {LineStyleLayer} from './line_style_layer'; import type {SymbolStyleLayer} from './symbol_style_layer'; -export type TypedStyleLayer = CircleStyleLayer | FillStyleLayer | FillExtrusionStyleLayer | HeatmapStyleLayer | HillshadeStyleLayer | LineStyleLayer | SymbolStyleLayer; +export type TypedStyleLayer = CircleStyleLayer | FillStyleLayer | FillExtrusionStyleLayer | HeatmapStyleLayer | HillshadeStyleLayer | ColorReliefStyleLayer | LineStyleLayer | SymbolStyleLayer; diff --git a/src/ui/map.ts b/src/ui/map.ts index 842c66f27f2..8a5db20ebbd 100644 --- a/src/ui/map.ts +++ b/src/ui/map.ts @@ -2067,12 +2067,15 @@ export class Map extends Camera { if (!sourceCache) throw new Error(`cannot load terrain, because there exists no source with ID: ${options.source}`); // Update terrain tiles when adding new terrain if (this.terrain === null) sourceCache.reload(); - // Warn once if user is using the same source for hillshade and terrain + // Warn once if user is using the same source for hillshade/color-relief and terrain for (const index in this.style._layers) { const thisLayer = this.style._layers[index]; if (thisLayer.type === 'hillshade' && thisLayer.source === options.source) { warnOnce('You are using the same source for a hillshade layer and for 3D terrain. Please consider using two separate sources to improve rendering quality.'); } + if (thisLayer.type === 'color-relief' && thisLayer.source === options.source) { + warnOnce('You are using the same source for a color-relief layer and for 3D terrain. Please consider using two separate sources to improve rendering quality.'); + } } this.terrain = new Terrain(this.painter, sourceCache, options); this.painter.renderToTexture = new RenderToTexture(this.painter, this.terrain); diff --git a/test/bench/benchmarks/color_relief_load.ts b/test/bench/benchmarks/color_relief_load.ts new file mode 100644 index 00000000000..49d9c48725f --- /dev/null +++ b/test/bench/benchmarks/color_relief_load.ts @@ -0,0 +1,80 @@ +import Benchmark from '../lib/benchmark'; +import createMap from '../lib/create_map'; +import type {StyleSpecification} from '@maplibre/maplibre-gl-style-spec'; + +/** + * Measures how long it takes the map to reach the idle state when only using color-relief tiles. + */ +export default class ColorReliefLoad extends Benchmark { + style: StyleSpecification; + + constructor() { + super(); + + // This is a longer running test and the duration will vary by device and network. + // To keep the test time more reasonable, lower the minimum number of measurements. + // 55 measurements => 10 observations for regression. + this.minimumMeasurements = 55; + + this.style = { + 'version': 8, + 'name': 'Color-relief-only', + 'center': [-112.81596278901452, 37.251160384573595], + 'zoom': 11.560975632435424, + 'bearing': 0, + 'pitch': 0, + 'sources': { + 'terrain-rgb': { + 'url': 'https://api.maptiler.com/tiles/terrain-rgb/tiles.json?key=get_your_own_OpIi9ZULNHzrESv6T2vL', + 'type': 'raster-dem', + 'tileSize': 256 + } + }, + 'layers': [ + { + 'id': 'maplibre-terrain-rgb', + 'type': 'color-relief', + 'source': 'terrain-rgb', + 'layout': {}, + 'paint': { + 'color-relief-color': [ + "interpolate", + ["linear"], + ["elevation"], + 0, 'rgb(112, 209, 255)', + 12.88581315, 'rgb(113, 211, 247)', + 51.5432526, 'rgb(114, 212, 234)', + 115.9723183, 'rgb(117, 213, 222)', + 206.1730104, 'rgb(120, 214, 209)', + 322.1453287, 'rgb(124, 215, 196)', + 463.8892734, 'rgb(130, 215, 183)', + 631.4048443, 'rgb(138, 215, 169)', + 824.6920415, 'rgb(149, 214, 155)', + 1043.750865, 'rgb(163, 212, 143)', + 1288.581315, 'rgb(178, 209, 134)', + 1559.183391, 'rgb(193, 205, 127)', + 1855.557093, 'rgb(207, 202, 121)', + 2177.702422, 'rgb(220, 197, 118)', + 2525.619377, 'rgb(233, 193, 118)', + 2899.307958, 'rgb(244, 188, 120)', + 3298.768166, 'rgb(255, 183, 124)', + 3724, 'rgb(255, 178, 129)' + ] + } + } + ] + }; + } + + async bench() { + const map = await createMap({ + width: 1024, + height: 1024, + style: this.style, + stubRender: false, + showMap: true, + idle: true + }); + map.remove(); + } +} diff --git a/test/bench/benchmarks/layers.ts b/test/bench/benchmarks/layers.ts index 21dd5efb5aa..9017c7f1416 100644 --- a/test/bench/benchmarks/layers.ts +++ b/test/bench/benchmarks/layers.ts @@ -169,6 +169,64 @@ export class LayerHillshade extends LayerBenchmark { } } +export class LayerColorRelief2Colors extends LayerBenchmark { + constructor() { + super(); + + this.layerStyle = Object.assign({}, style, { + sources: { + 'terrain-rgb': { + 'type': 'raster-dem', + 'url': 'https://api.maptiler.com/tiles/terrain-rgb/tiles.json?key=get_your_own_OpIi9ZULNHzrESv6T2vL' + } + }, + layers: generateLayers({ + 'id': 'layer', + 'type': 'color-relief', + 'source': 'terrain-rgb', + 'paint': { + 'color-relief-color': [ + "interpolate", + ["linear"], + ["elevation"], + 0, 'rgb(112, 209, 255)', + 3724, 'rgb(255, 178, 129)' + ] + } + }) + }); + } +} + +export class LayerColorRelief256Colors extends LayerBenchmark { + constructor() { + super(); + + const colorSpec: any[] = ["interpolate", ["linear"], ["elevation"]]; + for (let i = 0; i < 256; i++) { + colorSpec.push(i); + colorSpec.push(`rgb(${i}, 0, ${255-i})`); + } + + this.layerStyle = Object.assign({}, style, { + sources: { + 'terrain-rgb': { + 'type': 'raster-dem', + 'url': 'https://api.maptiler.com/tiles/terrain-rgb/tiles.json?key=get_your_own_OpIi9ZULNHzrESv6T2vL' + } + }, + layers: generateLayers({ + 'id': 'layer', + 'type': 'color-relief', + 'source': 'terrain-rgb', + 'paint': { + 'color-relief-color': colorSpec + } + }) + }); + } +} + export class LayerLine extends LayerBenchmark { constructor() { super(); diff --git a/test/bench/versions/index.ts b/test/bench/versions/index.ts index 63261f46f7e..3d95d4fe9ca 100644 --- a/test/bench/versions/index.ts +++ b/test/bench/versions/index.ts @@ -7,9 +7,10 @@ import WorkerTransfer from '../benchmarks/worker_transfer'; import Paint from '../benchmarks/paint'; import PaintStates from '../benchmarks/paint_states'; import {PropertyLevelRemove, FeatureLevelRemove, SourceLevelRemove} from '../benchmarks/remove_paint_state'; -import {LayerBackground, LayerCircle, LayerFill, LayerFillExtrusion, LayerHeatmap, LayerHillshade, LayerLine, LayerRaster, LayerSymbol, LayerSymbolWithIcons, LayerTextWithVariableAnchor, LayerSymbolWithSortKey} from '../benchmarks/layers'; +import {LayerBackground, LayerCircle, LayerFill, LayerFillExtrusion, LayerHeatmap, LayerHillshade, LayerColorRelief2Colors, LayerColorRelief256Colors, LayerLine, LayerRaster, LayerSymbol, LayerSymbolWithIcons, LayerTextWithVariableAnchor, LayerSymbolWithSortKey} from '../benchmarks/layers'; import Load from '../benchmarks/map_load'; import HillshadeLoad from '../benchmarks/hillshade_load'; +import ColorReliefLoad from '../benchmarks/color_relief_load'; import Validate from '../benchmarks/style_validate'; import StyleLayerCreate from '../benchmarks/style_layer_create'; import QueryPoint from '../benchmarks/query_point'; @@ -64,6 +65,8 @@ register('LayerFill', new LayerFill()); register('LayerFillExtrusion', new LayerFillExtrusion()); register('LayerHeatmap', new LayerHeatmap()); register('LayerHillshade', new LayerHillshade()); +register('LayerColorRelief2Colors', new LayerColorRelief2Colors()); +register('LayerColorRelief256Colors', new LayerColorRelief256Colors()); register('LayerLine', new LayerLine()); register('LayerRaster', new LayerRaster()); register('LayerSymbol', new LayerSymbol()); @@ -76,6 +79,7 @@ register('SymbolLayout', new SymbolLayout(style, styleLocations.map(location => register('FilterCreate', new FilterCreate()); register('FilterEvaluate', new FilterEvaluate()); register('HillshadeLoad', new HillshadeLoad()); +register('ColorReliefLoad', new ColorReliefLoad()); register('CustomLayer', new CustomLayer()); register('MapIdle', new MapIdle()); register('SymbolCollisionBox', new SymbolCollisionBox(false)); diff --git a/test/build/min.test.ts b/test/build/min.test.ts index 410ac9b6e31..0de1b7ac526 100644 --- a/test/build/min.test.ts +++ b/test/build/min.test.ts @@ -38,7 +38,7 @@ describe('test min build', () => { const decreaseQuota = 4096; // feel free to update this value after you've checked that it has changed on purpose :-) - const expectedBytes = 925625; + const expectedBytes = 930287; expect(actualBytes).toBeLessThan(expectedBytes + increaseQuota); expect(actualBytes).toBeGreaterThan(expectedBytes - decreaseQuota); diff --git a/test/examples/color-relief.html b/test/examples/color-relief.html new file mode 100644 index 00000000000..d0ac6c4e6f6 --- /dev/null +++ b/test/examples/color-relief.html @@ -0,0 +1,74 @@ + + + + Add a color relief layer + + + + + + + + +
+ + + + + \ No newline at end of file diff --git a/test/integration/render/tests/color-relief/hillshade/expected.png b/test/integration/render/tests/color-relief/hillshade/expected.png new file mode 100644 index 00000000000..558998762c9 Binary files /dev/null and b/test/integration/render/tests/color-relief/hillshade/expected.png differ diff --git a/test/integration/render/tests/color-relief/hillshade/style.json b/test/integration/render/tests/color-relief/hillshade/style.json new file mode 100644 index 00000000000..5e7e4dde7d6 --- /dev/null +++ b/test/integration/render/tests/color-relief/hillshade/style.json @@ -0,0 +1,59 @@ +{ + "version": 8, + "metadata": { + "test": { + "height": 256, + "width": 256 + } + }, + "center": [-113.26903, 35.9654], + "zoom": 11, + "sources": { + "source": { + "type": "raster-dem", + "tiles": [ + "local://tiles/{z}-{x}-{y}.terrain.png" + ], + "maxzoom": 15, + "tileSize": 256 + } + }, + "layers": [ + { + "id": "color-relief", + "type": "color-relief", + "source": "source", + "paint": { + "color-relief-opacity": 1, + "color-relief-color": [ + "interpolate", + ["linear"], + ["elevation"], + 400, "rgb(112, 209, 255)", + 494.1176471, "rgb(113, 211, 247)", + 588.2352941, "rgb(114, 212, 234)", + 682.3529412, "rgb(117, 213, 222)", + 776.4705882, "rgb(120, 214, 209)", + 870.5882353, "rgb(124, 215, 196)", + 964.7058824, "rgb(130, 215, 183)", + 1058.823529, "rgb(138, 215, 169)", + 1152.941176, "rgb(149, 214, 155)", + 1247.058824, "rgb(163, 212, 143)", + 1341.176471, "rgb(178, 209, 134)", + 1435.294118, "rgb(193, 205, 127)", + 1529.411765, "rgb(207, 202, 121)", + 1623.529412, "rgb(220, 197, 118)", + 1717.647059, "rgb(233, 193, 118)", + 1811.764706, "rgb(244, 188, 120)", + 1905.882353, "rgb(255, 183, 124)", + 2000, "rgb(255, 178, 129)" + ] + } + }, + { + "id": "hillshade", + "type": "hillshade", + "source": "source" + } + ] +} diff --git a/test/integration/render/tests/color-relief/opacity/expected.png b/test/integration/render/tests/color-relief/opacity/expected.png new file mode 100644 index 00000000000..79d561dac09 Binary files /dev/null and b/test/integration/render/tests/color-relief/opacity/expected.png differ diff --git a/test/integration/render/tests/color-relief/opacity/style.json b/test/integration/render/tests/color-relief/opacity/style.json new file mode 100644 index 00000000000..b4de87cacff --- /dev/null +++ b/test/integration/render/tests/color-relief/opacity/style.json @@ -0,0 +1,43 @@ +{ + "version": 8, + "metadata": { + "test": { + "height": 256, + "width": 256 + } + }, + "center": [-113.26903, 35.9654], + "zoom": 11, + "sources": { + "source": { + "type": "raster-dem", + "tiles": [ + "local://tiles/{z}-{x}-{y}.terrain.png" + ], + "maxzoom": 15, + "tileSize": 256 + } + }, + "layers": [ + { + "id": "color-relief", + "type": "color-relief", + "source": "source", + "paint": { + "color-relief-opacity": 0.5, + "color-relief-color": [ + "interpolate", + ["linear"], + ["elevation"], + 400, "#F00", + 800, "#AA0", + 1000, "#AF0", + 1200, "#0F0", + 1400, "#0AA", + 1600, "#00F", + 2000, "#C0C" + ] + } + } + ] +} diff --git a/test/integration/render/tests/color-relief/rainbow/expected.png b/test/integration/render/tests/color-relief/rainbow/expected.png new file mode 100644 index 00000000000..2f28acbba81 Binary files /dev/null and b/test/integration/render/tests/color-relief/rainbow/expected.png differ diff --git a/test/integration/render/tests/color-relief/rainbow/style.json b/test/integration/render/tests/color-relief/rainbow/style.json new file mode 100644 index 00000000000..f9b8cf2adce --- /dev/null +++ b/test/integration/render/tests/color-relief/rainbow/style.json @@ -0,0 +1,43 @@ +{ + "version": 8, + "metadata": { + "test": { + "height": 256, + "width": 256 + } + }, + "center": [-113.26903, 35.9654], + "zoom": 11, + "sources": { + "source": { + "type": "raster-dem", + "tiles": [ + "local://tiles/{z}-{x}-{y}.terrain.png" + ], + "maxzoom": 15, + "tileSize": 256 + } + }, + "layers": [ + { + "id": "color-relief", + "type": "color-relief", + "source": "source", + "paint": { + "color-relief-opacity": 1, + "color-relief-color": [ + "interpolate", + ["linear"], + ["elevation"], + 400, "#F00", + 800, "#AA0", + 1000, "#AF0", + 1200, "#0F0", + 1400, "#0AA", + 1600, "#00F", + 2000, "#C0C" + ] + } + } + ] +} diff --git a/test/integration/render/tests/color-relief/transparency/expected.png b/test/integration/render/tests/color-relief/transparency/expected.png new file mode 100644 index 00000000000..c8aa0bc916c Binary files /dev/null and b/test/integration/render/tests/color-relief/transparency/expected.png differ diff --git a/test/integration/render/tests/color-relief/transparency/style.json b/test/integration/render/tests/color-relief/transparency/style.json new file mode 100644 index 00000000000..24dd5b1e62e --- /dev/null +++ b/test/integration/render/tests/color-relief/transparency/style.json @@ -0,0 +1,43 @@ +{ + "version": 8, + "metadata": { + "test": { + "height": 256, + "width": 256 + } + }, + "center": [-113.26903, 35.9654], + "zoom": 11, + "sources": { + "source": { + "type": "raster-dem", + "tiles": [ + "local://tiles/{z}-{x}-{y}.terrain.png" + ], + "maxzoom": 15, + "tileSize": 256 + } + }, + "layers": [ + { + "id": "color-relief", + "type": "color-relief", + "source": "source", + "paint": { + "color-relief-opacity": 1, + "color-relief-color": [ + "interpolate", + ["linear"], + ["elevation"], + 400, "#F00C", + 800, "#AA0A", + 1000, "#AF09", + 1200, "#0F08", + 1400, "#0AA7", + 1600, "#00F6", + 2000, "#C0C4" + ] + } + } + ] +}