地图的渲染其实能够分解为线、面、纹理、文字的渲染。为了了解地图渲染的实现原理并实际练习WebGL,进行了这个系列的练习,线是第一步。web
本文不赘述WebGL的基本知识,只对运用到的知识点进行一下简单的回顾:canvas
WebGL须要两种着色器:顶点着色器和片元着色器,以OpenGL ES着色器语言进行编写,本文中使用的着色器以下:数组
var VSHADER_SOURCE =
'attribute vec4 a_Position;\n' + // 顶点坐标
'uniform mat4 u_MvpMatrix;\n' + // 模型视图投影矩阵
'void main() {\n' +
' gl_Position = u_MvpMatrix * a_Position;\n' +
'}\n';
var FSHADER_SOURCE =
'precision mediump float;\n' +
'uniform vec4 u_Color;\n' +
'void main() {\n' +
' gl_FragColor = u_Color;\n' + // 颜色
'}\n';
复制代码
考虑到绘制一条线使用同一种颜色,与顶点无关,因此在片元着色器中定义了一个uniform变量u_Color。bash
WebGL绘制模型的基本单位是三角形,绘制一条有宽度的线并不能像Canvas2D那样设置strokeStyle以后调用stroke()便可,而是须要将整条线拆分红多个小三角形,这个过程称为三角剖分。 ide
WebGL的drawArrays
方法支持多种模式进行多个三角形的绘制,以下所示: 函数
三角剖分的计算过程当中使用到了矢量和矩阵的一些基本运算,涉及到了矢量的加减法、乘法、单位化、旋转等,这些读者应自行了解和掌握。本文封装了二维矢量的相关计算方法到Vector2
类中。webgl
/**
* Constructor of Vector2
* If opt_src is specified, new vector is initialized by opt_src.
* @param opt_src source vector(option)
*/
function Vector2(opt_src) {
var v = new Float32Array(2);
if (opt_src && typeof opt_src === 'object') {
v[0] = opt_src[0]; v[1] = opt_src[1];
}
this.elements = v;
}
/**
* Vector2.prototype.normalize 单位化
* Vector2.prototype.scalarProduct 与标量相乘
* Vector2.prototype.dotProduct 与矢量点乘
* Vector2.prototype.add 与矢量相加
* Vector2.prototype.minus 与矢量相减
* Vector2.prototype.rotate 旋转角度
* Vector2.prototype.copy 复制
* Vector2.prototype.getVertical 获取单位法向量
* /
复制代码
线这里专指折线,使用线段将一组离散的坐标点依次链接而造成。因为地图是呈如今z=0平面上,本文也只探讨在同一平面上延伸的线(扁平的),因此线的坐标点不用关心z坐标,使用二维矢量(x, y)便可。后文以coords
表示线的坐标数组。ui
除了coords
,线的样式也是其重要的属性。以下例所示,线可设置宽度、颜色,同时可设置边线的宽度和颜色;端头以canvas为标准,可支持三种样式:butt-平头,square-方头,round-圆头;拐角以canvas为标准,支持三种样式:bevel-平角,miter-尖角,round-圆角。this
defaultLineStyle = {
strokeColor: new WebglColor(0.5, 0.5, 1, 1), // 边线颜色
strokeWidth: 5, // 边线宽度
fillColor: new WebglColor(0.9, 0.9, 1, 1), // 线颜色
fillWidth: 20, // 线宽度
lineCap: 'butt', // 端头样式
lineJoin: 'bevel' // 拐角样式
}
复制代码
为了以后的一系列练习,本文封装了一个Shape
类用于WebGL绘制基本图形,抽象出了一个构造的接口和通用的方法、属性以下:spa
new Shape(opts)
,参数说明以下字段名 | 类型 | 说明 |
---|---|---|
type | String | 图形类型:polyline , polygon , circle |
glCtx | WebGLRenderingContext | WebGL绘图上下文 |
camera | Matrix4 | 视图投影矩阵 |
coords | Array. | 坐标 |
style | Object | 样式(不一样图形类型支持的样式字段不一样) |
方法 | 返回值 | 说明 |
---|---|---|
setCamera(camera: Matrix4) | None | 设置视图投影矩阵 |
setCoords(coords: Array.) | None | 设置坐标 |
setStyle(style: Object) | None | 设置样式 |
另外还封装了WebglColor
、Matrix4
、Vector2
,最终使用示例以下:
/**
* 建立Camera矩阵
* @param {Number} width 画布宽度
* @param {Number} height 画布高度
* @param {Number} pitch 视线俯仰角
*/
function createCamera(width, height, pitch) {
var camera = new Matrix4();
var fov = 60;
var distance = height / 2 / Math.tan(fov / 2 / 180 * Math.PI);
var near = 1;
var far = 1.5 * distance;
var aspect = width / height;
camera.setPerspective(fov, aspect, near, far);
camera.lookAt(0, 0, distance, 0, 0, 0, 0, 1, 0);
camera.rotate(pitch, 1, 0, 0);
return camera;
}
var canvas = document.getElementById('webgl');
var gl = canvas.getContext('webgl');
var camera = createCamera(canvas.clientWidth, canvas.clientHeight, -30); // 构建视图投影矩阵
var polyline = new Shape({
type: 'polyline',
glCtx: gl,
camera: camera,
coords: [100,100,-100,100,-100,0,100,0,100,-100,-100,-100],
style: {
strokeColor: new WebglColor(0.5, 0.5, 1, 1),
strokeWidth: 5,
fillColor: new WebglColor(0.9, 0.9, 1, 1),
fillWidth: 20
}
});
// 构造完成或重置属性以后会自动绘制图形
复制代码
咱们先了解一下绘制的总体流程,而后依次详解每一个步骤。
function drawSolidLine(gl, camera, coords, style) {
var mvpMatrix = camera;
var color = style.color;
// 三角剖分
var triangulation = getLineTriangulation(coords, style);
// 建立并初始化着色器,获取变量存储位置
var locations = initUColorShader(gl);
if (!locations) {
return;
}
// 建立缓冲区并传入数据
var vertices = triangulation.vertices;
if (!initVertexBuffers(gl, vertices)) {
return;
}
// 变量赋值
gl.uniformMatrix4fv(locations.u_MvpMatrix, false, mvpMatrix.elements);
gl.uniform4f(locations.u_Color, color.r, color.g, color.b, color.a);
gl.vertexAttribPointer(locations.a_Position, 3, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(location.a_Position);
// 执行绘制任务
var tasks = triangulation.tasks;
tasks.forEach(function(task) {
gl.drawArrays(gl[task.mode], task.start, task.cnt);
});
}
复制代码
如代码所示:
triangulation = {
vertices: [x0, y0, z, x1, y1, z, ...]
tasks: [task0, task1, ...]
}
复制代码
initUColorShader
建立一个单一颜色的着色器,而后建立、使用程序,获取并返回着色器中每一个变量的存储位置。locations = {
a_Position: ..,
u_MvpMatrix: ..,
u_Color: ..
}
复制代码
triangulation.vertices
写入缓冲区locations
写入数据triangulation.tasks
,按指定的模式、索引范围进行绘制下文详细讲解每一个步骤的具体实现。
线的剖分能够分解为三个部分,一是线段,二是端头,三是拐角。
转换coords
为二维点,并计算每一个线段的单位法向量。由于须要在路径上进行垂直扩宽,且宽度与线段长度无关,因此法向量取单位长度便可。
// 将坐标转换为点、线段矢量、线段单位法向量
var path = [],
segments = [],
verticalVectors = [],
pathLength = 0;
for (let index = 0; index < coords.length; index += 2) {
let x = coords[index];
let y = coords[index + 1];
let pathPoint = new Point2([x, y]);
path.push(pathPoint);
if (pathLength) {
// 相邻两点相减获得线段矢量
let prePoint = path[pathLength - 1];
let segment = pathPoint.minus(prePoint);
segments.push(segment);
verticalVectors.push(segment.getVertical());
}
pathLength++;
}
复制代码
线段剖分比较简单,在路径点坐标上加扩宽的法向量便可,需注意链接两个线段的路径点须要根据两条线段的法向量,拓展出4个顶点。
path.forEach((pathPoint, index) => {
// basePoints为扩宽后的顶点坐标
var width = style.width / 2;
var v0 = index == 0 ? null : verticalVectors[index-1].copy().scalarProduct(width);
var v1 = index == pathLength - 1 ? null : verticalVectors[index].copy().scalarProduct(width);
if (v0) {
basePoints.push(pathPoint.add(v0));
basePoints.push(pathPoint.minus(v0));
}
if (v1) {
basePoints.push(pathPoint.add(v1));
basePoints.push(pathPoint.minus(v1));
}
});
复制代码
端头只须要在首尾路径点上进行扩展。端头支持三种样式:butt
不须要增长坐标点,square
须要扩展出半个正方形,边长为线宽,round
须要扩展出半个圆形,直径为线宽。 square
端头剖分须要找到正方形的顶点,只需将线段法向量旋转90度,便可获得偏移向量offsetVector
,示意图以下:
round
端头剖分须要在圆形弧线上找到等距且密集的点,只需将线段法向量以小角度旋转n次直到2*PI,便可获得弧线上的顶点,最终将圆心与顶点以TRIANGLE_FAN的方式绘制便可实现圆形,示意图以下:
function getLineCapTrigl(pathPoint, verticalVector, style, isHead) {
var subPoints = [];
var mode = "TRIANGLE_STRIP";
var width = style.width / 2;
var v = verticalVector.copy().scalarProduct(width);
switch (style.lineCap) {
case 'butt':
break;
case 'square':
var offsetVector = v.getVertical().scalarProduct(width);
if (isHead) {
subPoints.push(pathPoint.add(v).add(offsetVector));
subPoints.push(pathPoint.minus(v).add(offsetVector));
} else {
subPoints.push(pathPoint.add(v).minus(offsetVector));
subPoints.push(pathPoint.minus(v).minus(offsetVector));
}
subPoints.push(pathPoint.add(v));
subPoints.push(pathPoint.minus(v));
break;
case 'round':
subPoints.push(pathPoint);
var rotateVector;
for (let angle = 0; angle < 2.1 * Math.PI; angle += Math.PI/16) {
rotateVector = v.rotate(angle);
subPoints.push(pathPoint.add(rotateVector));
}
mode = "TRIANGLE_FAN";
break;
default:
console.error('Invalid lineCap:' + style.lineCap);
}
return {
points: subPoints,
mode: mode
};
}
复制代码
拐角是在除去首尾两端的路经点上进行扩展。支持三种样式:bevel
不须要增长坐标点(线段剖分后链接处天然造成了平角),miter
须要填补线段延长线交汇出的尖角,round
须要填补扇形,直径为线宽。 miter
的剖分相对来讲比较复杂一点,以下图所示,并不是是一个菱形,而是两个以线段法向量为直角边的直角三角形拼接而成,计算公式以下:
function getLineJoinTrigl(pathPoint, v0, v1, style) {
var subPoints = [];
var mode = "TRIANGLE_STRIP";
var width = style.width / 2;
var v0_scale = v0.copy().scalarProduct(width);
var v1_scale = v1.copy().scalarProduct(width);
switch (style.lineJoin) {
case 'miter':
var length = width / Math.sqrt((v0.dotProduct(v1) + 1) / 2);
var joinVector = v0.add(v1).normalize().scalarProduct(length);
subPoints.push(pathPoint);
subPoints.push(pathPoint.add(v0_scale));
subPoints.push(pathPoint.add(joinVector));
subPoints.push(pathPoint.add(v1_scale));
subPoints.push(pathPoint.minus(v0_scale));
subPoints.push(pathPoint.minus(joinVector));
subPoints.push(pathPoint.minus(v1_scale));
mode = "TRIANGLE_FAN";
break;
case 'bevel':
break;
case 'round':
subPoints.push(pathPoint);
var rotateVector;
for (let angle = 0; angle < 2.1 * Math.PI; angle += Math.PI/16) {
rotateVector = v0_scale.rotate(angle);
subPoints.push(pathPoint.add(rotateVector));
}
mode = "TRIANGLE_FAN";
break;
default:
console.error('Invalid lineJoin:' + style.lineJoin);
}
return {
points: subPoints,
mode: mode
};
}
复制代码
initUColorShader
负责创建和初始化着色器,主要分为三个步骤,一是经过UColorShader()
获取单一颜色着色器代码;二是建立并使用程序;三是获取变量位置。
/**
* 建立并初始化着色器
* @param {WebGLRenderingContext} gl
*/
function initUColorShader(gl) {
// 获取着色器代码
var shaders = UColorShader();
// 建立并使用程序
if (!initShaders(gl, shaders.vshader, shaders.fshader)) {
console.error('Failed to intialize shaders.');
return null;
}
// 获取变量位置
return getLocations();
}
复制代码
如前文所述,UColorShader
用以生成单一颜色着色器,代码以下:
/**
* UColorShader: 单颜色着色器
* 单一颜色u_Color,支持矩阵变换u_MvpMatrix, 顶点坐标a_Position
*/
function UColorShader() {
var VSHADER_SOURCE =
'attribute vec4 a_Position;\n' +
'uniform mat4 u_MvpMatrix;\n' +
'void main() {\n' +
' gl_Position = u_MvpMatrix * a_Position;\n' +
'}\n';
var FSHADER_SOURCE =
'precision mediump float;\n' +
'uniform vec4 u_Color;\n' +
'void main() {\n' +
' gl_FragColor = u_Color;\n' +
'}\n';
return {
vshader: VSHADER_SOURCE,
fshader: FSHADER_SOURCE
};
}
复制代码
initShaders
这部分是WebGL绘制流程中通用的步骤,不进行过多的解释,主要有如下7个步骤。
gl.createShader(type)
gl.shaderSource(shader, source)
gl.compileShader(shader)
gl.createProgram()
gl.attachShader(program, shader)
// 注:顶点着色器、片元着色器须要分别分配gl.linkProgram(program)
// 注:将顶点着色器与片元着色器链接gl.useProgram(program)
至此,咱们建立好了一个具备三个属性变量的着色程序,以后咱们须要为这三个变量赋值,因此须要获取到这三个变量的存储位置。a_Position
和u_MvpMatrix
、u_Color
的变量声明不一样,获取存储位置的方法也相应的不一样:
function getLocations() {
var a_Position = gl.getAttribLocation(gl.program, 'a_Position');
var u_MvpMatrix = gl.getUniformLocation(gl.program, 'u_MvpMatrix');
var u_Color = gl.getUniformLocation(gl.program, 'u_Color');
return {
a_Position: a_Position,
u_MvpMatrix: u_MvpMatrix,
u_Color: u_Color
};
}
复制代码
由于须要一次性将所有顶点传入顶点着色器,因此须要initVertexBuffers
负责建立数据缓冲区并写入数据。
/**
* 建立缓冲区并传入数据
* @param {WebGLRenderingContext} gl
* @param {Float32Array} vertices
*/
function initVertexBuffers(gl, vertices) {
// 建立缓冲区
var vertexBuffer = gl.createBuffer();
if (!vertexBuffer) {
console.error('Failed to create the buffer object');
return false;
}
// 绑定缓冲区对象:指明其用途
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
// 写入数据
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
return true;
}
复制代码
u_MvpMatrix
和u_Color
变量可直接调用对应类型的方法进行一次传值,好比:
gl.uniformMatrix4fv(locations.u_MvpMatrix, false, mvpMatrix.elements);
复制代码
WebGLRenderingContext.uniformMatrix[234]fv(location, transpose, value)
用于给矩阵类型的变量赋值,二、三、4表示矩阵的维度。
a_Position
变量赋值须要从缓冲区中读取数据,须要调用vertexAttribPointer
方法将缓冲区对象分配给变量a_Position
,并开启访问权:
gl.vertexAttribPointer(locations.a_Position, 3, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(location.a_Position);
复制代码
其中3
表示每一个顶点的份量数,a_Position
是一个vec4
变量,这里读取三个份量的数据赋值给x、y、z,第4位会自动补1。gl.FLOAT
表示数据格式为浮点型。false
标明无需将数据归一化。最后两个0
表示顶点数据间无间隔,数据无偏移。
三角剖分步骤中生成了绘制任务tasks = [{mode, start, cnt}, ...]
,每一个任务指定了模式(TRIANGLE_STRIP
/TRIANGLE_FAN
/TRIANGLES
)、起始点索引值、绘制点数量,因此遍历绘制任务并调用drawArrays
进行绘制便可:
tasks.forEach(function(task) {
gl.drawArrays(gl[task.mode], task.start, task.cnt);
});
复制代码
至此,绘制线的流程就结束了。
利用上文中构造的Shape
类,最终实现了以下的demo,绘制了一条S折线,而且能够动态改变其颜色、宽度、端头、拐角样式,同时经过键盘方向键控制Camera
,动态改变视图投影矩阵。