You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

340 lines
16 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 26 | 如何绘制带宽度的曲线?
你好,我是月影。
在可视化应用中我们经常需要绘制一些带有特定宽度的曲线。比如说在地理信息可视化中我们会使用曲线来描绘路径而在3D地球可视化中我们会使用曲线来描述飞线、轮廓线等等。
在Canvas2D中要绘制带宽度的曲线非常简单我们直接设置上下文对象的lineWidth属性就行了。但在WebGL中绘制带宽度的曲线是一个难点很多开发者都在这一步被难住过。
那今天我就来说说怎么用Canvas2D和WebGL分绘制曲线。我要特别强调一下我们讲的曲线指广义的曲线线段、折线和平滑曲线都包含在内。
## 如何用Canvas2D绘制带宽度的曲线
刚才我说了用Canvas2D绘制曲线非常简单。这是为什么呢因为Canvas2D提供了相应的API能够绘制出不同宽度、具有特定**连线方式**和**线帽形状**的曲线。
这句话怎么理解呢我们从两个关键词“连线方式lineJoin”和“线帽形状lineCap”入手理解。
我们知道曲线是由线段连接而成的两个线段中间转折的部分就是lineJoin。如果线宽只有一个像素那么连接处没有什么不同的形式就是直接连接。但如果线宽超过一个像素那么连接处的缺口就会有不同的填充方式而这些不同的填充方式就对应了不同的lineJoin。
比如说你可以看我给出的这张图上面就显示了四种不同的lineJoin。其中miter是尖角round是圆角bevel是斜角none是不添加lineJoin。很好理解我就不多说了
[![](https://static001.geekbang.org/resource/image/45/9c/458c1d4c49519c2ac897fe89397a0b9c.jpeg "4种不同的lineJoin")](https://mapserver.org/mapfile/symbology/construction.html)
说完了lineJoin那什么是lineCap呢lineCap就是指曲线头尾部的形状它有三种类型。第一种是square方形线帽它会在线段的头尾端延长线宽的一半。第二种round也叫圆弧线帽它会在头尾端延长一个半圆。第三种是butt就是不添加线帽。
[![](https://static001.geekbang.org/resource/image/60/c8/60cfd2020c014d88b7ac4b3b69e8e7c8.jpeg "3种不同的lineCap")](http://falcon80.com/HTMLCanvas/Attributes/lineCap.html)
理解了这两个关键词之后我们接着尝试一下怎么在Canvas的上下文中通过设置lineJoin和lineCap属性来实现不同的曲线效果。
首先我们要实现一个drawPolyline函数。这个函数非常简单就是设置lineWidth、lingJoin、lineCap然后根据points数据的内容设置绘图指令执行绘制。
```
function drawPolyline(context, points, {lineWidth = 1, lineJoin = 'miter', lineCap = 'butt'} = {}) {
context.lineWidth = lineWidth;
context.lineJoin = lineJoin;
context.lineCap = lineCap;
context.beginPath();
context.moveTo(...points[0]);
for(let i = 1; i < points.length; i++) {
context.lineTo(...points[i]);
}
context.stroke();
}
```
在设置lingJoin、lineCap时候我们要注意Canvas2D的lineJoin只支持miter、bevel和round不支持none。lineCap支持butt、square和round。
接着我们就可以执行JavaScript代码绘制曲线了。比如我们绘制两条线一条宽度为10个像素的红线另一条宽度为1个像素的蓝线具体的代码
```
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
const points = [
[100, 100],
[100, 200],
[200, 150],
[300, 200],
[300, 100],
];
ctx.strokeStyle = 'red';
drawPolyline(ctx, points, {lineWidth: 10});
ctx.strokeStyle = 'blue';
drawPolyline(ctx, points);
```
因为我们把连接设置成miter、线帽设置成了butt所以我们绘制出来的曲线是尖角并且不带线帽的。
![](https://static001.geekbang.org/resource/image/d9/eb/d9ed91c48f6a44bcaa26dbe2yya4d5eb.jpeg)
其实我们还可以修改lineJoins和lineCap参数。比如我们将线帽设为圆的连接设为斜角。除此之外你还可以尝试不同的组合我就不再举例了。
```
ctx.strokeStyle = 'red';
drawPolyline(ctx, points, {lineWidth: 10, lineCap: 'round', lineJoin: 'bevel'});
```
![](https://static001.geekbang.org/resource/image/fe/0e/fe56yy2032acef591b6051328331d10e.jpeg)
除了lineJoin和lineCap外我们还可以设置Canvas2D上下文的miterLimit属性来改变lineJoin等于miter时的连线形式miterLimit属性等于miter和线宽的最大比值。当我们把lineJoin设置成miter的时候miterLimit属性就会限制尖角的最大值。
那具体会产生什么效果呢我们可以先修改drawPolyline代码添加miterLimit。代码如下
```
function drawPolyline(context, points, {lineWidth = 1, lineJoin = 'miter', lineCap = 'butt', miterLimit = 10} = {}) {
context.lineWidth = lineWidth;
context.lineJoin = lineJoin;
context.lineCap = lineCap;
context.miterLimit = miterLimit;
context.beginPath();
context.moveTo(...points[0]);
for(let i = 1; i < points.length; i++) {
context.lineTo(...points[i]);
}
context.stroke();
}
```
然后我们修改参数把miterLimit:设置为1.5
```
ctx.strokeStyle = 'red';
drawPolyline(ctx, points, {lineWidth: 10, lineCap: 'round', lineJoin: 'miter', miterLimit: 1.5});
```
![](https://static001.geekbang.org/resource/image/fe/0e/fe56yy2032acef591b6051328331d10e.jpeg)
你会发现这样渲染出来的图形它两侧的转角由于超过了miterLimit限制所以表现为斜角而中间的转角因为没有超过miterLimit限制所以是尖角。
总的来说Canvas2D绘制曲线的方法很简单只要我们调用对应的API就可以了。但用WebGL来绘制同样的曲线会非常麻烦。在详细讲解之前我希望你先记住lineJoin、lineCap以及miterLimit这些属性在WebGL中我们需要自己去实现它们。接下来我们一起来看一下WebGL中是怎么做的。
## 如何用WebGL绘制带宽度的曲线
我们先从绘制宽度为1的曲线开始。因为WebGL本身就支持线段类的图元所以我们直接用图元就能绘制出宽度为1的曲线。
下面我结合代码来说说具体的绘制过程。与Canvas2D类似我们直接设置position顶点坐标然后设置mode为gl.LINE\_STRIP。这里的LINE\_STRIP是一种图元类型表示以首尾连接的线段方式绘制。这样我们就可以得到宽度为1的折线了。具体的代码和效果如下所示
```
import {Renderer, Program, Geometry, Transform, Mesh} from '../common/lib/ogl/index.mjs';
const vertex = `
attribute vec2 position;
void main() {
gl_PointSize = 10.0;
float scale = 1.0 / 256.0;
mat3 projectionMatrix = mat3(
scale, 0, 0,
0, -scale, 0,
-1, 1, 1
);
vec3 pos = projectionMatrix * vec3(position, 1);
gl_Position = vec4(pos.xy, 0, 1);
}
`;
const fragment = `
precision highp float;
void main() {
gl_FragColor = vec4(1, 0, 0, 1);
}
`;
const canvas = document.querySelector('canvas');
const renderer = new Renderer({
canvas,
width: 512,
height: 512,
});
const gl = renderer.gl;
gl.clearColor(1, 1, 1, 1);
const program = new Program(gl, {
vertex,
fragment,
});
const geometry = new Geometry(gl, {
position: {size: 2,
data: new Float32Array(
[
100, 100,
100, 200,
200, 150,
300, 200,
300, 100,
],
)},
});
const scene = new Transform();
const polyline = new Mesh(gl, {geometry, program, mode: gl.LINE_STRIP});
polyline.setParent(scene);
renderer.render({scene});
```
![](https://static001.geekbang.org/resource/image/ef/b3/ef7cdd7a0df26a237592d229bec226b3.jpeg)
你可能会问我们不能直接修改gl\_PointSize来给折线设置宽度吗很遗憾这是不行的。因为gl\_PointSize只能影响gl.POINTS图元的显示并不能对线段图元产生影响。
那我们该怎么让线的宽度大于1个像素呢
## 通过挤压(extrude)曲线绘制有宽度的曲线
我们可以用一种挤压Extrude曲线的技术通过将曲线的顶点沿法线方向向两侧移出让1个像素的曲线变宽。
那挤压曲线要怎么做呢?我们先看一张示意图:
![](https://static001.geekbang.org/resource/image/39/67/396bc27a64ec8cb608734b63cf44ee67.jpeg "挤压线段")
如上图所示黑色折线是原始的1个像素宽度的折线蓝色虚线组成的是我们最终要生成的带宽度曲线红色虚线是顶点移动的方向。因为折线两个端点的挤压只和一条线段的方向有关而转角处顶点的挤压和相邻两条线段的方向都有关所以顶点移动的方向我们要分两种情况讨论。
首先是折线的端点。假设线段的向量为x, y因为它移动方向和线段方向垂直所以我们只要沿法线方向移动它就可以了。根据垂直向量的点积为0我们很容易得出顶点的两个移动方向为-y, xy, -x。如下图所示
![](https://static001.geekbang.org/resource/image/48/12/482af6fd993dbf91f3635bddffdcd412.jpeg "折线端点挤压方向")
端点挤压方向确定了,接下来要确定转角的挤压方向了,我们还是看示意图。
![](https://static001.geekbang.org/resource/image/fa/e3/fa6b04dabfba6aaca02a66dde1d743e3.jpeg "转角的挤压方向示意图")
如上图我们假设有折线abcb是转角。我们延长ab就能得到一个单位向量v1反向延长bc可以得到另一个单位向量v2那么挤压方向就是向量v1+v2的方向以及相反的-(v1+v2)的方向。
现在我们得到了挤压方向,接下来就需要确定挤压向量的长度。
首先是折线端点的挤压长度它等于lineWidth的一半。而转角的挤压长度就比较复杂了我们需要再计算一下。
![](https://static001.geekbang.org/resource/image/d2/82/d2be1cd10fe7cdd0ab5611c13ab56882.jpeg "计算转角挤压长度示意图")
绿色这条辅助线应该等于lineWidth的一半而它又恰好是v1+v2在绿色这条向量方向的投影所以我们可以先用向量点积求出红色虚线和绿色虚线夹角的余弦值然后用lineWidth的一半除以这个值得到的就是挤压向量的长度了。
具体用JavaScript实现的代码如下所示
```
function extrudePolyline(gl, points, {thickness = 10} = {}) {
const halfThick = 0.5 * thickness;
const innerSide = [];
const outerSide = [];
// 构建挤压顶点
for(let i = 1; i < points.length - 1; i++) {
const v1 = (new Vec2()).sub(points[i], points[i - 1]).normalize();
const v2 = (new Vec2()).sub(points[i], points[i + 1]).normalize();
const v = (new Vec2()).add(v1, v2).normalize(); // 得到挤压方向
const norm = new Vec2(-v1.y, v1.x); // 法线方向
const cos = norm.dot(v);
const len = halfThick / cos;
if(i === 1) { // 起始点
const v0 = new Vec2(...norm).scale(halfThick);
outerSide.push((new Vec2()).add(points[0], v0));
innerSide.push((new Vec2()).sub(points[0], v0));
}
v.scale(len);
outerSide.push((new Vec2()).add(points[i], v));
innerSide.push((new Vec2()).sub(points[i], v));
if(i === points.length - 2) { // 结束点
const norm2 = new Vec2(v2.y, -v2.x);
const v0 = new Vec2(...norm2).scale(halfThick);
outerSide.push((new Vec2()).add(points[points.length - 1], v0));
innerSide.push((new Vec2()).sub(points[points.length - 1], v0));
}
}
...
}
```
在这段代码中v1、v2是线段的延长线v是挤压方向我们计算法线方向与挤压方向的余弦值就能算出挤压长度了。你还要注意我们要把起始点和结束点这两个端点的挤压也给添加进去也就是两个if条件中的处理逻辑。
这样一来我们就把挤压之后的折线顶点坐标给计算出来了。向内和向外挤压的点现在分别保存在innerSide和outerSide数组中。
接下来我们就要构建对应的Geometry对象所以我们继续添加extrudePolyline函数的后半部分。
```
function extrudePolyline(gl, points, {thickness = 10} = {})
...
const count = innerSide.length * 4 - 4;
const position = new Float32Array(count * 2);
const index = new Uint16Array(6 * count / 4);
// 创建 geometry 对象
for(let i = 0; i < innerSide.length - 1; i++) {
const a = innerSide[i],
b = outerSide[i],
c = innerSide[i + 1],
d = outerSide[i + 1];
const offset = i * 4;
index.set([offset, offset + 1, offset + 2, offset + 2, offset + 1, offset + 3], i * 6);
position.set([...a, ...b, ...c, ...d], i * 8);
}
return new Geometry(gl, {
position: {size: 2, data: position},
index: {data: index},
});
}
```
这一步骤就非常简单了我们根据innerSide和outerSide中的顶点来构建三角网格化的几何体顶点数据最终返回Geometry对象。
![](https://static001.geekbang.org/resource/image/1c/01/1c423ae467bce04f49a46a4fed376d01.jpeg "构建折线的顶点数据")
最后我们只要调用extrudePolyline传入折线顶点和宽度然后用返回的Geometry对象来构建三角网格对象将它渲染出来就可以了。
```
const geometry = extrudePolyline(gl, points, {lineWidth: 10});
const scene = new Transform();
const polyline = new Mesh(gl, {geometry, program});
polyline.setParent(scene);
renderer.render({scene});
```
我们最终渲染出来的效果如下图:
![](https://static001.geekbang.org/resource/image/7f/d4/7fb7607de6d1396ba92f53ac18e9acd4.jpeg)
这样我们就在WebGL中实现了与Canvas2D一样带宽度的曲线。
当然这里我们只实现了最基础的带宽度曲线它对应于Canvas2D中的lineJoin为miterlineCap为butt的曲线。不过想要实现lineJoins为bevel或roundlineCap为square或round的曲线也不会太困难。我们可以基于extrudePolyline函数对它进行扩展计算出相应属性下对应的顶点就行了。因为基本原理是一样的我就不详细说了我把扩展的任务留给你作为课后练习。
## 要点总结
这节课,我们讲了绘制带宽度曲线的方法。
首先在Canvas2D中绘制这样的曲线比较简单我们直接通过API设置lineWidth即可。而且Canvas2D还支持不同的lineJoin、lineCap设置以及miterLimit设置。
在WebGL中绘制带宽度的曲线则比较麻烦因为没有现成的API可以使用。这个时候我们可以使用挤压曲线的技术来得到带宽度的曲线挤压曲线的具体步骤可以总结为三步
1. 确定端点和转角的挤压方向,端点可以沿线段的法线挤压,转角则通过两条线段延长线的单位向量求和的方式获得。
2. 确定端点和转角挤压的长度端点两个方向的挤压长度是线宽lineWidth的一半。求转角挤压长度的时候我们要先计算方向向量和线段法线的余弦然后将线宽lineWidth的一半除以我们计算出的余弦值。
3. 由步骤1、2计算出顶点后我们构建三角网格化的几何体顶点数据然后将Geometry对象返回。
这样我们就可以用WebGL绘制出有宽度的曲线了。
## 小试牛刀
1. 你能修改extrudePolyline函数让它支持lineCap为square和round吗或者让它支持lineJoin为round吗
2. 我想让你试着修改一下extrudePolyline函数让它支持lineJoin为bevel以及miterLimit。并且当lineJoin为miter的时候如果转角挤压长度超过了miterLimit我们就按照bevel处理向外的挤压。
那通过今天的学习,你是不是已经学会绘制带宽度曲线的方法。那不妨就把这节课分享给你的朋友,也帮助他解决这个难题吧。好了,今天的内容就到这里了,我们下节课再见
* * *
## 源码
课程完整示例代码详见[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/polyline-curve)