向量和矩阵运算、参数方程、三角剖分和仿射变换等简介及综合运用。
HTML 采用的是窗口坐标系,以参考对象(参考对象通常是最接近图形元素的 position 非 static 的元素)的元素盒子左上角为坐标原点,x 轴向右,y 轴向下,坐标值对应像素值。
SVG 采用的是视区盒子(viewBox)坐标系。这个坐标系在默认情况下,是以 svg 根元素左上角为坐标原点,x 轴向右,y 轴向下,svg 根元素右下角坐标为它的像素宽高值。如果设置了 viewBox 属性,那么 svg 根元素左上角为 viewBox 的前两个值,右下角为 viewBox 的后两个值。
Canvas 采用的坐标系,默认以画布左上角为坐标原点,右下角坐标值为 Canvas 的画布宽高值。
WebGL 的坐标系比较特殊,是一个三维坐标系。它默认以画布正中间为坐标原点,x 轴朝右,y 轴朝上,z 轴朝外,x 轴、y 轴在画布中范围是 -1 到 1。
尽管这四个坐标系在原点位置、坐标轴方向、坐标范围上有所区别,但都是直角坐标系,所以它们都满足直角坐标系的特性:不管原点和轴的方向怎么变,用同样的方法绘制几何图形,它们的形状和相对位置都不变。
使用默认坐标系计算关键点:
利用 Rough.js (opens new window) 绘制:
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.jsdelivr.net/npm/roughjs@3.1.0/dist/rough.min.js"></script>
</head>
<body>
<canvas width="512" height="256"></canvas>
<script>
const rc = rough.canvas(document.querySelector('canvas'));
const hillOpts = {
roughness: 2.8,
strokeWidth: 2,
fill: 'blue',
};
rc.path('M76 256L176 156L276 256', hillOpts);
rc.path('M236 256L336 156L436 256', hillOpts);
rc.circle(256, 106, 105, {
stroke: 'red',
strokeWidth: 4,
fill: 'rgba(255, 255, 0, 0.4)',
fillStyle: 'solid',
});
</script>
</body>
</html>
为了减少坐标计算工作量,对坐标系进行变换。通过 translate 变换将 Canvas 画布的坐标原点,从左上角 (0, 0) 点移动至 (256, 256) 位置,即画布的底边上的中点位置。接着,以移动了原点后新的坐标为参照,通过 scale(1, -1) 将 y 轴向下的部分,即 y>0 的部分沿 x 轴翻转 180 度,坐标系就变成以画布底边中点为原点,x 轴向右,y 轴向上的坐标系了。
使用新的坐标系进行绘制:
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.jsdelivr.net/npm/roughjs@3.1.0/dist/rough.min.js"></script>
</head>
<body>
<canvas width="512" height="256"></canvas>
<script>
const rc = rough.canvas(document.querySelector('canvas'));
const ctx = rc.ctx;
ctx.translate(256, 256);
ctx.scale(1, -1);
const hillOpts = {
roughness: 2.8,
strokeWidth: 2,
fill: 'blue',
};
rc.path('M-180 0L-80 100L20 0', hillOpts);
rc.path('M-20 0L80 100L180 0', hillOpts);
rc.circle(0, 150, 105, {
stroke: 'red',
strokeWidth: 4,
fill: 'rgba(255, 255, 0, 0.4)',
fillStyle: 'solid',
});
</script>
</body>
</html>
在原始坐标下通过计算顶点来绘制图形,计算量会非常大,很麻烦。采用坐标变换的方式是一个很好的优化思路,能够简化计算量,这不仅让代码更容易理解,也可以节省 CPU 运算的时间。
假设,这个平面直角坐标系上有一个向量 v。向量 v 有两个含义:一是可以表示该坐标系下位于 (x, y) 处的一个点;二是可以表示从原点 (0,0) 到坐标 (x,y) 的一根线段。
首先,向量和标量一样可以进行数学运算。举个例子,现在有两个向量,v1和 v2,如果让它们相加,其结果相当于将 v1向量的终点(x1, y1),沿着 v2向量的方向移动一段距离,这段距离等于 v2向量的长度。
其次,一个向量包含有长度和方向信息。它的长度可以用向量的 x、y 的平方和的平方根来表示,它的方向可以用与 x 轴的夹角来表示:
v.length = function() {
return Math.hypot(this.x, this.y);
};
v.dir = function() {
return Math.atan2(this.y, this.x);
};
可以推导出:
v.x = v.length * Math.cos(v.dir);
v.y = v.length * Math.sin(v.dir);
我们可以很简单地构造出一个绘图向量。如果希望以点 (x0, y0) 为起点,沿着某个方向画一段长度为 length 的线段,只需要构造出如下的一个向量就可以了。
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.jsdelivr.net/npm/roughjs@3.1.0/dist/rough.min.js"></script>
</head>
<body>
<canvas width="512" height="512"></canvas>
<script>
class Vector2D extends Array {
constructor (x = 1, y = 0) {
super(x, y);
}
set x (v) {
this[0] = v;
}
get x () {
return this[0];
}
set y (v) {
this[1] = v;
}
get y () {
return this[1];
}
get length () {
return Math.hypot(this.x, this.y);
}
get dir () {
return Math.atan2(this.y, this.x);
}
copy () {
return new Vector2D(this.x, this.y);
}
add (v) {
this.x += v.x;
this.y += v.y;
return this;
}
sub (v) {
this.x -= v.x;
this.y -= v.y;
return this;
}
scale (s) {
this.x *= s;
this.y *= s;
return this;
}
cross (v) {
return this.x * v.y - this.y * v.x;
}
dot (v) {
return this.x * v.x + this.y * v.y;
}
normalize () {
return this.scale(1 / this.length);
}
rotate (rad) {
const c = Math.cos(rad);
const s = Math.sin(rad);
const [x, y] = this;
this.x = x * c - y * s;
this.y = x * s + y * c;
return this;
}
}
function drawBranch (ctx, v0, len, thickness, dir, bias) {
const v = new Vector2D().rotate(dir).scale(len);
const v1 = v0.copy().add(v);
ctx.lineWidth = thickness;
ctx.beginPath();
ctx.moveTo(...v0);
ctx.lineTo(...v1);
ctx.stroke();
if (thickness > 2) {
const left = Math.PI / 4 + 0.5 * (dir + 0.2) + bias * (Math.random() - 0.5);
drawBranch(ctx, v1, len * 0.9, thickness * 0.8, left, bias * 0.9);
const right = Math.PI / 4 + 0.5 * (dir - 0.2) + bias * (Math.random() - 0.5);
drawBranch(ctx, v1, len * 0.9, thickness * 0.8, right, bias * 0.9);
}
if (thickness < 5 && Math.random() < 0.3) {
ctx.save();
ctx.strokeStyle = '#c72c35';
const th = Math.random() * 6 + 3;
ctx.lineWidth = th;
ctx.beginPath();
ctx.moveTo(...v1);
ctx.lineTo(v1.x, v1.y - 2);
ctx.stroke();
ctx.restore();
}
}
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
ctx.translate(0, canvas.height);
ctx.scale(1, -1);
ctx.lineCap = 'round';
const v0 = new Vector2D(256, 0);
drawBranch(ctx, v0, 50, 10, 1, 3);
</script>
</body>
</html>
假设,现在有两个 N 维向量 a 和 b,a = [a1, a2, ...an],b = [b1, b2, ...bn],那么 a 点乘 b 等于 a1b1 + a2b2 + ... + anbn。
在 N 维线性空间中,a、b 向量点积的几何含义,是 a 向量乘以 b 向量在 a 向量上的投影分量。它的物理含义相当于 a 力作用于物体,产生 b 位移所做的功。点积公式如下图所示:
有两个比较特殊的情况。第一种是,当 a、b 两个向量平行时,它们的夹角就是 0°,那么 a·b=|a|*|b|
;第二种是,当 a、b 两个向量垂直时,它们的夹角就是 90°,那么 a·b=0
。
叉乘和点乘有两点不同:首先,向量叉乘运算的结果不是标量,而是一个向量;其次,两个向量的叉积与两个向量组成的坐标平面垂直。
以二维空间为例,向量 a 和 b 的叉积,就相当于向量 a(蓝色带箭头线段)与向量 b 沿垂直方向的投影(红色带箭头线段)的乘积。二维向量叉积的几何意义就是向量 a、b 组成的平行四边形的面积。
假设,现在有两个三维向量 a(x1, y1, z1) 和 b(x2, y2, z2),那么,a 与 b 的叉积可以表示为一个如下图的行列式:
其中 i、j、k 分别是 x、y、z 轴的单位向量。把这个行列式展开,就能得到如下公式:
a X b = [y1 * z2 - y2 * z1, - (x1 * z2 - x2 * z1), x1 * y2 - x2 * y1]
计算这个公式,得到的值还是一个三维向量,它的方向垂直于 a、b 所在平面。在右手系中求向量 a、b 叉积的方向时,可以把右手食指的方向朝向 a,把右手中指的方向朝向 b,那么大拇指所指的方向就是 a、b 叉积的方向,这个方向是垂直纸面向外。因此,右手系中向量叉乘的方向就是右手拇指的方向,那左手系中向量叉乘的方向自然就是左手拇指的方向了。
曲线可以用折线来模拟,用向量绘制折线的方法来绘制正多边形,当多边形的边数非常多的时候,这个图形就会接近圆。
在 regularShape 函数中,给定边数 edges、起点 x, y、一条边的长度 step,就可以绘制一个正多边形了。
function regularShape (edges = 3, x, y, step) {
const ret = [];
const delta = Math.PI * (1 - (edges -2) / edges);
let p = new Vector2D(x, y);
const dir = new Vector2D(step, 0);
ret.push(p);
for (let i = 0; i < edges; i++) {
p = p.copy().add(dir.rotate(delta));
ret.push(p);
}
return ret;
}
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.jsdelivr.net/npm/roughjs@3.1.0/dist/rough.min.js"></script>
</head>
<body>
<canvas width="512" height="512"></canvas>
<script>
class Vector2D extends Array {
constructor (x = 1, y = 0) {
super(x, y);
}
set x (v) {
this[0] = v;
}
get x () {
return this[0];
}
set y (v) {
this[1] = v;
}
get y () {
return this[1];
}
get length () {
return Math.hypot(this.x, this.y);
}
get dir () {
return Math.atan2(this.y, this.x);
}
copy () {
return new Vector2D(this.x, this.y);
}
add (v) {
this.x += v.x;
this.y += v.y;
return this;
}
sub (v) {
this.x -= v.x;
this.y -= v.y;
return this;
}
scale (s) {
this.x *= s;
this.y *= s;
return this;
}
cross (v) {
return this.x * v.y - this.y * v.x;
}
dot (v) {
return this.x * v.x + this.y * v.y;
}
normalize () {
return this.scale(1 / this.length);
}
rotate (rad) {
const c = Math.cos(rad);
const s = Math.sin(rad);
const [x, y] = this;
this.x = x * c - y * s;
this.y = x * s + y * c;
return this;
}
}
function regularShape (edges = 3, x, y, step) {
const ret = [];
const delta = Math.PI * (1 - (edges -2) / edges);
let p = new Vector2D(x, y);
const dir = new Vector2D(step, 0);
ret.push(p);
for (let i = 0; i < edges; i++) {
p = p.copy().add(dir.rotate(delta));
ret.push(p);
}
return ret;
}
function draw (points, strokeStyle = 'black', fillStyle = null) {
ctx.strokeStyle = strokeStyle;
ctx.beginPath();
ctx.moveTo(...points[0]);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(...points[i]);
}
ctx.closePath();
if (fillStyle) {
ctx.fillStyle = fillStyle;
ctx.fill();
}
ctx.stroke();
}
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
const { width, height } = canvas;
ctx.translate(0.5 * width, 0.5 * height);
ctx.scale(1, -1);
draw(regularShape(3, 128, 128, 100), 'black', 'red');
draw(regularShape(6, -64, 128, 50), 'black', 'green');
draw(regularShape(11, -64, -64, 30), 'black', 'blue');
draw(regularShape(60, 128, -64, 6), 'black', 'yellow');
</script>
</body>
</html>
这个做法虽然能够绘制出圆这样的曲线,但它还有一些缺点,很难精确对应到图形的位置和大小的。
通过参数方程,不仅可以描述常见的圆、椭圆、抛物线、正余弦等曲线,还能描述更具有一般性的曲线,也就是没有被数学公式预设好的曲线,比如贝塞尔曲线,或者 Catmull–Rom 曲线等等。
定义了一个圆心在(x0, y0),半径为 r 的圆
const TAU_SEGMENTS = 60;
const TAU = Math.PI * 2;
function arc (x0, y0, radius, startAng = 0, endAng = TAU) {
const ang = Math.min(TAU, endAng - startAng);
const ret = ang === TAU ? [] : [[x0, y0]];
const segments = Math.round(TAU_SEGMENTS * ang / TAU);
for (let i = 0; i < segments; i++) {
const theta = startAng + ang * i / segments;
const x = x0 + radius * Math.cos(theta);
const y = y0 + radius * Math.sin(theta);
ret.push([x, y]);
}
return ret;
}
draw(arc(0, 0, 100));
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.jsdelivr.net/npm/roughjs@3.1.0/dist/rough.min.js"></script>
</head>
<body>
<canvas width="512" height="512"></canvas>
<script>
class Vector2D extends Array {
constructor (x = 1, y = 0) {
super(x, y);
}
set x (v) {
this[0] = v;
}
get x () {
return this[0];
}
set y (v) {
this[1] = v;
}
get y () {
return this[1];
}
get length () {
return Math.hypot(this.x, this.y);
}
get dir () {
return Math.atan2(this.y, this.x);
}
copy () {
return new Vector2D(this.x, this.y);
}
add (v) {
this.x += v.x;
this.y += v.y;
return this;
}
sub (v) {
this.x -= v.x;
this.y -= v.y;
return this;
}
scale (s) {
this.x *= s;
this.y *= s;
return this;
}
cross (v) {
return this.x * v.y - this.y * v.x;
}
dot (v) {
return this.x * v.x + this.y * v.y;
}
normalize () {
return this.scale(1 / this.length);
}
rotate (rad) {
const c = Math.cos(rad);
const s = Math.sin(rad);
const [x, y] = this;
this.x = x * c - y * s;
this.y = x * s + y * c;
return this;
}
}
function draw (points, strokeStyle = 'black', fillStyle = null) {
ctx.strokeStyle = strokeStyle;
ctx.beginPath();
ctx.moveTo(...points[0]);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(...points[i]);
}
ctx.closePath();
if (fillStyle) {
ctx.fillStyle = fillStyle;
ctx.fill();
}
ctx.stroke();
}
const TAU_SEGMENTS = 60;
const TAU = Math.PI * 2;
function arc (x0, y0, radius, startAng = 0, endAng = TAU) {
const ang = Math.min(TAU, endAng - startAng);
const ret = ang === TAU ? [] : [[x0, y0]];
const segments = Math.round(TAU_SEGMENTS * ang / TAU);
for (let i = 0; i < segments; i++) {
const theta = startAng + ang * i / segments;
const x = x0 + radius * Math.cos(theta);
const y = y0 + radius * Math.sin(theta);
ret.push([x, y]);
}
return ret;
}
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
const { width, height } = canvas;
ctx.translate(0.5 * width, 0.5 * height);
ctx.scale(1, -1);
draw(arc(0, 0, 100));
</script>
</body>
</html>
椭圆的参数方程,和圆的参数方程很接近。其中,a、b 分别是椭圆的长轴和短轴,当 a = b = r 时,这个方程是就圆的方程式。所以,圆实际上就是椭圆的特例。
function ellipse (x0, y0, radiusX, radiusY, startAng = 0, endAng = TAU) {
const ang = Math.min(TAU, endAng - startAng);
const ret = ang === TAU ? [] : [[x0, y0]];
const segments = Math.round(TAU_SEGMENTS * ang / TAU);
for (let i = 0; i < segments; i++) {
const theta = startAng + ang * i / segments;
const x = x0 + radiusX * Math.cos(theta);
const y = y0 + radiusY * Math.sin(theta);
ret.push([x, y]);
}
return ret;
}
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.jsdelivr.net/npm/roughjs@3.1.0/dist/rough.min.js"></script>
</head>
<body>
<canvas width="512" height="512"></canvas>
<script>
class Vector2D extends Array {
constructor (x = 1, y = 0) {
super(x, y);
}
set x (v) {
this[0] = v;
}
get x () {
return this[0];
}
set y (v) {
this[1] = v;
}
get y () {
return this[1];
}
get length () {
return Math.hypot(this.x, this.y);
}
get dir () {
return Math.atan2(this.y, this.x);
}
copy () {
return new Vector2D(this.x, this.y);
}
add (v) {
this.x += v.x;
this.y += v.y;
return this;
}
sub (v) {
this.x -= v.x;
this.y -= v.y;
return this;
}
scale (s) {
this.x *= s;
this.y *= s;
return this;
}
cross (v) {
return this.x * v.y - this.y * v.x;
}
dot (v) {
return this.x * v.x + this.y * v.y;
}
normalize () {
return this.scale(1 / this.length);
}
rotate (rad) {
const c = Math.cos(rad);
const s = Math.sin(rad);
const [x, y] = this;
this.x = x * c - y * s;
this.y = x * s + y * c;
return this;
}
}
function draw (points, strokeStyle = 'black', fillStyle = null) {
ctx.strokeStyle = strokeStyle;
ctx.beginPath();
ctx.moveTo(...points[0]);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(...points[i]);
}
ctx.closePath();
if (fillStyle) {
ctx.fillStyle = fillStyle;
ctx.fill();
}
ctx.stroke();
}
const TAU_SEGMENTS = 60;
const TAU = Math.PI * 2;
function arc (x0, y0, radius, startAng = 0, endAng = TAU) {
const ang = Math.min(TAU, endAng - startAng);
const ret = ang === TAU ? [] : [[x0, y0]];
const segments = Math.round(TAU_SEGMENTS * ang / TAU);
for (let i = 0; i < segments; i++) {
const theta = startAng + ang * i / segments;
const x = x0 + radius * Math.cos(theta);
const y = y0 + radius * Math.sin(theta);
ret.push([x, y]);
}
return ret;
}
function ellipse (x0, y0, radiusX, radiusY, startAng = 0, endAng = TAU) {
const ang = Math.min(TAU, endAng - startAng);
const ret = ang === TAU ? [] : [[x0, y0]];
const segments = Math.round(TAU_SEGMENTS * ang / TAU);
for (let i = 0; i < segments; i++) {
const theta = startAng + ang * i / segments;
const x = x0 + radiusX * Math.cos(theta);
const y = y0 + radiusY * Math.sin(theta);
ret.push([x, y]);
}
return ret;
}
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
const { width, height } = canvas;
ctx.translate(0.5 * width, 0.5 * height);
ctx.scale(1, -1);
draw(ellipse(0, 0, 100, 50));
</script>
</body>
</html>
抛物线的参数方程。其中 p 是常数,为焦点到准线的距离。
const LINE_SEGMENTS = 60;
function parabola (x0, y0, p, min, max) {
const ret = [];
for (let i = 0; i <= LINE_SEGMENTS; i++) {
const s = i / LINE_SEGMENTS;
const t = min * (1 - s) + max * s;
const x = x0 + 2 * p * t ** 2;
const y = y0 + 2 * p * t;
ret.push([x, y]);
}
return ret;
}
See the Pen <a href="https://codepen.io/cellinlab/pen/eYVmGLo"> Untitled</a> by cellinlab (<a href="https://codepen.io/cellinlab">@cellinlab</a>) on <a href="https://codepen.io">CodePen</a>.
使用高阶函数进行封装
/**
* draw ()
* @description 根据点绘制图形
*/
function draw (points, context, {
strokeStyle = '#000',
fillStyle = null,
close = false,
} = {}) {
context.strokeStyle = strokeStyle;
context.beginPath();
context.moveTo(...points[0]);
for (let i = 1; i < points.length; i++) {
context.lineTo(...points[i]);
}
if (close) {
context.closePath();
}
if (fillStyle) {
context.fillStyle = fillStyle;
context.fill();
}
context.stroke();
}
function parametric (xFunc, yFunc) {
return function (start, end, seg = 100, ...args) {
const points = [];
for (let i = 0; i <= seg; i++) {
const p = i / seg;
const t = start * (1 - p) + end * p;
const x = xFunc(t, ...args);
const y = yFunc(t, ...args);
points.push([x, y]);
}
return {
draw: draw.bind(null, points),
points,
};
};
}
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.jsdelivr.net/npm/roughjs@3.1.0/dist/rough.min.js"></script>
</head>
<body>
<canvas width="512" height="512"></canvas>
<script>
class Vector2D extends Array {
constructor (x = 1, y = 0) {
super(x, y);
}
set x (v) {
this[0] = v;
}
get x () {
return this[0];
}
set y (v) {
this[1] = v;
}
get y () {
return this[1];
}
get length () {
return Math.hypot(this.x, this.y);
}
get dir () {
return Math.atan2(this.y, this.x);
}
copy () {
return new Vector2D(this.x, this.y);
}
add (v) {
this.x += v.x;
this.y += v.y;
return this;
}
sub (v) {
this.x -= v.x;
this.y -= v.y;
return this;
}
scale (s) {
this.x *= s;
this.y *= s;
return this;
}
cross (v) {
return this.x * v.y - this.y * v.x;
}
dot (v) {
return this.x * v.x + this.y * v.y;
}
normalize () {
return this.scale(1 / this.length);
}
rotate (rad) {
const c = Math.cos(rad);
const s = Math.sin(rad);
const [x, y] = this;
this.x = x * c - y * s;
this.y = x * s + y * c;
return this;
}
}
function draw (points, strokeStyle = 'black', fillStyle = null) {
ctx.strokeStyle = strokeStyle;
ctx.beginPath();
ctx.moveTo(...points[0]);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(...points[i]);
}
ctx.closePath();
if (fillStyle) {
ctx.fillStyle = fillStyle;
ctx.fill();
}
ctx.stroke();
}
const TAU_SEGMENTS = 60;
const TAU = Math.PI * 2;
function arc (x0, y0, radius, startAng = 0, endAng = TAU) {
const ang = Math.min(TAU, endAng - startAng);
const ret = ang === TAU ? [] : [[x0, y0]];
const segments = Math.round(TAU_SEGMENTS * ang / TAU);
for (let i = 0; i < segments; i++) {
const theta = startAng + ang * i / segments;
const x = x0 + radius * Math.cos(theta);
const y = y0 + radius * Math.sin(theta);
ret.push([x, y]);
}
return ret;
}
function ellipse (x0, y0, radiusX, radiusY, startAng = 0, endAng = TAU) {
const ang = Math.min(TAU, endAng - startAng);
const ret = ang === TAU ? [] : [[x0, y0]];
const segments = Math.round(TAU_SEGMENTS * ang / TAU);
for (let i = 0; i < segments; i++) {
const theta = startAng + ang * i / segments;
const x = x0 + radiusX * Math.cos(theta);
const y = y0 + radiusY * Math.sin(theta);
ret.push([x, y]);
}
return ret;
}
const LINE_SEGMENTS = 60;
function parabola (x0, y0, p, min, max) {
const ret = [];
for (let i = 0; i <= LINE_SEGMENTS; i++) {
const s = i / LINE_SEGMENTS;
const t = min * (1 - s) + max * s;
const x = x0 + 2 * p * t ** 2;
const y = y0 + 2 * p * t;
ret.push([x, y]);
}
return ret;
}
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
const { width, height } = canvas;
ctx.translate(0.5 * width, 0.5 * height);
ctx.scale(1, -1);
draw(parabola(0, 0, 5.5, -10, 10));
</script>
</body>
</html>
绘制抛物线:
const para = parametric(
t => 25 * t,
t => 25 * t ** 2,
);
para(-5.5, 5.5).draw(ctx);
绘制阿基米德螺旋线:
const helical = parametric(
(t, l) => l * t * Math.cos(t),
(t, l) => l * t * Math.sin(t),
);
helical(0, 50, 500, 5).draw(ctx, {strokeStyle: 'blue'});
绘制星形线:
const star = parametric(
(t, l) => l * Math.cos(t) ** 3,
(t, l) => l * Math.sin(t) ** 3,
);
star(0, Math.PI * 2, 50, 150).draw(ctx, {strokeStyle: 'red'});
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.jsdelivr.net/npm/roughjs@3.1.0/dist/rough.min.js"></script>
</head>
<body>
<canvas width="512" height="512"></canvas>
<script>
/**
* draw ()
* @description 根据点绘制图形
*/
function draw (points, context, {
strokeStyle = '#000',
fillStyle = null,
close = false,
} = {}) {
context.strokeStyle = strokeStyle;
context.beginPath();
context.moveTo(...points[0]);
for (let i = 1; i < points.length; i++) {
context.lineTo(...points[i]);
}
if (close) {
context.closePath();
}
if (fillStyle) {
context.fillStyle = fillStyle;
context.fill();
}
context.stroke();
}
function parametric (xFunc, yFunc) {
return function (start, end, seg = 100, ...args) {
const points = [];
for (let i = 0; i <= seg; i++) {
const p = i / seg;
const t = start * (1 - p) + end * p;
const x = xFunc(t, ...args);
const y = yFunc(t, ...args);
points.push([x, y]);
}
return {
draw: draw.bind(null, points),
points,
};
};
}
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
const { width, height } = canvas;
ctx.translate(0.5 * width, 0.5 * height);
ctx.scale(1, -1);
const para = parametric(
t => 25 * t,
t => 25 * t ** 2,
);
para(-5.5, 5.5).draw(ctx);
const helical = parametric(
(t, l) => l * t * Math.cos(t),
(t, l) => l * t * Math.sin(t),
);
helical(0, 50, 500, 5).draw(ctx, {strokeStyle: 'blue'});
const star = parametric(
(t, l) => l * Math.cos(t) ** 3,
(t, l) => l * Math.sin(t) ** 3,
);
star(0, Math.PI * 2, 50, 150).draw(ctx, {strokeStyle: 'red'});
</script>
</body>
</html>
贝塞尔曲线(Bezier Curves),通过起点、终点和少量控制点,就能定义参数方程来生成复杂的平滑曲线。贝塞尔曲线又分为二阶贝塞尔曲线(Quadratic Bezier Curve)和三阶贝塞尔曲线(Qubic Bezier Curve)。
其中,二阶贝塞尔曲线由三个点确定,P0是起点,P1是控制点,P2是终点
三阶贝塞尔曲线的参数方程为:
三阶贝塞尔曲线有 4 个点,其中 P0和 P3是起点和终点,P1、P2是控制点。
多边形可以定义为由三条或三条以上的线段首尾连接构成的平面图形,其中,每条线段的端点就是多边形的顶点,线段就是多边形的边。
多边形又可以分为简单多边形和复杂多边形。如果一个多边形的每条边除了相邻的边以外,不和其他边相交,那它就是简单多边形,否则就是复杂多边形。一般来说,在绘图时,要尽量构建简单多边形,因为简单多边形的图形性质比较简单,绘制起来比较方便。
不同的图形系统会用不同的方法来填充多边形。
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.jsdelivr.net/npm/roughjs@3.1.0/dist/rough.min.js"></script>
</head>
<body>
<canvas width="512" height="512"></canvas>
<script>
class Vector2D extends Array {
constructor (x = 1, y = 0) {
super(x, y);
}
set x (v) {
this[0] = v;
}
get x () {
return this[0];
}
set y (v) {
this[1] = v;
}
get y () {
return this[1];
}
get length () {
return Math.hypot(this.x, this.y);
}
get dir () {
return Math.atan2(this.y, this.x);
}
copy () {
return new Vector2D(this.x, this.y);
}
add (v) {
this.x += v.x;
this.y += v.y;
return this;
}
sub (v) {
this.x -= v.x;
this.y -= v.y;
return this;
}
scale (s) {
this.x *= s;
this.y *= s;
return this;
}
cross (v) {
return this.x * v.y - this.y * v.x;
}
dot (v) {
return this.x * v.x + this.y * v.y;
}
normalize () {
return this.scale(1 / this.length);
}
rotate (rad) {
const c = Math.cos(rad);
const s = Math.sin(rad);
const [x, y] = this;
this.x = x * c - y * s;
this.y = x * s + y * c;
return this;
}
}
function draw (context, points, {
strokeStyle = 'black',
fillStyle = null,
close = false,
rule = 'nonzero',
} = {}) {
context.strokeStyle = strokeStyle;
context.beginPath();
context.moveTo(...points[0]);
for (let i = 1; i < points.length; i++) {
context.lineTo(...points[i]);
}
if (close) {
context.closePath();
}
if (fillStyle) {
context.fillStyle = fillStyle;
context.fill(rule);
}
context.stroke();
}
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
const { width, height } = canvas;
ctx.translate(0.5 * width, 0.5 * height);
ctx.scale(1, -1);
// 1. 构建多边形的顶点
const points = [new Vector2D(0, 100)];
for (let i = 1; i <= 4; i++) {
const p = points[0].copy().rotate(i * Math.PI * 0.4);
points.push(p);
}
// 2. 绘制多边形,并填充
const polygon = [...points];
ctx.save();
ctx.translate(-128, 0);
draw(ctx, polygon, {
fillStyle: '#f00',
});
ctx.restore();
const stars = [
points[0],
points[2],
points[4],
points[1],
points[3],
];
ctx.save();
ctx.translate(128, 0);
draw(ctx, stars, {
fillStyle: '#0f0',
rule: 'evenodd',
});
ctx.restore();
</script>
</body>
</html><html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.jsdelivr.net/npm/roughjs@3.1.0/dist/rough.min.js"></script>
</head>
<body>
<canvas width="512" height="256"></canvas>
<script>
class Vector2D extends Array {
constructor (x = 1, y = 0) {
super(x, y);
}
set x (v) {
this[0] = v;
}
get x () {
return this[0];
}
set y (v) {
this[1] = v;
}
get y () {
return this[1];
}
get length () {
return Math.hypot(this.x, this.y);
}
get dir () {
return Math.atan2(this.y, this.x);
}
copy () {
return new Vector2D(this.x, this.y);
}
add (v) {
this.x += v.x;
this.y += v.y;
return this;
}
sub (v) {
this.x -= v.x;
this.y -= v.y;
return this;
}
scale (s) {
this.x *= s;
this.y *= s;
return this;
}
cross (v) {
return this.x * v.y - this.y * v.x;
}
dot (v) {
return this.x * v.x + this.y * v.y;
}
normalize () {
return this.scale(1 / this.length);
}
rotate (rad) {
const c = Math.cos(rad);
const s = Math.sin(rad);
const [x, y] = this;
this.x = x * c - y * s;
this.y = x * s + y * c;
return this;
}
}
function draw (context, points, {
strokeStyle = 'black',
fillStyle = null,
close = false,
rule = 'nonzero',
} = {}) {
context.strokeStyle = strokeStyle;
context.beginPath();
context.moveTo(...points[0]);
for (let i = 1; i < points.length; i++) {
context.lineTo(...points[i]);
}
if (close) {
context.closePath();
}
if (fillStyle) {
context.fillStyle = fillStyle;
context.fill(rule);
}
context.stroke();
}
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
const { width, height } = canvas;
ctx.translate(0.5 * width, 0.5 * height);
ctx.scale(1, -1);
// 1. 构建多边形的顶点
const points = [new Vector2D(0, 100)];
for (let i = 1; i <= 4; i++) {
const p = points[0].copy().rotate(i * Math.PI * 0.4);
points.push(p);
}
// 2. 绘制多边形,并填充
const polygon = [...points];
ctx.save();
ctx.translate(-128, 0);
draw(ctx, polygon, {
fillStyle: '#f00',
});
ctx.restore();
const stars = [
points[0],
points[2],
points[4],
points[1],
points[3],
];
ctx.save();
ctx.translate(128, 0);
draw(ctx, stars, {
fillStyle: '#0f0',
rule: 'evenodd',
});
ctx.restore();
</script>
</body>
</html>
在 WebGL 中,虽然没有提供自动填充多边形的方法,但是可以用三角形这种基本图元来快速地填充多边形。因此,在 WebGL 中填充多边形的第一步,就是将多边形分割成多个三角形。这种将多边形分割成若干个三角形的操作,在图形学中叫做三角剖分(Triangulation)。
针对 3D 模型,WebGL 在绘制的时候,也需要使用三角剖分,而 3D 的三角剖分又被称为网格化(Meshing)。因为 3D 模型比 2D 模型更加复杂,顶点的数量更多,所以针对复杂的 3D 模型,一般不在运行的时候进行三角剖分,而是通过设计工具把图形的三角剖分结果直接导出进行使用。也就是说,在 3D 渲染的时候,一般使用的模型数据都是已经经过三角剖分以后的顶点数据。
在 SVG 这样的图形系统里,由于多边形本身就是一个元素节点,因此直接通过 DOM API 就可以判定鼠标是否在该元素上。而对于 Canvas2D,不能直接通过 DOM API 判定,而是要通过 Canvas2D 提供的 isPointInPath 方法来判定。
const vertices = [
[-20, 56],
[20, 80],
[25, -20],
[-30, -50],
];
const { left: rectLeft, top: rectTop } = canvas.getBoundingClientRect();
let fillStyle = 'red';
draw(ctx, vertices, { fillStyle });
canvas.addEventListener('mousemove', e => {
const { x, y} = e;
const offsetX = x - rectLeft;
const offsetY = y - rectTop;
ctx.clearRect(-width / 2, -height / 2, width, height);
if (ctx.isPointInPath(offsetX, offsetY)) {
fillStyle = 'green';
} else {
fillStyle = 'red';
}
draw(ctx, vertices, { fillStyle });
});
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.jsdelivr.net/npm/roughjs@3.1.0/dist/rough.min.js"></script>
</head>
<body>
<canvas width="512" height="256"></canvas>
<script>
function draw (context, points, {
strokeStyle = 'black',
fillStyle = null,
close = false,
rule = 'nonzero',
} = {}) {
context.strokeStyle = strokeStyle;
context.beginPath();
context.moveTo(...points[0]);
for (let i = 1; i < points.length; i++) {
context.lineTo(...points[i]);
}
if (close) {
context.closePath();
}
if (fillStyle) {
context.fillStyle = fillStyle;
context.fill(rule);
}
context.stroke();
}
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
const { width, height } = canvas;
ctx.translate(0.5 * width, 0.5 * height);
ctx.scale(1, -1);
const vertices = [
[-20, 56],
[20, 80],
[25, -20],
[-30, -50],
];
const { left: rectLeft, top: rectTop } = canvas.getBoundingClientRect();
let fillStyle = 'red';
draw(ctx, vertices, { fillStyle });
canvas.addEventListener('mousemove', e => {
const { x, y} = e;
const offsetX = x - rectLeft;
const offsetY = y - rectTop;
ctx.clearRect(-width / 2, -height / 2, width, height);
if (ctx.isPointInPath(offsetX, offsetY)) {
fillStyle = 'green';
} else {
fillStyle = 'red';
}
draw(ctx, vertices, { fillStyle });
});
</script>
</body>
</html>
isPointInPath 仅能判断鼠标是否在最后一次绘制图形内。
直接通过点与几何图形的数学关系来判断点是否在图形内。可以把视角放在最简单的多边形,也就是三角形上。如果要判断一个点是否在任意多边形的内部,只需要在判断之前将它进行三角剖分就可以了。
已知一个三角形的三条边分别是向量 a、b、c,平面上一点 u 连接三角形三个顶点的向量分别为 u1、u2、u3,那么 u 点在三角形内部的充分必要条件是:u1 × a、 u2 × b、 u3 x c 的符号相同。
仿射变换简单来说就是“线性变换 + 平移”。仿射变换具有以下 2 个性质:(1)仿射变换前是直线段的,仿射变换后依然是直线段;(2)对两条直线段 a 和 b 应用同样的仿射变换,变换前后线段长度比例保持不变。
常见的仿射变换形式包括平移、旋转、缩放以及它们的组合。其中,平移变换是最简单的仿射变换。
如果想让向量 P(x0, y0) 沿着向量 Q(x1, y1) 平移,只要将 P 和 Q 相加就可以了。
假设向量 P 的长度为 r,角度是 ⍺,要将它逆时针旋转⍬角,此时新的向量 P’的参数方程为:
即
缩放变换可以直接让向量与标量(标量只有大小、没有方向)相乘。