开源教程 | 用 Mapbox 进行高级地图着色(上) Hillshading - Mapbox 一分钟
近期总有很多小伙伴提到倾斜摄影这块的东西,正好最近有一个国外的 Mapbox 开发者制作了一套美国 50 个州的地形渲染图(可以关注公众号回复“渲染图”获得下载链接哦),非常好看。
他还分享了自己的制作过程,这期 Mapbox 一分钟就带你一起学习一下。
Mapbox 一分钟往期回顾:
高级地图着色后的 Crater Lake 附近区域,有点高级!
当前我们知道的,只有一为地图添加阴影的办法 —— hillshading。这是一种为地图添加浮雕的简单而有效的办法,但我们其实可以做得更好。
下面给大家介绍一下hillshading 的基础知识,以及柔和阴影(soft shadows)和环境照明(ambient lighting)等进阶内容。为了提高速度,我们将会使用稍微有点难理解的 regl 库,并在 WebGL 的 GPU 上运行。
🌈前期准备:从瓦片服务器(Tile Server)上获得瓦片(Tiles)
const demo = require("./demo");
const fs = require("fs");
const MAPBOX_KEY = fs.readFileSync("mapbox.key").toString();
async function main() {
// Define a zoom and a (lat,long) location.
const zoom = 10;
const [lat, long] = [36.133487, -112.239884]; // Grand Canyon
// Determine the tile coordinates for the location and zoom level.
const tLat = Math.floor(demo.lat2tile(lat, zoom));
const tLong = Math.floor(demo.long2tile(long, zoom));
// Download the tile image.
const image = await demo.loadImage(
`https://api.mapbox.com/v4/mapbox.satellite/${zoom}/${tLong}/${tLat}.pngraw?access_token=${MAPBOX_KEY}`
);
// Display it.
document.body.appendChild(image);
}
main();
源码分析
在这里,我们将会使用 Mapbox 去加载瓦片。正如上面的代码,我们进行详细分析一下。
首先,我们需要一些实用的功能(稍后会详细介绍)。
const demo = require("./demo");
接下来,我们加载 Mapbox key(我使用了 Browserify 和其插件 brfs 在构建时进行以下工作)。
const fs = require("fs");
const MAPBOX_KEY = fs.readFileSync("mapbox.key").toString();
然后,我们定义缩放比例和经纬度坐标。我选了一个在 Colorado 河上的点,它的缩放指数是 10,坐标是(36.133487, -112.239884),如下代码所示。
async function main() {
const zoom = 10;
const [lat, long] = [36.133487, -112.239884];
下一步,将它们转化为瓦片坐标(tile coordinates)。我们稍后介绍 lat2tile 和 long2tile 函数,目前需要注意的是这些函数返回的 x&y 坐标是浮点数而不是整数,但是与瓦片服务器交互的话,是需要整数的,我们需要做一个向下舍入操作,以便获得浮点坐标所在的瓦片。
const tLat = Math.floor(demo.lat2tile(lat, zoom));
const tLong = Math.floor(demo.long2tile(long, zoom));
下面我们加载图片。
const image = await demo.loadImage(
`https://api.mapbox.com/v4/mapbox.satellite/${zoom}/${tLong}/${tLat}.pngraw?access_token=${MAPBOX_KEY}`
);
并最终在页面中显示出来。
document.body.appendChild(image);
}
main();
实用功能
现在,一起看一下 demo 中的实用功能。
1. 将经纬度转化为 tile 坐标的函数 - long2tile 和 lat2tile
下面的代码展示了函数详情,我们可以在 OpenStreetMap Wiki 上找到对它们的详细剖析。
function long2tile(l, zoom) {
return ((l + 180) / 360) * Math.pow(2, zoom);
}
function lat2tile(l, zoom) {
return (
((1 -
Math.log(
Math.tan((l * Math.PI) / 180) + 1 / Math.cos((l * Math.PI) / 180)
) /
Math.PI) /
2) *
Math.pow(2, zoom)
);
}
2. 加载图片的功能 - loadImage
这个函数是 Image 对象的一个 Wrapper,它返回 Promise。
function loadImage(url) {
console.log(`Downloading ${url}...`);
return new Promise((accept, error) => {
const img = new Image();
img.onload = () => {
accept(img);
};
img.onerror = error;
img.crossOrigin = "anonymous";
img.src = url;
});
}
最终的结果是这样的。
从 Mapbox 服务加载的卫星图像瓦片
🌈Hillshading
Hillshading 只是直接照明,没有阴影。我们只需要两条直接照明信息:每个像素的法线和光源的方向。一些瓦片服务提供表面法线贴图,但有些不提供,所以我们将继续假设我们只有高程信息。
高程(Elevation)
我们从 Mapbox terrain-rgb 瓦片收集高程信息。这些只是 PNG,用于编码红色、绿色和蓝色通道中的高程。我们将解码高程,并根据我们选择的因子对其进行缩放。
首先,需要用 regl 库与 WebGL 交互。
const REGL = require("regl");
和以前一样,我们将从 Mapbox 中获取瓦片,但这次会用 mapbox.terrain - rgb 的端点。(注意:从这里开始,我们将排除代码的重复部分,查看源以恢复完整图片)
const image = await demo.loadImage(
`https://api.mapbox.com/v4/mapbox.terrain-rgb/${zoom}/${tLong}/${tLat}.pngraw?access_token=${MAPBOX_KEY}`
);
处理之后,照片是这样的。
从 Mapbox 服务器加载了一个 terrain-rgb 瓦片
让我们创建一个画布(canvas),根据瓦片的尺寸调节大小,并附到页面上。
const canvas = document.createElement("canvas");
canvas.width = image.width;
canvas.height = image.height;
document.body.appendChild(canvas);
接下来创建一个 regl 内容。
const regl = REGL({ canvas: canvas });
然后我们使用刚刚下载的 terrain-rgb 瓦片图像创建一个 regl 纹理图像,需要垂直翻转一下,以便符合 WebGL 的规则。
const tElevation = regl.texture({
data: image,
flipY: true
});
接下来,我们将创建用于处理高程的 regl 命令。这里有几点需要注意:
首先,渲染一个完全填充视口的四边形(参考 attributes.position 部分)
其次,添加 uniform 用来缩放高程
在这里,我们设置 elevationScale 为(0.0005)方便展示,可以显示出合理的对比度,您可以根据实际情况进行修改。
const cmdProcessElevation = regl({
vert: `
precision highp float;
attribute vec2 position;
void main() {
gl_Position = vec4(position, 0, 1);
}
`,
frag: `
precision highp float;
uniform sampler2D tElevation;
uniform vec2 resolution;
uniform float elevationScale;
void main() {
// Sample the terrain-rgb tile at the current fragment location.
vec3 rgb = texture2D(tElevation, gl_FragCoord.xy/resolution).rgb;
// Convert the red, green, and blue channels into an elevation.
float e = -10000.0 + ((rgb.r * 255.0 * 256.0 * 256.0 + rgb.g * 255.0 * 256.0 + rgb.b * 255.0) * 0.1);
// Scale the elevation and write it out.
gl_FragColor = vec4(vec3(e * elevationScale), 1.0);
}
`,
attributes: {
position: [-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1]
},
uniforms: {
tElevation: tElevation,
elevationScale: 0.0005,
resolution: [image.width, image.height]
},
viewport: { x: 0, y: 0, width: image.width, height: image.height },
count: 6
});
最后,调用刚刚编写的 regl 命令。
cmdProcessElevation();
最终结果呈现如下。
terrain-rgb 解码为高程,较暗的像素表示高程比较低
法线
现在我们有了高程数据,准备计算法线贴图。
首先,我们需要对存储高程数据的方式进行一些小的改动。我们想要将高程存储在帧缓冲区中,并且希望该帧缓冲区支持浮点纹理支持。为了在 WebGL 1.0 中使用浮点纹理,我们需要使用 OES_texture_float 扩展。以下是在 regl 中完成的操作:
// Create the regl context.
const regl = REGL({ canvas: canvas, extensions: ["OES_texture_float"] });
一旦我们有了扩展,就可以创建一个浮点 regl 帧缓冲区来存储高程数据:
const fboElevation = regl.framebuffer({
width: image.width,
height: image.height,
colorType: "float"
});
在 cmdProcessElevation regl 命令中,我们把高程比例设置为 1.0。
elevationScale: 1.0,
并将目标帧缓冲区设置为我们刚刚制作的帧缓冲区:
framebuffer: fboElevation,
目前的单位有点混乱。 x 坐标和 y 坐标仍表示像素,但高程以米为单位存储。我们需要一些可以从像素到米的转换。这不是我们可以为整个瓦片解决的问题 - 由于墨卡托投影使地图变平,因此像素之间的比例会有所不同。在这里,我们将通过计算瓦片的纵向宽度,将其转换为弧长,然后将该弧长除以瓦片中像素的数量来近似拼接瓦片中的所有像素:
long0 = demo.tile2long(tLong, zoom);
long1 = demo.tile2long(tLong + 1, zoom);
const pixelScale =
(6371000 * (long1 - long0) * 2 * Math.PI) / 360 / image.width;
接下来,我们将创建一个 regl 命令,用于计算每个像素的法线。有很多方法可以做到这一点,但这就是我们要做的事情:
获取当前像素的高程,北边一个像素的高程,以及东边一个像素的高程。
构造两个向量 - 一个从当前像素指向北边的像素,一个从当前像素指向东边的像素。
这些是包含我们刚刚计算的 pixelScale 和高程的 3D 矢量。一旦我们有了这两个向量,我们就可以得到它们的叉积并对其进行标准化,以便为当前像素创建法向量。该法向量的分量存在于 [-1, 1] 的范围内。要显示它们,我们将它们转换为 [0, 1] 范围。
请注意,地图正 x 和 y 边缘的法线定义不明确 - 因为没有关于北部和东部像素的数据!我们稍后会解决这个问题,但是现在如果你试图像这样渲染一个平铺的地图,你可能会在瓦片边缘的看到一些如同人工制作的粗糙部分。
const cmdNormal = regl({
vert: `
precision highp float;
attribute vec2 position;
void main() {
gl_Position = vec4(position, 0, 1);
}
`,
frag: `
precision highp float;
uniform sampler2D tElevation;
uniform vec2 resolution;
uniform float pixelScale;
void main() {
vec2 dr = 1.0/resolution;
float p0 = texture2D(tElevation, dr * (gl_FragCoord.xy + vec2(0.0, 0.0))).r;
float px = texture2D(tElevation, dr * (gl_FragCoord.xy + vec2(1.0, 0.0))).r;
float py = texture2D(tElevation, dr * (gl_FragCoord.xy + vec2(0.0, 1.0))).r;
vec3 dx = vec3(pixelScale, 0.0, px - p0);
vec3 dy = vec3(0.0, pixelScale, py - p0);
vec3 n = normalize(cross(dx, dy));
gl_FragColor = vec4(0.5 * n + 0.5, 1.0);
}
`,
attributes: {
position: [-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1]
},
uniforms: {
tElevation: fboElevation,
pixelScale: pixelScale,
resolution: [image.width, image.height]
},
viewport: { x: 0, y: 0, width: image.width, height: image.height },
count: 6
});
最后,调用刚刚新的 regl 命令。
cmdNormal();
最终的法向地图效果是这样的。
从高程数据生成的法线地图
这里是我们之前调用的 tile2long 功能。
function tile2long(x, z) {
return (x / Math.pow(2, z)) * 360 - 180;
}
直接光照
现在地形有了法线地图,我们可以很容易地计算出没有阴影和 hillshading 的直接照明情况,首先,一些细节。
我们稍后要为太阳定义方向,希望将该方向标准化,可以用 gl-matrix 库的 vec3 模块来使进行标准化。
const { vec3 } = require("gl-matrix");
和前面一样,我们希望将我们的法线存储在浮点帧缓冲区中。让我们制作帧缓冲:
const fboNormal = regl.framebuffer({
width: image.width,
height: image.height,
colorType: "float"
});
当我们之前显示法线时,我们将它们转换为范围[0..1]。我们现在只需在 cmdNormals regl命令中写出未转换的法线:
gl_FragColor = vec4(n, 1.0);
我们将它们写入我们刚创建的帧缓冲区:
framebuffer: fboNormal,
我们将太阳方向定义为标准化(1, 1, 1)向量,这将使它正好位于我们的地形的北部,东部和上方。我们将获取每个像素的法线,并采用标准化太阳方向的点积。结果将是在[-1,1]范围内变化的光强度。
小于零的值只是黑色,所以我们丢弃了关于表面的一半信息。相反,我们将光强度转换为范围[0,1]以获得良好的阴影效果。这里你可以根据自己的喜好随意设置,达到你想要的任何照明。
以下是我们新的 regl 命令的所有内容:
const cmdDirect = regl({
vert: `
precision highp float;
attribute vec2 position;
void main() {
gl_Position = vec4(position, 0, 1);
}
`,
frag: `
precision highp float;
uniform sampler2D tNormal;
uniform vec2 resolution;
uniform vec3 sunDirection;
void main() {
vec2 dr = 1.0/resolution;
vec3 n = texture2D(tNormal, gl_FragCoord.xy/resolution).rgb;
float l = dot(n, sunDirection);
l = 0.5 * l + 0.5;
gl_FragColor = vec4(l, l, l, 1.0);
}
`,
attributes: {
position: [-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1]
},
uniforms: {
tNormal: fboNormal,
resolution: [image.width, image.height],
sunDirection: vec3.normalize([], [1, 1, 1])
},
viewport: { x: 0, y: 0, width: image.width, height: image.height },
count: 6
});
最后,调用我们新的命令:
cmdDirect();
最终的结果是非常好看的 hillshaded 地形效果:
没有阴影的直接光照
未完待续……
我们下周会为大家带来《用 Mapbox 进行高级地图着色(下)- 柔和阴影与环境光》,你可以了解到更高级,更逼真的着色方法,如下图所示。
添加柔和阴影
添加环境光
添加柔和阴影和环境光
添加卫星图
最后你可以像作者一样,做出相当好看的着色效果了,比如下面这样。
关注公众号,不仅仅可以及时获得(下)的教程,还可以回复“渲染图”提前下载根据本教程做出来的美国 50 个州的高清渲染图片哦。