diff --git a/CHANGELOG.md b/CHANGELOG.md index a740dea62e0..bd123df1814 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,12 @@ ## main ### ✨ Features and improvements +- Add additional hillshade methods ([#5768](https://github.com/maplibre/maplibre-gl-js/pull/5768)) - _...Add new stuff here..._ ### 🐞 Bug fixes - Fix scroll min zoom on globe view ([#5775](https://github.com/maplibre/maplibre-gl-js/pull/5775)) +- ⚠️ Fix hillshade appearance change between 256x256 and 512x512 tiles. This will change the appearance of hillshade layers using 512x512 tiles. ([#5768](https://github.com/maplibre/maplibre-gl-js/pull/5768)) - _...Add new stuff here..._ ## 5.4.0 diff --git a/build/generate-style-code.ts b/build/generate-style-code.ts index 6b49d1aef3c..e356705c225 100644 --- a/build/generate-style-code.ts +++ b/build/generate-style-code.ts @@ -29,6 +29,10 @@ function nativeType(property) { return 'Color'; case 'padding': return 'Padding'; + case 'numberArray': + return 'NumberArray'; + case 'colorArray': + return 'ColorArray'; case 'variableAnchorOffsetCollection': return 'VariableAnchorOffsetCollection'; case 'sprite': @@ -186,7 +190,7 @@ import { CrossFaded } from '../properties'; -import type {Color, Formatted, Padding, ResolvedImage, VariableAnchorOffsetCollection} from '@maplibre/maplibre-gl-style-spec'; +import type {Color, Formatted, Padding, NumberArray, ColorArray, ResolvedImage, VariableAnchorOffsetCollection} from '@maplibre/maplibre-gl-style-spec'; import {StylePropertySpecification} from '@maplibre/maplibre-gl-style-spec'; `); diff --git a/docs/assets/examples/hillshade-multidirectional.png b/docs/assets/examples/hillshade-multidirectional.png new file mode 100644 index 00000000000..000443b26ab Binary files /dev/null and b/docs/assets/examples/hillshade-multidirectional.png differ diff --git a/docs/assets/examples/hillshade-simple.png b/docs/assets/examples/hillshade-simple.png new file mode 100644 index 00000000000..12f3eff8661 Binary files /dev/null and b/docs/assets/examples/hillshade-simple.png differ diff --git a/package-lock.json b/package-lock.json index d930c367b05..ad01caa01c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@mapbox/unitbezier": "^0.0.1", "@mapbox/vector-tile": "^1.3.1", "@mapbox/whoots-js": "^3.1.0", - "@maplibre/maplibre-gl-style-spec": "^23.1.0", + "@maplibre/maplibre-gl-style-spec": "^23.2.1", "@types/geojson": "^7946.0.16", "@types/geojson-vt": "3.2.5", "@types/mapbox__point-geometry": "^0.1.4", @@ -2446,9 +2446,9 @@ } }, "node_modules/@maplibre/maplibre-gl-style-spec": { - "version": "23.1.0", - "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-23.1.0.tgz", - "integrity": "sha512-R6/ihEuC5KRexmKIYkWqUv84Gm+/QwsOUgHyt1yy2XqCdGdLvlBWVWIIeTZWN4NGdwmY6xDzdSGU2R9oBLNg2w==", + "version": "23.2.1", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-23.2.1.tgz", + "integrity": "sha512-SDeCvKyrPqkW1wsoNKRLuE+urRgnYa9qDE/9bVZnz7alNILPPIFPa5jcq1VgFSivnktbnoVZNNXXqNoOUKZYNg==", "dependencies": { "@mapbox/jsonlint-lines-primitives": "~2.0.2", "@mapbox/unitbezier": "^0.0.1", diff --git a/package.json b/package.json index d307f15cd5a..333585861af 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "@mapbox/unitbezier": "^0.0.1", "@mapbox/vector-tile": "^1.3.1", "@mapbox/whoots-js": "^3.1.0", - "@maplibre/maplibre-gl-style-spec": "^23.1.0", + "@maplibre/maplibre-gl-style-spec": "^23.2.1", "@types/geojson": "^7946.0.16", "@types/geojson-vt": "3.2.5", "@types/mapbox__point-geometry": "^0.1.4", diff --git a/src/render/draw_hillshade.ts b/src/render/draw_hillshade.ts index d106ba2875b..adad0341403 100644 --- a/src/render/draw_hillshade.ts +++ b/src/render/draw_hillshade.ts @@ -59,7 +59,9 @@ function renderHillshade( const context = painter.context; const transform = painter.transform; const gl = context.gl; - const program = painter.useProgram('hillshade'); + + const defines = [`#define NUM_ILLUMINATION_SOURCES ${layer.paint.get('hillshade-highlight-color').values.length}`]; + const program = painter.useProgram('hillshade', null, false, defines); const align = !painter.options.moving; for (const coord of coords) { diff --git a/src/render/painter.ts b/src/render/painter.ts index 6c7c58c7384..c6deab985b0 100644 --- a/src/render/painter.ts +++ b/src/render/painter.ts @@ -711,12 +711,12 @@ export class Painter { * Finds the required shader and its variant (base/terrain/globe, etc.) and binds it, compiling a new shader if required. * @param name - Name of the desired shader. * @param programConfiguration - Configuration of shader's inputs. - * @param defines - Additional macros to be injected at the beginning of the shader. Expected format is `['#define XYZ']`, etc. * @param forceSimpleProjection - Whether to force the use of a shader variant with simple mercator projection vertex shader. + * @param defines - Additional macros to be injected at the beginning of the shader. Expected format is `['#define XYZ']`, etc. * False by default. Use true when drawing with a simple projection matrix is desired, eg. when drawing a fullscreen quad. * @returns */ - useProgram(name: string, programConfiguration?: ProgramConfiguration | null, forceSimpleProjection: boolean = false): Program { + useProgram(name: string, programConfiguration?: ProgramConfiguration | null, forceSimpleProjection: boolean = false, defines: Array = []): Program { this.cache = this.cache || {}; const useTerrain = !!this.style.map.terrain; @@ -729,8 +729,9 @@ export class Painter { const configurationKey = (programConfiguration ? programConfiguration.cacheKey : ''); const overdrawKey = (this._showOverdrawInspector ? '/overdraw' : ''); const terrainKey = (useTerrain ? '/terrain' : ''); + const definesKey = (defines ? `/${defines.join('/')}` : ''); - const key = name + configurationKey + projectionKey + overdrawKey + terrainKey; + const key = name + configurationKey + projectionKey + overdrawKey + terrainKey + definesKey; if (!this.cache[key]) { this.cache[key] = new Program( @@ -741,7 +742,8 @@ export class Painter { this._showOverdrawInspector, useTerrain, projectionPrelude, - projectionDefine + projectionDefine, + defines ); } return this.cache[key]; diff --git a/src/render/program.ts b/src/render/program.ts index efdbe51c452..71f991efdab 100644 --- a/src/render/program.ts +++ b/src/render/program.ts @@ -52,7 +52,8 @@ export class Program { showOverdrawInspector: boolean, hasTerrain: boolean, projectionPrelude: PreparedShader, - projectionDefine: string) { + projectionDefine: string, + extraDefines: Array = []) { const gl = context.gl; this.program = gl.createProgram(); @@ -85,6 +86,9 @@ export class Program { if (projectionDefine) { defines.push(projectionDefine); } + if (extraDefines) { + defines.push(...extraDefines); + } let fragmentSource = defines.concat(shaders.prelude.fragmentSource, projectionPrelude.fragmentSource, source.fragmentSource).join('\n'); let vertexSource = defines.concat(shaders.prelude.vertexSource, projectionPrelude.vertexSource, source.vertexSource).join('\n'); diff --git a/src/render/program/hillshade_program.ts b/src/render/program/hillshade_program.ts index 7dcdcf65a74..842dff64daf 100644 --- a/src/render/program/hillshade_program.ts +++ b/src/render/program/hillshade_program.ts @@ -5,6 +5,8 @@ import { Uniform1f, Uniform2f, UniformColor, + UniformFloatArray, + UniformColorArray, UniformMatrix4f, Uniform4f } from '../uniform_binding'; @@ -22,10 +24,13 @@ import type {OverscaledTileID} from '../../source/tile_id'; export type HillshadeUniformsType = { 'u_image': Uniform1i; 'u_latrange': Uniform2f; - 'u_light': Uniform2f; - 'u_shadow': UniformColor; - 'u_highlight': UniformColor; + 'u_exaggeration': Uniform1f; + 'u_altitudes': UniformFloatArray; + 'u_azimuths': UniformFloatArray; 'u_accent': UniformColor; + 'u_method': Uniform1i; + 'u_shadows': UniformColorArray; + 'u_highlights': UniformColorArray; }; export type HillshadePrepareUniformsType = { @@ -39,10 +44,13 @@ export type HillshadePrepareUniformsType = { const hillshadeUniforms = (context: Context, locations: UniformLocations): HillshadeUniformsType => ({ 'u_image': new Uniform1i(context, locations.u_image), 'u_latrange': new Uniform2f(context, locations.u_latrange), - 'u_light': new Uniform2f(context, locations.u_light), - 'u_shadow': new UniformColor(context, locations.u_shadow), - 'u_highlight': new UniformColor(context, locations.u_highlight), - 'u_accent': new UniformColor(context, locations.u_accent) + 'u_exaggeration': new Uniform1f(context, locations.u_exaggeration), + 'u_altitudes': new UniformFloatArray(context, locations.u_altitudes), + 'u_azimuths': new UniformFloatArray(context, locations.u_azimuths), + 'u_accent': new UniformColor(context, locations.u_accent), + 'u_method': new Uniform1i(context, locations.u_method), + 'u_shadows': new UniformColorArray(context, locations.u_shadows), + 'u_highlights': new UniformColorArray(context, locations.u_highlights) }); const hillshadePrepareUniforms = (context: Context, locations: UniformLocations): HillshadePrepareUniformsType => ({ @@ -58,22 +66,45 @@ const hillshadeUniformValues = ( tile: Tile, layer: HillshadeStyleLayer, ): UniformValues => { - const shadow = layer.paint.get('hillshade-shadow-color'); - const highlight = layer.paint.get('hillshade-highlight-color'); const accent = layer.paint.get('hillshade-accent-color'); + let method; + switch (layer.paint.get('hillshade-method')) { + case 'basic': + method = 4; + break; + case 'combined': + method = 1; + break; + case 'igor': + method = 2; + break; + case 'multidirectional': + method = 3; + break; + case 'standard': + default: + method = 0; + break; + } + + const illumination = layer.getIlluminationProperties(); - let azimuthal = layer.paint.get('hillshade-illumination-direction') * (Math.PI / 180); - // modify azimuthal angle by map rotation if light is anchored at the viewport - if (layer.paint.get('hillshade-illumination-anchor') === 'viewport') { - azimuthal += painter.transform.bearingInRadians; + for (let i = 0; i < illumination.directionRadians.length; i++) { + // modify azimuthal angle by map rotation if light is anchored at the viewport + if (layer.paint.get('hillshade-illumination-anchor') === 'viewport') { + illumination.directionRadians[i] += painter.transform.bearingInRadians; + } } return { 'u_image': 0, 'u_latrange': getTileLatRange(painter, tile.tileID), - 'u_light': [layer.paint.get('hillshade-exaggeration'), azimuthal], - 'u_shadow': shadow, - 'u_highlight': highlight, - 'u_accent': accent + 'u_exaggeration': layer.paint.get('hillshade-exaggeration'), + 'u_altitudes': illumination.altitudeRadians, + 'u_azimuths': illumination.directionRadians, + 'u_accent': accent, + 'u_method': method, + 'u_highlights': illumination.highlightColor, + 'u_shadows': illumination.shadowColor }; }; diff --git a/src/render/uniform_binding.test.ts b/src/render/uniform_binding.test.ts index fb34ab1ab41..1732aaaab43 100644 --- a/src/render/uniform_binding.test.ts +++ b/src/render/uniform_binding.test.ts @@ -7,8 +7,11 @@ import { Uniform2f, Uniform3f, Uniform4f, + UniformFloatArray, + UniformColorArray, UniformMatrix4f } from './uniform_binding'; +import {Color} from '@maplibre/maplibre-gl-style-spec'; describe('Uniform Binding', () => { test('Uniform1i', () => { @@ -120,4 +123,40 @@ describe('Uniform Binding', () => { u.set([2, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); }); + test('UniformColorArray', () => { + expect.assertions(4); + + const context = { + gl: { + uniform4fv: () => { expect(true).toBeTruthy(); } + } + } as any as Context; + + const u = new UniformColorArray(context, 0); + expect(u.current).toEqual(new Array); + const v = [new Color(0.1, 0.2, 0.3), new Color(0.7, 0.8, 0.9)]; + u.set(v); + expect(u.current).toEqual(v); + u.set(v); + u.set([new Color(0.3, 0.4, 0.5), new Color(0.4, 0.5, 0.6), new Color(0.5, 0.6, 0.7)]); + }); + + test('UniformFloatArray', () => { + expect.assertions(4); + + const context = { + gl: { + uniform1fv: () => { expect(true).toBeTruthy(); } + } + } as any as Context; + + const u = new UniformFloatArray(context, 0); + expect(u.current).toEqual(new Array); + const v = [1.2, 3.4]; + u.set(v); + expect(u.current).toEqual(v); + u.set(v); + u.set([5.6, 7.8, 9.1]); + }); + }); diff --git a/src/render/uniform_binding.ts b/src/render/uniform_binding.ts index 28006fd68fc..f44e28a6a91 100644 --- a/src/render/uniform_binding.ts +++ b/src/render/uniform_binding.ts @@ -113,6 +113,42 @@ class UniformColor extends Uniform { } } +class UniformColorArray extends Uniform> { + constructor(context: Context, location: WebGLUniformLocation) { + super(context, location); + this.current = new Array(); + } + + set(v: Array): void { + if (v != this.current) { + this.current = v; + const values = new Float32Array(v.length*4); + for( let i = 0; i < v.length; i++) { + values[4*i] = v[i].r; + values[4*i+1] = v[i].g; + values[4*i+2] = v[i].b; + values[4*i+3] = v[i].a; + } + this.gl.uniform4fv(this.location, values); + } + } +} + +class UniformFloatArray extends Uniform> { + constructor(context: Context, location: WebGLUniformLocation) { + super(context, location); + this.current = new Array(); + } + + set(v: Array): void { + if (v != this.current) { + this.current = v; + const values = new Float32Array(v); + this.gl.uniform1fv(this.location, values); + } + } +} + const emptyMat4 = new Float32Array(16) as mat4; class UniformMatrix4f extends Uniform { constructor(context: Context, location: WebGLUniformLocation) { @@ -147,6 +183,8 @@ export { Uniform3f, Uniform4f, UniformColor, + UniformColorArray, + UniformFloatArray, UniformMatrix4f }; diff --git a/src/shaders/hillshade.fragment.glsl b/src/shaders/hillshade.fragment.glsl index 1b4842d70c5..44a796f7e0c 100644 --- a/src/shaders/hillshade.fragment.glsl +++ b/src/shaders/hillshade.fragment.glsl @@ -2,30 +2,55 @@ uniform sampler2D u_image; in vec2 v_pos; uniform vec2 u_latrange; -uniform vec2 u_light; -uniform vec4 u_shadow; -uniform vec4 u_highlight; +uniform float u_exaggeration; uniform vec4 u_accent; +uniform int u_method; +uniform float u_altitudes[NUM_ILLUMINATION_SOURCES]; +uniform float u_azimuths[NUM_ILLUMINATION_SOURCES]; +uniform vec4 u_shadows[NUM_ILLUMINATION_SOURCES]; +uniform vec4 u_highlights[NUM_ILLUMINATION_SOURCES]; #define PI 3.141592653589793 -void main() { - vec4 pixel = texture(u_image, v_pos); +#define STANDARD 0 +#define COMBINED 1 +#define IGOR 2 +#define MULTIDIRECTIONAL 3 +#define BASIC 4 - vec2 deriv = ((pixel.rg * 2.0) - 1.0); +float get_aspect(vec2 deriv) +{ + return deriv.x != 0.0 ? atan(deriv.y, -deriv.x) : PI / 2.0 * (deriv.y > 0.0 ? 1.0 : -1.0); +} - // We divide the slope by a scale factor based on the cosin of the pixel's approximate latitude - // to account for mercator projection distortion. see #4807 for details - float scaleFactor = cos(radians((u_latrange[0] - u_latrange[1]) * (1.0 - v_pos.y) + u_latrange[1])); - // We also multiply the slope by an arbitrary z-factor of 1.25 - float slope = atan(1.25 * length(deriv) / scaleFactor); - float aspect = deriv.x != 0.0 ? atan(deriv.y, -deriv.x) : PI / 2.0 * (deriv.y > 0.0 ? 1.0 : -1.0); +// Based on GDALHillshadeIgorAlg() (https://github.com/OSGeo/gdal/blob/ad4280be5aee202eea412c075e4591878aaeb018/apps/gdaldem_lib.cpp#L849). +// GDAL's version only calculates shading. +// This version also adds highlighting. To match GDAL's output, make hillshade-highlight-color transparent. +void igor_hillshade(vec2 deriv) +{ + deriv = deriv * u_exaggeration * 2.0; + float aspect = get_aspect(deriv); + float azimuth = u_azimuths[0] + PI; + float slope_stength = atan(length(deriv)) * 2.0/PI; + float aspect_strength = 1.0 - abs(mod((aspect + azimuth) / PI + 0.5, 2.0) - 1.0); + float shadow_strength = slope_stength * aspect_strength; + float highlight_strength = slope_stength * (1.0-aspect_strength); + fragColor = u_shadows[0] * shadow_strength + u_highlights[0] * highlight_strength; +} - float intensity = u_light.x; +// MapLibre's legacy hillshade algorithm +void standard_hillshade(vec2 deriv) +{ // We add PI to make this property match the global light object, which adds PI/2 to the light's azimuthal // position property to account for 0deg corresponding to north/the top of the viewport in the style spec // and the original shader was written to accept (-illuminationDirection - 90) as the azimuthal. - float azimuth = u_light.y + PI; + float azimuth = u_azimuths[0] + PI; + + // We also multiply the slope by an arbitrary z-factor of 0.625 + float slope = atan(0.625 * length(deriv)); + float aspect = get_aspect(deriv); + + float intensity = u_exaggeration; // We scale the slope exponentially based on intensity, using a calculation similar to // the exponential interpolation function in the style spec: @@ -43,8 +68,116 @@ void main() { // while intensity values < 0.5 make the overall color more transparent. vec4 accent_color = (1.0 - accent) * u_accent * clamp(intensity * 2.0, 0.0, 1.0); float shade = abs(mod((aspect + azimuth) / PI + 0.5, 2.0) - 1.0); - vec4 shade_color = mix(u_shadow, u_highlight, shade) * sin(scaledSlope) * clamp(intensity * 2.0, 0.0, 1.0); + vec4 shade_color = mix(u_shadows[0], u_highlights[0], shade) * sin(scaledSlope) * clamp(intensity * 2.0, 0.0, 1.0); fragColor = accent_color * (1.0 - shade_color.a) + shade_color; +} + +// Based on GDALHillshadeAlg(). (https://github.com/OSGeo/gdal/blob/ad4280be5aee202eea412c075e4591878aaeb018/apps/gdaldem_lib.cpp#L908) +// GDAL's output ranges from black to white, and is gray in the middle. +// The output of this function ranges from hillshade-shadow-color to hillshade-highlight-color, and +// is transparent in the middle. To match GDAL's output, make hillshade-highlight-color white, +// hillshade-shadow color black, and the background color gray. +void basic_hillshade(vec2 deriv) +{ + deriv = deriv * u_exaggeration * 2.0; + float azimuth = u_azimuths[0] + PI; + float cos_az = cos(azimuth); + float sin_az = sin(azimuth); + float cos_alt = cos(u_altitudes[0]); + float sin_alt = sin(u_altitudes[0]); + + float cang = (sin_alt - (deriv.y*cos_az*cos_alt - deriv.x*sin_az*cos_alt)) / sqrt(1.0 + dot(deriv, deriv)); + + float shade = clamp(cang, 0.0, 1.0); + if(shade > 0.5) + { + fragColor = u_highlights[0]*(2.0*shade - 1.0); + } + else + { + fragColor = u_shadows[0]*(1.0 - 2.0*shade); + } +} + +// This functioon applies the basic_hillshade algorithm across multiple independent light sources. +// The final color is the average of the contribution from each light source. +void multidirectional_hillshade(vec2 deriv) +{ + deriv = deriv * u_exaggeration * 2.0; + fragColor = vec4(0,0,0,0); + + for(int i = 0; i < NUM_ILLUMINATION_SOURCES; i++) + { + float cos_alt = cos(u_altitudes[i]); + float sin_alt = sin(u_altitudes[i]); + float cos_az = -cos(u_azimuths[i]); + float sin_az = -sin(u_azimuths[i]); + + float cang = (sin_alt - (deriv.y*cos_az*cos_alt - deriv.x*sin_az*cos_alt)) / sqrt(1.0 + dot(deriv, deriv)); + + float shade = clamp(cang, 0.0, 1.0); + + if(shade > 0.5) + { + fragColor += u_highlights[i]*(2.0*shade - 1.0)/float(NUM_ILLUMINATION_SOURCES); + } + else + { + fragColor += u_shadows[i]*(1.0 - 2.0*shade)/float(NUM_ILLUMINATION_SOURCES); + } + } +} + +// Based on GDALHillshadeCombinedAlg(). (https://github.com/OSGeo/gdal/blob/ad4280be5aee202eea412c075e4591878aaeb018/apps/gdaldem_lib.cpp#L1084) +// GDAL's version only calculates shading. +// This version also adds highlighting. To match GDAL's output, make hillshade-highlight-color transparent. +void combined_hillshade(vec2 deriv) +{ + deriv = deriv * u_exaggeration * 2.0; + float azimuth = u_azimuths[0] + PI; + float cos_az = cos(azimuth); + float sin_az = sin(azimuth); + float cos_alt = cos(u_altitudes[0]); + float sin_alt = sin(u_altitudes[0]); + + float cang = acos((sin_alt - (deriv.y*cos_az*cos_alt - deriv.x*sin_az*cos_alt)) / sqrt(1.0 + dot(deriv, deriv))); + + cang = clamp(cang, 0.0, PI/2.0); + + float shade = cang* atan(length(deriv)) * 4.0/PI/PI; + float highlight = (PI/2.0-cang)* atan(length(deriv)) * 4.0/PI/PI; + + fragColor = u_shadows[0]*shade + u_highlights[0]*highlight; +} + +void main() { + vec4 pixel = texture(u_image, v_pos); + + // We divide the slope by a scale factor based on the cosin of the pixel's approximate latitude + // to account for mercator projection distortion. see #4807 for details + float scaleFactor = cos(radians((u_latrange[0] - u_latrange[1]) * (1.0 - v_pos.y) + u_latrange[1])); + + vec2 deriv = ((pixel.rg * 8.0) - 4.0) / scaleFactor; + + switch(u_method) + { + case BASIC: + basic_hillshade(deriv); + break; + case COMBINED: + combined_hillshade(deriv); + break; + case IGOR: + igor_hillshade(deriv); + break; + case MULTIDIRECTIONAL: + multidirectional_hillshade(deriv); + break; + case STANDARD: + default: + standard_hillshade(deriv); + break; + } #ifdef OVERDRAW_INSPECTOR fragColor = vec4(1.0); diff --git a/src/shaders/hillshade_prepare.fragment.glsl b/src/shaders/hillshade_prepare.fragment.glsl index 2192aeafe5d..f97ec8025a0 100644 --- a/src/shaders/hillshade_prepare.fragment.glsl +++ b/src/shaders/hillshade_prepare.fragment.glsl @@ -14,11 +14,12 @@ float getElevation(vec2 coord, float bias) { // Convert encoded elevation value to meters vec4 data = texture(u_image, coord) * 255.0; data.a = -1.0; - return dot(data, u_unpack) / 4.0; + return dot(data, u_unpack); } void main() { vec2 epsilon = 1.0 / u_dimension; + float tileSize = u_dimension.x - 2.0; // queried pixels: // +-----------+ @@ -48,8 +49,8 @@ void main() { // Here we divide the x and y slopes by 8 * pixel size // where pixel size (aka meters/pixel) is: // circumference of the world / (pixels per tile * number of tiles) - // which is equivalent to: 8 * 40075016.6855785 / (512 * pow(2, u_zoom)) - // which can be reduced to: pow(2, 19.25619978527 - u_zoom). + // which is equivalent to: 8 * 40075016.6855785 / (tileSize * pow(2, u_zoom)) + // which can be reduced to: pow(2, 28.25619978527 - u_zoom) / tileSize. // We want to vertically exaggerate the hillshading because otherwise // it is barely noticeable at low zooms. To do this, we multiply this by // a scale factor that is a function of zooms below 15, which is an arbitrary @@ -63,11 +64,11 @@ void main() { vec2 deriv = vec2( (c + f + f + i) - (a + d + d + g), (g + h + h + i) - (a + b + b + c) - ) / pow(2.0, exaggeration + (19.2562 - u_zoom)); + ) * tileSize / pow(2.0, exaggeration + (28.2562 - u_zoom)); fragColor = clamp(vec4( - deriv.x / 2.0 + 0.5, - deriv.y / 2.0 + 0.5, + deriv.x / 8.0 + 0.5, + deriv.y / 8.0 + 0.5, 1.0, 1.0), 0.0, 1.0); diff --git a/src/style/style_layer/hillshade_style_layer.test.ts b/src/style/style_layer/hillshade_style_layer.test.ts new file mode 100644 index 00000000000..9d3da2d83f1 --- /dev/null +++ b/src/style/style_layer/hillshade_style_layer.test.ts @@ -0,0 +1,107 @@ +import {describe, test, expect} from 'vitest'; +import {HillshadeStyleLayer} from './hillshade_style_layer'; +import {Color, type LayerSpecification} from '@maplibre/maplibre-gl-style-spec'; +import {createStyleLayer} from '../create_style_layer'; +import {degreesToRadians, extend} from '../../util/util'; + +function createLayerSpec(properties?): LayerSpecification { + return extend({ + type: 'hillshade', + id: 'hillshade', + source: 'hillshadeSource' + }, properties); +} + +describe('HillshadeStyleLayer', () => { + + test('default', () => { + const layerSpec = createLayerSpec(); + const layer = createStyleLayer(layerSpec); + expect(layer).toBeInstanceOf(HillshadeStyleLayer); + + const illumination = (layer as HillshadeStyleLayer).getIlluminationProperties(); + expect(illumination.directionRadians).toEqual([degreesToRadians(335)]); + expect(illumination.altitudeRadians).toEqual([degreesToRadians(45)]); + expect(illumination.highlightColor).toEqual([Color.white]); + expect(illumination.shadowColor).toEqual([Color.black]); + }); + + test('single-value illumination parameters', () => { + const layerSpec = createLayerSpec({ + paint: { + 'hillshade-illumination-direction': 3, + 'hillshade-illumination-altitude': 4, + 'hillshade-highlight-color': '#FF0000', + 'hillshade-shadow-color': '#FFFFFF', + } + }); + + const layer = createStyleLayer(layerSpec); + expect(layer).toBeInstanceOf(HillshadeStyleLayer); + + const illumination = (layer as HillshadeStyleLayer).getIlluminationProperties(); + expect(illumination.directionRadians).toEqual([degreesToRadians(3)]); + expect(illumination.altitudeRadians).toEqual([degreesToRadians(4)]); + expect(illumination.highlightColor).toEqual([Color.red]); + expect(illumination.shadowColor).toEqual([Color.white]); + }); + + test('array-value illumination parameters', () => { + const layerSpec = createLayerSpec({ + paint: { + 'hillshade-illumination-direction': [6, 7], + 'hillshade-illumination-altitude': [8, 9], + 'hillshade-highlight-color': ['#FF0000','#FF0000'], + 'hillshade-shadow-color': ['#FF0000', '#FFFFFF'], + } + }); + + const layer = createStyleLayer(layerSpec); + expect(layer).toBeInstanceOf(HillshadeStyleLayer); + + const illumination = (layer as HillshadeStyleLayer).getIlluminationProperties(); + expect(illumination.directionRadians).toEqual([degreesToRadians(6), degreesToRadians(7)]); + expect(illumination.altitudeRadians).toEqual([degreesToRadians(8), degreesToRadians(9)]); + expect(illumination.highlightColor).toEqual([Color.red, Color.red]); + expect(illumination.shadowColor).toEqual([Color.red, Color.white]); + }); + + test('mixed illumination parameters: number, color, and arrays', () => { + const layerSpec = createLayerSpec({ + paint: { + 'hillshade-illumination-direction': [6, 7], + 'hillshade-illumination-altitude': 23, + 'hillshade-highlight-color': ['#FF0000'], + 'hillshade-shadow-color': '#000000', + } + }); + + const layer = createStyleLayer(layerSpec); + expect(layer).toBeInstanceOf(HillshadeStyleLayer); + + const illumination = (layer as HillshadeStyleLayer).getIlluminationProperties(); + expect(illumination.directionRadians).toEqual([degreesToRadians(6), degreesToRadians(7)]); + expect(illumination.altitudeRadians).toEqual([degreesToRadians(23), degreesToRadians(23)]); + expect(illumination.highlightColor).toEqual([Color.red, Color.red]); + expect(illumination.shadowColor).toEqual([Color.black, Color.black]); + }); + + test('mixed illumination parameters: default, number, and arrays', () => { + const layerSpec = createLayerSpec({ + paint: { + 'hillshade-illumination-altitude': 23, + 'hillshade-highlight-color': ['#FF0000'], + 'hillshade-shadow-color': ['#000000', '#FFFFFF'], + } + }); + + const layer = createStyleLayer(layerSpec); + expect(layer).toBeInstanceOf(HillshadeStyleLayer); + + const illumination = (layer as HillshadeStyleLayer).getIlluminationProperties(); + expect(illumination.directionRadians).toEqual([degreesToRadians(335), degreesToRadians(335)]); + expect(illumination.altitudeRadians).toEqual([degreesToRadians(23), degreesToRadians(23)]); + expect(illumination.highlightColor).toEqual([Color.red, Color.red]); + expect(illumination.shadowColor).toEqual([Color.black, Color.white]); + }); +}); diff --git a/src/style/style_layer/hillshade_style_layer.ts b/src/style/style_layer/hillshade_style_layer.ts index 77ffe1a64fc..a74642f8c85 100644 --- a/src/style/style_layer/hillshade_style_layer.ts +++ b/src/style/style_layer/hillshade_style_layer.ts @@ -4,7 +4,9 @@ import properties, {type HillshadePaintPropsPossiblyEvaluated} from './hillshade import {type Transitionable, type Transitioning, type PossiblyEvaluated} from '../properties'; import type {HillshadePaintProps} from './hillshade_style_layer_properties.g'; -import type {LayerSpecification} from '@maplibre/maplibre-gl-style-spec'; +import type {Color, LayerSpecification} from '@maplibre/maplibre-gl-style-spec'; +import {degreesToRadians} from '../../util/util'; +import type {EvaluationParameters} from '../evaluation_parameters'; export const isHillshadeStyleLayer = (layer: StyleLayer): layer is HillshadeStyleLayer => layer.type === 'hillshade'; @@ -15,6 +17,26 @@ export class HillshadeStyleLayer extends StyleLayer { constructor(layer: LayerSpecification) { super(layer, properties); + this.recalculate({zoom: 0, zoomHistory: {}} as EvaluationParameters, undefined); + } + + getIlluminationProperties(): {directionRadians: number[]; altitudeRadians: number[]; shadowColor: Color[]; highlightColor: Color[]} { + let direction = this.paint.get('hillshade-illumination-direction').values; + let altitude = this.paint.get('hillshade-illumination-altitude').values; + let highlightColor = this.paint.get('hillshade-highlight-color').values; + let shadowColor = this.paint.get('hillshade-shadow-color').values; + + // ensure all illumination properties have the same length + const numIlluminationSources = Math.max(direction.length, altitude.length, highlightColor.length, shadowColor.length); + direction = direction.concat(Array(numIlluminationSources - direction.length).fill(direction.at(-1))); + altitude = altitude.concat(Array(numIlluminationSources - altitude.length).fill(altitude.at(-1))); + highlightColor = highlightColor.concat(Array(numIlluminationSources - highlightColor.length).fill(highlightColor.at(-1))); + shadowColor = shadowColor.concat(Array(numIlluminationSources - shadowColor.length).fill(shadowColor.at(-1))); + + const altitudeRadians = altitude.map(degreesToRadians); + const directionRadians = direction.map(degreesToRadians); + + return {directionRadians, altitudeRadians, shadowColor, highlightColor}; } hasOffscreenPass() { diff --git a/test/build/min.test.ts b/test/build/min.test.ts index 3466eeb75f3..7bc5ae45549 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 = 915048; + const expectedBytes = 924399; expect(actualBytes).toBeLessThan(expectedBytes + increaseQuota); expect(actualBytes).toBeGreaterThan(expectedBytes - decreaseQuota); diff --git a/test/examples/hillshade-multidirectional.html b/test/examples/hillshade-multidirectional.html new file mode 100644 index 00000000000..2ff2bcb985e --- /dev/null +++ b/test/examples/hillshade-multidirectional.html @@ -0,0 +1,56 @@ + + + + Add a multidirectional hillshade layer + + + + + + + + +
+ + + + + \ No newline at end of file diff --git a/test/examples/hillshade-simple.html b/test/examples/hillshade-simple.html new file mode 100644 index 00000000000..713fad5651b --- /dev/null +++ b/test/examples/hillshade-simple.html @@ -0,0 +1,58 @@ + + + + Add a hillshade layer + + + + + + + + +
+ + + + + \ No newline at end of file diff --git a/test/integration/render/tests/hillshade-alt/basic-0/expected.png b/test/integration/render/tests/hillshade-alt/basic-0/expected.png new file mode 100644 index 00000000000..30893b753be Binary files /dev/null and b/test/integration/render/tests/hillshade-alt/basic-0/expected.png differ diff --git a/test/integration/render/tests/hillshade-alt/basic-0/style.json b/test/integration/render/tests/hillshade-alt/basic-0/style.json new file mode 100644 index 00000000000..cd86fa55520 --- /dev/null +++ b/test/integration/render/tests/hillshade-alt/basic-0/style.json @@ -0,0 +1,39 @@ +{ + "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": "background", + "type": "background", + "paint": { + "background-color": "gray" + } + }, + { + "id": "hillshade", + "type": "hillshade", + "source": "source", + "paint": { + "hillshade-method": "basic", + "hillshade-illumination-altitude": 0 + } + } + ] +} diff --git a/test/integration/render/tests/hillshade-alt/basic-90/expected.png b/test/integration/render/tests/hillshade-alt/basic-90/expected.png new file mode 100644 index 00000000000..2ff03d7c955 Binary files /dev/null and b/test/integration/render/tests/hillshade-alt/basic-90/expected.png differ diff --git a/test/integration/render/tests/hillshade-alt/basic-90/style.json b/test/integration/render/tests/hillshade-alt/basic-90/style.json new file mode 100644 index 00000000000..cfff20b4f8a --- /dev/null +++ b/test/integration/render/tests/hillshade-alt/basic-90/style.json @@ -0,0 +1,39 @@ +{ + "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": "background", + "type": "background", + "paint": { + "background-color": "gray" + } + }, + { + "id": "hillshade", + "type": "hillshade", + "source": "source", + "paint": { + "hillshade-method": "basic", + "hillshade-illumination-altitude": 90 + } + } + ] +} diff --git a/test/integration/render/tests/hillshade-alt/combined-60/expected.png b/test/integration/render/tests/hillshade-alt/combined-60/expected.png new file mode 100644 index 00000000000..4073d6c8a22 Binary files /dev/null and b/test/integration/render/tests/hillshade-alt/combined-60/expected.png differ diff --git a/test/integration/render/tests/hillshade-alt/combined-60/style.json b/test/integration/render/tests/hillshade-alt/combined-60/style.json new file mode 100644 index 00000000000..9a3fc90817d --- /dev/null +++ b/test/integration/render/tests/hillshade-alt/combined-60/style.json @@ -0,0 +1,39 @@ +{ + "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": "background", + "type": "background", + "paint": { + "background-color": "gray" + } + }, + { + "id": "hillshade", + "type": "hillshade", + "source": "source", + "paint": { + "hillshade-method": "combined", + "hillshade-illumination-altitude": 60 + } + } + ] +} diff --git a/test/integration/render/tests/hillshade-methods/basic/expected.png b/test/integration/render/tests/hillshade-methods/basic/expected.png new file mode 100644 index 00000000000..f626de6a51d Binary files /dev/null and b/test/integration/render/tests/hillshade-methods/basic/expected.png differ diff --git a/test/integration/render/tests/hillshade-methods/basic/style.json b/test/integration/render/tests/hillshade-methods/basic/style.json new file mode 100644 index 00000000000..08512da4015 --- /dev/null +++ b/test/integration/render/tests/hillshade-methods/basic/style.json @@ -0,0 +1,38 @@ +{ + "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": "background", + "type": "background", + "paint": { + "background-color": "gray" + } + }, + { + "id": "hillshade", + "type": "hillshade", + "source": "source", + "paint": { + "hillshade-method": "basic" + } + } + ] +} diff --git a/test/integration/render/tests/hillshade-methods/combined/expected.png b/test/integration/render/tests/hillshade-methods/combined/expected.png new file mode 100644 index 00000000000..b5f19d63e7f Binary files /dev/null and b/test/integration/render/tests/hillshade-methods/combined/expected.png differ diff --git a/test/integration/render/tests/hillshade-methods/combined/style.json b/test/integration/render/tests/hillshade-methods/combined/style.json new file mode 100644 index 00000000000..c2e2eaa60f9 --- /dev/null +++ b/test/integration/render/tests/hillshade-methods/combined/style.json @@ -0,0 +1,38 @@ +{ + "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": "background", + "type": "background", + "paint": { + "background-color": "gray" + } + }, + { + "id": "hillshade", + "type": "hillshade", + "source": "source", + "paint": { + "hillshade-method": "combined" + } + } + ] +} diff --git a/test/integration/render/tests/hillshade-methods/igor/expected.png b/test/integration/render/tests/hillshade-methods/igor/expected.png new file mode 100644 index 00000000000..7ed9e5c798d Binary files /dev/null and b/test/integration/render/tests/hillshade-methods/igor/expected.png differ diff --git a/test/integration/render/tests/hillshade-methods/igor/style.json b/test/integration/render/tests/hillshade-methods/igor/style.json new file mode 100644 index 00000000000..e3c4f1fac70 --- /dev/null +++ b/test/integration/render/tests/hillshade-methods/igor/style.json @@ -0,0 +1,38 @@ +{ + "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": "background", + "type": "background", + "paint": { + "background-color": "gray" + } + }, + { + "id": "hillshade", + "type": "hillshade", + "source": "source", + "paint": { + "hillshade-method": "igor" + } + } + ] +} diff --git a/test/integration/render/tests/hillshade-methods/multidirectional/expected.png b/test/integration/render/tests/hillshade-methods/multidirectional/expected.png new file mode 100644 index 00000000000..d2ef9a3e66a Binary files /dev/null and b/test/integration/render/tests/hillshade-methods/multidirectional/expected.png differ diff --git a/test/integration/render/tests/hillshade-methods/multidirectional/style.json b/test/integration/render/tests/hillshade-methods/multidirectional/style.json new file mode 100644 index 00000000000..18553477dcb --- /dev/null +++ b/test/integration/render/tests/hillshade-methods/multidirectional/style.json @@ -0,0 +1,41 @@ +{ + "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": "background", + "type": "background", + "paint": { + "background-color": "gray" + } + }, + { + "id": "hillshade", + "type": "hillshade", + "source": "source", + "paint": { + "hillshade-method": "multidirectional", + "hillshade-highlight-color": ["#FF4000", "#FFFF00", "#40ff00", "#00FF80"], + "hillshade-shadow-color": ["#00bfff", "#0000ff", "#bf00ff", "#FF0080"], + "hillshade-illumination-direction": [270,315,0,45] + } + } + ] +} diff --git a/test/integration/render/tests/terrain-shading/default/expected.png b/test/integration/render/tests/terrain-shading/default/expected.png index af48186aedb..dea05bd0f20 100644 Binary files a/test/integration/render/tests/terrain-shading/default/expected.png and b/test/integration/render/tests/terrain-shading/default/expected.png differ diff --git a/test/integration/render/tests/terrain/fill-extrusion-multipolygon/expected.png b/test/integration/render/tests/terrain/fill-extrusion-multipolygon/expected.png index e421d3b7476..03922e57938 100644 Binary files a/test/integration/render/tests/terrain/fill-extrusion-multipolygon/expected.png and b/test/integration/render/tests/terrain/fill-extrusion-multipolygon/expected.png differ