查看原文
其他

开源教程 | 用 Mapbox 进行高级地图着色(上) Hillshading - Mapbox 一分钟

Mapbox 2019-06-01


近期总有很多小伙伴提到倾斜摄影这块的东西,正好最近有一个国外的 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 个州的高清渲染图片哦。


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存