近日mapbox-gl 在发布的 v3.0.0 中新增了新的 raster
特性,仅从日志似乎感觉并没有什么特别,但是实际上非常有趣。
下面就让我们仔细探索一下究竟有什么有趣的地方。
简介
此特性新增了 4 个 paint 属性:
raster-color
:栅格着色,支持插值表达式。raster-value
:引入了栅格表达式,支持从栅格中提取像素值。raster-color-mix
: 当配置raster-color
时,指定用于计算raster-value
的 RGB 通道组合。 计算公式为mix.r * src.r + mix.g * src.g + mix.b * src.b + mix.a
前三个分量分别指定图片的红、绿、蓝通道的混合。第四个分量是一个常量偏移,不会与栅格 alpha 值相乘。 数据源中的透明度将会应用于着色结果。默认值对应于 RGB 亮度。raster-color-range
:当配置raster-color
时,指定着色的的色值范围。单位与通过raster-color-mix
计算的值相对应。
下面我们看一下相关的应用。
示例
大致示例如下,核心代码来源于raster-color:
vue
<template>
<div class="playground-content" ref="mapRef">
<div class="gui-panel" ref="guiPanel"></div>
</div>
</template>
<script setup lang="ts">
import 'mapbox-gl/dist/mapbox-gl.css';
import { onMounted, ref } from 'vue';
import mapboxgl from 'https://esm.sh/mapbox-gl@3.0.1';
import * as d3 from 'd3';
import { Pane } from 'tweakpane';
import { usePlayground } from '../../../.vitepress/hooks/usePlayground';
import { config } from '../../../config';
defineOptions({
name: 'RasterValue',
});
const mapRef = ref<HTMLDivElement>();
const guiPanel = ref<HTMLDivElement>();
const emits = defineEmits(['mount']);
let map;
const { pause, resume } = usePlayground(
() => {},
() => {},
);
const mixes = {
luminosity: [0.2126, 0.7152, 0.0722, 0],
average: [1 / 3, 1 / 3, 1 / 3, 0],
red: [1, 0, 0, 0],
green: [0, 1, 0, 0],
blue: [0, 0, 1, 0],
'decode terrain-rgb': [256 * 256 * 256 * 0.1, 256 * 256 * 0.1, 256 * 0.1, -10000],
'decode population log-density': [
256 * 256 * 256 * 8.348993603394451e-7,
256 * 256 * 8.348993603394451e-7,
256 * 8.348993603394451e-7,
-9,
],
};
const ranges = {
'[0, 1]': [0, 1],
'[0, 8848]': [0, 8848],
'[-3, 4]': [-3, 4],
};
const quantize = (interpolator, opac = (x) => 1, n = 100) =>
d3.quantize(interpolator, n).map((c: any, i) => {
const col = d3.rgb(c);
const t = i / (n - 1);
return [t, `rgba(${col.r},${col.g},${col.b},${col.opacity * opac(t)})`];
});
const colorScales = {
Viridis: quantize(d3.interpolateViridis),
Greys: quantize((x) => d3.interpolateGreys(1 - x)),
Turbo: quantize(d3.interpolateTurbo),
Inferno: quantize(d3.interpolateInferno),
Magma: quantize(d3.interpolateMagma),
Plasma: quantize(d3.interpolatePlasma),
Cividis: quantize(d3.interpolateCividis),
Cool: quantize(d3.interpolateCool),
Warm: quantize(d3.interpolateWarm),
Cubehelix: quantize(d3.interpolateCubehelixDefault),
RdPu: quantize((x) => d3.interpolateRdPu(1 - x)),
YlGnBu: quantize((x) => d3.interpolateYlGnBu(1 - x)),
Rainbow: quantize(d3.interpolateRainbow),
Sinebow: quantize(d3.interpolateSinebow),
RdBu: quantize(d3.interpolateRdBu),
PopDensity: quantize(d3.interpolateMagma, (t) => 3 * t * t - 2 * t * t * t),
Hypsometric: [
[0.004, 'rgb(48, 167, 228)'],
[0.005, 'rgb(57, 143, 83)'],
[0.031, 'rgb(116, 166, 129)'],
[0.055, 'rgb(178, 205, 174)'],
[0.076, 'rgb(188, 195, 169)'],
[0.108, 'rgb(221, 207, 153)'],
[0.153, 'rgb(211, 174, 114)'],
[0.205, 'rgb(207, 155, 103)'],
[0.277, 'rgb(179, 120, 85)'],
[0.375, 'rgb(227, 210, 197)'],
[0.66, 'rgb(255, 255, 255)'],
],
'Transparent white': [
[0.0, 'rgba(255, 255, 255, 0)'],
[1.0, 'rgba(255, 255, 255, 1)'],
],
Radar: [
[0.0, 'rgba(35,23,27,0)'],
[0.0714, 'rgba(68, 188, 116, 0.1)'],
[0.1428, 'rgba(30, 183, 66, 0.3)'],
[0.2142, 'rgba(45, 193, 81, 0.5)'],
[0.2857, 'rgba(38, 208, 43, 0.7)'],
[0.3571, 'rgba(58, 237, 55, 0.9)'],
[0.4285, 'rgb(143, 252, 95)'],
[0.5, 'rgb(190, 251, 81)'],
[0.5714, 'rgb(200, 235, 26)'],
[0.6428, 'rgb(245,199,43)'],
[0.7142, 'rgb(255,155,33)'],
[0.7857, 'rgb(251,105,25)'],
[0.8571, 'rgb(214,57,15)'],
[0.9285, 'rgb(168,22,4)'],
[1.0, 'rgb(183, 44, 204)'],
],
};
const tilesets = {
'mapbox.terrain-rgb': 'mapbox.terrain-rgb',
'mapbox.satellite': 'mapbox.satellite',
};
const presets = {
Satellite: {
tileset: "mapbox.satellite",
rasterColor: "Cubehelix",
rasterColorMix: "luminosity",
rasterColorRange: "[0, 1]"
},
Terrain: {
tileset: "mapbox.terrain-rgb",
rasterColor: "Hypsometric",
rasterColorMix: "decode terrain-rgb",
rasterColorRange: "[0, 8848]",
forcePngraw: true
}
};
function GUIParams() {
this.preset = 'Satellite';
this.opacity = 1;
Object.assign(this, presets[this.preset]);
}
const guiParams = new GUIParams();
function setStyle(tileset) {
map.setStyle({
version: 8,
sources: {
mapbox: {
type: 'raster',
url: 'mapbox://mapbox.satellite',
tileSize: 256,
},
raster: {
type: 'raster',
tiles: [`https://api.mapbox.com/v4/${tileset}/{z}/{x}/{y}@2x.webp`],
maxzoom: 14,
tileSize: 256,
},
},
layers: [
{
id: 'background',
type: 'background',
paint: {
'background-color': 'rgb(4,7,14)',
},
},
{
id: 'satellite',
type: 'raster',
source: 'mapbox',
'source-layer': 'mapbox_satellite_full',
paint: {
'raster-saturation': -0.3,
'raster-brightness-max': 0.5,
},
},
{
type: 'raster',
source: 'raster',
id: 'raster',
paint: {
'raster-opacity': guiParams.opacity,
},
},
],
});
}
function initMap() {
mapboxgl.accessToken = config.mbglToken;
map = new mapboxgl.Map({
container: mapRef.value,
zoom: 0,
center: [0, 0],
style: {version: 8, layers: [], sources: {}},
transformRequest (url, type) {
if (type !== "Tile") return;
const preset = presets[guiParams.preset];
if (preset.forbid2x) url = url.replace('@2x', '');
if (preset.forcePngraw) url = url.replace('.webp', '.pngraw');
return {url};
}
});
map.once('load', () => {
const pane = new Pane({
title: 'raster color',
container: guiPanel.value,
});
const preset = pane.addBinding(guiParams, 'preset', {
options: Object.keys(presets).reduce((prev: any, current: string) => ({
...prev,
[current]: current,
}), {} as any),
});
const tileset = pane.addBinding(guiParams, 'tileset', {
options: Object.keys(tilesets).reduce((prev: any, current: string) => ({
...prev,
[current]: current,
}), {} as any)
});
const scale = pane.addBinding(guiParams, 'rasterColor', {
options: Object.keys(colorScales).reduce((prev: any, current: string) => ({
...prev,
[current]: current,
}), {} as any)
});
const mix = pane.addBinding(guiParams, 'rasterColorMix', {
options: Object.keys(mixes).reduce((prev: any, current: string) => ({
...prev,
[current]: current,
}), {} as any)
});
const range = pane.addBinding(guiParams, 'rasterColorRange', {
options: Object.keys(ranges).reduce((prev: any, current: string) => ({
...prev,
[current]: current,
}), {} as any)
});
const opacity = pane.addBinding(guiParams, 'opacity', {
min: 0,
max: 1,
});
function setColorScale(scale) {
const range = ranges[guiParams.rasterColorRange];
const cs = colorScales[scale];
map.setPaintProperty('raster', 'raster-color', [
'interpolate',
['linear'],
['raster-value'],
...cs.map(([pos, col]) => [range[0] + (range[1] - range[0]) * pos, col]).flat(),
]);
}
function setColorMix(mix) {
map.setPaintProperty('raster', 'raster-color-mix', mixes[mix]);
}
function setColorRange(range) {
map.setPaintProperty('raster', 'raster-color-range', ranges[range]);
setColorScale(guiParams.rasterColor);
}
function setTileset(range) {
setStyle(guiParams.tileset);
setColorMix(guiParams.rasterColorMix);
setColorScale(guiParams.rasterColor);
setColorRange(guiParams.rasterColorRange);
}
function setPreset(presetName) {
const preset = presets[presetName];
Object.assign(guiParams, preset);
setTileset(guiParams.tileset);
}
mix.on('change', ({ value }) => setColorMix(value));
scale.on('change', ({ value }) => setColorScale(value));
range.on('change', ({ value }) => setColorRange(value));
tileset.on('change', ({ value }) => setTileset(value));
preset.on('change', ({ value }) => setPreset(value));
opacity.on('change', ({ value }) => {
map.setPaintProperty('raster', 'raster-opacity', value);
});
setTileset(guiParams.tileset);
});
emits('mount');
}
onMounted(() => {
initMap();
});
defineExpose({
pause,
resume,
});
</script>
<style></style>


应用场景
针对栅格自定义着色除了能让开发者更精准的控制叠加的栅格数据,还能通过这些配置去实现诸如 DEM 自定义着色,自定义数据源着色等场景。一个常见的应用场景是气象数据渲染,比如我们的温度数据渲染:
我们准备一张原始归一化后的栅格数据,栅格的每个格点的值压入方式为 (原始值 - 当前瓦片最大值) / ( 当前瓦片最大值 - 当前瓦片最小值 ) * 255,得到的图片如下:

然后向地图添加一个数据源:
js
map.addSource('temp', {
type: 'image',
url: './temp-surface.jpeg',
coordinates: [
[-180, 85.051129],
[180, 85.051129],
[180, -85.051129],
[-180, -85.051129],
],
// 201.81008911132812,317.9674377441406
})
添加图层:
js
map.addLayer({
type: 'raster',
source: 'temp',
id: 'temp',
paint: {
'raster-opacity': 1,
'raster-color': [
'interpolate',
['linear'],
['raster-value'],
...tempInterpolateColor,
],
// 相当于实现了 r * (max - min) + min
'raster-color-mix': [(317.9674377441406 - 273.15) - (201.81008911132812 - 273.15), 0, 0, 201.81008911132812 - 273.15],
'raster-color-range': [201.81008911132812 - 273.15, 317.9674377441406 - 273.15]
},
})
最终渲染效果如下:

这样我们就简单的实现了常规气象数据的渲染,而且可以走 mapbox 内部渲染流程贴球和地形。
当然其使用场景不止如此,更多的需要我们结合自身业务,探寻不一样的场景。这样大部分的简单场景我们都不需要再使用自定义图层做渲染,仅仅使用官方 api 就能实现,而且也能方便的融合 mapbox 的三维场景。
实现原理
主要是通过 render 层 栅格绘制和着色器 配合实现,主要新增了 raster color 的配置获取解析

以及新增的 uniformValues变量rasterColor.mix
和 rasterColor.range

在片段着色器中先对传入的两个纹理进行插值 mix(color0, color1, u_fade_t)
,再根据对应的值从颜色图 u_color_ramp
采样出相应的颜色,最终将原栅格的透明度应用到采样后的颜色。
