Hi there! I'm Fly 公众号: 前端图形的作者~
- 💬 专注在前端互动方向方面,目前在某电商公司从事游戏开发, 主要技术栈、react、cocos、canvas...
- 📫 [email protected]
- 😄 个人博客: 数据可视化的一些文章关于 canvas、three.js Blog
- 👯 Follow me on
Hi there! I'm Fly 公众号: 前端图形的作者~
大家好! 我是热爱图形的fly, 之前在群里和粉丝讨论canvas 如何事件系统, 然后呢? 我自己其实也对这个比较感兴趣, 我看过很多canvas 实现的项目, 比如canvas 实现思维导图 xmind , canvas 实现一个绘图工具。 然后呢无论是哪一个,其实背后都是在canavs 背后实现了一套事件系统,可惜这些源码都不开源。所以本着学习的激情, 我参考了一些文章实现一个简单事件系统。本篇文章你可以学到下面👇这些内容
本篇文章我全是干货。欢迎点赞、关注、收藏。
第一步我要做的事就是进行概念抽象,大家去想一下,canvas本质是一层画布,然后画布上很多图形,有长方形、圆形、以及任意闭合的多边形. 从面向对象的角度考虑的话, 我们可以封装一个基类 —— shape 每个图形是不是都在canvas 去显示,所以都应该有一个
draw 方法, 还有一个方法就是判断鼠标的点 是不是在当前图形的内部,这个我我们后面在讨论吧。 然后每个图形有自己的特有的属性,结合canvas 的api 去设置。
export class Circle extends Shape {
constructor(props) {
super()
this.props = props
}
draw(ctx) {
}
// 判断鼠标的点是否在图形内部
isPointInClosedRegion(mouse) {
}
}
export class Rect extends Shape {
constructor(props) {
super()
this.props = props
}
draw(ctx) {
}
// 判断鼠标的点是否在图形内部
isPointInClosedRegion(mouse) {
}
}
上面两个图形看结构都是一样的,不一样的draw方法, 我给你1分钟时间思考🤔下,canvas 是如何画矩形和画圆的。 其实 就是两个api一个 arc一个rect 然后 你传入对应的参数就好了。这里没什么, 不知道的同学可以去MDN去看下, 我已经讲了很多篇了。我就直接给出代码:
const { center, radius, fillColor = 'black' } = this.props
const { x, y } = center
ctx.save()
ctx.beginPath()
ctx.fillStyle = fillColor
ctx.arc(x, y, radius, 0, Math.PI * 2)
ctx.fill()
ctx.closePath()
ctx.restore()
这是圆的, save 和 restore 的方法 妙处 就是 比如我给圆设置红色 ,如果我再去画矩形, 矩形也会变成红色, 这样就不可控了,圆的话就是 圆心 加 半径,加填充颜色。
看完圆的我们在看下矩形的。
const { leftTop, width, height, fillColor = 'black' } = this.props
const { x, y } = leftTop
ctx.save()
ctx.beginPath()
ctx.fillStyle = fillColor
ctx.fillRect(x, y, width, height)
ctx.closePath()
ctx.restore()
矩形的属性 一个左上角的点一个长度,一个宽度。ok ,到这里图形基本搭建完成,下面开始搭建画布类
画布类的目前做的事情非常简单哈,初始化一些属性。首先他有个add() 方法,去往画布增加各个图形。增加的图形,每一个图形内部都去实现了draw 方法。这样实现了往canvas 加图形的操作哈。直接看代码:
// 新建一个画布类
export class Canvas {
constructor() {
this.canvas = document.getElementById('canvas')
this.ctx = this.canvas.getContext('2d')
this.allShapes = []
}
add(shape) {
shape.draw(this.ctx)
this.allShapes.push(shape)
}
}
是不是很简单,我们写一些代码测试下:
const canvas = new Canvas()
const circle = new Circle({
center: new Point2d(50, 50),
radius: 50,
fillColor: 'green',
})
const rect = new Rect({
leftTop: new Point2d(50, 50),
width: 100,
height: 100,
fillColor: 'black',
})
// 添加
canvas.add(circle)
canvas.add(rect)
这样写代码是不是感觉十分的舒服, 很清除, 可读性非常的高哇
OK,看来我们写的代码是没有问题的,下面写一个稍微复杂的图形,任意点组成的闭合polygon
同样是也是有draw 和 isPointInClosedRegion 这个两个方法, 画图的这个方法呢, 属性就是一堆2d点, 第一个点是移动画笔🖌, 其余的点调用canvas lineTo的方法。 然后 闭合区域就好了 。
export class Polygon extends Shape {
constructor(props) {
super()
this.props = props
}
draw(ctx) {
const { points, fillColor = 'black' } = this.props
ctx.save()
ctx.beginPath()
ctx.fillStyle = fillColor
points.forEach((point, index) => {
const { x, y } = point
if (index === 0) {
ctx.moveTo(x, y)
} else {
ctx.lineTo(x, y)
}
})
ctx.fill()
ctx.closePath()
ctx.restore()
}
getDispersed() {
return this.props.points
}
isPointInClosedRegion(event) {
}
}
测试的话,我是随机在画布取了5个点, 我用了我之前写的Point2d类, 上有个random方法, 传入canvas 的长度和宽度。不清楚的同学看看我之前写 canvas实现点的移动, 那里 我有详细介绍过。测试代码如下:
const points = []
for (let i = 0; i < 5; i++) {
points.push(Point2d.random(800, 600))
}
const shape = new Polygon({
points,
fillColor: 'orange',
})
// 添加到画布中
canvas.add(shape)
我们看下结果:
写到这里就有人问到, 这个三个类 都继承 基类 shape, shape 有什么通用的能力呢? 这里开始到我们本文的主题了, 就是每个图形的是不是有监听事件, 事件有很多种类型。每个类型下肯定有一大堆的监听函数, OK ,首先这是大家通用的能力, 或者是大家都需要的额东西, 我们就把放在基类中就好了, 那么我们用什么数据结构去存储呢—— 这种key Value 一看就是用Map, 行吧我们看下代码吧:
// 图形的基类
export class Shape {
constructor() {
this.listenerMap = new Map()
}
on(eventName, listener) {
if (this.listenerMap.has(eventName)) {
this.listenerMap.get(eventName).push(listener)
} else {
this.listenerMap.set(eventName, [listener])
}
}
}
On 这个方法哈, 第一个参数是事件名字, 第二个参数就是listener了, OK到目前为止, 每个图形对应的事件,都有了listener。
这个小节,就是将所有canvas 绑定的事件,传递到每个图形上去。第一步哈,我们首先为canvas 绑定监听函数。
小Tips: 为canvas 增加键盘事件的时候,需要给canvas 增加一个属性 tabinex = 0 , 不然 绑定无效。
this.canvas.addEventListener(move, this.handleEvent(move))this.canvas.addEventListener(click, this.handleEvent(click))
Move 和click 是我定义个两个常量哈:
export const move = 'mousemove'export const click = 'mousedown'
handleEvent 这个方法 用到了函数式编程, 将事件名字 和逻辑 进行解耦哇。
handleEvent = (name) => (event) => { this.allShapes.forEach((shape) => { // 获取当前事件的所有监听者 const listerns = shape.listenerMap.get(name) if ( listerns ) { listerns.forEach((listener) => listener(event)) } }) }
这样其实就实现了事件的分发,我们来测试下:
circle.on(click, (event) => { //event.isStopBubble = true console.log(event, 'circle')})rect.on(click, (event) => { console.log(event, 'rect')})
不知道大家有没有发现问题, 虽然我们实现了事件分发,但是存在一个问题,我在画布上任意一点击, 都会触发,可能其实我点击的根本不在我画的图形内部。所以我们进行事件分发的时候,还要判断下鼠标的点 是不是在闭合的区域内部。所以说呢,每一个shape 内部都要去实现 isPointInClosedRegion 这个方法。
判断一个点是不是在于圆内,这个其实很简单,主要去比较 鼠标的点 和圆心的距离 与 半径做比较,然后就可以判断了哈, 这个没什么。直接上代码:
const { center, radius } = this.propsreturn mouse.point.distance(center) <= radius * radius
判断一个点是不是在矩形内, 这里其实有个包围盒的概念,但是矩形 本来就是方方正正的,所以第一部根据, 左上角的点,算出矩形的minX, minY, maxX,maxY 然后 去拿鼠标的点去比较就好了。 这里我给大家画个图:
看到这张图应该不用说什么了, 直接上代码:
// 判断鼠标的点是否在图形内部 isPointInClosedRegion(mouse) { const { x, y } = mouse.point const { leftTop, width, height } = this.props const { x: minX, y: minY } = leftTop const maxX = minX + width const maxY = minY + height if (x >= minX && x <= maxX && y >= minY && y <= maxY) { return true } return false }
简单的图形我们可以通过一个数学关系去比较,但是复杂的多边形呢, 多边形分为 凹多边形 和凸多边形。那我们该怎么去解决呢?社区有下面几种方法:
具体做法:将测试点的Y坐标与多边形的每一个点进行比较,会得到一个测试点所在的行与多边形边的交点的列表。在下图的这个例子中有8条边与测试点所在的行相交,而有6条边没有相交。如果测试点的两边点的个数都是奇数个则该测试点在多边形内,否则在多边形外。在这个例子中测试点的左边有5个交点,右边有三个交点,它们都是奇数,所以点在多边形内。
这里有人会问为什么奇数是在内部, 偶数是在外部呢?
我以最简单的例子,带你去解释为什么?这时候又到了, 画图时刻:
我们先从内部选一个点,然后向任意方向发出一条射线。 你会发现一个问题,我们射线第一次与直线相交 叫做 穿入, 后面再相交 叫做穿出, 你会发现内部的最后永远是穿入,没有穿出, 但是外部的点, 永远穿入的同时, 然后穿出。最后永远是穿出
这里涉及到一个主要的算法就是 线段 和线段求焦点。我们新建一个Seg2d的类 线段肯定是有两个端点:
export class Seg2d { constructor(start, end) { this.endPoints = [start, end] this._asVector = undefined } get start() { return this.endPoints[0] } get end() { return this.endPoints[1] } reverse() { return new Seg2d(this.end.clone(), this.start.clone()) } clone() { return new Seg2d(this.start.clone(), this.end.clone()) } get asVector() { return ( this._asVector || (this._asVector = new Point2d( this.endPoints[1].x - this.endPoints[0].x, this.endPoints[1].y - this.endPoints[0].y )) ) }}
这都是基本操作没什么好讲的, 主要在类上 实现了 两个静态方法
我先来讲第一个,因为我们我们传给任意多边形的就是 点的集合, 所以,我们得将这些点连成线段组成闭合区域。
//一堆点 获得闭合一堆线段 static getSegments(points, closed = false) { const list = [] for (let i = 1; i < points.length; i++) { list.push(new Seg2d(points[i - 1], points[i])) } if (closed && !points[0].equal(points[points.length - 1])) { list.push(new Seg2d(points[points.length - 1], points[0])) } return list }
Closed 这个参数, 因为区域是满足一个方向的。所以闭合区域 肯定是首尾相连的。
这里我们用第二种方法去实现 :
第一步判断两个点是否在某条线段的两侧, 通常可采用投影法:
求出线段的法线向量, 然后把点投影到法线上, 最后根据投影的位置来判断点和线段的关系. 见下图
点a和点b在线段cd法线上的投影如图所示, 这时候我们还要做一次线段cd在自己法线上的投影(选择点c或点d中的一个即可).
主要用来做参考.
图中点a投影和点b投影在点c投影的两侧, 说明线段ab的端点在线段cd的两侧.
同理, 再判断一次cd是否在线段ab两侧即可.
求法线 , 求投影 什么的听起来很复杂的样子, 皆有公式可循:
const nx=b.y - a.y, ny=a.x - b.x; const normalLine = { x: nx, y: ny };
求点c在法线上的投影位置:
const dist= normalLine.x*c.x + normalLine.y*c.y;
注意: 这里的"投影位置"是一个标量, 表示的是到法线原点的距离, 而不是投影点的坐标.
当我们把图中 点a投影(distA),点b投影(distB),点c投影(distC) 都求出来之后, 就可以很容易的根据各自的大小判断出相对位置.
distA==distB==distC 时, 两条线段共线
distA==distB!=distC 时, 两条线段平行
distA 和 distB 在distC 同侧时, 两条线段不相交.
distA 和 distB 在distC 异侧时, 两条线段是否相交需要再判断点c点d与线段ab的关系.
这个优化 就优化在这里, 回去做一层检测, 然后再去求焦点, 求焦点用的也是固定公式。 我给出下面实现:
static lineLineIntersect(line1, line2) { const a = line1.start const b = line1.end const c = line2.start const d = line2.end const interInfo = [] //线段ab的法线N1 const nx1 = b.y - a.y, ny1 = a.x - b.x //线段cd的法线N2 const nx2 = d.y - c.y, ny2 = c.x - d.x //两条法线做叉乘, 如果结果为0, 说明线段ab和线段cd平行或共线,不相交 const denominator = nx1 * ny2 - ny1 * nx2 if (denominator == 0) { return interInfo } //在法线N2上的投影 const distC_N2 = nx2 * c.x + ny2 * c.y const distA_N2 = nx2 * a.x + ny2 * a.y - distC_N2 const distB_N2 = nx2 * b.x + ny2 * b.y - distC_N2 // 点a投影和点b投影在点c投影同侧 (对点在线段上的情况,本例当作不相交处理); if (distA_N2 * distB_N2 >= 0) { return interInfo } // //判断点c点d 和线段ab的关系, 原理同上 // //在法线N1上的投影 const distA_N1 = nx1 * a.x + ny1 * a.y const distC_N1 = nx1 * c.x + ny1 * c.y - distA_N1 const distD_N1 = nx1 * d.x + ny1 * d.y - distA_N1 if (distC_N1 * distD_N1 >= 0) { return interInfo } //计算交点坐标 const fraction = distA_N2 / denominator const dx = fraction * ny1, dy = -fraction * nx1 interInfo.push(new Point2d(a.x + dx, a.y + dy)) return interInfo }
这个ok 之后,我们去把任意多边形的方法的是否在闭合区域内的方法去实现。
isPointInClosedRegion(event) { const allSegs = Seg2d.getSegments(this.getDispersed(), true) // 选取任意一条射线 const start = event.point const xAxias = new Point2d(1, 0).multiplyScalar(800) const end = start.clone().add(xAxias) const anyRaySeg = new Seg2d(start, end) let total = 0 allSegs.forEach((item) => { const intersetSegs = Seg2d.lineLineIntersect(item, anyRaySeg) total += intersetSegs.length }) // 奇数在内部 if (total % 2 === 1) { return true } return false }
任意射线,我以鼠标的点,作为起始点, 方向是X轴, 算出终点。 然后得到任意线段。去和所有线段 去求焦点。 统计焦点个数, 来确定是不是在内部。
OK, 这时候我们吧触发事件的条件改写下。
handleEvent = (name) => (event) => { this.allShapes.forEach((shape) => { // 获取当前事件的所有监听者 const listerns = shape.listenerMap.get(name) if ( listerns && shape.isPointInClosedRegion(event) ) { listerns.forEach((listener) => listener(event)) } }) }
这样其实就已经实现了,在区域内部实现事件触发了。 看下gif
一开始点击的是空白处,然后我分别点了 polygon 和 矩形 和圆形 ,看控制台 你能看到结果。说明我们的算法实现成功了。
这时候有同学又要问了,我点击两个图形相交的部分,我只想选中内部的, 外面的不想选中。 这是个很正常的需求,首先原生的event 肯定已经满足不了我们了, 解决这个问题就是,分发到这个图形的时候不去触发的所有listeners。不就搞定了。所以我重写了event,其实 也没什么,也就做了两件事
代码如下:
getNewEvent(event) { const point = new Point2d(event.offsetX, event.offsetY) return { point, isStopBubble: false, ...event, } }
我这样的实现的依据是 图形的增加到场景是有序的。这里和大家说下React 事件系统, 由于有Vdom的存在,所以他将事件监听到 document 上, 然后再去按照顺序,去收集所有的lsiteners。 事件的捕获 和冒泡 其实 就是一个 顺序 和倒叙的问题。 他是这么去实现的。 他阻止合成事件冒泡, 就是合成事件有个e.stopPropagation() 。由于我们canvas 没有dom这个概念,所以我们人为封装了一个属性,并且将event传给每个图形 有他们控制 是否阻止。看代码:
handleEvent = (name) => (event) => { event = this.getNewEvent(event) this.allShapes.forEach((shape) => { // 获取当前事件的所有监听者 const listerns = shape.listenerMap.get(name) if ( listerns && shape.isPointInClosedRegion(event) && !event.isStopBubble ) { listerns.forEach((listener) => listener(event)) } }) }
主要是加了个条件。我们来测试下:
没阻止,我点击公共区域。
阻止冒泡, 代码如下:
circle.on(click, (event) => { event.isStopBubble = true console.log(event, 'circle')})rect.on(click, (event) => { console.log(event, 'rect')})
如图:
本篇文章大概就是简单的实现了canvas 的事件系统了,水平有限,能表达的就这么多。如果有更好的欢迎补充学习和交流,文章有错误的欢迎指正。我是热爱图形的Fly,我们下次再见👋啦。 最后觉得看完对你有帮助的话,点赞👍 再走吧。 知识输出不容易,我会持续持续输出高质量文章的。
如果对你有帮助的话,可以关注公众号**【前端图形】**,回复 【事件】 可以获得全部源码。
各位同学们大家好,又到了周末写文章的时间,之前群里有粉丝提问, 就是shader不是很理解。 然后今天他就来了, 废话不多说,读完今天的这篇文章你可以学到以下几点:
你如果会了,这篇文章你可以不用看👀,不用浪费时间,去看别的文章。 如果哪里写的有问题欢迎大家指正,我也在不断地学习当中。
这里我结合自己的思考🤔,讲讲webgl的整个的一个渲染过程。
Webgl的渲染依赖底层GPU的渲染能力。所以WEBGL 渲染流程和 GPU 内部的渲染管线是相符的。
渲染管线的作用是将3D模型转换为2维图像。
在早期,渲染管线是不可编程的,叫做固定渲染管线,工作的细节流程已经固定,修改的话需要调整一些参数。
现代的 GPU 所包含的渲染管线为可编程渲染管线,可以通过编程 GLSL 着色器语言 来控制一些渲染阶段的细节。
简单来说: 就是使用shader,我们可以对画布中每个像素点做处理,然后就可以生成各种酷炫的效果了。
渲染过程大概经历了下面这么多过程, 因为本篇文章的重点其实是在着色器,所以我重点分析从顶点着色器—— 片元着色器的一个过程
WebGL就是和GPU打交道,在GPU上运行的代码是一对着色器,一个是顶点着色器,另一个是片元着色器。每次调用着色程序都会先执行顶点着色器,再执行片元着色器。
一个顶点着色器的工作是生成裁剪空间坐标值,通常是以下的形式:
const vertexShaderSource = `
attribute vec3 position;
void main() {
gl_Position = vec4(position,1);
}
`
每个顶点调用一次(顶点)着色器,每次调用都需要设置一个特殊的全局变量 gl_Position。 该变量的值就是裁减空间坐标值。 这里有同学就问了, 什么是裁剪空间的坐标值???
其实我之前有讲过,我在讲一遍。
何为裁剪空间坐标?就是无论你的画布有多大,裁剪坐标的坐标范围永远是 -1 到 1 。
看下面这张图:
如果运行一次顶点着色器, 那么gl_Position 就是**(-0.5,-0.5,0,1)** 记住他永远是个 Vec4, 简单理解就是对应x、y、z、w。即使你没用其他的,也要设置默认值, 这就是所谓的 3维模型转换到我们屏幕中。
顶点着色器需要的数据,可以通过以下四种方式获得。
属性可以用 float
, vec2
, vec3
, vec4
, mat2
, mat3
和 mat4
数据类型
所以它内建的数据类型例如vec2
, vec3
和 vec4
分别代表两个值,三个值和四个值, 类似的还有mat2
, mat3
和 mat4
分别代表 2x2, 3x3 和 4x4 矩阵。 你可以做一些运算例如常量和矢量的乘法。看几个例子吧:
vec4 a = vec4(1, 2, 3, 4);
vec4 b = a * 2.0;
// b 现在是 vec4(2, 4, 6, 8);
向量乘法 和矩阵乘法 :
mat4 a = ???
mat4 b = ???
mat4 c = a * b;
vec4 v = ???
vec4 y = c * v;
它还支持矢量调制,意味者你可以交换或重复分量。
v.yyyy === vec4(y, y, y,y )
v.bgra === vec4(v.b,v.g,v.r,v.a)
vec4(v.rgb, 1) === vec4(v.r, v.g, v.b, 1)
vec4(1) === vec4(1, 1, 1, 1)
这样你在处理图片的时候可以轻松进行 颜色通道 对调, 发现你可以实现各种各样的滤镜了。
后面的属性在下面实战中会讲解:我们接着往下走:
什么是图元?
描述各种图形元素的函数叫做图元,描述几何元素的称为几何图元(点,线段或多边形)。点和线是最简单的几何图元
经过顶点着色器计算之后的坐标会被组装成组合图元。
通俗解释:图元就是一个点、一条线段、或者是一个多边形。
什么是图元装配呢?
简单理解就是说将我们设置的顶点、颜色、纹理等内容组装称为一个可渲染的多边形的过程。
组装的类型取决于: 你最后绘制选择的图形类型
gl.drawArrays(gl.TRIANGLES, 0, 3)
如果是三角形的话,顶点着色器就执行三次
什么是光栅化:
通过图元装配生成的多边形,计算像素并填充,剔除不可见的部分,剪裁掉不在可视范围内的部分。最终生成可见的带有颜色数据的图形并绘制。
光栅化流程图解:
剔除:
在日常生活中,对于不透明物体,背面对于观察者来说是不可见的。同样,在webgl中,我们也可以设定物体的背面不可见,那么在渲染过程中,就会将不可见的部分剔除,不参与绘制。节省渲染开销。
剪裁:
日常生活中不论是在看电视还是观察物体,都会有一个可视范围,在可视范围之外的事物我们是看不到的。类似的,图形生成后,有的部分可能位于可视范围之外,这一部分会被剪裁掉,不参与绘制。以此来提高性能。这个就是视椎体, 在📷范围内能看到的东西,才进行绘制。
光珊化后,每一个像素点都包含了 颜色 、深度 、纹理数据, 这个我们叫做片元
小tips : 每个像素的颜色由片元着色器的gl_FragColor提供
接收光栅化阶段生成的片元,在光栅化阶段中,已经计算出每个片元的颜色信息,这一阶段会将片元做逐片元挑选的操作,处理过的片元会继续向后面的阶段传递。 片元着色器运行的次数由图形有多少个片元决定的。
逐片元挑选
通过模板测试和深度测试来确定片元是否要显示,测试过程中会丢弃掉部分无用的片元内容,然后生成可绘制的二维图像绘制并显示。
在进行实战之前,我们先给你看一张图,让你能大概了解,用原生webgl生成一个三角形需要那些步骤:
我们就跟着这个流程图一步一步去操作:
新建一个webgl画布
<canvas id="webgl" width="500" height="500"></canvas>
创建webgl 上下文:
const gl = document.getElementById('webgl').getContext('webgl')
着色器的程序这些代码,其实是重复的,我们还是先看下图,看下我们到底需要哪些步骤:
那我们就跟着这个流程图: 一步一步来好吧。
const vertexShader = gl.createShader(gl.VERTEX_SHADER)
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER)
gl.VERTEX_SHADER 和 gl.FRAGMENT_SHADER 这两个是全局变量 分别表示顶点着色器 和片元着色器
顾名思义: 数据源,也就是我们的着色器 代码。
编写着色器代码有很多种方式:
我们先写顶点着色器:
const vertexShaderSource = `
attribute vec4 a_position;
void main() {
gl_Position = a_position;
}
`
顶点着色器 必须要有 main 函数 ,他是强类型语言, 记得加分号哇 不是js 兄弟们。 我这段着色器代码非常简单 定义一个vec4 的顶点位置, 然后传给 gl_Position
这里有小伙伴会问 ? 这里a_position一定要这么搞??
这里其实是这样的哇, 就是我们一般进行变量命名的时候 都会的前缀 用来区分 他是属性 还是 全局变量 还是纹理 比如这样:
uniform mat4 u_mat;
表示个矩阵,如果不这样也可以哈。 但是要专业呗,防止bug 影响。
我们接着写片元着色器:
const fragmentShaderSource = `
void main() {
gl_FragColor = vec4(1.0,0.0,0.0,1.0);
}
`
这个其实理解起来非常简单哈, 每个像素点的颜色 是红色 , gl_FragColor 其实对应的是 rgba 也就是颜色的表示。
有了数据源之后开始绑定:
// 创建着色器
const vertexShader = gl.createShader(gl.VERTEX_SHADER)
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER)
//绑定数据源
gl.shaderSource(vertexShader, vertexShaderSource)
gl.shaderSource(fragmentShader, fragmentShaderSource)
是不是很简答哈哈哈哈,我觉得你应该会了。
其实后面编译着色器、绑定着色器、连接着色器程序、使用着色器程序 都是一个api 搞定的事不多说了 直接看代码:
// 编译着色器
gl.compileShader(vertexShader)
gl.compileShader(fragmentShader)
// 创建着色器程序
const program = gl.createProgram()
gl.attachShader(program, vertexShader)
gl.attachShader(program, fragmentShader)
// 链接 并使用着色器
gl.linkProgram(program)
gl.useProgram(program)
这样我们就创建好了一个着色器程序了。
这里又有人问,我怎么知道我创建的着色器是对的还是错的呢? 我就是很粗心的人呢??? 好的他来了 如何调试:
const success = gl.getProgramParameter(program, gl.LINK_STATUS)
if (success) {
gl.useProgram(program)
return program
}
console.error(gl.getProgramInfoLog(program), 'test---')
gl.deleteProgram(program)
getProgramParameter 这个方法用来判断 我们着色器 glsl 语言写的是不是对的, 然后你可以通过 getProgramInfoLog这个方法 类似于打 日志 去发现❌了。
有了着色器,现在我们差的就是数据了对吧。
上文在写顶点着色器的时候用到了Attributes属性,说明是这个变量要从缓冲中读取数据,下面我们就来把数据存入缓冲中。
首先创建一个顶点缓冲区对象(Vertex Buffer Object, VBO)
const buffer = gl.createBuffer()
gl.createBuffer()函数创建缓冲区并返回一个标识符,接下来需要为WebGL绑定这个buffer
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
gl.bindBuffer()函数把标识符buffer设置为当前缓冲区,后面的所有的数据都会都会被放入当前缓冲区,直到bindBuffer绑定另一个当前缓冲区。
我们新建一个数组 然后并把数据存入到缓冲区中。
const data = new Float32Array([0.0, 0.0, -0.3, -0.3, 0.3, -0.3])
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW)
因为JavaScript与WebGL通信必须是二进制的,不能是传统的文本格式,所以这里使用了ArrayBuffer对象将数据转化为二进制,因为顶点数据是浮点数,精度不需要太高,所以使用Float32Array就可以了,这是JavaScript与GPU之间大量实时交换数据的有效方法。
gl.STATIC_DRAW 指定数据存储区的使用方法: 缓存区的内容可能会经常使用,但是不会更改
gl.DYNAMIC_DRAW 表示 缓存区的内容经常使用,也会经常更改。
gl.STREAM_DRAW 表示缓冲区的内容可能不会经常使用
GLSL着色程序的唯一输入是一个属性值a_position。 我们要做的第一件事就是从刚才创建的GLSL着色程序中找到这个属性值所在的位置。
const aposlocation = gl.getAttribLocation(program, 'a_position')
接下来我们需要告诉WebGL怎么从我们之前准备的缓冲中获取数据给着色器中的属性。 首先我们需要启用对应属性
gl.enableVertexAttribArray(aposlocation)
最后是从缓冲中读取数据绑定给被激活的aposlocation的位置
gl.vertexAttribPointer(aposlocation, 2, gl.FLOAT, false, 0, 0)
gl.vertexAttribPointer()函数有六个参数:
现在着色器程序 和数据都已经ready 了, 现在就差渲染了。 渲染之前和2d canvas 一样做一个清除画布的动作:
// 清除canvas
gl.clearColor(0, 0, 0, 0)
gl.clear(gl.COLOR_BUFFER_BIT)
我们用0、0、0、0清空画布,分别对应 r, g, b, alpha (红,绿,蓝,阿尔法)值, 所以在这个例子中我们让画布变透明了。
开启绘制三角形:
gl.drawArrays(gl.TRIANGLES, 0, 3)
绘制类型共有下列几种 看图:
这里我们看下画面是不是一个红色的三角形 :
我们创建的数据是这样的:
画布的宽度是 500 * 500 转换出来的实际数据其实是这样的
0,0 ====> 0,0
-0.3, -0.3 ====> 175, 325
0.3, -0.3 ====> 325, 325
有了静态的图形我们开始着色器,对三角形做一个缩放。
改写顶点着色器: 其实在顶点着色器上加一个全局变量 这就用到了 着色器的第二个属性 uniform
const vertexShaderSource = `
attribute vec4 a_position;
// 添加矩阵代码
uniform mat4 u_mat;
void main() {
gl_Position = u_mat * a_position;
}
`
然后和属性一样,我们需要找到 uniform 对应的位置:
const matlocation = gl.getUniformLocation(program, 'u_mat')
然后初始化一个旋转举证:
// 初始化一个旋转矩阵。
const mat = new Float32Array([
Tx, 0.0, 0.0, 0.0,
0.0, Ty, 0.0, 0.0,
0.0, 0.0, Tz, 0.0,
0.0, 0.0, 0.0, 1.0,
]);
Tx, Ty, Tz 对应的其实就是 x y z 轴缩放的比例。
最后一步, 将矩阵应用到着色器上, 在画之前, 这样每个点 就可以✖️ 这个缩放矩阵了 ,所以整体图形 也就进行了缩放。
gl.uniformMatrix4fv(matlocation, false, mat)
三个参数分别代表什么意思:
OK 我写了三角形缩放的动画:
let Tx = 0.1 //x坐标的位置
let Ty = 0.1 //y坐标的位置
let Tz = 1.0 //z坐标的位置
let Tw = 1.0 //差值
let isOver = true
let step = 0.08
function run() {
if (Tx >= 3) {
isOver = false
}
if (Tx <= 0) {
isOver = true
}
if (isOver) {
Tx += step
Ty += step
} else {
Tx -= step
Ty -= step
}
const mat = new Float32Array([
Tx, 0.0, 0.0, 0.0,
0.0, Ty, 0.0, 0.0,
0.0, 0.0, Tz, 0.0,
0.0, 0.0, 0.0, 1.0,
]);
gl.uniformMatrix4fv(matlocation, false, mat)
gl.drawArrays(gl.TRIANGLES, 0, 3)
// 使用此方法实现一个动画
requestAnimationFrame(run)
}
效果图如下:
最后 给大家看一下webgl 内部是怎么搞的 一张gif 动画 :
原始的数据通过 顶点着色器 生成一系列 新的点。
说完矩阵了下面👇,我们开始说下着色器中的varying 这个变量 是如何和片元着色器进行联动的。
我们还是继续改造顶点着色器:
const vertexShaderSource = `
attribute vec4 a_position;
uniform mat4 u_mat;
// 变量
varying vec4 v_color;
void main() {
gl_Position = u_mat * a_position;
v_color = gl_Position * 0.5 + 0.5;
}
`
这里有一个小知识 , gl_Position 他的值范围是 -1 -1 但是片元着色 他是颜色 他的范围是 0 - 1 , 所以呢这时候呢,我们就要 做一个范围转换 所以为什么要 乘 0.5 在加上 0.5 了, 希望你们明白。
改造下片元着色器:
const fragmentShaderSource = `
precision lowp float;
varying vec4 v_color;
void main() {
gl_FragColor = v_color;
}
`
只要没一个像素点 改为由顶点着色器传过来的就好了。
我们看下这时候的三角形 变成啥样子了。
是不是变成彩色三角形了, 这里很多人就会问, 这到底是怎么形成呢, 本质是在三角形的三个顶点, 做线性插值的过程:
本篇文章大概是对webgl 做了一个基本的介绍, 和带你用几个简单的小例子 带你入门了glsl 语言, 你以为webgl 就这样嘛 那你就错了,其实有一个texture 我是没有讲的, 后面我去专门写一篇文章去将纹理贴图 , 漫反射贴图、 法线贴图。 希望你关注下我,不然找不到我了, 如果你觉得本篇文章对你有帮助的话,欢迎 点赞 、评论、收藏。 我们下期再见👋, 我是喜欢图形的Fly。
最后欢迎关注前端图形:
大家好!!又到周末了,最近项目忙完了,有时间写文章了。 之前有粉丝问我, fly哥怎么实现自定义路径动画, 当时给他说的就是路径无非不就是直线 或者曲线。也就这两种, 直线的话 可以用直线方程, 曲线的话稍微复杂点 ,需要用贝塞尔曲线去做lerp。 也就是动画的每一幁得算出路径的对应的坐标就可以了。 但是这套方案学习成本太高了, 有没有一种更加简单的方式呢? 本篇文章大概花费你5分钟, 你可以学到什么呢
制作动画前,先要拿到动画的路径,对此我们可以直接使用svg的path定义规则,比如我们定义了一条较为复杂的路径(它到底长什么样大家可以自己试试,这里就不展示了),然后,我们需要将定义好的路径导入进一个新生成的path元素中(我们只是借助svg的api,因此并不需要将其插到页面内)但是为了看效果, 我还是在页面放了 svg 的图形, 毕竟直观点。
代码如下:
<canvas id="canvas" width="800" height="600" tabindex="0"></canvas>
<svg width="100%" height="100%" viewBox="0 0 400 400"
xmlns="http://www.w3.org/2000/svg">
<path d='M0,0 C8,33.90861 25.90861,16 48,16 C70.09139,16 88,33.90861 88,56 C88,78.09139 105.90861,92 128,92 C150.09139,92 160,72 160,56 C160,40 148,24 128,24 C108,24 96,40 96,56 C96,72 105.90861,92 128,92 C154,93 168,78 168,56 C168,33.90861 185.90861,16 208,16 C230.09139,16 248,33.90861 248,56 C248,78.09139 230.09139,96 208,96 L48,96 C25.90861,96 8,78.09139 8,56 Z'
fill="orange" stroke="black" stroke-width="3" />
</svg>
我们先看下路径:
左边是canvas, 右边是svg 。等下我们注意观察小球的运动!!!!
这就是创建dom节点, 然后往svg 加 path 属性。 然后继续就可以了。
const path = 'M0,0 C8,33.90861 25.90861,16 48,16 C70.09139,16 88,33.90861 88,56 C88,78.09139 105.90861,92 128,92 C150.09139,92 160,72 160,56 C160,40 148,24 128,24 C108,24 96,40 96,56 C96,72 105.90861,92 128,92 C154,93 168,78 168,56 C168,33.90861 185.90861,16 208,16 C230.09139,16 248,33.90861 248,56 C248,78.09139 230.09139,96 208,96 L48,96 C25.90861,96 8,78.09139 8,56 Z';
const pathElement = document.createElementNS('http://www.w3.org/2000/svg',"path");
pathElement.setAttributeNS(null, 'd', path);
document.body.appendChild(pathElement)
然后呢我们就有了 这个 svg 节点, 有了节点就可以做一些操作了, 这里给大家揭秘下这两个api
我们先看下第一个的官方解释:
**SVGPathElement.getTotalLength()**
该方法返回用户代理对路径总长度(以用户单位为单位)的计算值。
顾名思义就是会计算出,你设置svg Path 的总路径 总长度。有了总长度, 可以算出在某个长度的 x 和 y坐标呢??
就是下面这个api
**SVGGeometryElement.getPointAtLength()**
方法沿路径返回给定距离的点。
我总觉得官方写的不够直白, 不过看 API 应该就能明白求出在某个长度的点。 有个这两个其实动画就出来了
我给大家画一个图 去模拟下:
你可以从一开始的地方,可以获得距离 起点任意长度的 点的坐标。 这样从动画的角度,你就可以在单位时间内不停地改变图形的位置,从而达成连续的效果,其实也就是动画了。 直接上代码
const length = pathElement.getTotalLength();
const duration = 1000; // 动画总时长
const interval = length / duration;
const canvas = document.getElementById('canvas');
const context = canvas.getContext('2d');
// 定义了步数
let step = 0;
function move(x, y) {
context.clearRect(0, 0, canvas.width, canvas.height);
context.beginPath();
context.arc(x, y, 25, 0, Math.PI*2, true);
context.fillStyle = '#f0f';
context.fill();
context.closePath();
}
// animate()
function animate() {
if(step > length) {
step = 0;
}
const x = parseInt(pathElement.getPointAtLength(step).x);
const y = parseInt(pathElement.getPointAtLength(step).y);
move(x,y)
step++
requestAnimationFrame(animate)
}
这里的在 requestAnimation 中不断地 去增加步数, 然后大于总长度 就让长度变为0 。 实现了循环往复。看下效果:
大家自己想象一下。 然后你就可以做任意路径的动画了,如果设计 要你做一个什么路径动画。 这时候你可以小装一波, 直接指导他。
第一步:建立工作路径
第二步:路径导出
第三步: 用AI 打开这个 路径
然后就可以导出 svg path了, 要他把这个东西给你, 你不就轻轻松松做出一个自定义动画了。 设计师肯定会夸你🐂
你可以将我上面的动画封装一下,然后支持动画的缓动函数,配合path。就可以轻松驾驭了。好了 今天的分享就到这里了, 我的写作灵感 很多都来自于大家的提问!我们下期见,我是热爱图形的Fly哥。
大家好,我是Fly, canvas真是个强大的东西,每天沉迷这个无法自拔, 可以做游戏,可以对图片处理,后面会给大家分享一篇,canvas实现两张图片找不同的功能, 听着是不是挺有意思的, 有点像游戏 找你妹,但是这都不是本篇文章想要表达的重点,读完今天这篇文章,你可以学到什么呢
这时候有人问我她??, 我的心里没有她的,只有你们coder, 下面一起学习吧,预计阅读10分钟。
因为也比较简单,我大概说下思路:
代码如下:
class board {
constructor() {
this.canvas = document.getElementById('canvas')
this.canvas.addEventListener('mousemove', this.move.bind(this))
this.canvas.addEventListener('mousedown', this.down.bind(this))
this.canvas.addEventListener('mouseup', this.up.bind(this))
this.ctx = this.canvas.getContext('2d')
this.startP = null
this.endP = null
this.isDown = false
this.setLineStyle()
}
setLineStyle() {
this.ctx.strokeStyle = 'red'
this.ctx.lineWidth = 1
this.ctx.lineJoin = 'round'
this.ctx.lineCap = 'round'
}
move(e) {
if (!this.isDown) {
return
}
this.endP = this.getPot(e)
this.drawLine()
this.startP = this.endP
}
down(e) {
this.isDown = true
this.startP = this.getPot(e)
}
getPot(e) {
return new Point2d(e.offsetX, e.offsetY)
}
drawLine() {
if (!this.startP || !this.endP) {
return
}
this.ctx.beginPath()
this.ctx.moveTo(this.startP.x, this.startP.y)
this.ctx.lineTo(this.endP.x, this.endP.y)
this.ctx.stroke()
this.ctx.closePath()
}
up(e) {
this.startP = null
this.endP = null
this.isDown = false
}
}
new board()
point2d是我自己写的一个2d点的一个类,不清楚的同学可以看我前几篇文章, 这里就不重复阐述了。我们看下gif:
细心的同学可能发现,画的线折线感比较强,出现这个本质的原因—— 就是我们画出的线其实是一个多段线polyline, 连接两个点之间的线是直线
想起曲线,就不得不提到贝塞尔曲线了,我之前的文章有系统的介绍过贝塞尔曲线,以及贝塞尔曲线方程的推导过程—— 传送门
canvas 肯定是支持贝塞尔曲线的quadraticCurveTo(cp1x, cp1y, x, y) , 主要是一个起始点, 一个终点,一个控制点。 其实这里可以用一个巧妙的算法去解决这样的问题。
假设我们在鼠标移动的过程中有A、B、C、D、E、F、G、这6个点。如何画出平滑的曲线呢, 我们取B点和C点的中点B1 作为第一条贝塞尔曲线的终点,B点作为控制点。如图:
接下来呢 算出 cd 的中点 c1 以 B1 为起点, c点为控制点, c1为终点画出下面图形:
然后后面按照这样的步骤不断画下去,就可以获得平滑的曲线了。 理论基础我们明白了, 我们改造上面的画线的方法:
上面涉及到求两个点的中间坐标:其实两个坐标的x 和y 分别除以2: 代码如下:
getMid(p1, p2) {
const x = (p1.x + p2.x) / 2
const y = (p1.y + p2.y) / 2
return new Point2d(x, y)
}
我们画出二阶贝塞尔曲线至少所示需要3个点, 所以我们需要数组去存放移动过程中所有的点的信息。
我先实现画贝塞尔曲线的方法:
drawCurve(controlP, endP) {
this.ctx.beginPath()
this.ctx.moveTo(this.startP.x, this.startP.y)
this.ctx.quadraticCurveTo(controlP.x, controlP.y, endP.x, endP.y)
this.ctx.stroke()
this.ctx.closePath()
}
然后在修改move 中的事件
move(e) {
if (!this.isDown) {
return
}
this.endP = this.getPot(e)
this.points.push(this.endP)
if (this.points.length >= 3) {
const [controlP, endP] = this.points.slice(-2)
const middle = this.getMid(controlP, endP)
this.drawCurve(controlP, middle)
this.startP = middle
}
}
这里实现永远取倒数后两个点,然后画完贝塞尔曲线后再将 这个贝塞尔的终点设置为开始点方便下次画。这样是能保证画出连续的贝塞尔曲线的。
我们看下gif 图:
至此本篇文章也算是写完了, 如果你有更好的思路欢迎和我交流,我这只是粗略的表示。canvas画连续平滑的曲线重点——还是怎么去找控制点这一点非常的重要哈!下一篇文章预告: canvas的离屏渲染和webworker的使用。
亲爱的coder们,我又来了,一个喜欢图形的程序员👩💻,前几篇文章一直都在教大家怎么画地图、画折线图、画烟花🎆,难道图形就是这样嘛,当然不是,一个很简单的问题, 如果我在canvas中画了10万个点,鼠标在画布上移动,靠近哪一个点,哪一个点高亮。有同学就说遇事不决 用for循环遍历哇,我也知道可以用循环解决哇,循环解决几百个点可以,如果是几万甚至几百万个点你还循环,你想让用户等死?这时就引入今天的主角他来了就是Rbush
我们先看下定义,这个rbush到底能帮我们解决了什么问题?
RBush是一个high-performanceJavaScript库,用于点和矩形的二维空间索引。它基于优化的R-tree数据结构,支持大容量插入。空间索引是一种用于点和矩形的特殊数据结构,允许您非常高效地执行“此边界框中的所有项目”之类的查询(例如,比在所有项目上循环快数百倍)。它最常用于地图和数据可视化。
看定义他是基于优化的R-tree数据结构,那么R-tree又是什么呢?
R-trees是用于空间访问方法的树数据结构,即用于索引多维信息,例如地理坐标、矩形或多边形。R-tree 在现实世界中的一个常见用途可能是存储空间对象,例如餐厅位置或构成典型地图的多边形:街道、建筑物、湖泊轮廓、海岸线等,然后快速找到查询的答案例如“查找我当前位置 2 公里范围内的所有博物馆”、“检索我所在位置 2 公里范围内的所有路段”(以在导航系统中显示它们)或“查找最近的加油站”(尽管不将道路进入帐户)。
R-tree的关键**是将附近的对象分组,并在树的下一个更高级别中用它们的最小边界矩形表示它们;R-tree 中的“R”代表矩形。由于所有对象都位于此边界矩形内,因此不与边界矩形相交的查询也不能与任何包含的对象相交。在叶级,每个矩形描述一个对象;在更高级别,聚合包括越来越多的对象。这也可以看作是对数据集的越来越粗略的近似。说着有点抽象,还是看一张图:
我来详细解释下这张图:
为了插入一个对象,树从根节点递归遍历。在每一步,检查当前目录节点中的所有矩形,并使用启发式方法选择候选者,例如选择需要最少放大的矩形。搜索然后下降到这个页面,直到到达叶节点。如果叶节点已满,则必须在插入之前对其进行拆分。同样,由于穷举搜索成本太高,因此采用启发式方法将节点一分为二。将新创建的节点添加到上一层,这一层可以再次溢出,并且这些溢出可以向上传播到根节点;当这个节点也溢出时,会创建一个新的根节点并且树的高度增加。
在范围搜索中,输入是一个搜索矩形(查询框)。搜索从树的根节点开始。每个内部节点包含一组矩形和指向相应子节点的指针,每个叶节点包含空间对象的矩形(指向某个空间对象的指针可以在那里)。对于节点中的每个矩形,必须确定它是否与搜索矩形重叠。如果是,则还必须搜索相应的子节点。以递归方式进行搜索,直到遍历所有重叠节点。当到达叶节点时,将针对搜索矩形测试包含的边界框(矩形),如果它们位于搜索矩形内,则将它们的对象(如果有)放入结果集中。
读着就复杂,但是社区里肯定有大佬替我们封装好了,就不用自己再去手写了,写了写估计不一定对哈哈哈。
// as a ES module
import RBush from 'rbush';
// as a CommonJS module
const RBush = require('rbush');
const tree = new RBush(16);
后面的16 是一个可选项,RBush 的一个可选参数定义了树节点中的最大条目数。 9(默认使用)是大多数应用程序的合理选择。 更高的值意味着更快的插入和更慢的搜索,反之亦然。
const item = {
minX: 20,
minY: 40,
maxX: 30,
maxY: 50,
foo: 'bar'
};
tree.insert(item);
tree.remove(item);
默认情况下,RBush按引用移除对象。但是,您可以传递一个自定义的equals
函数,以便按删除值进行比较,当您只有需要删除的对象的副本时(例如,从服务器加载),这很有用:
tree.remove(itemCopy, (a, b) => {
return a.id === b.id;
});
删除所有数据
tree.clear();
const result = tree.search({
minX: 40,
minY: 20,
maxX: 80,
maxY: 70
});
api 介绍完毕下面👇开始进入实战环节一个简单的小案例——canvas中画布搜索🔍的。
填充画布的的过程中,这里和大家介绍一个canvas点的api ——createPattern
**CanvasRenderingContext2D**
**.createPattern()
**是 Canvas 2D API 使用指定的图像 (CanvasImageSource
)创建模式的方法。 它通过repetition参数在指定的方向上重复元图像。此方法返回一个CanvasPattern
对象。
第一个参数是填充画布的数据源可以是下面这:
HTMLImageElement
HTMLVideoElement
HTMLCanvasElement
CanvasRenderingContext2D
,ImageBitmap
,ImageData
,Blob
.第二个参数指定如何重复图像。允许的值有:
"repeat"
(both directions),"repeat-x"
(horizontal only),"repeat-y"
(vertical only),"no-repeat"
(neither).如果为空字符串 (''
) 或 null
(但不是 undefined
),repetition将被当作"repeat"。
代码如下:
class search {
constructor() {
this.canvas = document.getElementById('map')
this.ctx = this.canvas.getContext('2d')
this.tree = new RBush()
this.fillCanvas()
}
fillCanvas() {
const img = new Image()
img.src =
'https://ztifly.oss-cn-hangzhou.aliyuncs.com/%E6%B2%B9%E7%94%BB.jpeg'
img.onload = () => {
const pattern = this.ctx.createPattern(img, '')
this.ctx.fillStyle = pattern
this.ctx.fillRect(0, 0, 960, 600)
}
}
}
这边有个小提醒的就是图片加载成功的回调里面去给画布创建模式,然后就是this 指向问题, 最后就是填充画布。
如图:
数据生成主要在画布的宽度 和长度的范围内随机生成10万个矩形。插入到rbush数据的格式就是有minX、maxX、minY、maxY。这个实现的思路也是非常的简单哇, minX用画布的长度Math.random minY 就是画布的高度Math.random. 然后最大再此基础上随机*20 就OK了,一个矩形就形成了。这个实现的原理就是左上和右下两个点可以形成一个矩形。代码如下:
randomRect() { const rect = {} rect.minX = parseInt(Math.random() * 960) rect.maxX = rect.minX + parseInt(Math.random() * 20) rect.minY = parseInt(Math.random() * 600) rect.maxY = rect.minY + parseInt(Math.random() * 20) rect.name = 'rect' + this.id this.id += 1 return rect}
然后循环加入10万条数据:
loadItems(n = 100000) { let items = [] for (let i = 0; i < n; i++) { items.push(this.randomRect()) } this.tree.load(items)}
这里我创建一个和当前画布一抹一样的canvas,但是里面画了n个矩形,将这个画布 当做图片填充到原先的画布中。
memCanva() { this.memCanv = document.createElement('canvas') this.memCanv.height = 600 this.memCanv.width = 960 this.memCtx = this.memCanv.getContext('2d') this.memCtx.strokeStyle = 'rgba(255,255,255,0.7)'}loadItems(n = 10000) { let items = [] for (let i = 0; i < n; i++) { const item = this.randomRect() items.push(item) this.memCtx.rect( item.minX, item.minY, item.maxX - item.minX, item.maxY - item.minY ) } this.memCtx.stroke() this.tree.load(items)}
然后在加载数据的时候,在当前画布画了10000个矩形。这时候新建的画布有东西了,然后我们用一个drawImage api ,
这个api做了这样的一个事,就是将画布用特定资源填充,然后你可以改变位置,后面有参数可以修改,这里我就不多介绍了, 传送门
this.ctx.drawImage(this.memCanv, 0, 0)
我们看下效果:
添加交互, 就是对画布添加mouseMove 事件, 然后呢我们以鼠标的位置,形成一个搜索的数据,然后我在统计花费的时间,然后你就会发现,这个Rbush 是真的快。代码如下:
this.canvas.addEventListener('mousemove', this.handler.bind(this)) // mouseMove 事件 handler(e) { this.clearRect() const x = e.offsetX const y = e.offsetY this.bbox.minX = x - 20 this.bbox.maxX = x + 20 this.bbox.minY = y - 20 this.bbox.maxY = y + 20 const start = performance.now() const res = this.tree.search(this.bbox) this.ctx.fillStyle = this.pattern this.ctx.strokeStyle = 'rgba(255,255,255,0.7)' res.forEach((item) => { this.drawRect(item) }) this.ctx.fill() this.res.innerHTML = 'Search Time (ms): ' + (performance.now() - start).toFixed(3) }
这里给大家讲解一下,现在我们画布是黑白的, 然后以鼠标搜索到数据后,然后我们画出对应的矩形,这时候呢,可以将矩形的填充模式改成 pattern 模式,这样便于我们看的更加明显。fillStyle可以填充3种类型:
ctx.fillStyle = color;ctx.fillStyle = gradient;ctx.fillStyle = pattern;
分别代表的是:
OK讲解完毕, 直接gif 看在1万个矩形的搜索中Rbush的表现怎么样。
这是1万个矩形我换成10万个矩形我们在看看效果:
我们发现增加到10万个矩形,速度还是非常快的,增加到100万个矩形,canvas 已经有点画不出来了,整个页面已经卡顿了,这边涉及到canvas的性能问题,当图形的数量过多,或者数量过大的时候,fps会大幅度下降的。
最后总结下:rbush 是一种空间索引搜索🔍算法,当你涉及到空间几何搜索的时候,尤其在地图场景下,因为Rbush 实现的原理是比较搜索物体的boundingBox 和已知的boundingBox 求交集, 如果不相交,那么在树的遍历过程中就已经过滤掉了。最后文章写作不易,如果有错误的话欢迎指正。如果看了对你有帮助的话,希望你能为我点个关注 和👍, 这是对我最大的支持!
搜索公众号【前端图形】,后台回复"加群"二字, 就可以加入可视化学习交流群哦! 一起学习吧!
之前在群里一直有粉丝对我做的3d文字感兴趣,今天它来了,我是如何去做的。本篇文章可能不会讲太多代码层面的东西,主要是一个技术方案从选型到最终实现中的遇到的一些问题。 主要是结合自己项目做的一些思考。希望能对你有所帮助,或者是开阔眼界。
首先three.js原生有个textGeometry, 原生是支持的,但是你如果想支持各种中文字体,首先你需要一个下载字体的ttf文件。然后你就去一个网站叫做, http://gero3.github.io/facetype.js/ 。 你把你的ttf文件上传,然后将这些字体转成json, 再用three.js 自带的fontLoader 去解析这个json, 配合textGeometry 你就可以实现了。我这里做了一个简单的实现:
const loader = new THREE.FontLoader()
loader.load('../json/alibaba.json', (font) => {
const geometry = new THREE.TextGeometry('我爱掘金', {
font: font,
size: 20,
height: 5,
curveSegments: 12,
bevelEnabled: false,
bevelThickness: 10,
bevelSize: 8,
bevelOffset: 0,
bevelSegments: 5,
})
const material = new THREE.MeshBasicMaterial({ color: 0x50ff22 })
const mesh = new THREE.Mesh(geometry, material)
this.scene.add(mesh)
})
给大家看下gif效果图:
其实不同的字体,对应不同的加载json,至于字体加粗,其实就是看字体有没有加粗的类型,如果有加粗的类型, 你就去展示就好了,其实还是不同的json, 我们这次的3D文字其实是没有采用这个three 这一套的。
首先第一点不满足的就是我们的造型, 我们是做家居的,我们不光有3D视图展示,还有2D视图展示,所以就是一套数据分别在3D和2D都有对应的表达。看下面两张图:
对吧,所以这是我当时去做技术评估不去考虑的最重要问题, 我们2D所有的数据都是用SVG去展示。所以说当时第一时间思考🤔,有没有一个库是可以支持解析字体文件转成svg的,功夫不负有心人哇,终于找到去npm找到了一个叫opentype.js 我们看下这个库的介绍:
opentype.js is a JavaScript parser and writer for TrueType and OpenType fonts.
It gives you access to the letterforms of text from the browser or Node.js. See https://opentype.js.org/ for a live demo.
其实他的特性总结下来有下面:
其实当时我找到了很多社区方案, 有一个叫text-to-svg这个库, 看名字好像很满足我们的要求, 但是本着学习的本质,我只喜欢看源码,看看他到底用了啥,结果发现他是基于上面opentype.js 这个库去做了封装,那我肯定不用它了。 我只需要字体被转换出来的svg信息,其实选用opentype.js 这个库还有两个原因哈**,第一支持ts ,第二的话他的周下载量是十分高的,至少说明他是稳定的。**
有了opentype.js的加成,我们可以把输入的文字变成了转成svg的信息,这里主要用的一个api就是loadFont,然后就可以根据我们输入的文字,然后生成对应的svg, 我下面写一些伪代码:
async function make() {
const font = await opentype.load(
'https://backend-public-asset-alpha.oss-cn-shanghai.aliyuncs.com/resources/website/font/11c302dd8c50619e4131da5d645fb422.otf'
)
const map = new Map()
return function (text) {
// 防止重复添加
for (let i = 0; i < text.length - 1; i++) {
const parseFont = font.getPath(text[i], 0, 150, 72)
const char = text[i]
console.log(text[i], '999')
if (!map.has(char)) {
map.set(text[i], parseFont.commands)
}
}
return map
}
}
然后输入任何文字会产生,一些SVGpath 信息。我们看下2 这个svgpath信息。然后你可以看下:
M其实对应的就是画布移动, L 就是画直线, C就是三阶贝塞尔曲线, Z 就是闭合path。 svg的path 信息有了, 这里第一个难点出来了
因为我们2d 可以用贝塞尔曲线去表达,但是我们3D的dataModel 中是没有这个数据去表示的,所以说什么呢,我得想好一个替代方案, 这里其实就设计到一个离散, 就是我将贝塞尔曲线,离散成多个点, 然后用直线去表达。这里不清楚的话,可以看我之前的一篇文章, 我里面对贝塞尔曲线做了详情讲解: 面试官问我会canvas? 我可以绘制一个烟花🎇动画
所以我将这些数组信息,去都转成2d点,去存储, 然后到这里很多人以为结束了,然后把这些2D线段去转成3D线段,你以为这样就结束了?
我也以为事情就这么简单,直到我打了个 e,才发现事情并没有辣么简单。我们看下他的svg信息。
好家伙不仔细一看,原来有两个闭合路径,为什么会有这样呢? 我这里给大家画个图 就知道了。
蓝色的其实对应的是第一个path 我们称作Outer, 红色其实对应的是内部。然后我就自然而然去思考了, 我去对数组进行分类。 主要是根据闭合曲线的Z 去分组, 也就是一个字分成多个数据。
这里的话很多人以为结束了,但是其实并没有。这里涉及到射线检测法。 算出一个文字每一个对应的order ,大概是由【true, false..】组成的数组。 false 表示逆时针, true表示 顺时针。 射线检测法的目的, 其实去判断这个path 和其他path 有没有交点, 交点为奇数其实就是逆时针, 为偶数其实就是顺时针。
射线检测法: 其实就是取每个path 的第一个点在X轴方向上发出射线,然后算出与其他path 的交点个数,这里我不细讲了, 感兴趣的可以看我这篇文章 canvas 实现事件系统
至于为什么要去判断顺序, 与我们用的算法库clipper 有关系。有外轮廓和内轮廓之分, 内轮廓我们一般叫做洞也就是hole, 为了让大家有简单的概念, 我还是画图去表示:我就以回这个字举例子:
首先回这个字是也就是有两个path, 第二个path 肯定是内轮廓 也就是顺序肯定是【false,true】
我们先看下正确✅的图形:
注意方向:外轮廓是逆时针, 内轮廓是顺时针
看下都是顺序是【true,true】的图形是这样的:
顺序错误会导致,区域都会填充。 所以为什么要有顺序了相信你也就明白了。 看下一个复杂的字吧感受下**文字的博大精深。圗 和国
我们现在其实只是一个平面图形,文字肯定是个立方体, 这里 其实主要是生成顶面和侧面, 顶面的话其实就是通过底面上的点, 在底面的法向量延长一定距离。侧面的话,其实还是底面的点和顶面对应的点连起来的一条直线, 然后形成侧面。 我还是画图:
每一个侧面大概是这样的一个过程。虚线就是对应点的连线,然后形成侧面。这个过程看着十分简单,其实在去写的时候还是十分复杂的。
交互层面的思考主要是三维空间中矩阵的应用。我们主要讲下这几点:
这里的话是这样的生成的svg 信息比如说他的开始点, 并不是在原点,但是我转到3d的世界坐标系,肯定默认是在原点的。所以的话,这里算出输入的字体的所有2d的信息,都要做一个偏移Matrix,因为在画布中移动,也就是文字跟着鼠标的点移动, 鼠标在哪里然后文字就在那里。这时候的移动Matrix 是相对世界原点的。所以这一层转换是非常重要的,而且还有一个非常值得注意的点是: svg 和canvas 的坐标系是在左上角的,也就是转到3d下来Y轴是要取反。 我还是画图表示下哈:
其实是这样的, 当你输入一行字默认是水平的,但是有需求我想把他搞成垂直的。 这里就是对应的就是在X轴偏移和 Y轴偏移的问题。 openType 默认是 可以批量解析字体的,但是呢我们不采用, 我还是一个个文字去处理,做到可控制。问题来了,每一个文字之间的间距, 怎么确保他们不相交呢? 其实这里又涉及到计算每一个文字的boundingBox, 算出boundingBox之后呢,然后做一个距离叠加, 类似于reduce。因为输入的字有很多越往后面, 距离越大呗。 缩放的话,其实是这样的,根据现有字体的大小 除上 基础字体大小 比如是20 算出一个scale, scale 可以算出缩放矩阵。物体字体大小变大, 然后✖️ 缩放矩阵。 那么bounding box 自然也变化了。 整个一流程就是这样的:
虚线框可以想象成每个矩形的bouding, 就是每个字, 每个字变化了, 矩形变化,想在 X轴 就在X轴,想在Y轴 就在Y轴。
吸附这东西其实没有啥悬乎的东西:
总结下来就这两个东西。 这里因为文字默认加载到的是相对于 世界坐标系的原点的, 比如你想吸附三维空间中的任意平面。 所以说你可以基于这个平面建立一个局部坐标系,其实本质上就是世界坐标系 —— 局部坐标系的转换, 吸附到任意平面本质上,你可以只可以获得一个平面的法向量, 至少2个轴去确定一个局部坐标系, 这里默认选取X轴的正方向, 这样。这里 用到了three.js 的一个方法叫做lookat, 其实也就是模拟相机去算出这个矩阵。
参数就是个vector
vector - 一个表示世界空间中位置的向量。
也可以使用世界空间中x、y和z的位置分量。
旋转物体使其在世界空间中面朝一个点。
由于还要让文字始终面对照相机📷 ,所以要计算照相机的方向 和平面的法向量去做点乘,来判断其他轴是否反向。大概就是这样:
我们看下gif:
本期的分享到此结束,如果你举得我哪里有写的不对的地方,欢迎评论区交流指正,如果想试玩的话, 可以百度搜索🔍红星设计云,https://www.mshejiyun.com/ 里面有很多好玩的工具。我是喜欢图形的Fly,我们下次再见👋拉, 如果有收获,别忘了点赞收藏加关注。
关注我的公众号 前端图形,获取更多好玩与有趣的图形知识。如果你也一样对技术热爱,喜欢图形和数据可视化📚并且为之着迷,欢迎加我个人微信(wzf582344150),将会邀请你加入我们的可视化交流学习群一起面向快乐编程~ 🦄。
我是Fly,在这个互联网技术疯狂快速迭代的时代中,很高兴能和你一起变强!😉
这是我参与更文挑战的第6天,活动详情查看: 更文挑战
在我们日常开发中贝塞尔曲线无处不在:
所以掌握贝塞尔曲线势在必得。 这篇文章主要是实战篇,不会介绍和贝塞尔相关的知识, 如果有同学对贝塞尔曲线不是很清楚的话:可以查看我这篇文章——深入理解SVG
第一步我们先创建ctx, 用ctx 画一个二阶贝塞尔曲线看下。二阶贝塞尔曲线有1个控制点,一个起点,一个终点。
const canvas = document.getElementById( 'canvas' );
const ctx = canvas.getContext( '2d' );
ctx.beginPath();
ctx.lineWidth = 2;
ctx.strokeStyle = '#000';
ctx.moveTo(100,100)
ctx.quadraticCurveTo(180,50, 200,200)
ctx.stroke();
这样我们就画好了一个贝塞尔曲线了。
画一条线谁不会哇?接下来文章的主体内容。 首先试想一下动画我们肯定一步步画出曲线? 但是这个ctx给我们全部画出来了是不是有点问题。我们重新看下二阶贝塞尔曲线的实现过程动画,看看是否有思路。
从图中可以分析得出贝塞尔上的曲线是和t有关系的, t的区间是在0-1之间,我们是不是可以通过二阶贝塞尔的曲线方程去算出每一个点呢,这个专业术语叫离散化,但是这样的得出来的点的信息是不太准的,我们先这样实现。
先看下方程:
我们模拟写出代码如如下:
//这个就是二阶贝塞尔曲线方程
function twoBezizer(p0, p1, p2, t) {
const k = 1 - t
return k * k * p0 + 2 * (1 - t) * t * p1 + t * t * p2
}
//离散
function drawWithDiscrete(ctx, start, control, end,percent) {
for ( let t = 0; t <= percent / 100; t += 0.01 ) {
const x = twoBezizer(start[0], control[0], end[0], t)
const y = twoBezizer(start[1], control[1], end[1], t)
ctx.lineTo(x, y)
}
}
我们看下效果:
和我们画的几乎是一模一样,接下啦就用requestAnimationFrame 开始我们的动画给出以下代码:
let percent = 0
function animate() {
ctx.clearRect( 0, 0, 800, 800 );
ctx.beginPath();
ctx.moveTo(100,100)
drawWithDiscrete(ctx,[100,100],[180,50],[200,200],percent)
ctx.stroke();
percent = ( percent + 1 ) % 100;
id = requestAnimationFrame(animate)
}
animate()
这里有两个要注意的是, 我是是percent 不断加1 和100 求余,所以呢 percent 会不断地从1-100 这样往复,OK所以我们必须要动画之前做一次区域清理, ctx.clearRect( 0, 0, 800, 800 ); 这样就可以不断的从开始到结束循环往复,我们看下效果:
看着样子是不是还不错哈哈哈😸。
你以为这样就结束了? 当然不是难道我们真的没有办法画出某一个t的贝塞尔曲线了? 当前不是,这里放一下二阶贝塞尔方程的推导过程:
二阶贝塞尔曲线上的任意一点,都是可以通过同样比例获得。 在两点之间的任意一点,其实满足的一阶贝塞尔曲线, 一阶贝塞尔曲线满足的其实是线性变化。我给出以下方程
function oneBezizer(p0,p1,t) {
return p0 + (p1-p0) * t
}
从我画的图可以看出,我们只要 不断求A点 和C点就可以画出在某一时间段的贝塞尔了。
我给出以下代码和效果图:
function drawWithDiscrete2(ctx, start, control, end,percent) {
const t = percent/ 100;
// 求出A点
const A = [];
const C = [];
A[0] = oneBezizer(start[0],control[0],t);
A[1] = oneBezizer(start[1],control[1],t);
C[0] = twoBezizer(start[0], control[0], end[0], t)
C[1] = twoBezizer(start[1], control[1], end[1], t)
ctx.quadraticCurveTo(
A[ 0 ], A [ 1 ],
C[ 0 ], C[ 1 ]
);
}
上文我们实现了一条贝塞尔线,我们将这条贝塞尔的曲线的开始点作为一个圆的圆心,然后按照某个次数求出不同的结束点。 再写一个随机颜色,礼花效果就成了, 直接上代码,
for(let i=0; i<count; i++) {
const angle = Math.PI * 2 / count * i;
const x = center[ 0 ] + radius * Math.sin( angle );
const y = center[ 1 ] + radius * Math.cos( angle );
ctx.strokeStyle = colors[ i ];
ctx.beginPath();
drawWithDiscrete(ctx, center,[180,50],[x,y],percent)
ctx.stroke();
}
function getRandomColor(colors, count) {
// 生成随机颜色
for ( let i = 0; i < count; i++ ) {
colors.push(
'rgb( ' +
( Math.random() * 255 >> 0 ) + ',' +
( Math.random() * 255 >> 0 ) + ',' +
( Math.random() * 255 >> 0 ) +
' )'
);
}
}
我们看下动画吧:
本篇文章到这里就结束了,如果看了对你有帮助的, 欢迎点个赞👍和关注。 你的支持是我持续更新的最大动力。 所有代码都在我的github上。最后祝大家端午节快乐!
大家好!我是Fly哥,最近做了很多粒子动画, 对canvas 实现粒子动画有了一点小感悟,前几天北方都下雪了, 身在魔都的我们, 一点点雪的影子都没有。而下雪动画作为粒子动画中我觉得算是比较简单的好理解, 先把这篇文章讲完,后面再去给大家讲 酷炫的 canvas 文字烟花动画。 本篇文章大概阅读花费7分钟, 你可以学到如何去实现一个粒子动画,我觉得把思路学会了, 后面产品假设提任何需求你都是可以cover 住的。 废话不多说, 直接先看先看效果:
看着是不是像辣么回事,哈哈哈哈, fly哥写的基础版,圣诞节快到了,你可以再我的基础上做下面👇🏻几个优化
可能有的人到现在还不是很清楚啥是粒子动画:
粒子是什么?粒子是一种微小的物体,比如像我们周边环境中的雪花,火星等物体。因此在游戏中一般都用粒子特效来模拟咱们现实生活中的许多自然现象。
粒子系统是众多粒子的集合。一般具有具有粒子的更新,显示,销毁及其创建粒子等性质。不同的粒子系统具有不同的粒子行为,所以所具有的性质会略有区别。
粒子动画其实很容易理解了,就是很多个粒子按照某个特定的运动轨迹组合起来的动画,所以做粒子类属性 会带有一些物理属性, 比如重力,风的阻力, 加速度。。。 这些东西,其实主要为了更加贴近自然,更加真实。物理比较差的同学记得把物理知识补一下。
简单介绍上面的概念后,大家可以结合上面的动画简单分析一下,雪粒子这个类应该有哪些属性呢???
首先我雪是用ctx.arc 去画的,所以呢?? 肯定有 x 和 y 轴 坐标 ,还有 radius 半径
这时候有同学开始抢答了?说fly哥,你上面提到了粒子的速度, 雪花肯定每一个下的速度不一样, 肯定有一个 vx, vy
哎呦不错哦, 现在已经5个属性了, 还有没有属性呢, 这时候有个细心的妹子站出来了, 我看到了透明度的变化,确实 还是妹子细心哇!!
大家在思考下, 还有没有什么属性呢? 看着同学们雅雀无声, 我给一个提醒, 如何保持雪一直下,难道我要不断添加粒子嘛, 有没有边界啥的??
有同学就说:不亏是fly哥,思考问题就是全面哇,哈哈哈哈其实这里涉及到 就一个属性 边界检测呗, 不过没辣么夸张, 就是是一个最大距离 maxDistance, 如果超过了 最大距离 我们就让它从一开始落呗, 不就实现了永动机的效果!
理论说的都差不多了,我这里写的都是伪代码,本着 授人以鱼不如授人以渔 的目的, 你如果真的想学, 跟着我的思路做一篇肯定很没问题!
创建canvas 拿到canvas 的上下文 这个应该不用多讲了
const canvas = document.getElementById( 'canvas' );
const ctx = canvas.getContext( '2d' );
主要是每一个小的雪花粒子:
class Snow {
dia: number
fill: string
vy: number
vx: number
z: number
y: number
x: number
maxDistance: number
width: number
height: number
constructor(width: number, height: number, maxDistance: number) {
this.x = Math.random() * (width + maxDistance) - maxDistance / 2
this.y = Math.random() * (height + maxDistance) - maxDistance / 2
this.maxDistance = maxDistance
this.width = width
this.height = height
this.z = Math.random() * 0.5 + 0.5
this.vx = (Math.random() * 2 - 0.5) * this.z
this.vy = (Math.random() * 1.5 + 1.5) * this.z
this.fill = 'rgba(255,255,255,' + (0.5 * Math.random() + 0.5) + ')'
this.dia = (Math.random() * 2.5 + 1.5) * this.z
}
draw(ctx: CanvasRenderingContext2D) {
ctx.beginPath()
ctx.strokeStyle = 'transparent'
ctx.fillStyle = this.fill
ctx.arc(this.x, this.y, this.dia, 0, 2 * Math.PI)
ctx.closePath()
ctx.stroke()
ctx.fill()
return this
}
update() {
this.x += this.vx
this.y += this.vy
if (this.x > this.width + this.maxDistance / 2) {
this.x = -(this.maxDistance / 2)
} else if (this.x < -(this.maxDistance / 2)) {
this.x = this.width + this.maxDistance / 2
}
if (this.y > this.height + this.maxDistance / 2) {
this.y = -(this.maxDistance / 2)
} else if (this.y < -(this.maxDistance / 2)) {
this.y = this.height + this.maxDistance / 2
}
}
}
这里面 把上面的所说的属性 都讲到了, 其实无论你做任何粒子, 他都有一个创建 和 更新 ,因为你是做动画,
比如第一帧画面 是没有雪花粒子的, 所以第一帧 就是创建粒子,然后后面每一帧其实就是改变粒子的位置 就好了,不断重复这样的过程 配合 requestanimation 去实现。 后面每一帧 就是更新 update 函数,
我这里还是解释下: 粒子不断加 一个固定的速度 vx vy ,然后做了边界判断 , 你可以自己去修改的。 由于 有不同的粒子, 粒子的大小 透明度 、速度 都是不同的, 所以 使用了random
动画实现 很简单 就两步骤
// 第一帧 创建1000 个
for (let i = 0; i < 1000; i++) {
points.push(new Snow(100, 100, 100))
}
// 后面都是更新
ctx.clearRect(0, 0, view.width, view.height)
ctx.fillStyle = 'rgba(0,128,255,1)'
ctx.fillRect(0, 0, view.width, view.height)
// 调用每个粒子的更新 函数
points.forEach((point) => {
point.draw(ctx).update()
})
如果还是有同学还是要看源码的, 直接关注公众号,私信我就好了!!
很感谢你能看到这里,如果觉得写的不错, 帮我点个赞和在看,让更多同学看到, 如果有任何问题欢迎直接私信我。多交流,多学习。欢迎**「长按图片 加 Fly 为好友」**,我会精选优质数据可视化、游戏、图形相关好文、还有各种大厂面试文章等等,立志做一个有温度的公众号。
围观fly哥朋友圈👬🏻
2021陪你一起度过!
回复思维导图 免费获取20G 前端数据可视化学习资料!
回复 加群 拉你进可视化交流技术群,一起吹水,长期学习交流2021陪你一起度过!
回复面试 获得一线大厂相关面经文章
终于到周末了,前几篇的文章一直给大家介绍2d,canvas 和svg的一些东西。7月份我打算输出3篇万字长文带大家系统地学习可视化表达的3种方式,svg、canvas、webgl。所以这是第一篇文章3d的。 读完本篇文章,你可以学到什么
官网对 Threejs 的介绍非常简单:“Javascript 3D library”。openGL 是一个跨平台3D/2D的绘图标准,WebGL 则是openGL 在浏览器上的一个实现。web前端开发人员可以直接用WebGL 接口进行编程,但 WebGL 只是非常基础的绘图API,需要编程人员有很多的数学知识、绘图知识才能完成3D编程任务,而且代码量巨大。Threejs 对 WebGL 进行了封装,让前端开发人员在不需要掌握很多数学知识和绘图知识的情况下,也能够轻松进行web 3D开发,降低了门槛,同时大大提升了效率。总结来一句话: 就是你不懂计算机图形学,只要理解了three.js的一些基本概念你可以。
定义如下:
场景:是一个三维空间,所有物品的容器,可以把场景想象成一个空房间,接下来我们会往房间里放要呈现的物体、相机、光源等。
用代码表示就是如下:
const scene = new THREE.Scene();
你就把他想象成一个房间,然后你可以往里面去添加一些物体,加一个正方体哈,加矩形,什么都可以。其实three.js 整个之间的关系是一个 树形结构。
相机:Threejs必须要往场景中添加一个相机,相机用来确定位置、方向、角度,相机看到的内容就是我们最总在屏幕上看到的内容。在程序运行过程中,可以调整相机的位置、方向和角度。
three.js 中的相机分为两种一种是正交相机📷 和透视相机📷,接下来我给大家一一介绍,但是理解照相机的情况下,你要先理解一个概念——视椎体
视锥体是摄像机可见的空间,看上去像截掉顶部的金字塔。视锥体由6个裁剪面围成,构成视锥体的4个侧面称为上左下右面,分别对应屏幕的四个边界。为了防止物体离摄像机过近,设置近切面,同时为了防止物体离摄像机太远而不可见,设置远切面。
oc 就是照相机的位置, 近平面、和远平面图中已经标注。从图中可以看出,棱台组成的6个面之内的东西,是可以被看到的。 影响透视照相机的大小因素:
对应的three 中的照相机:
const camera = new THREE.PerspectiveCamera( 45, width / height, 1, 1000 );
透视相机最大的特点:就是符合我们人眼观察事物的特点, 近大远小。
近大远小的背后的实现原理就是相机会有一个投影矩阵: 投影矩阵的做的事情很简单,就是把视椎体转换成一个正方体。 所以远截面的点就要缩小, 近距离的反而放大。
正交相机的特点就是视椎体的是一个立方体
在这种投影模式下,无论物体距离相机距离远或者近,在最终渲染的图片中物体的大小都保持不变。
这对于渲染2D场景或者UI元素是非常有用的。如图:
three中代码如下:
const camera = new THREE.OrthographicCamera( width / - 2, width / 2, height / 2, height / - 2, 1, 1000 );
说完相机就要介绍下图形的组成形式了。
在计算机的世界里,一条弧线是由有限个点构成的有限条线段连接得到的。当线段数量越多,长度就越短,当达到你无法察觉这是线段时,一条平滑的弧线就出现了。 计算机的三维模型也是类似的。只不过线段变成了平面,普遍用三角形组成的网格来描述。我们把这种模型称之为 Mesh 模型。
一条弧线由多条线段得到,线段的数量越多,越接近弧线。 不懂的小伙伴,可以看下我的这篇文章:面试官问我会canvas? 我可以绘制一个烟花🎇动画里面贝塞尔曲线可以是用一段段小线段去拟合起来的 。
three.js 背后所有的图形在进行渲染之前, 都会进行三角化, 然后交给webgl 去渲染。
Threejs提供了一些常见的几何形状,有三维的也有二维的,三维的比如长方体、球体等,二维的比如长方形圆形等,如果默认提供的形状不能满足需求,也可以自定义通过定义顶点和顶点之间的连线绘制自定义几何形状,更复杂的模型还可以用建模软件建模后导入。
有了形状,可能渲染出来的图形没有美丽的样子,这时候材质就出来了。 组成的mesh其实是有两个部分:
材质(Material)+几何体(Geometry)就是一个 mesh,Threejs提供了集中比较有代表性的材质,常用的用漫反射、镜面反射两种材质,还可以引入外部图片,贴到物体表面,成为纹理贴图。大家有兴趣可以自己去试一下。如图:
假如没有光,摄像机看不到任何东西,因此需要往场景添加光源,为了跟真实世界更加接近,Threejs支持模拟不同光源,展现不同光照效果,有点光源、平行光、聚光灯、环境光等。
环境光会均匀的照亮场景中的所有物体,环境光不能用来投射阴影,因为它没有方向。
const light = new THREE.AmbientLight( 0x404040 ); // soft white light
平行光是沿着特定方向发射的光。这种光的表现像是无限远,从它发出的光线都是平行的。常常用平行光来模拟太阳光 的效果; 太阳足够远,因此我们可以认为太阳的位置是无限远,所以我们认为从太阳发出的光线也都是平行的。
const directionalLight = new THREE.DirectionalLight( 0xffffff, 0.5 );
从一个点向各个方向发射的光源。一个常见的例子是模拟一个灯泡发出的光。
const light = new THREE.PointLight( 0xff0000, 1, 100 );
光线从一个点沿一个方向射出,随着光线照射的变远,光线圆锥体的尺寸也逐渐增大。
const spotLight = new THREE.SpotLight( 0xffffff );
还有一些其他的灯光,感兴趣的小伙伴可以自行去three.js 官网查看。
渲染器就是去渲染你场景中灯光、相机、网格哇。
let renderer = new THREE.WebGLRenderer({
antialias: true, // true/false表示是否开启反锯齿
alpha: true, // true/false 表示是否可以设置背景色透明
precision: 'highp', // highp/mediump/lowp 表示着色精度选择
premultipliedAlpha: false, // true/false 表示是否可以设置像素深度(用来度量图像的分率)
preserveDrawingBuffer: true, // true/false 表示是否保存绘图缓冲
maxLights: 3, // 最大灯光数
stencil: false // false/true 表示是否使用模板字体或图案
three.js大体的一些要素我都介绍过了,接下面就进入在正题了,three.js 如何实现一个可视化地图呢?
我先不管地图不地图的,地图的这些形状肯定是放置到场景中的。跟着我的脚步一步一步去搭建一个场景。场景的搭建就照相机,渲染器。我用一个map类来表示代码如下:
class chinaMap {
constructor() {
this.init()
}
init() {
// 第一步新建一个场景
this.scene = new THREE.Scene()
this.setCamera()
this.setRenderer()
}
// 新建透视相机
setCamera() {
// 第二参数就是 长度和宽度比 默认采用浏览器 返回以像素为单位的窗口的内部宽度和高度
this.camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
)
}
// 设置渲染器
setRenderer() {
this.renderer = new THREE.WebGLRenderer()
// 设置画布的大小
this.renderer.setSize(window.innerWidth, window.innerHeight)
//这里 其实就是canvas 画布 renderer.domElement
document.body.appendChild(this.renderer.domElement)
}
// 设置环境光
setLight() {
this.ambientLight = new THREE.AmbientLight(0xffffff) // 环境光
this.scene.add(ambientLight)
}
}
上面我做了一一的解释,现在场景有了,灯光也有了, 我们看下样子。
对场景黑乎乎的什么都没有, 接下来我们我随便随便加一个长方体并且调用renderer的render方法。代码如下:
init() {
//第一步新建一个场景
this.scene = new THREE.Scene()
this.setCamera()
this.setRenderer()
const geometry = new THREE.BoxGeometry()
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 })
const cube = new THREE.Mesh(geometry, material)
this.scene.add(cube)
this.render()
}
//render 方法
render() {
this.renderer.render(this.scene, this.camera)
}
按照上面👆去做你会页面还是明明都已经加了,为什么呢?
默认情况下,当我们调用scene.add()的时候,物体将会被添加到(0,0,0)坐标。但将使得摄像机和立方体彼此在一起。为了防止这种情况的发生,我们只需要将摄像机稍微向外移动一些即可
所以只要将照相机的位置z轴属性调整一下就可以到图片了
// 新建透视相机
setCamera() {
// 第二参数就是 长度和宽度比 默认采用浏览器 返回以像素为单位的窗口的内部宽度和高度
this.camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
)
this.camera.position.z = 5
}
图片如下:
这时候有同学就会问,嗯搞半天不和canvas 2d 一样嘛,有什么区别? 看不出立体的感觉? OK 接下来我就让这个立方体动起来。 其实就是不停的去调用 我们render 函数。 我们用reqestanimationframe。尽量还是不要用setInterval,有一个很简单的优化。
requestAnimationFrame有很多的优点。最重要的一点或许就是当用户切换到其它的标签页时,它会暂停,因此不会浪费用户宝贵的处理器资源,也不会损耗电池的使用寿命。
我这里做的让立方体的x,y 不断的+0.1。 先看下代码:
render() {
this.renderer.render(this.scene, this.camera)
}
animate() {
requestAnimationFrame(this.animate.bind(this))
this.cube.rotation.x += 0.01
this.cube.rotation.y += 0.01
this.render()
}
效果图如下:
是不是有那个那个感觉了, 我是以最简单的立方体的旋转,带大家从头入门下three.js。 如果看到这里觉得这里,觉得对你有帮助的话,希望你能给我点个赞👍哦,感谢各位老铁了!下面正式地图需求分析。
其实最重要的是获取地图数据, 大家可以了解下openStreetMap
这个是一个可供自由编辑的世界地图。OpenStreetMap允许你查看,编辑或者使用世界各地的地理数据来帮助你。
这里我自己把**地图的数据json拷贝下来了,代码如下:
// 加载地图数据
loadMapData() {
const loader = new THREE.FileLoader()
loader.load('../json/china.json', (data) => {
const jsondata = JSON.parse(JSON.stringify(data))
})
}
我给大家先看下json 数据的格式
![image-20210703154646470](/Users/wangzhengfei/Library/Application Support/typora-user-images/image-20210703154646470.png)
其实主要的是下面有个经纬度坐标, 其实这个才是我关心的,有了点才能生成线,最后才能生成平面。 这里涉及到一个知识点, 墨卡托投影转换。 墨卡托投影转换可以把我们经纬度坐标转换成我们对应平面的2d坐标。 大家对这个推导过程的感性的可以看下这篇文章: 传送门
这里我直接用可视化框架——d3 它里面有自带的墨卡托投影转换。
// 墨卡托投影转换
const projection = d3
.geoMercator()
.center([104.0, 37.5])
.scale(80)
.translate([0, 0])
由于**有很多省,每个省都对应一个Object3d。
Object3d是three.js 所有的基类, 提供了一系列的属性和方法来对三维空间中的物体进行操纵。可以通过.add( object )方法来将对象进行组合,该方法将对象添加为子对象
我这里的整个**是一个大的Object3d,每一个省是一个Object3d,省是挂在**下的。 然后**这个Map挂在scene这个Object3d下。 很明显,在three.js 是一个很典型的树形数据结构,我画了张图给大家看下。
Scence场景下挂了很多东西, 其中有一个就是Map, 整个地图, 然后每个省份, 每个省份又是由Mesh和lLine 组成的。
我们看下代码:
generateGeometry(jsondata) {
// 初始化一个地图对象
this.map = new THREE.Object3D()
// 墨卡托投影转换
const projection = d3
.geoMercator()
.center([104.0, 37.5])
.scale(80)
.translate([0, 0])
jsondata.features.forEach((elem) => {
// 定一个省份3D对象
const province = new THREE.Object3D()
this.map.add(province)
})
this.scene.add(this.map)
}
看到这里我想你可能没有什么问题,我们整体框架定下来了,接下来我们进入核心环节
这里用到了 Three.shape() 和 THREE.ExtrudeGeometry() 为什么会用到这个呢? 我给大家解释下, 首先每一个省份轮廓组成的下标是一个 2d坐标,但是我们要生成立方体,shape() 可以定义一个二维形状平面。 它可以和ExtrudeGeometry一起使用,获取点,或者获取三角面。
代码如下:
// 每个的 坐标 数组
const coordinates = elem.geometry.coordinates
// 循环坐标数组
coordinates.forEach((multiPolygon) => {
multiPolygon.forEach((polygon) => {
const shape = new THREE.Shape()
const lineMaterial = new THREE.LineBasicMaterial({
color: 'white',
})
const lineGeometry = new THREE.Geometry()
for (let i = 0; i < polygon.length; i++) {
const [x, y] = projection(polygon[i])
if (i === 0) {
shape.moveTo(x, -y)
}
shape.lineTo(x, -y)
lineGeometry.vertices.push(new THREE.Vector3(x, -y, 4.01))
}
const extrudeSettings = {
depth: 10,
bevelEnabled: false,
}
const geometry = new THREE.ExtrudeGeometry(
shape,
extrudeSettings
)
const material = new THREE.MeshBasicMaterial({
color: '#2defff',
transparent: true,
opacity: 0.6,
})
const material1 = new THREE.MeshBasicMaterial({
color: '#3480C4',
transparent: true,
opacity: 0.5,
})
const mesh = new THREE.Mesh(geometry, [material, material1])
const line = new THREE.Line(lineGeometry, lineMaterial)
province.add(mesh)
province.add(line)
})
})
遍历第一个点的的和canvas2d画图其实是一模一样的, 移动起点, 然后后面在划线, 画出轮廓。然后我们在这里可以设置拉伸的深度, 然后接下来就是设置材质了。lineGeometry 其实 对应的是轮廓的边线。我们看下图片吧:
为了方便调相机位置, 我增加了辅助视图, cameraHelper。 然后你回看下屏幕会出现一个十字架,然后我们就可以不断地调整相机的位置,让我们地地图处于画面的**:
addHelper() {
const helper = new THREE.CameraHelper(this.camera)
this.scene.add(helper)
}
经过辅助的视图地不断调整:
哈哈哈哈,是不是有那个味道了。到这里我们的**地图已经在画布的**了就已经实现了。
现在地图是已经生成了,但是用户交互感比较差,这里我们引入three的OrbitControls 可以用鼠标在画面随意转动,就可以看到立方体的每一个部分了。但是这个方法不在three 的包里面, 得单独引入一个文件代码如下:
setController() {
this.controller = new THREE.OrbitControls(
this.camera,
document.getElementById('canvas')
)
}
我们看下效果:
但是对于我自己而言还是不满意, 我怎么知道的我点击的是哪一个省份呢,OK这时候就要引入我们three中非常重要的一个类了,Raycaster 。
这个类用于进行raycasting(光线投射)。 光线投射用于进行鼠标拾取(在三维空间中计算出鼠标移过了什么物体)。
我们可以对canvas监听的onmouseMove 事件,然后 我们就可以知道当前移动的鼠标是选择的哪一个mesh。但是在这之前,我们先对每一个province这个对象上增加一个属性来表示他是哪一个省份的。
// 将省份的属性 加进来
province.properties = elem.properties
Ok, 我们可以引入射线追踪了带入如下:
setRaycaster() {
this.raycaster = new THREE.Raycaster()
this.mouse = new THREE.Vector2()
const onMouseMove = (event) => {
// 将鼠标位置归一化为设备坐标。x 和 y 方向的取值范围是 (-1 to +1)
this.mouse.x = (event.clientX / window.innerWidth) * 2 - 1
this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1
}
window.addEventListener('mousemove', onMouseMove, false)
}
animate() {
requestAnimationFrame(this.animate.bind(this))
// 通过摄像机和鼠标位置更新射线
this.raycaster.setFromCamera(this.mouse, this.camera)
this.render()
}
由于我们不停地在在画布移动, 所以需要不停的的射线位置。现在有了射线, 那我们需要场景的所有东西去比较了,rayCaster 也提供了方法代码如下:
const intersects = this.raycaster.intersectObjects(
this.scene.children, // 场景的
true // 若为true,则同时也会检测所有物体的后代。否则将只会检测对象本身的相交部分
)
这个intersects得到的交叉很多,但是呢我们只选择其中一个,那就是物体材质个数有两个的, 因为我们上面就是用对mesh用两个材质
const mesh = new THREE.Mesh(geometry, [material, material1])
所以过滤代码如下
animate() {
requestAnimationFrame(this.animate.bind(this))
// 通过摄像机和鼠标位置更新射线
this.raycaster.setFromCamera(this.mouse, this.camera)
// 算出射线 与当场景相交的对象有那些
const intersects = this.raycaster.intersectObjects(
this.scene.children,
true
)
const find = intersects.find(
(item) => item.object.material && item.object.material.length === 2
)
this.render()
}
我怎么知道我到底找到没,我们对找到的mesh将它的表面变成灰色,但是这样会导致一个问题,我们鼠标再一次移动的时候要把上一次的材质给他恢复过来。
代码如下:
animate() {
requestAnimationFrame(this.animate.bind(this))
// 通过摄像机和鼠标位置更新射线
this.raycaster.setFromCamera(this.mouse, this.camera)
// 算出射线 与当场景相交的对象有那些
const intersects = this.raycaster.intersectObjects(
this.scene.children,
true
)
// 恢复上一次清空的
if (this.lastPick) {
this.lastPick.object.material[0].color.set('#2defff')
this.lastPick.object.material[1].color.set('#3480C4')
}
this.lastPick = null
this.lastPick = intersects.find(
(item) => item.object.material && item.object.material.length === 2
)
if (this.lastPick) {
this.lastPick.object.material[0].color.set(0xff0000)
this.lastPick.object.material[1].color.set(0xff0000)
}
this.render()
}
看下效果图:
为了让交互更加完美,找到了同时在鼠标右下方显示个tooltip,那这个肯定是一个div默认是影藏的,然后根据鼠标的移动移动相应的位置。
第一步新建div
<div id="tooltip"></div>
第二步设置样式 默认是影藏的
#tooltip {
position: absolute;
z-index: 2;
background: white;
padding: 10px;
border-radius: 2px;
visibility: hidden;
}
第三步更改div的位置:
setRaycaster() {
this.raycaster = new THREE.Raycaster()
this.mouse = new THREE.Vector2()
this.tooltip = document.getElementById('tooltip')
const onMouseMove = (event) => {
this.mouse.x = (event.clientX / window.innerWidth) * 2 - 1
this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1
// 更改div位置
this.tooltip.style.left = event.clientX + 2 + 'px'
this.tooltip.style.top = event.clientY + 2 + 'px'
}
window.addEventListener('mousemove', onMouseMove, false)
}
最后一步设置tooltip的名字:
showTip() {
// 显示省份的信息
if (this.lastPick) {
const properties = this.lastPick.object.parent.properties
this.tooltip.textContent = properties.name
this.tooltip.style.visibility = 'visible'
} else {
this.tooltip.style.visibility = 'hidden'
}
}
到这里,整个3d可视化地球项目已经完成了, 我们一起看下效果吧。
各位读者,如果觉得看完对你有帮助的话, 希望你不要吝啬你手中的👍,点个👍和关注是对我最大的支持,知识输出不容易,而我勿忘初心,持续分享可视化的好文章,如果你对可视化感兴趣,你可以关注下面我的可视化专栏,或者可以关注我的公众号: 前端图形,持续分享计算机图形学知识。本篇文章的所有代码都在我的github 上 欢迎star, 最后文章有哪里写的不对的地方,欢迎指正交流。
大家好我是Fly哥,最近公众号来了很新朋友, 正如我公众号(前端图形)的名字一样,我会持续专注分享前端图形学知识,很多同学后台问我这个可以做什么,我这里简单回答下
图形学这个领域目前来看是很好玩也很有前景的一个方向,当我们了解它的基础知识,get到它好玩地方的时候,我们可以很轻松延伸到可视化这一领域进行拓展。本文会尽量以很通俗很详细的方式来向大家介绍,希望读者有所收获。
像这个由抛物线,阿基米德螺旋线以及星形线构成的图片,以及这颗随机树都是通过我们一些简单的数学知识来绘制成,下面我也会以详细的代码来实现这些图片。
因为是系列篇之基础入门篇,我会先从图形基础和数学基础这两个部分开始讲起:
在 Web 上,图形通常是通过浏览器绘制的。现代浏览器是一个复杂的系统,其中负责绘制图形的部分是渲染引擎。渲染引擎绘制图形的方式,一般大体上有 4 种。
与传统的 Web 应用相比,可视化项目,尤其是 PC 端的可视化大屏展现,使用HTML 与 CSS 情景相对较少,于是可能有些人会误认为,可视化只能使用 SVG、Canvas 这些方式,不能使用 HTML 与 CSS。当然了,这个想法是不对。
其实现代浏览器的HTML、CSS 表现能力很强大,其实一些简单的可视化图表,完全可以用 CSS 来实现,比如,我们常见的柱状图、饼图和折线图。能简化开发,又不需要引入额外的库,可以节省资源,提高网页打开的速度。
但是使用HTML + CSS也是有一定得弊端:
1)维护麻烦,在 CSS 代码里,我们很难看出数据与图形的对应关系,有很多换算也需要开发人员自己来做。这样一来,一旦图表或数据发生改动,就需要我们重新计算,所以维护起来会很麻烦。
那有没有更好的实现方式,当我们在重绘图像时,不会发生重新解析文档和构建结构的过程,这个当然是有的,那后面也会介绍到。
SVG 它是浏览器支持的一种基于 XML 语法的图像格式,它的 XML 语言本身和 HTML 非常接近,都是由标签 + 属性构成的,而且浏览器的 CSS、JavaScript 都能够正常作用于 SVG 元素。
接下来到了图形基础的重点,canvas2D,后续的数学基础部分也是大多数以它为基础进行绘制。
这里说一下它的声明式绘图系统和指令式绘图系统区别:
1)声明式绘图系统:我们根据数据创建各种不同的图形元素(或者 CSS 规则),然后利用浏览器渲染引擎解析它们并渲染出来。
2)指令式绘图系统:它更多的是浏览器提供的一种可以直接用代码在一块平面的画布上绘制图形的api,使用它来绘图更像是传统的“编写代码”,简单来说就是调用绘图指令,然后引擎直接在页面上绘制图形。
总结来说,如下面图所示,像Canvas 能够直接操作绘图上下文,不需要经过 HTML、CSS 解析、构建渲染树、布局等一系列操作。因此单纯绘图的话,Canvas 比 HTML/CSS 和 SVG 要快得多、在重绘图像时,也不会发生重新解析文档和构建结构的过程,开销要小很多。
这里webGL我们也不作为重点,这里我们简单说一下其使用场景:
第一种情况,如果我们要绘制的图形数量非常多,比如有多达数万个几何图形需要绘制,而且它们的位置和方向都在不停地变化,如果使用 Canvas2D 绘制,性能是会达到瓶颈的。这个时候,我们就需要使用 GPU 能力,直接用 WebGL 来绘制。
第二种情况,如果我们要对较大图像的细节做像素处理,比如,实现物体的光影、流体效果和一些复杂的像素滤镜。由于这些效果往往要精准地改变一个图像全局或局部区域的所有像素点,要计算的像素点数量非常的多(一般是数十万甚至上百万数量级的),我们也要用 WebGL 来绘制。
第三种情况是绘制 3D 物体。因为 WebGL 内置了对 3D 物体的投影、深度检测等特性,所以用它来渲染 3D 物体就不需要我们自己对坐标做底层的处理了。那在这种情况下,WebGL 无论是在使用上还是性能上都有很大优势。
要使用 WebGL 绘图,我们必须要深入细节里,换句话说就是,我们必须要和内存、GPU 打交道,真正控制图形输出的每一个细节。
数据经过CPU(**处理单元,负责逻辑计算)处理,成为具有特定结构的几何信息。然后,信息会被送到GPU(图形处理单元,负责图形计算)中进行处理。在GPU中要经过两个步骤生成光栅信息(构成图像的像素矩阵),这些光栅信息会输出到帧缓存(一块内存地址)中,最后渲染到屏幕上。
GPU 是由大量的小型处理单元构成的,它可能远远没有 CPU 那么强大,但胜在数量众多,可以保证每个单元处理一个简单的任务。即使我们要处理一张 800 * 600 大小的图片,GPU 也可以保证这 48 万个像素点分别对应一个小单元,这样我们就可以同时对每个像素点进行计算了。
这里注意一下,图里红框框住的Default levels。里面按照严重的级别排序分别为:Verbose(详细),Info(信息),Warnings(警告),Error(错误)。我们可以通过下拉框的选择搭配Filter的功能来对控制台打印的信息进行筛选。
这里首先我要先从对坐标系进行转换进行讲起,那为什么我要先讲坐标系的转换问题:因为转换坐标系对于图形学绘制而言,实在太重要了,后续所有图形的绘制都要用到这个**,具体为什么我们先从一个之前前面看到的图形讲起:
首先经过一顿坐标点换算,我们得出每个点具体的坐标(这里我用了一个Rough.js的库,绘制一个手绘风格的图像),最终算出山顶的坐标就是 (-80, 100) 和 (80, 100),山脚的坐标就是 (-180, 0)、(20, 0)、(-20, 0)、(180, 0),太阳的中心点的坐标就是 (0, 150)。
坐标系变化的方案如下:
1:首先,我们通过 translate 变换将 Canvas 画布的坐标原点,从左上角 (0, 0) 点移动至 (256, 256) 位置,即画布的底边上的中点位置。接着,以移动了原点后新的坐标为参照,通过 scale(1, -1) 将 y 轴向下的部分,即 y>0 的部分沿 x 轴翻转 180 度,这样坐标系就变成以画布底边中点为原点,x 轴向右,y 轴向上的坐标系了。
2:山顶的坐标就是 (-80, 100) 和 (80, 100),山脚的坐标就是 (-180, 0)、(20, 0)、(-20, 0)、(180, 0),太阳的中心点的坐标就是 (0, 150)。
3:其实这个思路是非常重要的,因为这个例子要绘制的图形很少,所以还不太能体现使用坐标系变换的好处。不过,可以想一下,在许多应用场景中,我们都要处理成百上千的图形。如果这个时候,我们在原始坐标下通过计算顶点来绘制图形,计算量会非常大,很麻烦。那采用坐标变换的方式就是一个很好的优化思路,它能够简化计算量,这不仅让代码更容易理解,也可以节省 CPU 运算的时间。
不管我们用什么绘图系统绘制图形,一般的几何图形都是由点、线段和面构成。其中,点和线段是基础的图元信息,因此,如何描述它们是绘图的关键。在讲解完转换坐标系后,那么接下来带大家真正开启数学知识中的向量基础
这里我先用一些代码来表示一些向量的基础知识:
接下来我们进行实战部分,用刚才介绍的向量知识来绘制一个随机的小树,这里的枝干方向是随机的。
1:第一步还是非常重要的一个坐标变换,这里,我们要做的变换是将坐标原点从左上角移动到左下角,并且让 y 轴翻转为向上。
2:我们定义一个画树枝的函数 drawBranch。
3:创建一个单位向量 (1, 0),它是一个朝向 x 轴,长度为 1 的向量。然后我们旋转 dir 弧度,再乘以树枝长度 length。这样,我们就能计算出树枝的终点坐标了。(这里我封装了一个class Vector2D里面定义了一一些方法,包括向量的旋转)
4:我们可以从一个起始角度开始递归地旋转树枝,每次将树枝分叉成左右两个分枝。这样,我们得到的就是一棵形状规律的树。
5:我们修改代码,加入随机因子,让迭代生成的新树枝有一个随机的偏转角度。这样,我们就可以得到一棵随机的树。
接下来会从向量过渡到参数方程的阶段:
1:向量绘制折线的方法来绘制正多边形,当多边形的边数非常多的时候,这个图形就会接近圆,将多边形的边数设置得很大,我们就可以绘制出圆形。
2:由于很难精确到图形的位置和大小,并且换算的过程比较繁琐会很容易出错,但是为了画出更多样的以及更多的曲线样式,我们需要选择更好的模型,接下来自然会引出参数方程。
接下来还是先带大家熟悉一下参数方程的基础概念,首先以圆形与椭圆形进行举例,他们的参数方程比较相似,这里我把公式给大家展示出来。
在代码实现参数方程的过程中呢,这里我通过设置TAU_SEGMENTS的点的数量为60平均分摊到2π(360度)上,它们可以理解成这个圆或者这个椭圆由多少个坐标点来绘制而成,然后将一个个坐标点加入到数组中。然后将包含有60个点的数组坐标返回,传入我接下来封装的draw函数中。
这里再说一下抛物线的参数方程,当x0,y0为0时,经推导 t = x/ y,这里t的含义可以x除以y的值
接下来会通过一个小demo将我们的代码整合起来,这里的图片在分享的开始也和大家介绍过,通过抛物线,阿基米德螺旋线和星形线组成。
如果我们为每一种曲线都分别对应实现一个函数,就会非常笨拙和繁琐。那为了方便,这里我们采用函数式编程**,封装一个更简单的javascript参数方程绘图模块,以此来绘制出不同的曲线。
那么封装的这个绘图模块的使用过程主要分为三步:
第一步,我们实现一个叫做 parametric 的高阶函数,它的参数分别是 x坐标的参数方程和y坐标的参数方程。
第二步,parametric 会返回一个函数,这个函数会接受几个参数,比如,start、end 这样表示参数方程中关键参数范围的参数,以及 seg 这样表示采样点个数的参数等等。在下面的代码中,当 seg 默认 100 时,就表示在 start、end 范围内采样 101(seg+1)个点,后续其他参数是作为常数传给参数方程的数据。
第三步,我们调用 parametric 返回的函数之后,它会返回一个对象。这个对象有两个属性:一个是 points,也就是它生成的顶点数据;另一个是 draw 方法,我们可以利用这个 draw 方法完成绘图
这里我们就不需要像之前一样,每一个图形线都实现其对应的一个函数,只需要统一调用我们封装好的高阶函数,传入我们的参数方程即可,最后统一调用draw函数进行绘制。
这样的话,通过这个封装好的javascript参数方程绘图模块,就可以绘制出很多有意思的图形。
接下来呢,仿射变换其实也是图形学需要了解的一个数学基础,但是由于时间关系,我会带大家简单了解一下其基本的概念。
这里先关注一下应用场景,痛点,解决方式和仿射变换。
前面我们学习了用向量表示的顶点,来描述曲线和多边形的方法。但是在实际绘制的时候,我们经常需要在画布上绘制许多轮廓相同的图形,难道这也需要我们重复地去计算每个图形的顶点吗?当然不需要。我们只需要创建一个基本的几何轮廓,然后通过仿射变换来改变几何图形的位置、形状、大小和角度。
仿射变换是拓扑学和图形学中一个非常重要的基础概念。利用它,我们才能在可视化应用中快速绘制出形态、位置、大小各异的众多几何图形。所以接下来我们就来说一说仿射变换的数学基础和基本操作,在以后的后续篇中在所有视觉呈现的案例中,是非常重要的。
仿射变换最基本的概念,大家可以记住就是 线性变换 + 平移 线性变换又分为(旋转和缩放) ,总结起来就是平移,旋转加缩放。
总结下来,合到一起就是左图的表达式,经过公示优化后为右面的矩阵形式,在改写的公式里,我们实际上是给线性空间增加了一个维度。换句话说,我们用高维度的线性变换表示了低维度的仿射变换!
以粒子动画应用场景举例,这个公式是非常重要的,它能在一定时间内生成许多随机运动的小图形,这类动画通常是通过给人以视觉上的震撼,来达到获取用户关注的效果。
在前端整体迅猛发展的大环境下,因为产品需求的驱动,图形学方向技术发展也非常迅速,前端图形学属于一个比较小众的领域,甚至延伸到可视化方向依然属于一比较小众的领域,那么真正阻挡的技术门槛是什么呢?
其实就是对使用者的数学基础比较高,但当我们真正突破了这个技术门槛,甚至可视化方向上突破了WebGL这种更偏底层的门槛,在行业里我们会是特别非常有竞争力的。
那么本次文章还是先带大家来认识一下图形学,后续系列篇中会再延伸到动画、滤镜等其他视觉基础上。这里分享给大家一句话,学习技术的过程,是从接纳和记忆知识开始的,但绝不仅仅是接纳和记忆知识,而是需要深入思考,并自己总结和沉淀的。
2020东京奥运会已经开幕很多天了,还记得小时候看奥运会的是在2008年的北京奥运会,主题曲是北京欢迎你, 那个时候才上小学吧,几乎有**队的每场必看,当时也是热血沸腾了, 时间转眼已经到了2021年而我也从小学生变成了一个每天不断敲代码的程序员👩💻,看奥运的时间又少,但是又想出分力,既然是程序员,想着能为奥运会搞点什么?第一时间想到了就是给奥运奖牌数🏅做可视化,因为单看表格数据,不能体现出我们**的牛逼🐂, 废话不多说,直接开写。
我们先看下奥运奖牌数的表格,这东西肯定是接口获得的吧,我不可能手写吧,而且每天都是更新的,难道我要每天去改,肯定不是这样的,我当时脑子里就想着去做爬虫,去用puppeteer 去模拟浏览器的行为然后获取页面的原生dom,然后将表格的数据搞出来, 然后我就很兴奋的去搞了,写了下面的代码:
const puppeteer = require('puppeteer')
async function main() {
// 启动chrome浏览器
const browser = await puppeteer.launch({
// // 指定该浏览器的路径
// executablePath: chromiumPath,
// 是否为无头浏览器模式,默认为无头浏览器模式
headless: false,
})
// 在一个默认的浏览器上下文中被创建一个新页面
const page1 = await browser.newPage()
// 空白页刚问该指定网址
await page1.goto(
'https://tiyu.baidu.com/tokyoly/home/tab/%E5%A5%96%E7%89%8C%E6%A6%9C/from/pc'
)
// 等待title节点出现
await page1.waitForSelector('title')
// 用page自带的方法获取节点
// 用js获取节点
const titleDomText2 = await page1.evaluate(() => {
const titleDom = document.querySelectorAll('#kw')
return titleDom
})
console.log(titleDomText2, '查看数据---')
// 截图
//await page1.screenshot({ path: 'google.png' })
// await page1.pdf({
// path: './baidu.pdf',
// })
browser.close()
}
main()
然后当我很兴奋的想要去结果的时候,结果发现是空。百度是不是做了反爬虫协议, 毕竟我是爬虫菜鸟,搞了很久。还是没搞出来。如果有大佬会,欢迎指点我下哦!
不过这个puppeteer,这个库有点牛皮的,可以实现网页截图、生成pdf、拦截请求,其实有点自动化测试的感觉。感兴趣的同学可以自行了解一下,这不在本篇文章介绍的重点。
然后这时候就开始疯狂百度,开始寻找有没有现成的api, 真是踏破铁鞋无觅处,得来全不费工夫。被我找到了,原来是有大佬已经开始做了, 这时候我本地直接去请求那个接口是有问题的,前端不得不处理的问题—— 跨域。 看着东西我头疼哇, 不过没关系, 我直接node起一个服务器, 我node去请求那个接口,然后后台在配置下跨域, 搞定接口数据就直接获得了, 后台服务我是用的express, 搭建的服务器直接随便搞搞的。代码如下:
const axios = require('axios')
const express = require('express')
const request = require('request')
const app = express()
const allowCrossDomain = function (req, res, next) {
res.header('Access-Control-Allow-Origin', '*')
res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE')
res.header('Access-Control-Allow-Headers', 'Content-Type')
res.header('Access-Control-Allow-Credentials', 'true')
next()
}
app.use(allowCrossDomain)
app.get('/data', (req, res) => {
request(
{
url: 'http://apia.yikeapi.com/olympic/?appid=43656176&appsecret=I42og6Lm',
method: 'GET',
headers: { 'Content-Type': 'application/json' },
},
function (error, response, body) {
if (error) {
res.send(error)
} else {
res.send(response)
}
}
)
})
app.listen(3030)
这样我就是实现了接口转发,也搞定了跨域问题,前台我直接用 fetch去请求数据然后做一层数据转换,但是这个接口不能频繁请求,动不动就crash, 是真的烦, OK所以直接做了一个操作, 将数据 存到localstorage中,然后做一个定时刷新,时间大概是一天一刷。这样就保证数据的有效性。代码如下:
getData() {
let curTime = Date.now()
if (localStorage.getItem('aoyun')) {
let { list, time } = JSON.parse(localStorage.getItem('aoyun'))
console.log(curTime - time, '查看时间差')
if (curTime - time <= 24 * 60 * 60 * 60) {
this.data = list
} else {
this.fetchData()
}
} else {
this.fetchData()
}
}
fetchData() {
fetch('http://localhost:3030/data')
.then((res) => res.json())
.then((res) => {
const { errcode, list } = JSON.parse(res.body)
if (errcode === 100) {
alert('接口请求太频繁')
} else if (errcode === 0) {
this.data = list
const obj = {
list,
time: Date.now(),
}
localStorage.setItem('aoyun', JSON.stringify(obj))
}
})
.catch((err) => {
console.log(err)
})
}
数据如下图所示 :
其实我想了很多表达**金牌数的方式,最终我还是选择用2d柱状图去表示,并同时做了动画效果,显得每一快金牌🏅来的并不容易。我还是用原生手写柱状图不去使用Echarts 库, 我们首先先看下柱状图:
从图中可以分析出一些元素
在页面上创建canvas和获取canvas的一些属性,并对canvas绑上移动事件。代码如下:
get2d() {
this.canvas = document.getElementById('canvas')
this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this))
this.ctx = this.canvas.getContext('2d')
this.width = canvas.width
this.height = canvas.height
}
坐标轴本质上也是一个直线,直线对应的两个点,不同的直线其实就是对应的端点不同,所以我直接封装了一个画直线的方法:
// 画线的方法
drawLine(x, y, X, Y) {
this.ctx.beginPath()
this.ctx.moveTo(x, y)
this.ctx.lineTo(X, Y)
this.ctx.stroke()
this.ctx.closePath()
}
可能有的人对canvas不熟悉,这里我还是大概说下, 开启一段路径, 移动画笔到开始的点, 然后画直线到末尾的点,然后描边 这一步是canvas做渲染, 很重要,很多小白不写, 直线就不出来, 然后闭合路径。 结束over!
画坐标轴我们首先先确定原点在哪里,我们首先给画布向内缩一个padding距离,然后呢,算出画布实际的宽度和高度。
代码如下:
initChart() {
// 留一个内边距
this.padding = 50
// 算出画布实际的宽度和高度
this.cHeight = this.height - this.padding * 2
this.cWidth = this.width - this.padding * 2
// 计算出原点
this.originX = this.padding
this.originY = this.padding + this.cHeight
}
有了原点我们就可以画X轴和Y轴了, 只要加上实际画布对应的宽度和高度 就好了 。 代码如下:
//设置canvas 样式
this.setCanvasStyle()
// 画x轴
this.drawLine(
this.originX,
this.originY,
this.originX,
this.originY - this.cHeight
)
// 画Y轴
this.drawLine(
this.originX,
this.originY,
this.originX + this.cWidth,
this.originY
)
第一个 函数就是设置canvas画笔的样式的,其实这东西没什么。 我们看下效果:
很多人以为到这里就结束了哈哈哈, 那你想太多了, canvas我设置的画线宽度是1px 为什么看图片的线的宽度像是2px?不仔细观察根本发现不了这个问题, 所以我们要学会思考这到底是什么问题?其实这个问题也是我看Echarts源码发现的, 学而不思则罔,思而不学则殆哇!
在这里我举一个例子, 你就明白了, 假设我要画从(50,10) 到 (200,10)这样的一条直线。为了画这条线,浏览器首先到达初始起点(50,10)。这条线宽1px,所以两边各留0.5px。所以基本上初始起点是从(50,9.5)延伸到(50,10.5)。现在浏览器不能在屏幕上显示0.5像素——最小阈值是1像素。浏览器别无选择,只能将起点的边界延伸到屏幕上的实际像素边界。它会在两边再加0.5倍的“垃圾”。所以现在,最初的起点是从(50,9)扩展到(50,11),所以看起来有2px宽。情况如下:
现在你就应该明白了原来浏览器不能显示0.5像素哇, 四舍五入了, 知道了 问题我们就一定有解决方案
ctx.translate (x,y ) 这个方法:
translate()
方法, 将 canvas 按原始 x点的水平方向、原始的 y点垂直方向进行平移变换
如图:
说的更直白点, 你对canvas做了translate变化后, 你之前所有画的点,都会相对偏移。 所以呢,回到我们这个问题上来, 解决办法就是什么呢?就我将画布 整体向下偏移 0.5 , 所以原本坐标 (50,10) 变成了(50.5,10.5) 和(200.5, 10.5)ok 然后浏览器的再去画的 他还是要预留像素, 所以就是从(50.5, 10) 到(50.5, 11) 这个区间去画OK, 就是1px了。我们来try it.
代码如下:
this.ctx.translate(0.5, 0.5)
// 画x轴
this.drawLine(
this.originX,
this.originY,
this.originX,
this.originY - this.cHeight
)
// 画Y轴
this.drawLine(
this.originX,
this.originY,
this.originX + this.cWidth,
this.originY
)
this.ctx.translate(-0.5, -0.5)
偏移完之后还是要恢复过去的, 还是要十分注意的。 我画了两张图作比对:
不多说了, 看到这里,如果觉得对你有帮助的话, 或者学到了话, 我是希望你给我点赞👍、评论、加收藏。
我们现在只有X轴和Y轴, 光秃秃的,我给X轴和Y轴底部增加一些标尺,X轴对应的标尺,肯定就是每个国家的名字,大概的思路就是数据的数量去做一个分段, 然后去填充就好了。
代码如下:
drawXlabel() { const length = this.data.slice(0, 10).length this.ctx.textAlign = 'center' for (let i = 0; i < length; i++) { const { country } = this.data[i] const totalWidth = this.cWidth - 20 const xMarker = parseInt( this.originX + totalWidth * (i / length) + this.rectWidth ) const yMarker = this.originY + 15 this.ctx.fillText(country, xMarker, yMarker, 40) // 文字 }}
这里的话我截取了排名前10的国家, 分断的思路, 首先两边留白20px, 我们首先先定义每一个柱状图的宽度 假设是 30 对应上文的 this.rectWidth, 然后每个文字的坐标 其实就很好算了, 起初的x + 所占的分端数 + 矩形宽度就可以画出来了
如图:
x轴画完了,我们开始画Y轴, Y轴的大概思路就是 以最多的奖牌数去做分段, 这里我就分成6段吧。
// 定义Y轴的分段数this.ySegments = 6//定义字体最大宽度this.fontMaxWidth = 40
接下啦我们就开始计算Y轴每个点的Y坐标, X坐标其实很好计算 只要原点坐标的X向左平移几个距离就好了,主要是计算Y轴的坐标, 这里一定要注意的是, 我们从坐标是相对于左上角的, 所以呢, Y轴的坐标应该是向上递减的。
drawYlabel() { const { jin: maxValue } = this.data[0] this.ctx.textAlign = 'right' for (let i = 1; i <= this.ySegments; i++) { const markerVal = parseInt(maxValue * (i / this.ySegments)) const xMarker = this.originX - 5 const yMarker = parseInt((this.cHeight * (this.ySegments - i)) / this.ySegments) + this.padding + 20 this.ctx.fillText(markerVal, xMarker, yMarker) // 文字 }}
最大的数据就是数组的第一个数据, 然后每个标尺就是所占的比例就好了, Y轴的坐标由于我们是递减的所以 对应的坐标应该是 1- 所占的份额, 由于这只是算的图标的实际高度 ,换算到画布里面, 还要加上原先我们设置的内边距,由于又加上了文字, 文字也占有一定像素, 所以有加上了20。 OK Y轴画结束了, 有了Y轴每个分断的坐标, 同时就画出背后的对应的几条实线。
代码如下:
this.drawLine( this.originX, yMarker - 4, this.originX + this.cWidth, yMarker - 4)
最终呈现的效果图如下:
everything isReady, 下面开始画矩形, 还是同样的方式 先封装画矩形的方法, 然后我们只要传入对应的数据就OK了。
这里用到了,canvas原生的rect 方法。参数理解如下:
矩形宽度 我们自定义的, 矩形的高度就是对应的奖牌数在画布中的高度, 所以我们只要确定 矩形的起点就搞定了, 这里矩形的(x,y) 其实是左上角的点。
代码如下:
//绘制方块drawRect(x, y, width, height) { this.ctx.beginPath() this.ctx.rect(x, y, width, height) this.ctx.fill() this.ctx.closePath()}
第一步我们先做一个点的映射, 我们在画Y轴的时候,将Y轴的上的画布的所有的点都放在一个数组中, 注意记得将原点的Y放进去。所以只要计算出每个奖牌数在总部的比例是多少? 然后再用原点的Y值做一个相减就可以得到真正的Y轴坐标了。X轴的坐标就比较简单了,原点的X坐标加上 ( 所占的比例 / 总长度 ) 然后在加上 一半的矩形宽度就好了。 这个道理和画文字是一样的, 只不过文字要居中嘛。
代码如下:
drawBars() { const length = this.data.slice(0, 10).length const { jin: max } = this.data[0] const diff = this.yPoints[0] - this.yPoints[this.yPoints.length - 1] for (let i = 0; i < length; i++) { const { jin: count } = this.data[i] const barH = (count / max) * diff const y = this.originY - barH const totalWidth = this.cWidth - 20 const x = parseInt( this.originX + totalWidth * (i / length) + this.rectWidth / 2 ) this.drawRect(x, y, this.rectWidth, barH) }}
画出的效果图如下:
黑秃秃的也丑了吧,一个不知道的人根本不知道这是哪一个国家获得多少快金牌。
现在画矩形的基础上加一些文字吧,代码如下:
this.ctx.save()this.ctx.textAlign = 'center'this.ctx.fillText(count, x + this.rectWidth / 2, y - 5)this.ctx.restore()
渐变就设计到Canvas一个api了,createLinearGradient
createLinearGradient()
方法需要指定四个参数,分别表示渐变线段的开始和结束点。
那我就开始了首先肯定创建渐变:
getGradient() { const gradient = this.ctx.createLinearGradient(0, 0, 0, 300) gradient.addColorStop(0, 'green') gradient.addColorStop(1, 'rgba(67,203,36,1)') return gradient}
然后呢我们就改造drawReact下 ,这里用了 restore 和save 这个方法, 防止污染文字的样式。
//绘制方块drawRect(x, y, width, height) { this.ctx.save() this.ctx.beginPath() const gradient = this.getGradient() this.ctx.fillStyle = gradient this.ctx.strokeStyle = gradient this.ctx.rect(x, y, width, height) this.ctx.fill() this.ctx.closePath() this.ctx.restore()}
如图所示:
光一个静态的不能看出我们的牛皮🐂,所以得有动画的效果慢慢的增加对吧。其实我们可以思考🤔下整个动画过程,变化的其实就两个, 柱状图的高度和文字, 其实坐标轴, 以及柱状图的x坐标是不变的, 所以我只要定义两个变量一个开始的值 ,和一个总共的值,高度和文字的大小 其实在每一帧去乘以对应的高度就可以了。
代码如下:
// 运动相关this.ctr = 1this.numctr = 100
我们改造下drawBars 这个方法:
// 每一次的比例是多少const dis = this.ctr / this.numctr// 柱状图的高度 乘以对应的比例const barH = (count / max) * diff * dis// 文字这里取整下,因为有可能除不尽 this.ctx.fillText( parseInt(count * dis), x + this.rectWidth / 2, y - 5)// 最后执行动画if (this.ctr < this.numctr) { this.ctr++ requestAnimationFrame(() => { this.ctx.clearRect(0, 0, this.width, this.height) this.drawLineLabelMarkers() })}
每一次都加一,直到比总数大, 然后不断重画。 就可以形成动画效果了。我们看下gif图吧:
本篇文章写到这里也算结束了,我大概总结下:
本篇文章算是canvas实现可视化图表的第二篇吧,后面我会持续分享、饼图、树状图、K线图等等各种可视化图表,我自
己在写文章的同时也在不断地思考,怎么去表达的更好。如果你对可视化感兴趣,点赞收藏关注👍吧!,可以关注我下
面的数据可视化专栏, 每周分享一篇 文章, 要么是2d、要么是three.js的。我会用心创作每一篇文章,绝不水文。
我们一起为**🇨🇳奥运加油! 奥利给!!!
关注公众号【前端图形】, 回复【奥运】 两个字,就可以获得所有源码。
我在上一篇文章简单实现了在canvas中移动矩形(点线面),不清楚的小伙伴请看我这篇文章:用canvas 实现矩形的移动(点、线、面)(1)。 ok,废话不多说,直接进入文章主题, 上一篇文章我留了很多问题,就是我在画步中移动我怎么知道我移动的是哪一个类型,到底是点还是线还是面, 这就是本篇文章要解决的问题。 读完本篇可以学到下面几点:
其实我上面说了这么多,其实就是为了在2d图形做一个效果就是 snap ——吸附,判断当前点与当前画布上多边形的关系。
读者你可以思考下,如果要你去做你会怎么去做呢? 假设画布上有很多多边形,还有很多点。有人说了,哪一个靠近它不就是哪一个。ok 你答对了,其实就是去判断当前点和画布上所有的点去比对,哪一个离的近,就是选中的哪一个点,这里会涉及到一个查询性能问题? 有同学就会问如果画布中有很多点呢?我们难道就要一个个去遍历比较大小嘛,当然不是这里给大家科普一下一个空间几何索引算法Rbush
RBush是一个高性能JavaScript库,用于点和矩形的二维空间索引。它基于优化的R树数据结构,支持批量插入。
我后面有时间会带大家撸一遍Rbush的,这里我给出参考链接 有兴趣的同学自行了解下。本篇就不用Rbush,就用集合去存储数据了哈! 这里还有一点需要强调的就是画布中的每一个点应该都每一个点都一个是实例,具有独特的id。 我们接下来就重新改造下:
const current = 0;
const map = new Map();
constructor(x,y) {
this.x = x || 0;
this.y = y || 0;
this.id = ++current;
map.set(this.id,[x,y]);
}
// 增加到Map上
add2Map() {
pointMap.push(this)
return this
}
//用来随机生成一个点
random(width,height){
this.x = Math.random() * width;
this.y = Math.random() * height;
return this;
}
// 取绝对值
abs() {
return [Math.abs(this.x), Math.abs(this.y)]
}
//计算两个点之间的距离
distance(p) {
const [x,y] = this.clone().sub(p).abs();
return Math.sqrt(x*x + y * y);
}
我又重新写了一个画多边形的方法代码如下:
// 画多边形
function drawAnyPolygon(points) {
if(!Array.isArray(points)) {
return;
}
ctx.strokeStyle = 'black'
ctx.setLineDash([]);
ctx.beginPath();
const start = points[0];
ctx.moveTo(start.x,start.y)
points.slice(1).forEach(item => {
ctx.lineTo(item.x,item.y)
})
ctx.closePath()
ctx.stroke()
}
这个没什么最重要的是什么呢,我们如何根据一个点去生成正多边形的点集合
这里我们看下多边形的定义:
正多边形是指二维平面内各边相等,各角也相等的多边形,也叫正多角形。
这里又带大家复习下数学知识: 我们先看张图:
现在有了map,我们可以去比较鼠标的点和画布中的点的距离了。我们先看第一部分根据类型生成点:
// 根据移动的类型重新生成点
function generatePointsByType(mousePoint,type = 'point',width = 200, height = 200) {
const results = [];
const { x, y } = mousePoint;
const moveVec = end.clone().sub(start);
const p1 = new Point2d(x- width /2, y - height/2).add2Map();
const p2 = new Point2d(x+ width / 2, y - height/2).add2Map();
const p3 = new Point2d(x+ width / 2, y + height/2).add2Map();
const p4 = new Point2d(x - width / 2, y + height/2).add2Map();
return [p1,p2,p3,p4]
}
这里有一点要注意的是就是p1,p2,p3,p4 满足的是顺时针,因为我们canvas画图是从左上----->左下的。 这一点大家在自己调试的要十分注意!!add2Map, 就是把点加入到Map中。我在上面补充上。我给出下一部分代码:比较鼠标的点和画布中的点之间的大小。
原理我们知道了,应用到我们canvas怎么去实现呢? 其实也很简单,我们以圆心和圆上的一点,作为起始的向量。然后不断地旋转 2π/n 的角度 就可以得到所有的点了。 有了点我们就可以画出正多边形了。 这里是外接圆算多边形的思路,至于内接圆怎么去算, 给大家一个课后思考题🤔自己去想一下。 我给出以下代码实现:
第一部分点的绕着某一个中心点旋转的:
rotate(center, angle) {
const c = Math.cos( angle ), s = Math.sin( angle );
const x = this.x - center.x;
const y = this.y - center.y;
this.x = x * c - y * s + center.x;
this.y = x * s + y * c + center.y;
return this;
}
这里的大概思路向量的旋转然后在加上中心点的位置。 如果看不懂的话, 我给大家找一个推导过程: 传送门
第二部分就是如果生成多边形的顶点了:
function getAnyPolygonPoints(start, end, n = 3) {
const angle = (Math.PI * 2) / n
const points = [end]
for (let i = 1; i < n; i++) {
points.push(
end
.clone()
.rotate(start.clone(), angle * i)
.add2Map()
)
}
return points
}
接下我就给大家看下 n = 5|10 |20 |50 的 这些正多边形。然后你会发现随着边数的增加,我们画的多边形越越像个圆了。
有没有解锁你们的新世界?各位读者们。看到这里如果觉得对你有帮助的话。点个赞继续往下看吧。 👇还有一些数学方法的介绍。
我们设想鼠标不停地在画布上移动,我肯定哪一个点离我近,我就去选择哪一个点。 所以也就是不停的比较鼠标移动的点和已经存在的点的距离做判断。ok思路有了,我给出以下代码:
function calcClosestPoint() {
const minMap = []
for (let value of pointMap) {
const dis = value.distance(start.clone())
minMap.push({ ...value, dis })
}
// 找出最近的的一个点
const sort = minMap.sort((a, b) => a.dis - b.dis)
return sort[0]
}
这段代码肯可能要讲的就是两点之间求距离? 这个就很简单了,就是两个坐标相减求绝对值,然后开方。一般人肯定会这么想对吧,一开始我也是这么想的。 这么想没问题, 但是其实我不不需要开方,我们要比较的是距离。这里会有一个性能小优化。因为你要开方,然后cpu又去计算,如果画布中点的数量过多呢,并且数字很大的情况下。代码如下:
distance(p) {
const [x, y] = this.clone().sub(p).abs()
return x * x + y * y
}
distanceSq(p) {
const [x, y] = this.clone().sub(p).abs()
return Math.sqrt(x * x + y * y)
}
找到最小的点,我们就可以重复上一篇文章实现移动了。这里就不做过多讲解了,不清楚的小伙伴,可以去看过上一篇文章。 给出以下代码:
//画出任意多边形 满足顺时针方向
function drawAnyPolygon(points) {
if (!Array.isArray(points)) {
return
}
ctx.strokeStyle = 'black'
ctx.setLineDash([])
ctx.beginPath()
// 存在移动的点
if (movePoint.length > 0) {
const moveVec = end.clone().sub(start)
points = points.map((item) => {
if (item.equal(movePoint[0])) {
return item.clone().add(moveVec)
}
return item
})
}
ctx.moveTo(points[0].x, points[0].y)
points.slice(1).forEach((item) => {
ctx.lineTo(item.x, item.y)
})
ctx.closePath()
ctx.stroke()
}
canvas.addEventListener('click', (e) => {
if (e.altKey) {
isMove = false
return
}
isMove = !isMove
const x = e.clientX
const y = e.clientY
start = new Point2d(x, y)
movePoint.length = 0
movePoint.push(calcClosestPoint())
isSelect = true
})
这里我点击鼠标的以下就确定移动的点 和移动向量的起点,movePoint 其实是所有要移动的点。直接看效果图吧。
点的移动我们实现了,我们鼠标的点的那一刻,我们该如何确定点击的是线呢,这也归咎到一个数学问题? 就是比较点到直线的距离, 点到直线的距离,第一种解法就是直线方程去求解。 直线的直线方程是什么?
设直线 L 的方程为Ax+By+C=0,点 P 的坐标为(x0,y0),则点 P 到直线 L 的距离为:
同理可知,当P(x0,y0),直线L的解析式为y=kx+b时,则点P到直线L的距离为
考虑点(x0,y0,z0)与空间直线x-x1/l=y-y1/m=z-z1/n,有d=|(x1-x0,y1-y0,z1-z0)×(l,m,n)|/√(l²+m²+n²)
也就是两个点算出斜率和截距,但是要考虑直线与Y轴的特殊情况,也就是斜率无穷大的时刻。 这时候的距离就是x坐标相减。这样我们可以计算点到直线的距离,然后比较找出距离最小的线,接着找出移动的点就可以了。但这不是最优解,
首先我问一个问题哈? 向量的叉乘的几何意义是什么, 就是两个向量围成的平行四边形的面积。 我们计算点到直线的距离不就是计算,平行四边形的高嘛, 所以只要算出面积再除以底边就可以算出点到直线的距离了。 哈哈哈哈,是不是再一次被数学的魅力征服了。我给大家看个图吧:
红色的线就是点到直线的距离。 我们直接开始coding了,理论有了直接开干。
首先写一个点转为线段的一个方法,因为我们是首尾相连,所以点的个数,最后一个应该是和开始点相同的。
function points2Segs(points) {
const start = points[0]
points.push(start)
const segs = []
points.forEach((point, index) => {
if (index !== points.length - 1) {
segs.push([point, points[index + 1]])
}
})
return segs
}
叉乘的方法如下:
cross(v) {
return this.x * v.y - this.y * v.x
}
计算点到直线的距离如下:
function pointDistanceToLine(target, line) {
const [start, end] = line
const vec1 = start.clone().sub(target)
const vec2 = end.clone().sub(target)
return vec1.clone().cross(vec2) / start.clone().distanceSq(target)
}
// 找出最近的线
function calcClosestLine() {
let minMap = []
segs.forEach((line) => {
const dis = pointDistanceToLine(start, line)
minMap.push({
dis,
line,
})
})
minMap = minMap.sort((a, b) => a.dis - b.dis)
// 找出最近的直线然后将点放入到movePoint 中其实就好了
movePoint.push(...minMap[0].line)
}
移动那边代码改写一下:
if (movePoint.length > 0) {
const moveVec = end.clone().sub(start)
points = points.map((item) => {
// 线的移动对应的是两个点 面的话应该就是所有的点
if (item.equal(movePoint[0]) || item.equal(movePoint[1])) {
return item.clone().add(moveVec)
}
return item
})
}
直接来看效果:
完美实现很感谢你还能看到这里。 到这里因为点和线其实都会了,面就是所有的点移动这个是没什么难度的,后面大家可以自己去练习一下。
本篇文章主要是介绍了2d 下图形的移动, 点线面。 本质上都是点的移动,加上一个移动向量。核心就是这个,其实还有很多东西是需要大家慢慢体会的。一个闭合区域的形成,点的顺序,肯定是首尾相连的,按照某一个方向。还有就是对于叉乘、点乘的一些理解。 结合到实现项目中可以灵活运用。本篇文章的所有代码都在我的github,如果大家觉得看完对你有帮助的话,可以star一下。
最后最后的还是希望大家点个赞👍和评论。 知识输出不易,对图形感兴趣的话可以关注我的公众号: 前端图形 持续分享canvas、svg、webgl知识。
我在上一篇文章简单实现了在canvas中移动矩形(点线面),不清楚的小伙伴请看我这篇文章:用canvas 实现矩形的移动(点、线、面)(1)。 ok,废话不多说,直接进入文章主题, 上一篇文章我留了很多问题,就是我在画步中移动我怎么知道我移动的是哪一个类型,到底是点还是线还是面, 这就是本篇文章要解决的问题。 读完本篇可以学到下面几点:
其实我上面说了这么多,其实就是为了在2d图形做一个效果就是 snap ——吸附,判断当前点与当前画布上多边形的关系。
读者你可以思考下,如果要你去做你会怎么去做呢? 假设画布上有很多多边形,还有很多点。有人说了,哪一个靠近它不就是哪一个。ok 你答对了,其实就是去判断当前点和画布上所有的点去比对,哪一个离的近,就是选中的哪一个点,这里会涉及到一个查询性能问题? 有同学就会问如果画布中有很多点呢?我们难道就要一个个去遍历比较大小嘛,当然不是这里给大家科普一下一个空间几何索引算法Rbush
RBush是一个高性能JavaScript库,用于点和矩形的二维空间索引。它基于优化的R树数据结构,支持批量插入。
我后面有时间会带大家撸一遍Rbush的,这里我给出参考链接 有兴趣的同学自行了解下。本篇就不用Rbush,就用集合去存储数据了哈! 这里还有一点需要强调的就是画布中的每一个点应该都每一个点都一个是实例,具有独特的id。 我们接下来就重新改造下:
const current = 0;
const pointMap = []
constructor(x,y) {
this.x = x || 0;
this.y = y || 0;
this.id = ++current;
}
// 增加到Map上
add2Map() {
pointMap.push(this)
return this
}
//用来随机生成一个点
random(width,height){
this.x = Math.random() * width;
this.y = Math.random() * height;
return this;
}
// 取绝对值
abs() {
return [Math.abs(this.x), Math.abs(this.y)]
}
//计算两个点之间的距离
distance(p) {
const [x,y] = this.clone().sub(p).abs();
return Math.sqrt(x*x + y * y);
}
我又重新写了一个画多边形的方法代码如下:
// 画多边形
function drawAnyPolygon(points) {
if(!Array.isArray(points)) {
return;
}
ctx.strokeStyle = 'black'
ctx.setLineDash([]);
ctx.beginPath();
// 第一个点是开始点
const start = points[0];
ctx.moveTo(start.x,start.y)
points.slice(1).forEach(item => {
ctx.lineTo(item.x,item.y)
})
ctx.closePath()
ctx.stroke()
}
这个没什么最重要的是什么呢,我们如何根据一个点去生成正多边形的点列表
这里我们看下多边形的定义:
正多边形是指二维平面内各边相等,各角也相等的多边形,也叫正多角形。
我给出以下示意图:
原理我们知道了,应用到我们canvas怎么去实现呢? 其实也很简单,我们以圆心和圆上的一点,作为起始的向量。然后不断地旋转 2π/n 的角度 就可以得到所有的点了。 有了点我们就可以画出正多边形了。 这里是外接圆算多边形的思路,至于内接圆怎么去算, 给大家一个课后思考题🤔自己去想一下。 我给出以下代码实现:
第一部分点的绕着某一个中心点旋转的:
rotate(center, angle) {
const c = Math.cos( angle ), s = Math.sin( angle );
const x = this.x - center.x;
const y = this.y - center.y;
this.x = x * c - y * s + center.x;
this.y = x * s + y * c + center.y;
return this;
}
这里的大概思路向量的旋转然后在加上中心点的位置。 如果看不懂的话, 我给大家找一个推导过程: 传送门
第二部分就是如果生成多边形的顶点了:
function getAnyPolygonPoints(start, end, n = 3) {
const angle = (Math.PI * 2) / n
const points = [end]
for (let i = 1; i < n; i++) {
points.push(
end
.clone()
.rotate(start.clone(), angle * i)
.add2Map()
)
}
return points
}
接下我就给大家看下 n = 5|10 |20 |50 的 这些正多边形。然后你会发现随着边数的增加,我们画的多边形越越像个圆了。
有没有解锁你们的新世界?各位读者们。看到这里如果觉得对你有帮助的话。点个赞继续往下看吧。 👇还有一些数学方法的介绍。
我们设想鼠标不停地在画布上移动,我肯定哪一个点离我近,我就去选择哪一个点。 所以也就是不停的比较鼠标移动的点和已经存在的点的距离做判断。ok思路有了,我给出以下代码:
function calcClosestPoint() {
const minMap = []
for (let value of pointMap) {
const dis = value.distance(start.clone())
minMap.push({ ...value, dis })
}
// 找出最近的的一个点
const sort = minMap.sort((a, b) => a.dis - b.dis)
return sort[0]
}
这段代码肯可能要讲的就是两点之间求距离? 这个就很简单了,就是两个坐标相减求绝对值,然后开方。一般人肯定会这么想对吧,一开始我也是这么想的。 这么想没问题, 但是其实我不不需要开方,我们要比较的是距离。这里会有一个性能小优化。因为你要开方,然后cpu又去计算,如果画布中点的数量过多呢,并且数字很大的情况下,还有一个重要的问题就是Js存在精度问题。 代码如下:
distance(p) {
const [x, y] = this.clone().sub(p).abs()
return x * x + y * y
}
distanceSq(p) {
const [x, y] = this.clone().sub(p).abs()
return Math.sqrt(x * x + y * y)
}
找到最小的点,我们就可以重复上一篇文章实现移动了。这里就不做过多讲解了,不清楚的小伙伴,可以去看过上一篇文章。 给出以下代码:
//画出任意多边形 满足顺时针方向
function drawAnyPolygon(points) {
if (!Array.isArray(points)) {
return
}
ctx.strokeStyle = 'black'
ctx.setLineDash([])
ctx.beginPath()
// 存在移动的点
if (movePoint.length > 0) {
// 移动的向量
const moveVec = end.clone().sub(start)
points = points.map((item) => {
// 点列表中去找到和移动相等的就是把它的数据变化了
if (item.equal(movePoint[0])) {
return item.clone().add(moveVec)
}
return item
})
}
ctx.moveTo(points[0].x, points[0].y)
points.slice(1).forEach((item) => {
ctx.lineTo(item.x, item.y)
})
ctx.closePath()
ctx.stroke()
}
canvas.addEventListener('click', (e) => {
if (e.altKey) {
isMove = false
return
}
isMove = !isMove
const x = e.clientX
const y = e.clientY
start = new Point2d(x, y)
movePoint.length = 0
movePoint.push(calcClosestPoint())
isSelect = true
})
这里我点击鼠标的以下就确定移动的点 和移动向量的起点,movePoint 其实是所有要移动的点。直接看效果图吧。
点的移动我们实现了,我们鼠标的点的那一刻,我们该如何确定点击的是线呢,这也归咎到一个数学问题? 就是比较点到直线的距离, 点到直线的距离,第一种解法就是直线方程去求解。 直线的直线方程是什么?
设直线 L 的方程为Ax+By+C=0,点 P 的坐标为(x0,y0),则点 P 到直线 L 的距离为:
同理可知,当P(x0,y0),直线L的解析式为y=kx+b时,则点P到直线L的距离为
考虑点(x0,y0,z0)与空间直线x-x1/l=y-y1/m=z-z1/n,有d=|(x1-x0,y1-y0,z1-z0)×(l,m,n)|/√(l²+m²+n²)
也就是两个点算出斜率和截距,但是要考虑直线与Y轴的特殊情况,也就是斜率无穷大的时刻。 这时候的距离就是x坐标相减。这样我们可以计算点到直线的距离,然后比较找出距离最小的线,接着找出移动的点就可以了,但这不是最优解。最优解是用向量去计算,如果要算垂足可以用这个方法去搞。
首先我问一个问题哈? 向量的叉乘的几何意义是什么, 就是两个向量围成的平行四边形的面积。 我们计算点到直线的距离不就是计算,平行四边形的高嘛, 所以只要算出面积再除以底边就可以算出点到直线的距离了。 哈哈哈哈,是不是再一次被数学的魅力征服了。我给大家看个图吧:
红色的线就是点到直线的距离。 我们直接开始coding了,理论有了直接开干。
首先写一个点转为线段的一个方法,因为我们是首尾相连,所以点的个数,最后一个应该是和开始点相同的。
function points2Segs(points) {
const start = points[0]
points.push(start)
const segs = []
points.forEach((point, index) => {
if (index !== points.length - 1) {
segs.push([point, points[index + 1]])
}
})
return segs
}
叉乘的方法如下:
cross(v) {
return this.x * v.y - this.y * v.x
}
计算点到直线的距离如下:
function pointDistanceToLine(target, line) {
const [start, end] = line
const vec1 = start.clone().sub(target)
const vec2 = end.clone().sub(target)
return vec1.clone().cross(vec2) / start.clone().distanceSq(target)
}
// 找出最近的线
function calcClosestLine() {
let minMap = []
segs.forEach((line) => {
const dis = pointDistanceToLine(start, line)
minMap.push({
dis,
line,
})
})
minMap = minMap.sort((a, b) => a.dis - b.dis)
// 找出最近的直线然后将点放入到movePoint 中其实就好了
movePoint.push(...minMap[0].line)
}
移动那边代码改写一下:
if (movePoint.length > 0) {
const moveVec = end.clone().sub(start)
points = points.map((item) => {
// 线的移动对应的是两个点 面的话应该就是所有的点
if (item.equal(movePoint[0]) || item.equal(movePoint[1])) {
return item.clone().add(moveVec)
}
return item
})
}
直接来看效果:
完美实现很感谢你还能看到这里。 到这里因为点和线其实都会了,面就是所有的点移动这个是没什么难度的,后面大家可以自己去练习一下。
本篇文章主要是介绍了2d 下图形的移动, 点线面。 本质上都是点的移动,加上一个移动向量。核心就是这个,其实还有很多东西是需要大家慢慢体会的。一个闭合区域的形成,点的顺序,肯定是首尾相连的,按照某一个方向。还有就是对于叉乘、点乘的一些理解。 结合到实现项目中可以灵活运用。本篇文章的所有代码都在我的github,如果大家觉得看完对你有帮助的话,可以star一下。
最后最后的还是希望大家点个赞👍和评论。 知识输出不易,对图形感兴趣的话可以关注我的公众号: 前端图形 持续分享canvas、svg、webgl知识。
可视化开发中,尤其是在2d视图下,看到一些非常的好玩的特效,五颜六色的光。 好的本篇文章就带你去用canvas去模拟你自己想要的效果。涉及到一些数学知识,不过的都是基础的。我还是争取讲的更加通俗易懂一点。
我们能看到物体,是因为光照照射在物体上然后反射到我们的眼睛中,影响光照的因素非常多,位置,光的颜色,物体表面的颜色,材质和粗糙程度。 本篇文章讨论一下光源, 光源又分为环境光, 点光源,平行光, 聚光灯。如下图显示:
平行光顾名思义光线平行,对于一个平面而言,平面不同区域接收到平行光的入射角一样。对于平行光而言,主要是确定光线的方向,光线方向设定好了,光线的与物体表面入射角就确定了,仅仅设置光线位置是不起作用的。
模拟平行光源的光照非常简单,当光垂直照射到平面上,即光线方向和平面呈90度角时,这时光照是最强的。如果照射的角度不断变大(或者说光线和平面的夹角不断变小),光照也会随之变弱,当光线方向完全和平面平行时,这时没有光能照射到平面上,光强变成了0。
我们用一个垂直于平面的向量去描述平面的朝向,在图形学中,一般把这个向量称为“法向量”。 法向量一般只有方向没有长度,下面有个normalize 就是单位长度的1的向量。
我们可以用向量的“点乘”运算来计算光强变化。
点乘也叫数量积,是接受在实数R上的两个向量并返回一个实数值标量的二元运算。点乘运算规则非常简单,将两个向量对应坐标的乘积求和就行了。
但是这个只是点乘的数学意义, 但是点乘更重要的是他的几何意义:
因为点乘的结果是一个标量,所以决定大小的就是向量之间的夹角,cos的函数图像是0-90 是正的, 90-180 是负数嘛。 所以点乘和光强的变化十分符合。 这里我们计算的是三维向量,我们用数组来表示向量。 然后实现一些方法。 代码如下:
class Vector3 {
constructor(x, y, z) {
this.x = x || 0
this.y = y || 0
this.z = z || 0
}
//点乘
dot(vec) {
return this.x * vec.x + this.y * vec.y + this.z * vec.z
}
// 克隆
clone() {
return new this.constructor(this.x, this.y, this.z)
}
//求长度
length() {
return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z)
}
multiplyScalar(scalar) {
this.x *= scalar
this.y *= scalar
this.z *= scalar
return this
}
//向量相减
sub(v) {
this.x -= v.x
this.y -= v.y
this.z -= v.z
return this
}
// 单位化
normalize() {
return this.multiplyScalar(1 / this.length())
}
// 取反
negate() {
this.x = -this.x
this.y = -this.y
this.z = -this.z
return this
}
}
我们假设页面的左上角为原点O,右方向为x轴正方向,下方向为y轴正方向,垂直屏幕向外的方向为z轴正方向。我们可以这样定义一个宽高都为500的平面:
const plane = {
center: new Vector3(250, 250, 0), // 平面中心点坐标
width: 500, // 宽
height: 500, // 高
normal: new Vector3(0, 0, 1), // 朝向,即法向量
color: { r: 255, g: 0, b: 0 }, // 颜色为红色
}
对于平行光,只需要关心它的方向和颜色,我们可以这样来定义一个平行光源:
const directionalLight = {
direction: new Vector3(0, 0, -1), // 从屏幕外垂直照向屏幕
color: { r: 255, g: 255, b: 255 }, // 颜色为纯白色
}
平行光的光线都是平行的,所以它照射到平面上各个位置的效果都是一样的,换言之,整个平面都应该是同一个颜色。
根据上面的规则(光强等于光线反方向向量点乘平面法向量),我们可以计算出这个颜色:
const reverseLightDirection = directionalLight.direction.clone().negate() // 计算平行光的反方向向量
const intensity = reverseLightDirection.dot(plane.normal) // 计算两向量点乘
// 计算有光照时的颜色
const color = {
r: intensity * plane.color.r + intensity * directionalLight.r,
g: intensity * plane.color.g + intensity * directionalLight.g,
b: intensity * plane.color.b + intensity * directionalLight.g,
}
我写了例子去模拟下这个情况:
代码例子在我的github上欢迎fork
在日常生活中,点光源更加常见,白炽灯、台灯等都可以认为是点光源。
首先,我们先定义一个点光源,对于一个点光源来说,我们只需要关心它的位置和颜色:
const plane = {
center: new Vector3(250,250,0), // 平面中心点坐标
width: 500, // 宽
height: 500, // 高
normal: new Vector3(0,0,1), // 朝向,即法向量
color: { r: 0, g: 255, b: 0 } // 颜色为绿色
}
const pointLight = {
position: new Vector3(250,250,60),
color: {
r: 255,
g: 255,
b: 255
}
}
初始值设置之后, 这里其实要知道canvas 的createImageData 和 putImageData 这个方法可以直接填入一个区域的像素颜色值来绘图。 光照的效果原理主要是改变图片的每一个像素值, 达到光照的效果;
光强的计算:光强等于光线反方向向量点乘平面法向量。但是点光源的光是从一个点发射出来,它们照射到平面上时,所有光线的方向都不一样。所以,我们必须挨个计算平面上所有像素的光强。
const imageData = ctx.createImageData( plane.width, plane.height );
function render() {
for ( let x = 0; x < imageData.width; x++ ) {
for ( let y = 0; y < imageData.height; y++ ) {
let index = y * imageData.width + x;
// 每一个像素点
let position = new Vector3(x,y,0);
let normal = new Vector3(0,0,1);
// 点光源与每个像素点 之间的方向就是 光线的方向
let currentToLight = pointLight.position.clone().sub(position).normalize();
let light = currentToLight.dot(normal);
imageData.data[ index * 4 ] = Math.min( 255, ( pointLight.color.r + plane.color.r ) * light);
imageData.data[ index * 4 + 1 ] = Math.min( 255, ( pointLight.color.g + plane.color.g ) * light );
imageData.data[ index * 4 + 2 ] = Math.min( 255, ( pointLight.color.b + plane.color.b ) * light );
imageData.data[ index * 4 + 3 ] = 255;
}
}
ctx.putImageData( imageData, 100, 100 );
}
效果图如下所示:
为了看起来更加炫酷, 我增加了move 和 wheel 事件, move 就是改变点光源的x, y 坐标。
document.addEventListener( 'mousemove', function( e ) {
pointLight.position.x = e.clientX - 100
pointLight.position.y = e.clientY - 100
render()
}, false )
效果如下:
有种爱是一道光, 绿的你发慌的感觉😁。哈哈哈哈!
本篇主要是简单的介绍了几种光照并在canvas 下的模拟实现, 主要是理解光强的计算方式: 反向向量 和 平面的法向量 做点乘。本篇文章所有代码都在我的github上欢迎自己copy下来玩一玩。最后,文章写作不易,如果看完对你有帮助的话,你的点赞和关注是我持续更新的最大动力。 如果你也喜欢图形,喜欢可视化,你可以点个关注,后面我会持续分享高质量的文章, 勿忘初心!
因为群里粉丝一直要求我写一篇可视化入门指南,今天他来了。其实说起前端可视化,大家所能想到的就是各种图表,大屏。这种看着贼炫酷,而笔者呢工作也一直从事3D前端开发工作,慢慢对图形产生了兴趣。但是呢一直做的是三维的东西,没搞过二维的。大概是2月前开始学习2D的一些东西,然后并写了一些文章,效果还不错。所以我就写一些经验之谈,大佬勿喷。 我大概从4个方面去讲我是怎么学习的
可视化不得不掌握的数学基础
svg方面的学习
canvas方面的学习
可视化中不得不掌握的图形算法
读完本篇文章,你可以大概知道我该怎么去学,需要学什么?以及我推荐的一些学习资料和学习资源!
提起数学很多程序员头疼哇,我写代码还要学可恶的数学,但是我很明确的告诉你——很重要,如果你想学可可视化的话,数学很重要,背后的几何意义更重要。读者一开始理解不深,导致很多东西理解不了,吃了很多亏哇!
在二维空间或者三维空间中, 是不是都有点的概念,只不过一个是二维的一个是三维的, 假设,现在这个平面直角坐标系上有一个向量 v。向量 v 有两个含义:一是可以表示该坐标系下位于 (x, y) 处的一个点;二是可以表示从原点 (0,0) 到坐标 (x,y) 的一根线段。
我在写canvas的同时就喜欢用一个Point2d 类就是这个原理, canvas本身就是坐标系。画布上的点都可以用向量表示, 原点在左上方。
一个向量可以用其他两个向量去表示,也可以用两个向量去做减法,我说个实际工作中经常用到的例子: 如何让一个点在某一个方向延展多少长度呢?
这里其实就是用到了向量的加法, 首先这个方向肯定是是个单位向量 , 为什么是单位向量呢?? 因为向量是有大小和方向的, 而单位向量 只有方向, 长度 为1 ,然后我们只要开始点 加上 这个方向向量 ✖️ 长度。就可以得到了。 背后不就是向量加法的运用。 我还是画图给大家展示下吧。
如图:我要从A-B点 方向是od 然后你可以乘以任何长度 得到 OD 然后相加, 是不是就可以得到B点了。 一图胜千言!,减法大家可以自己去思考,同样的道理的
其实很多种实践,这里我就举一个例子哈,带你了解点乘。其实还有投影
向量点乘可以用来判断 连个向量是否同一方向, 我还是画图给大家讲解, 不说太多理论,都是实战中经常用到的。
A向量和B向量之间的夹角是锐角 所以是同向 , B向量和C量之间的夹角是钝角所以是反向 ,因为点乘的数学公式就是两个向量的模长 × cosθ 。
叉乘的几何意义也是非常重要的,可以算多边形的面积, 计算出另一个向量 垂直于这两个向量。 还是开始画图:
X向量 和 Y向量去做叉乘 得到的 向量Z 是 xy 平面的normal。
算面积:
叉乘的数学意义: A向量的模长 × B向量的模长 × sinθ 不就是平行四边形的高 H 所以可以用来算面积。
Corss 的几何叉积得到的是一个数值, 只要判断当前数值是大于0 小于 0就好了, 就知道这个三个点的方式是逆时针 还是顺时针就好了。
图中可以看到 OAB 和OA1B 的方向是不同,OA向量✖️ OB向量 的值 和 OA1 ✖️OB向量 算出的来的值 是相反的。公式我给大家列举下:
a.x * b.y - a.y * b.x
其实向量的点乘 和叉乘非常的重要,大家一定要要好理解,后面的图形算法,很多也是基于这个去实现的。
空间中图形的大部分变化都是可以通过矩阵去表示的,大概有下面几种类型:
这里我给大家推荐的学习资源是B站的:
https://www.bilibili.com/video/BV1ib411t7YR?from=search&seid=15308763710996235630
线性代数的本质,看完你就能够明白了,包括上面的向量之间的变化。
镜像矩阵我推荐你看我这篇文章, 我是求导了三维空间中任意平面的镜像矩阵的了,
我这里给大家简单的讲解下最简单的变化—— 平移矩阵
还是看下图吧:
在这样的三维坐标系中从A点平移到B点 x变化了 2 y变化了0 z 变化了 2 对应矩阵的写法是什么呢:
$$
\begin{bmatrix}
1&0&0&2\
0&1&0&0\
0&0&1&2\
0&0&0&1\
\end{bmatrix}
×
\begin{bmatrix}
2\
0\
4\
1\
\end{bmatrix}
=
\begin{bmatrix}
4\
0\
6\
1\
\end{bmatrix}
$$
其实矩阵中每一行都有对应的矩阵, 平移矩阵一般改变的第四列的前三个数字
无论是2d还是3d都需要曲线的表达,最简单的圆弧、椭圆弧、然后连续曲线可以用贝塞尔曲线去表达,还有B样条曲线,nurbs曲线。掌握曲线最终的还是数学哇。
圆的方程: x ^2 + y ^ 2 = r ^ 2
椭圆的方程: x ^ 2 / a ^ 2 + y ^ 2 / b ^ 2 = 1
n阶贝塞尔曲线的方程: 𝐵(𝑡)=∑𝑖=0𝑛𝑃𝑖(1−𝑡)𝑛−𝑖𝑡𝑖,𝑡𝜖[0,1]
b样条曲线和nurbs曲线我还没接触过,但是我们组的小伙伴正在做自由曲面,可能涉及到了。这里我只是简单表示了直线方程,有了方程你可以你去进行高度模拟,比如我在做3D文字的时候,我们底层算法库还没有支持贝塞尔, 不过没关系我们不是有方程嘛, 可以通过方程将贝塞尔曲线离散成多个点,然后用直线去表达。因为我们人眼去看屏幕上的东西,离散的很多的话,肉眼是完全看不出来的。 我这里给大家看一张图吧:
圗这个部分的弧线我就是用我贝塞尔曲线 离散成直线去表达的, 还有国中的点其实也是贝塞尔曲线离散成直线去做的。从视觉上来看是能够近似模拟的。3D文字中的 更多技术,我后面会专门写一篇文章去详细介绍, 顺便自己去梳理下。如果你感兴趣,那你可得关注我,不然就找不到我了。
为什么要有这个东西呢,canvas和svg的坐标系都是左上方是原点,这一点你不觉得有点反人类? 好不舒服,我在画折线图的时候就发现了,从原点向上,坐标轴是递减的。其实这个问题怎么解决呢,其实很简单就是我们进行坐标系的转换,我将原先画布的原点, 通过变化到左下角, 这样我们在计算点的坐标的时候,就没有心智负担了,该怎样就是怎样。 说完2d我再和你聊聊3D, 就拿Three.js 举例子吧有个局部坐标系,观察坐标系(相机)、 世界坐标系、裁剪坐标系、屏幕坐标系。
这是空间中某个物体到最终屏幕所做的一系列操作。
首先物体的自身有个坐标系我们叫做局部坐标系,他也有个原点,但是他在世界坐标系下也有对应的位置,所以他们之间有一个矩阵变化——模型矩阵
世界坐标系——到观察坐标系也有矩阵变化, 这叫视图矩阵
观察空间——裁剪空间 叫做 投影矩阵因为3维空间的东西我们是用相机去模拟人眼,在视椎体内的东西才能被看到。所有就有了投影矩阵, 有透视投影和正交投影, 一个近大远小,一个远近都是一样的
标准设备坐标-屏幕坐标。 这里就涉及到坐标系的原点的问题。
归一化的坐标是相对于画布中心的, 但是canvas默认的坐标系是左上角的。 我们分析下坐标系的变化,首先Y轴是相反的所以 第一个变化就是 X不变,然后Y都✖️ -1这下方向对了,差的就是偏移量。 x轴和Y轴差的偏移量都是画布的一半宽度和高度。 这样就实现了,到屏幕坐标的转换了。
SVG和canvas的学习我还是推荐Mdn, 大家去认真从头撸一遍,然后再谈进阶,再去如何优化, 你连基本的api都不熟悉和谈进阶对吧。
svg教程 https://developer.mozilla.org/zh-CN/docs/Web/SVG/Tutorial
canvas教程 https://developer.mozilla.org/zh-CN/docs/Web/API/Canvas_API/Tutorial
跟着后面学一遍,手敲一遍,自然就明白其中的奥秘。 下面👇是我用了这么久的小经验,和小tips
svg中的path 中大写字母和小写字母的 区别主要是相对定位和绝对定位的关系。
svg的defs标签 不会出现在画布上,是为了下面的组合使用的
svg的g 和 symbols 都可以实现组合 ,但是symbols 有viewbox 也就是视口的概念
use 复用标签 对应上文定义的id
canvas clearRect() 清除画布 由于canvas 每一帧都要进行重绘
restore() save() 保存当前canvas 的状态 确保不影响 其他绘图元素
isPointinPath() 可以用来点是不是在最后一个绘制的path 中(有坑) , 判断点是不是在图形内部最后用算法去解决。
beginPath() 和 closePath() 的使用
像素级别的处理 imageData 的使用
当你熟悉了这些可以进阶了, 推荐学习
https://joshondesign.com/p/books/canvasdeepdive/title.html
https://www.cnblogs.com/mopagunda/p/5622911.html
https://devnook.github.io/OffscreenCanvasDemo/keep-ui-responsive.html
https://www.bilibili.com/video/av26151775/
《HTML5 Canvas核心技术:图形、动画与游戏开发》
《HTML5 2D游戏编程核心技术》
1.学习图形学基础
一定一定一定要看闫令琪老师的GAMES101现代计算机图形学。建议1.5倍速,大概一个月内可以掌握。跟着课程,把光栅渲染器和光线追踪的作业都做掉,学了这门课,差不多图形学基础就打牢了。对图形学、游戏、3D引擎、OpenGL、Unity、UE差不多也有了基本的认识。我自己还没有看完,还在学习中。
https://www.bilibili.com/video/BV1X7411F744?from=search&seid=7915905348717479996
2.webgl 网站学习,这是我觉得质量非常不错同时又有点深度的学习网站
https://webglfundamentals.org/webgl/lessons/zh_cn/webgl-fundamentals.html
webgl 编程指南
3D游戏与计算机图形学中的数学方法
directx 9.0 3d游戏开发编程基础 这本书强力推荐 虽然是用c++写的 ,但是他把整个渲染流程讲解的很清楚,我反正看了收获很大。这也是我们老大推荐的一本书。
Three.js 是最知名的 WebGL 项目,Contributions 人数高达 1313,和 React 是一个量级的,尽管它自身的定位只是渲染引擎,但社区硬是把不少游戏引擎的功能都加上了,比如物理引擎、贴花、动画等,在源码中有大量例子,很适合学习,但不少重要功能,比如 gltf 加载器,都是放在 examples 目录里,让人感觉很不正式。
由于知名度最高,Three.js 最大的优势就是社区强大,搜索问题能找到很多答案,也有非常多开源和商业项目使用
但 Three.js 在版本管理方面很不专业,到现在都还没采用 semver 版本命名规范,每次发布都是一个叫 rXXX 的版本,我见过不少基于 Three.js 的项目都是固定在某个版本不敢升级了
最后压轴的是 Babylon,它也是 Sugar 最终采用的 WebGL 引擎,不仅功能强大,代码质量也很高,TypeScript 类型完善,几乎每个函数都有注释。
Babylon 在材质方面功能丰富,除了基础的 PBR,还提供了用于皮肤的次表面渲染 SubSurface、用于车漆的 ClearCoat、用于布料的 Sheen,以及用于光盘之类的各向异性材质 Anisotropy 等等。
Babylon 最后一个亮点是正在开发 WebGPU 版本,而其他引擎都没开始做,所以等 WebGPU 发布后,Babylon 应该是首批支持的,将得到更多关注。
在AntV中,有好几个不同的可视化引擎,事实上,它们是相互隔绝的,彼此独立的。学习的时候需要单独的去学习。ChartCube图表魔方支持在线的生成图表。地图则使用L7地理空间数据可视化。
ECharts最初是"Enterprise Charts"(企业图表)的简称,来自百度EFE数据可视化团队,是用JavaScript实现的开源可视化库。ECharts的功能非常强大,对移动端进行了细致的优化,适配微信小程序,支持多种渲染方式和千万数据的前端展现,甚至实现了无障碍访问。底层是用的z-render 这个库去进行封装的。还是很值的学习对的,有点类似于组件的概念,进行可配置的去展示图表。
D3是指数据驱动文档(Data-Driven Documents)。D3.js是一个JavaScript库,它可以通过数据来操作文档。D3可以通过使用HTML、SVG和CSS把数据鲜活形象地展现出来。D3严格遵循Web标准,因而可以让你的程序轻松兼容现代主流浏览器并避免对特定框架的依赖。同时,它提供了强大的可视化组件,可以让使用者以数据驱动的方式去操作DOM。被称为可视化版的jquery。
最后讲一下不得不掌握的图形算法, 比如很简答的例子。 判断点是不是在任意多边形内部对吧这就是 涉及到算法。
我大概列举下
这里我大概列了一下我工作中用到的一些算法。
https://www.cnblogs.com/i-gps/archive/2012/06/19/2554992.html
https://blog.csdn.net/wilson1068/article/details/44133303
https://blog.csdn.net/hjh2005/article/details/9246967
https://www.cnblogs.com/zhigu/p/11943118.html
本篇文章大概就是我的个人理解哈,水平有限,能表达的就这么多。如果有更好的欢迎补充学习和交流,文章有错误的欢迎指正。最后送给大家一张思维导图,对照学习哈。我是热爱图形的Fly,我们下次再见👋啦。
关注公众号【前端图形】,回复 思维导图 二字 可以免费获得高清思维导图 ,以及可视化学习视频 加文中部分书籍📚pdf版本
不知不觉又到了周末,又到了Fly写文章的日子,今天给大家介绍下一个web中很常见的功能, 就是撤销和复原这样一个功能,对于任何一个画图软件,或者是建模软件。没有撤销和复原。这不是傻👁了对啊吧,所以本篇文章,可以说是基于上一篇文章**Canvas 事件系统**的下集,如果你没有看过,建议看完再去看这一篇文章。读完本篇文章你可以学习到什么??
很多人说绑定键盘事件,有什么好讲的。对虽然很简单,但是有点小坑, 首先直接对canvas 监听键盘事件,是❌不行的。 这里涉及到一个小技巧, 就是给canvasdom元素 加上 tabindex 属性 ,很多人说这是啥,我来看下官方文档。
tabindex 全局属性 指示其元素是否可以聚焦,以及它是否/在何处参与顺序键盘导航(通常使用Tab键,因此得名)。
tabindex 可以设置 正数 和负数
OK,这下你应该明白了,我们要想canvas 可以聚焦, 但是直接加 tabindex = 0。 我给出以下代码:
<canvas id="canvas" width="800" height="600" tabindex="0"></canvas>
this.canvas.addEventListener(keydown,()=>{})
但是会有个问题, 你看下面图片。
有canvas有边框, 这个我们可以通过css 去解决, 不能让用户看到这个,好的交互是用户无感知。代码如下:
canvas {
background: red;
outline: 0px;
}
直接canvas 的外边框设置为0就OK了。
监听完成了之后,我开始对键盘事件进行处理, 首先无论是Mac 还是windows 一般用户的习惯就是 按 ctrl 或者 command, 加 z
和 y 之后进行回退, OK ,我们也这样去做。
首先定义两个变量:
export const Z = 'KeyZ'
export const Y = 'KeyY'
第二步就是写空的undo 和redo 方法
undo() {
console.log('走了undo')
}
redo() {
console.log('redo')
}
第三步开始绑定:
this.canvas.addEventListener(keydown, (e) => {
e.preventDefault()
if (e.ctrlKey || e.metaKey) {
if (e.code === Z) {
this.undo()
} else if (e.code === Y) {
this.redo()
}
}
})
这里需要讲解的就两个点哈,第一个就是 阻止事件的默认行为 , 因为,我按command + y 会打开新的标签页, 第二个就是兼容mac 和windows , 一个metaKey 一个是 ctrlKey. 看下结果:
撤销和复原 最主要的功能其实就是我们我们记录每一次往画布画图形的这个操作,因为我当前画布没有啥其他操作, 首先我们我用两个栈信息来,一个undo栈 一个 redo 栈。来记录每一次画布的信息。 我这里给大家画图演示:
我在画布中画了3个图形, 每一次添加瞬间我都对canvas 截图了, 并把这个信息,保存到undoStack 了。这时候我按下 ctrl + z 回退
undo栈中 只有rect 和circle,然后redo 栈 就有一个shape 了。如图:
如果在回退undo 就只有个cicrle, redo 中有 rect 和shape, 大概就是这么个过程。 原理搞清楚了直接看代码实现:
第一个先初始化属性:
this.undoStack = []
this.redoStack = []
第二个canvas实现截图功能主要是配合 使用 toDataUrl 这个api:
add(shape) {
shape.draw(this.ctx)
const dataUrl = this.canvas.toDataURL()
const img = new Image()
img.src = dataUrl
this.undoStack.push(img)
this.allShapes.push(shape)
}
关于这个api 的详情 用法可以查阅 Mdn, 可以修改图片的类型 和质量 其他没有什么。
第三个就是undo 和redo 方法的详细实现。
undo() {
this.clearCanvas()
const img = this.undoStack.pop()
if (!img) {
return
}
this.ctx.drawImage(img, 0, 0)
this.redoStack.push(img)
}
redo() {
this.clearCanvas()
const img = this.redoStack.pop()
if (!img) {
return
}
this.ctx.drawImage(img, 0, 0)
this.undoStack.push(img)
}
这里 this.clearCanvas 就是清空画布。 undo 取出 栈顶的元素, 用了canvas drawImage 的这个api , 这个是canvas 对外提供绘制图片的能力。然后并将元素 加到 redo栈中。 这样其实就已经实现了。 redo 的方法同理。 不清楚的同学,看我上面的画的图。
我们这里直接看gif:
这是很常见的需求,如果我们在一次操作中画了很多 图形,比如100个, 我如果想回到一开始的时候,我难道要一次我要回退100 次嘛?? 对于用户来说这绝对 impossible 的 所以我们得实现一个批量回退的功能 , 其实很简单,就是我们放入到undoStack的那张图片 是很多图形的就好了。给出以下实现:
batchAdd = (shapes) => { shapes.forEach((shape) => shape.draw(this.ctx)) const dataUrl = this.canvas.toDataURL() const img = new Image() img.src = dataUrl this.undoStack.push(img) this.allShapes.push(...shapes)}
我测试一下, 我吧矩形的添加 和任意多边形的添加 放到一起 给出下面代码:
canvas.add(circle)canvas.batchAdd([rect, shape])
我们看下gif:
其实本篇文章回退只是对图形添加这个动作去做了回退,但是其实对于一个画图工具还有很多其他操作,比如修改图形的颜色, 大小哇, 这些都是可以用来记录的, 难道我们每次都要去重新画整个画布嘛, 这样的性能 是在是太差了。所以局部渲染, 就出来了,我们只对画布上变化的东西去做重新绘制。 其实也就是去找出两次的不同 去做局部渲染。
我们来思考 Canvas 局部渲染方案时,需要看 Canvas 的 API 给我们提供了什么样的接口,这里主要用到两个方法:
通过这两个 API 我们可以得到 Canvas 局部刷新的方案:
为什么所有同这个区域相交的图形都要重新绘制, 我举个例子:
首先看上面这张图,如果我只改变了圆形的颜色, 那我去做裁剪的时候,首先我的裁剪路径肯定是是这个圆, 但是同时又包含了 黑色矩形的一部分, 如果我只对圆做颜色变化的, 你会发现黑色矩形少了一部分。我给你看下 图片:
你会发现有点奇怪对吧, 这个时候有人提出了一个问题, 为什么整个圆呢, 3/4个圆不好嘛。OK是可以的, 你杠我,我就要在给你举一个例子。 或者说我这里我为什么要给大家讲一下Boundbox 的概念呢?
假设在这样的情况下:我想做局部渲染, 同时画布中还有一个绿色的三角形。 那你怎么去计算路径呢 ??? 对吧,所以我们想着肯定得有一个框去把他们框柱, 然后框内所有的的图形都会重画,其他不变。是不是就好了。
我们刚才说的用一个框去把图形包围住, 其实在几何中我们叫包围盒 或者是boundingBox。 可以用来快速检测两个图形是否相交, 但是还是不够准确。最好还是用图形算法去解决。 或者游戏中的碰撞检测,都有这个概念。因为我这里讨论的是2d的boudingbox, 还是比较简单的。我给你看几张图, 或许你就瞬间明白了。
虚线框其实就是boundingBox, 其实就是根据图形的大小,算出一个矩形边框。理论我们知道了,映射到代码层次, 我们怎么去表达呢? 我这里带大家原生实现一下bound2d 类, 其实我们每个2d图形,都可以去实现。 因为2d图形都是由点组成的,所以只要获得每一个图形的离散点集合, 然后对这些点,去获得一个2d空间的boundBox。
box2 这个类的属性其实就有一个min, max。 这个其实就是对应的矩形的左上角 和 右下角 这里是因为canvas 的坐标系坐标原点是左上方的, 如果坐标原点在左下方。min, max 对应的就是, 左下 和 右上。 我给出下面代码实现:
export class Box2 { constructor(min, max) { this.min = min || new Point2d(-Infinity, -Infinity) this.max = max || new Point2d(Infinity, Infinity) } setFromPoints(points) { this.makeEmpty() for (let i = 0, il = points.length; i < il; i++) { this.expandByPoint(points[i]) } return this } containsBox(box) { return ( this.min.x <= box.min.x && box.max.x <= this.max.x && this.min.y <= box.min.y && box.max.y <= this.max.y ) } expandByPoint(point) { this.min.min(point) this.max.max(point) return this } intersectsBox(box) { return box.max.x < this.min.x || box.min.x > this.max.x || box.max.y < this.min.y || box.min.y > this.max.y ? false : true } makeEmpty() { this.min.x = this.min.y = +Infinity this.max.x = this.max.y = -Infinity return this }}
min和max 其实对应着我之前写的Point2d 点这个类。 由于expandPoint, 这个方法的存在。 所以相当于不断的去比较获取的最大的点 和最小的点, 从而获得包围盒。 我看下Point2d min 和 max 这个方法的实现:
min(v) { this.x = Math.min(this.x, v.x) this.y = Math.min(this.y, v.y) return this }max(v) { this.x = Math.max(this.x, v.x) this.y = Math.max(this.y, v.y) return this}
其实就是比较两个点的x 和y 不断地去比较。
然后我再看下, 包围盒 是否相交 和包含这两个方法:
我先讲下 包含(containsBox)这个方法:代码不好理解,我还是画一张图就理解了:
cd 这个包围盒 是不是在ab 包围盒的内部 我们怎么表示呢
Cx >= Ax && Cy >=Ay && Dx<=Bx && Dy<=By
上面的伪代码, 你理解了,你就理解了包围这个方法的实现了。
然后我在看相交这个方法的实现,实现思路判断不想交的情况就好了。
两个包围盒不想交的情况对应下面的这张图:其实是分4个象限:
这是4中不想交情况, 对应的伪代码如下:
dx < ax || cy > by || cx > bx || ay > dy
看到这里,我觉得你肯定有收获,我希望你给我个👍和关注,我会持续输出好文章的。
有了boundBox, 我们给每一个图形加一个getBounding 这个方法。 这里就不展示了, 直接展示代码。
// 圆getBounding() { const { center, radius } = this.props const { x, y } = center const min = new Point2d(x - radius, y - radius) const max = new Point2d(x + radius, y + radius) return new Box2(min,max)}//矩形getBounding() { const { leftTop, width, height } = this.props const min = leftTop const { x, y } = leftTop const max = new Point2d(x + width, y + height) return new Box2(min, max)}//任意多边形getDispersed() { return this.props.points}getBounding() { return new Box2().setFromPoints(this.getDispersed())}
一切知识都已经讲结束了,我们开始进行实战环节了。 我在底部加一个按钮, 用于改变圆的颜色。
<button id="btn">改变圆的颜色</button>// 改变圆的颜色document.getElementById('btn').addEventListener(click, () => { circle.change( { fillColor: 'blue', }, canvas )})
同时点击的时候改变圆的颜色,我们看下 change 这个方法实现:
change(props, canvas) { // 设置不同 canvas.shapePropsDiffMap.set(this, props) canvas.reDraw()}
这里我给大家讲解一下哈, 首先我们已经在画布中已经有了这个圆,我这是对圆再一次改变,所以我将这一次的改变用一个map 记录, 重画这个方法 主要是区域裁剪, 但是裁剪我们要去判断 当前图形是不是和其他图形有相交的,如果有相交的,我们需要扩大裁剪区域, 并且重画多个图形。
如果有相交的其他图形, 这里涉及到两个包围盒的合并。来确定这个裁剪区域
union( box ) { this.min.min( box.min ); this.max.max( box.max ); return this;}
区域合并了,我们开始进行清除包围盒区域的图形, 先看下代码实现。
reDraw() { this.shapePropsDiffMap.forEach((props, shape) => { shape.props = { ...shape.props, ...props } const curBox = shape.getBounding() const otherShapes = this.allShapes.filter( (other) => other !== shape && other.getBounding().intersectsBox(curBox) ) // 如果存在相交 进行包围盒合并 if (otherShapes.length > 0) { otherShapes.forEach((otherShape) => { curBox.union(otherShape.getBounding()) }) } //清除裁剪区域 this.ctx.clearRect(curBox.min.x, curBox.min.y, curBox.max.x, curBox.max.y) }) }
裁剪的区域 就是合并的boudingBox 区域。我们看下图片
哈哈哈成功实现, 我只改变的是圆, 接下来进行裁剪和重画就好了代码如下:
// 确定裁剪范围this.ctx.save()this.ctx.beginPath()// 裁剪区域curBox.getFourPoints().forEach((point, index) => { const { x, y } = point if (index === 0) { this.ctx.moveTo(x, y) } else { this.ctx.lineTo(x, y) }})this.ctx.clip()//重画每一个图形[...otherShapes, shape].forEach((shape) => { shape.draw(this.ctx)})this.ctx.closePath()this.ctx.restore()
上面的getFourPoints, 其实是确定裁剪的路径。 这个很重要的方法如下:
getFourPoints() { const rightTop = new Point2d(this.max.x, this.min.y) const leftBottom = new Point2d(this.min.x, this.max.y) return [this.min, rightTop, this.max, leftBottom]}
为了测试局部渲染的优势哈,我在画布中画了50个圆形,并且增加了走全部渲染的按钮, 看看到底有没有优势。到底有没有优化。
const shapes = []for (let i = 1; i <= 50; i++) { const circle = new Circle({ center: Point2d.random(800, 600), radius: i + 20, fillColor: 'rgb( ' + ((Math.random() * 255) >> 0) + ',' + ((Math.random() * 255) >> 0) + ',' + ((Math.random() * 255) >> 0) + ' )', }) shapes.push(circle)}reDraw2() { this.clearCanvas() this.allShapes.forEach((shape) => { shape.draw(this.ctx) })}
然后画布是这样子的如图:
分别加了时间 去测试代码如下:
// 局部改变圆的颜色document.getElementById('btn').addEventListener(click, () => { console.time(2) circle.change( { fillColor: 'blue', }, canvas ) console.timeEnd(2)})// 全部刷新 改变圆的颜色document.getElementById('btn2').addEventListener(click, () => { console.time(1) canvas.reDraw2() console.timeEnd(1)})
下面我们开始测试看下gif:
大家可以发现,局部渲染还速度还是快的。这是在50个图形的基础上,如果换成100个呢, 对吧,优化可能就是比较明显的了。
本篇文章写到这里也就结束了,如果你对文章的内容有困惑,欢迎评论区交流指正。我看到都会回复的, 最后还是希望大家如果看完对你有帮助,希望点个赞👍和关注。让更多人看到, 我是喜欢图形的Fly,我们下期再见👋。
如果对你有帮助的话,可以关注公众号 【前端图形】 ,回复 【box】 可以获得全部源码。
我在上一篇文章简单实现了在canvas中移动矩形(点线面),不清楚的小伙伴请看我这篇文章:用canvas 实现矩形的移动(点、线、面)(1)。 ok,废话不多说,直接进入文章主题, 上一篇文章我留了很多问题,就是我在画步中移动我怎么知道我移动的是哪一个类型,到底是点还是线还是面, 这就是本篇文章要解决的问题。 读完本篇可以学到下面几点:
其实我上面说了这么多,其实就是为了在2d图形做一个效果就是 snap ——吸附,判断当前点与当前画布上多边形的关系。
读者你可以思考下,如果要你去做你会怎么去做呢? 假设画布上有很多多边形,还有很多点。有人说了,哪一个靠近它不就是哪一个。ok 你答对了,其实就是去判断当前点和画布上所有的点去比对,哪一个离的近,就是选中的哪一个点,这里会涉及到一个查询性能问题? 有同学就会问如果画布中有很多点呢?我们难道就要一个个去遍历比较大小嘛,当然不是这里给大家科普一下一个空间几何索引算法Rbush
RBush是一个高性能JavaScript库,用于点和矩形的二维空间索引。它基于优化的R树数据结构,支持批量插入。
我后面有时间会带大家撸一遍Rbush的,这里我给出参考链接 有兴趣的同学自行了解下。本篇就不用Rbush,就用集合去存储数据了哈! 这里还有一点需要强调的就是画布中的每一个点应该都每一个点都一个是实例,具有独特的id。 我们接下来就重新改造下:
const current = 0;
const map = new Map();
constructor(x,y) {
this.x = x || 0;
this.y = y || 0;
this.id = ++current;
map.set(this.id,[x,y]);
}
// 增加到Map上
add2Map() {
pointMap.push(this)
return this
}
//用来随机生成一个点
random(width,height){
this.x = Math.random() * width;
this.y = Math.random() * height;
return this;
}
// 取绝对值
abs() {
return [Math.abs(this.x), Math.abs(this.y)]
}
//计算两个点之间的距离
distance(p) {
const [x,y] = this.clone().sub(p).abs();
return Math.sqrt(x*x + y * y);
}
我又重新写了一个画多边形的方法代码如下:
// 画多边形
function drawAnyPolygon(points) {
if(!Array.isArray(points)) {
return;
}
ctx.strokeStyle = 'black'
ctx.setLineDash([]);
ctx.beginPath();
const start = points[0];
ctx.moveTo(start.x,start.y)
points.slice(1).forEach(item => {
ctx.lineTo(item.x,item.y)
})
ctx.closePath()
ctx.stroke()
}
这个没什么最重要的是什么呢,我们如何根据一个点去生成正多边形的点集合
这里我们看下多边形的定义:
正多边形是指二维平面内各边相等,各角也相等的多边形,也叫正多角形。
这里又带大家复习下数学知识: 我们先看张图:
现在有了map,我们可以去比较鼠标的点和画布中的点的距离了。我们先看第一部分根据类型生成点:
// 根据移动的类型重新生成点
function generatePointsByType(mousePoint,type = 'point',width = 200, height = 200) {
const results = [];
const { x, y } = mousePoint;
const moveVec = end.clone().sub(start);
const p1 = new Point2d(x- width /2, y - height/2).add2Map();
const p2 = new Point2d(x+ width / 2, y - height/2).add2Map();
const p3 = new Point2d(x+ width / 2, y + height/2).add2Map();
const p4 = new Point2d(x - width / 2, y + height/2).add2Map();
return [p1,p2,p3,p4]
}
这里有一点要注意的是就是p1,p2,p3,p4 满足的是顺时针,因为我们canvas画图是从左上----->左下的。 这一点大家在自己调试的要十分注意!!add2Map, 就是把点加入到Map中。我在上面补充上。我给出下一部分代码:比较鼠标的点和画布中的点之间的大小。
从图中我们可以得到: 正多形的形成 无非就是两种
原理我们知道了,应用到我们canvas怎么去实现呢? 其实也很简单,我们以圆心和圆上的一点,作为起始的向量。然后不断地旋转 2π/n 的角度 就可以得到所有的点了。 有了点我们就可以画出正多边形了。 这里是外接圆算多边形的思路,至于内接圆怎么去算, 给大家一个课后思考题🤔自己去想一下。 我给出以下代码实现:
第一部分点的绕着某一个中心点旋转的:
rotate(center, angle) {
const c = Math.cos( angle ), s = Math.sin( angle );
const x = this.x - center.x;
const y = this.y - center.y;
this.x = x * c - y * s + center.x;
this.y = x * s + y * c + center.y;
return this;
}
这里的大概思路向量的旋转然后在加上中心点的位置。 如果看不懂的话, 我给大家找一个推导过程: 传送门
第二部分就是如果生成多边形的顶点了:
function getAnyPolygonPoints(start, end, n = 3) {
const angle = (Math.PI * 2) / n
const points = [end]
for (let i = 1; i < n; i++) {
points.push(
end
.clone()
.rotate(start.clone(), angle * i)
.add2Map()
)
}
return points
}
接下我就给大家看下 n = 5|10 |20 |50 的 这些正多边形。然后你会发现随着边数的增加,我们画的多边形越越像个圆了。
有没有解锁你们的新世界?各位读者们。看到这里如果觉得对你有帮助的话。点个赞继续往下看吧。 👇还有一些数学方法的介绍。
我们设想鼠标不停地在画布上移动,我肯定哪一个点离我近,我就去选择哪一个点。 所以也就是不停的比较鼠标移动的点和已经存在的点的距离做判断。ok思路有了,我给出以下代码:
function calcClosestPoint() {
const minMap = []
for (let value of pointMap) {
const dis = value.distance(start.clone())
minMap.push({ ...value, dis })
}
// 找出最近的的一个点
const sort = minMap.sort((a, b) => a.dis - b.dis)
return sort[0]
}
这段代码肯可能要讲的就是两点之间求距离? 这个就很简单了,就是两个坐标相减求绝对值,然后开方。一般人肯定会这么想对吧,一开始我也是这么想的。 这么想没问题, 但是其实我不不需要开方,我们要比较的是距离。这里会有一个性能小优化。因为你要开方,然后cpu又去计算,如果画布中点的数量过多呢,并且数字很大的情况下。代码如下:
distance(p) {
const [x, y] = this.clone().sub(p).abs()
return x * x + y * y
}
distanceSq(p) {
const [x, y] = this.clone().sub(p).abs()
return Math.sqrt(x * x + y * y)
}
找到最小的点,我们就可以重复上一篇文章实现移动了。这里就不做过多讲解了,不清楚的小伙伴,可以去看过上一篇文章。 给出以下代码:
//画出任意多边形 满足顺时针方向
function drawAnyPolygon(points) {
if (!Array.isArray(points)) {
return
}
ctx.strokeStyle = 'black'
ctx.setLineDash([])
ctx.beginPath()
// 存在移动的点
if (movePoint.length > 0) {
const moveVec = end.clone().sub(start)
points = points.map((item) => {
if (item.equal(movePoint[0])) {
return item.clone().add(moveVec)
}
return item
})
}
ctx.moveTo(points[0].x, points[0].y)
points.slice(1).forEach((item) => {
ctx.lineTo(item.x, item.y)
})
ctx.closePath()
ctx.stroke()
}
canvas.addEventListener('click', (e) => {
if (e.altKey) {
isMove = false
return
}
isMove = !isMove
const x = e.clientX
const y = e.clientY
start = new Point2d(x, y)
movePoint.length = 0
movePoint.push(calcClosestPoint())
isSelect = true
})
这里我点击鼠标的以下就确定移动的点 和移动向量的起点,movePoint 其实是所有要移动的点。直接看效果图吧。
![Jun-27-2021 12-11-25](/Users/wangzhengfei/Desktop/Jun-27-2021 12-11-25.gif)
点的移动我们实现了,我们鼠标的点的那一刻,我们该如何确定点击的是线呢,这也归咎到一个数学问题? 就是比较点到直线的距离, 点到直线的距离,第一种解法就是直线方程去求解。 直线的直线方程是什么?
设直线 L 的方程为Ax+By+C=0,点 P 的坐标为(x0,y0),则点 P 到直线 L 的距离为:
同理可知,当P(x0,y0),直线L的解析式为y=kx+b时,则点P到直线L的距离为
考虑点(x0,y0,z0)与空间直线x-x1/l=y-y1/m=z-z1/n,有d=|(x1-x0,y1-y0,z1-z0)×(l,m,n)|/√(l²+m²+n²)
也就是两个点算出斜率和截距,但是要考虑直线与Y轴的特殊情况,也就是斜率无穷大的时刻。 这时候的距离就是x坐标相减。这样我们可以计算点到直线的距离,然后比较找出距离最小的线,接着找出移动的点就可以了。但这不是最优解,
首先我问一个问题哈? 向量的叉乘的几何意义是什么, 就是两个向量围成的平行四边形的面积。 我们计算点到直线的距离不就是计算,平行四边形的高嘛, 所以只要算出面积再除以底边就可以算出点到直线的距离了。 哈哈哈哈,是不是再一次被数学的魅力征服了。我给大家看个图吧:
红色的线就是点到直线的距离。 我们直接开始coding了,理论有了直接开干。
首先写一个点转为线段的一个方法,因为我们是首尾相连,所以点的个数,最后一个应该是和开始点相同的。
function points2Segs(points) {
const start = points[0]
points.push(start)
const segs = []
points.forEach((point, index) => {
if (index !== points.length - 1) {
segs.push([point, points[index + 1]])
}
})
return segs
}
叉乘的方法如下:
cross(v) {
return this.x * v.y - this.y * v.x
}
计算点到直线的距离如下:
function pointDistanceToLine(target, line) {
const [start, end] = line
const vec1 = start.clone().sub(target)
const vec2 = end.clone().sub(target)
return vec1.clone().cross(vec2) / start.clone().distanceSq(target)
}
// 找出最近的线
function calcClosestLine() {
let minMap = []
segs.forEach((line) => {
const dis = pointDistanceToLine(start, line)
minMap.push({
dis,
line,
})
})
minMap = minMap.sort((a, b) => a.dis - b.dis)
// 找出最近的直线然后将点放入到movePoint 中其实就好了
movePoint.push(...minMap[0].line)
}
移动那边代码改写一下:
if (movePoint.length > 0) {
const moveVec = end.clone().sub(start)
points = points.map((item) => {
// 线的移动对应的是两个点 面的话应该就是所有的点
if (item.equal(movePoint[0]) || item.equal(movePoint[1])) {
return item.clone().add(moveVec)
}
return item
})
}
直接来看效果:
![Jun-27-2021 12-32-26](/Users/wangzhengfei/Desktop/Jun-27-2021 12-32-26.gif)
完美实现很感谢你还能看到这里。 到这里因为点和线其实都会了,面就是所有的点移动这个是没什么难度的,后面大家可以自己去练习一下。
本篇文章主要是介绍了2d 下图形的移动, 点线面。 本质上都是点的移动,加上一个移动向量。核心就是这个,其实还有很多东西是需要大家慢慢体会的。一个闭合区域的形成,点的顺序,肯定是首尾相连的,按照某一个方向。还有就是对于叉乘、点乘的一些理解。 结合到实现项目中可以灵活运用。本篇文章的所有代码都在我的github,如果大家觉得看完对你有帮助的话,可以star一下。 最后最后的还是希望大家点个赞👍和评论。 知识输出不易,对图形感兴趣的话可以关注我的公众号: 前端图形 持续分享canvas、svg、webgl知识。
终于又到周末了,上一周的一篇3d文章 带你入门three.js——从0到1实现一个3d可视化地图很开心😺收到了这么多小伙伴的喜欢,这是对我知识输出的肯定。再次感谢大家!这周我又来了,这次给大家分享一下可视化图表比较简单的图表📈但同时我们又不得不学会的 那就是————折线图。读完本篇文章你可以学到什么
我们先去非常有名的Echarts 官网看一看,他的折线图是什么样子的?如图:
从图中可以得到以下2d图形元素:
好像仔细分析一下也没什么嘛,其实就是画直线和加文字。OK, 问下自己canvas如何画直线?是不是有一个ctx.LineTo的方法,但是他画出来的是直线没有端点的所以呢? 我们以此基础进行封装,并且直线的端点的图形可控, 同时还有文字位于直线的位置是不是可以画出这样的图形呢? 我们接下来进行实操环节。
第一步我们肯定是进行画布的创建,这里没什么好讲的。这里我在html 新建一个canvas, 我新建了一个类叫lineChart 直接上代码:
class lineChart {
constructor(data, type) {
this.get2d()
}
get2d() {
const canvas = document.getElementById('canvas')
this.ctx = canvas.getContext('2d')
}
}
上面代码没什么好讲的,然后我在为canvas 画布设置背景色。代码如下:
<style>
* {
padding: 0;
margin: 0;
}
canvas {
background: aquamarine;
}
</style>
其实折线图,本质上就是一个画直线,只不过在原有画直线的能力上,给他做一些增强。我用一个画三角形的例子: 带你熟悉一下画线操作。
先看下api:
绘制一条从当前位置到指定x以及y位置的直线。
直线一般是由两个点组成的,该方法有两个参数:x以及y ,代表坐标系中直线结束的点。开始点和之前的绘制路径有关,之前路径的结束点就是接下来的开始点,等等。。。开始点也可以通过moveTo()
函数改变。
moveTo 是什么就在画布中移动笔触, 也就是你开始画的第一个点,或者你可以想象一下在纸上作业,一支钢笔或者铅笔的笔尖从一个点到另一个点的移动过程。
moveTo(*x*, *y*)
将笔触移动到指定的坐标x以及y上。
介绍完毕, 开始实战环节:
drawtriangle() {
this.ctx.moveTo(25, 25)
this.ctx.lineTo(105, 25)
this.ctx.lineTo(25, 105)
}
我们先移动一个点, 然后再画条直线, 然后再画条直线。 如果写到你认为结束了,你就错了
你还差一个很重要的一步就是画布描边或者是填充, 我刚开始学也会忘记这个。
这里給大家整理下canvas 的整个画图流程
也就是我们刚才所做的一切只是在准备路径,所以我们需要描边或者填充来渲染图形, 我们来看下这两个api。
// 通过线条来绘制图形轮廓。
ctx.stroke()
// 通过填充路径的内容区域生成实心的图形。
ctx.fill()
我们把填充加上去: 看下效果:
我们看下描边效果:
你会发现为什么没有闭合?,代码是这样的:
this.moveTo(25, 25)
this.lineTo(105, 25)
this.lineTo(25, 105)
this.stroke()
这就说明了一个重要问题就是什么呢?
描边是默认不闭合的,需要我们手动闭合
填充默认会帮我们闭合图形, 并且填充
既然发现了问题,我们就需要解决问题,那么canvas 如何闭合路径呢??
closePath:
闭合路径之后图形绘制命令又重新指向到上下文中。
代码如下:
this.moveTo(25, 25)
this.lineTo(105, 25)
this.lineTo(25, 105)
this.closePath()
this.stroke()
这时候效果图已经出来了:
有closePath? 难道没有开始路径? 答案是当然有的:
// 新建一条路径,生成之后,图形绘制命令被指向到路径上生成路径。
this.beginPath()
这里会问这个有什么作用呢?
首先 生成路径的第一步叫做beginPath()。本质上,路径是由很多子路径构成,这些子路径都是在一个列表中,所有的子路径(线、弧形、等等)构成图形。而每次这个方法调用之后,列表清空重置,然后我们就可以重新绘制新的图形。
注意:当前路径为空,即调用beginPath()之后,或者canvas刚建的时候,第一条路径构造命令通常被视为是moveTo(),无论实际上是什么。出于这个原因,你几乎总是要在设置路径之后专门指定你的起始位置。
closePath 其实也不是必须的,如果图形已经是闭合的,就不需要调用, 到这里canvas的基本绘图操作复习就到这里,后面还有一些实战api : 我就例子中给大家讲解, 不然会显得很生硬。
再次之前,我把canvas中每一个点的位置都用一个point2d 点去表示并且写了一些方法,我在之前的文章都有仔细讲过这里我就不展开说了: 3千字长文canvas实现任意正多边形的移动(点、线、面) 这一篇文章。 这里我就直接放上代码:
export class Point2d {
constructor(x, y) {
this.x = x || 0
this.y = y || 0
this.id = ++current
}
clone() {
return new Point2d(this.x, this.y)
}
equal(v) {
return this.x === v.x && this.y === v.y
}
add2Map() {
pointMap.push(this)
return this
}
add(v) {
this.x += v.x
this.y += v.y
return this
}
abs() {
return [Math.abs(this.x), Math.abs(this.y)]
}
sub(v) {
this.x -= v.x
this.y -= v.y
return this
}
equal(v) {
return this.x === v.x && this.y === v.y
}
rotate(center, angle) {
const c = Math.cos(angle),
s = Math.sin(angle)
const x = this.x - center.x
const y = this.y - center.y
this.x = x * c - y * s + center.x
this.y = x * s + y * c + center.y
return this
}
distance(p) {
const [x, y] = this.clone().sub(p).abs()
return x * x + y * y
}
distanceSq(p) {
const [x, y] = this.clone().sub(p).abs()
return Math.sqrt(x * x + y * y)
}
static random(width, height) {
return new Point2d(Math.random() * width, Math.random() * height)
}
cross(v) {
return this.x * v.y - this.y * v.x
}
}
分别对应的是一些静态方法、叉乘、 两个点之间求距离哇等等。
我们先在画布上画一条基础的直线, 我们先用random, 在画布上重新生成两个点,然后画出一条随机的直线, 代码如下:
new lineChart().drawLine(
Point2d.random(500, 500),
Point2d.random(500, 500)
)
// 画直线
drawLine(start, end) {
const { x: startX, y: startY } = start
const { x: endX, y: endY } = end
this.beginPath()
this.moveTo(startX, startY)
this.lineTo(endX, endY)
this.stroke()
}
这里没有好展示的,我们还是分析下echarts 官方的折线图直线,直线两旁是两个圆的,想一想?其实这边涉及到一个数学知识,各位小伙伴,Fly再一次化身数学老师给大家讲解,主要是帮有些小伙伴复习复习。 这里我们已经知道直线的开始点和结束点,在数学中我们可以确定一条直线方程,那么我们就可以求出直线上任意一点的(x,y)坐标。那么直线的两个端点的圆心我们就可以确定? 半径也可以确定了就是圆心分别到开始点和结束点的距离。
第一步: 实现直线方程
我们先看下直线方程的几种表达方式:
一般式: Ax+By+C=0(A、B不同时为0)【适用于所有直线】
点斜式: y-y0=k(x-x0) 【适用于不垂直于x轴的直线】 表示斜率为k,且过(x0,y0)的直线
截距式:x/a+y/b=1【适用于不过原点或不垂直于x轴、y轴的直线】
两点式:表示过(x1,y1)和(x2,y2)的直线 【适用于不垂直于x轴、y轴的直线】 (x1≠x2,y1≠y2)
这里很明显我们适合第四种:已经知道直线的起始点和结束点可以求出直线方程。我给出以下代码:
export function computeLine(p0, p1, t) {
let x1 = p0.x
let y1 = p0.y
let x2 = p1.x
let y2 = p1.y
// 说明直线平行 y轴
if (x1 === x2) {
return new Point2d(x1, t)
}
// 平行X轴的情况
if (y1 === y2) {
return new Point2d(t, y1)
}
const y = ((t - x1) / (x2 - x1)) * (y2 - y1) + y1
return new Point2d(t, y)
}
p0、p1、 对应的两个直线点 t 就是参数,对应直线的x,我们求出y,返回新的点就好了 。我们默认以开始点和结束点的 x 位置分别 减去或者加一个固定的值 , 求得圆心。直接看下图吧:
这个图已经很明显了, 1和2 之间的距离就是半径, 所以我们只要求出点1 和点4 好像 就OK了, canvas 中是怎么画圆呢有一个arc 这个api :
arc(x, y, radius, startAngle, endAngle, anticlockwise)
画一个以(x,y)为圆心的以radius为半径的圆弧(圆),从startAngle开始到endAngle结束,按照anticlockwise给定的方向(默认为顺时针)来生成。
注意:
arc()
函数中表示角的单位是弧度,不是角度。角度与弧度的js表达式:弧度=(Math.PI/180)*角度。
圆肯定就是从0-360度, 代码如下:
drawCircle(center, radius = 4) {
const { x, y } = center
this.ctx.beginPath()
this.ctx.arc(x, y, radius, 0, Math.PI * 2, true) // 绘制
this.ctx.fill()
}
准备工作都做好了, 我们就开始实现话带圆的直线吧。 画图的步骤就是
画开始圆和画结束圆其实可以封装成一个方法: 他们最主要的区别其实就是起始点的不同,代码如下:
drawLineCircle(start, end, type) {
const flag = type === 'left'
const { x: startX, y: startY } = start
const { x: endX, y: endY } = end
const center = this.getOnePointOnLine(
start.clone(),
end.clone(),
flag ? startX - this.distance : endX + this.distance
)
// 两点之间的距离 不熟悉的小伙伴可以看下上面的文章
const radius = (flag ? start : end).clone().distanceSq(center)
this.drawCircle(center, radius)
}
这样我们就可以画圆了。先看下效果图:
到这里我们就已经结束了折线图的第一个部分, 紧接着进入第二部分:
坐标轴本质上就是两条直线,所以第一步确定坐标原点,然后以坐标原点画出垂直和水平的两条直线。 我们设置坐标原点离画布的左内边距和底部内边距,这样我们可以通过画布的高度减去底部内边距得到 原点的y, 然后通过画布的宽度减去左内边距得到x, 有了坐标原点画坐标轴就没什么大问题了。代码如下:
//定义坐标轴相对于画布的内边距
this.paddingLeft = 30 // 至少大于绘制文字的宽度
this.paddingBottom = 30 // 至少大于绘制文字的高度
this.origin = new Point2d(
this.paddingLeft,
this.height - this.paddingBottom
)
this.drawCircle(this.origin, 1, 'red')
this.addxAxis()
this.addyAxis()
// 画 x 轴
addxAxis() {
const end = this.origin
.clone()
.add(new Point2d(this.width - this.paddingLeft * 2, 0))
this.drawLine(this.origin, end)
}
// 画y轴
addyAxis() {
const end = this.origin
.clone()
.sub(new Point2d(0, this.height - this.paddingBottom * 2))
this.drawLine(this.origin, end)
}
这里要特别提示的是 首先整个画布的 坐标轴 是在整个屏幕的左上方, 但是我们显示的坐标原点是在 左下方, 然后 画Y轴的时候是由原点向上减去, 是向量点的减法。
效果图如下 :
但是和echarts 那个不太一样, 他的x轴是有线段的和文字的,接下来我们就开始改造 x轴。就是将X轴分几段嘛,
然后生成一个点的集合,这些点的y都是相同的, 然后 x是不相同的。代码如下:
drawLineWithDiscrete(start, end, n = 5) {
// 由于 x 轴上的 y 都是相同的
const points = []
const startX = start.x
const endX = end.x
points.push(start)
const segmentValue = (endX - startX) / n
for (let i = 1; i <= n - 1; i++) {
points.push(new Point2d(startX + i * segmentValue, start.y))
}
points.push(end)
// 生成线段
points.forEach((point) => {
this.drawLine(point, point.clone().add(new Point2d(0, 5)))
})
}
这里要注意的就是循环的个数,因为起始点和终止点是有的。 看下效果图:
在指定的(x,y)位置填充指定的文本,绘制的最大宽度是可选的.
ctx.fillText(text,x,y,[,maxwidth])
所以说白了还是去计算文字点的坐标,首先在项目初始化的定义X轴和Y轴的数据。代码如下:
this.axisData = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
this.yxisData = ['0', '50', '100', '150', '200', '250', '300']
文字我们统一放在线段的中点处其实只要计算每个分段数的长度然后在端点处+分段数长度的一半就可以得到。代码如下:
// 生成X轴文字的点
const segmentValue = (endX - startX) / n
for (let i = 0; i <= n - 1; i++) {
const textpoint = new Point2d(
startX + i * segmentValue + segmentValue / 2,
start.y + 20
)
// 这里每个点的文字与X轴数据是互相呼应的
textPoints.push({
point: textpoint,
text: this.axisData[i],
})
}
// 生成文字
this.clearFillColor()
textPoints.forEach((info) => {
const { text, point } = info
this.ctx.fillText(text, point.x, point.y)
})
效果图如下:
但是看着图好像文字并没有处于居中的位置, 胖虎思考了🤔一下, 其实因为文字也有长度, 所以每一个文字的坐标要减去文字长度的一半值就对了。这时候this.ctx.fillText 的第三个参数就显得十分重要了, 限制文字的长度, 这样我们就可以处理了, 代码 重新修改下:
// 限制文字的长度
this.ctx.fillText(text, point.x, point.y, 20)
// 文字的每个点要减去长度的一半
const textpoint = new Point2d(
startX + i * segmentValue + segmentValue / 2 - 10,
start.y + 20
)
直接看效果图:
这下看一下就是完美。
X轴的处理好了,我们处理Y轴,Y轴其实相对比较简单就是每个数据对应的一条直线。
Y轴的话也是要计算每个线段的长度的值,然后画出直线, 这里要特别注意的是就是文字的放置, 在每个端点还要进行微调。使得文字和直线居中对齐。代码如下:
addyAxis() {
const end = this.origin
.clone()
.sub(new Point2d(0, this.height - this.paddingBottom * 2))
const points = []
const length = this.origin.y - end.y
const segmentValue = length / this.yxisData.length
for (let i = 0; i < this.yxisData.length; i++) {
const point = new Point2d(end.x, this.origin.y - i * segmentValue)
points.push({
point,
text: this.yxisData[i],
})
}
points.forEach((info) => {
const { text, point } = info
const end = point
.clone()
.add(new Point2d(this.width - this.paddingLeft * 2, 0))
this.setStrokeColor('#E0E6F1')
this.drawLine(point, end)
this.clearStrokeColor()
this.ctx.fillText(text, point.clone().x - 30, point.y + 4, 20)
})
}
因为过程和X轴十分相似, 提醒一下描边 设置后,要将它恢复默认,不然会引用上一个颜色哦。
如图:
整个画布就差最后一步了, 生成折线图, 我们在上面已经封装了,带圆的直线, 所以只要找到所有的点去画折线图就好了。首先每个点的X坐标没什么问题对应的就是每个文字的中点, 主要是Y轴的坐标: 回忆一下之前我们是怎么去计算Y轴的坐标的是, 长度/ 除以分段数 去计算的。 这样就导致一个问题,出来的结果可能是一个小数,因为我们实际的数据 可能是223 这种这样导致画出来的图形点误差太大, 所以为了减少误差, 我换一个计算模式,就是进行等分,这样在区间里面的点都可以表达, 误差可以稍微小点, 其实在实际项目中, 容差问题是计算肯定存在的问题,js 本身就有0.1+0.2 这样的问题, 所以或者说在容差范围内我们可以认为这两个点是等价的 代码如下:
const length = this.origin.y - end.y
const division = length / 300
const point = new Point2d(end.x, this.origin.y - i * division * 50)
然后我这时候引入真实的数据:
this.realData = [150, 230, 224, 218, 135, 147, 260]
this.xPoints = []
this.yPoints = []
分别对应的是真实的数据, xPoints是什么文字的中点坐标代码如下:
// 生成文字
this.clearFillColor()
textPoints.forEach((info) => {
const { text, point } = info
this.xPoints.push(point.x)
this.ctx.fillText(text, point.x, point.y, 20)
})
yPoints其实也就比较简单了, 真实数据 * 每一份的距离就好了。
const division = length / 300
for (let i = 0; i < this.yxisData.length; i++) {
const point = new Point2d(end.x, this.origin.y - i * division * 50)
// 在这里, 还是得注意坐标轴的位置
const realData = this.realData[i]
this.yPoints.push(this.origin.y - realData * division)
points.push({
point,
text: this.yxisData[i],
})
}
数据准备好了,我们就开始调用方法去画折线图:
let start = new Point2d(this.xPoints[0], this.yPoints[0])
// 生成折线图
this.setStrokeColor('#5370C6')
this.xPoints.slice(1).forEach((x, index) => {
const end = new Point2d(x, this.yPoints[index + 1])
this.drawLineWithCircle(start, end)
start = end
})
这段代码需要注意的是默认找一个开始点, 然后 不断地去更改开始点, 然后注意下标位置。
如图:
目前存在的问题:
到这里打大家可以这么去思考,为什么圆和直线要捆绑在一起? 单独画不就没有这样的问题了。说干就干,
let start = new Point2d(this.xPoints[0], this.yPoints[0])
this.drawCircle(start)
// 生成折线图
this.setStrokeColor('#5370C6')
this.xPoints.slice(1).forEach((x, index) => {
const end = new Point2d(x, this.yPoints[index + 1])
// 画圆
this.drawCircle(end)
// 画直线
this.drawLine(start, end)
start = end
})
这里注意会少一个开始圆,我们在开头的直接补上就好了, 圆的半径我都统一设置了。
如图:
至此到这里, 这折线图全部完成,为了做的更完美一点,我还是增加的提示和虚线。
这里我看大多数图表都在鼠标移动的时候都会显示一个虚线和提示,不然我怎么清除的看数据对吧。 我们还是初始化一个div将它的样式设置为隐藏。
#tooltip {
position: absolute;
z-index: 2;
background: white;
padding: 10px;
border-radius: 2px;
visibility: hidden;
}
<div id="tooltip"></div>
为canvas 增加监听事件:
canvas.addEventListener('mousemove', this.onMouseMove.bind(this))
// 这里取相对于画布原点的位置 offset
onMouseMove(e) {
const x = e.offsetX
const y = e.offsetY
}
其实我们要做的事情非常简单首先我们就是去比较鼠标的点 和 实际的点在某个范围内我就显示,类似于吸附, 从用户的角度不可能完全移动到那里才显示。
代码如下:
onMouseMove(e) {
const x = e.offsetX
const find = this.xPoints.findIndex(
(item) => Math.abs(x - item) <= this.tolerance
)
if (find > -1) {
this.tooltip.textContent = `数据:${this.axisData[find]}_ ${this.yxisData[find]}`
this.tooltip.style.visibility = 'visible'
this.tooltip.style.left = e.clientX + 2 + 'px'
this.tooltip.style.top = e.clientY + 2 + 'px'
} else {
this.tooltip.style.visibility = 'hidden'
}
}
这里其实只要比较x的位置就好了,容差可以自定义设置。
我看了很多图表他们都有垂直的虚线,这里就涉及到一个问题canvas 如何画虚线, 我在用canvas 实现矩形的移动(点、线、面)(1)这篇文章有介绍, 我就直接拿过来,不过多解释了,感兴趣的小伙伴可以看下这篇文章。 代码如下:
drawDashLine(start, end) {
if (!start || !end) {
return
}
this.ctx.setLineDash([5, 10])
this.beginPath()
this.moveTo(start.x, start.y)
this.lineTo(end.x, end.y)
ctx.stroke()
}
我们对onMouseMove 再一次进行改造:
onMouseMove(e) {
const x = e.offsetX
const find = this.xPoints.findIndex(
(item) => Math.abs(x - item) <= this.tolerance
)
if (find > -1) {
this.tooltip.textContent = `数据:${this.axisData[find]}_ ${this.yxisData[find]}`
this.tooltip.style.visibility = 'visible'
this.tooltip.style.left = e.clientX + 2 + 'px'
this.tooltip.style.top = e.clientY + 2 + 'px'
// 画虚线
const start = new Point2d(this.xPoints[find], this.origin.y)
const end = new Point2d(this.xPoints[find], 0)
this.drawDashLine(start, end)
} else {
this.tooltip.style.visibility = 'hidden'
}
}
增加了以下代码, 但是这样是有问题的,就是我们鼠标不停的移动, 所以上一次绘制的虚线不会取消。会出现下面这种情况:
所以我做了一个数据清除同时清除画布上的东西重新画:
clearData() {
this.ctx.clearRect(0, 0, 600, 600)
this.xPoints = []
this.yPoints = []
}
整体代码如下:
const start = new Point2d(this.xPoints[find], this.origin.y)
const end = new Point2d(this.xPoints[find], 0)
// 清除数据
this.clearData()
this.drawDashLine(start, end)
// 虚线样式也要每次清除 不然会影响下面的画的样式
this.ctx.setLineDash([])
this.addxAxis()
this.addyAxis()
this.setStrokeColor('#5370C6')
this.generateLineChart()
再给出一个小技巧**, 其实canvas 中 画图如果某次的样只想在某一个绘制中起作用:有save 和 restore方法
使用 save()
方法保存当前的状态,使用 restore()
进行恢复成一开始的样子
所以我们可以重新改写下画虚线的方法,在一开始的时候svae 一下, 然后结束在 restore , 有点像栈的感觉,先进去,然后画结束,弹出来。 每一项都有自己的独特的画图状态,不影响其他项。
drawDashLine(start, end) {
if (!start || !end) {
return
}
this.ctx.save()
this.ctx.setLineDash([5, 10])
this.beginPath()
this.moveTo(start.x, start.y)
this.lineTo(end.x, end.y)
this.stroke()
this.ctx.restore()
}
至此整个折线图我想给大家讲解的已经结束了,我们看下效果吧:
本篇文章算是canvas实现可视化图表的第一篇吧,后面我会持续分享、饼图、树状图、K线图等等各种可视化图表,我自
己在写文章的同时也在不断地思考,怎么去表达的更好。如果你对可视化感兴趣,点赞收藏关注👍吧!,可以关注我下
面的数据可视化专栏, 每周分享一篇 文章, 要么是2d、要么是three.js的。我会用心创作每一篇文章,绝不水文。
最后一句话: 大家和我一起做一个Api的创造者而不是调用者!
本篇文章例子的所有代码都在我的github上,欢迎star☆😯! 如果你对图形感兴趣,可以关注我的公众号【前端图形】,领取可视化学习资料哦!!我们下期再见👋
在canvas中实现图片移动、实现矩形移动,大家可能看的很多了。但是我为什么还要去写这样的一篇文章呢,因为笔者曾经做到3维图形下的移动。包括移动一个立方体上的一条边线、一个面、移动多边形的一个点。最近一直在写canvas的相关的文章,想着复习下,读完本篇文章你可以学到,通过移动矩形的一个点, 一个条边线,以及整个面的移动。本篇文章从浅到深,希望你耐心读下去。
试想一下,在canvas 下实现移动功能。第一步肯定创建canvas 并对canvas 添加move 事件, 这样我们实时获取到我们鼠标的位置,然后我们只需要不断的清除画布,然后重新画矩形。就OK了,面的移动初步实现。我在下面写点的移动会把这里重写了, 这里先写个快速简易版本的。
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const width = 100;
const height = 100;
drawRect();
function drawRect(x = 10,y = 10, scale = 1) {
ctx.clearRect( 0, 0, 1800, 800 );
const halfwidth = width * scale / 2;
const halfheight = height * scale / 2;
ctx.strokeRect(x - halfwidth, y - halfheight,width * scale, height * scale)
}
let isMove = true;
canvas.addEventListener('mousemove',(e)=>{
if(!isMove) {
return
}
const x = e.clientX;
const y = e.clientY;
drawRect(x,y)
})
canvas.addEventListener('click',(e)=> {
isMove = !isMove;
});
isMove变量就是个开关,我们总不能一直移动吧,那也太累了,鼠标点击的就暂停。 对于上文为什么要x - halfwidth, y - halfheight
这里和大家解释下: strokeRect 是从矩形的左上角开始画的,但是呢我想把矩形放在鼠标中心位置, 所以做一个宽度和高度相减就可以完美展示了。
首先第一个问题就是? 我们怎么知道我们选择的是哪一个点呢,这里我做了一个简单的判断就是通过判断鼠标点击的位置和矩形的每个点的位置作比较,看看哪一个离得近就作为目标点。因为我们2d其实点的移动其实也就是找这个点关联的线段, 所以我们只需要重新生成关联的线段就好了,但是这里有一个比较难以处理的地方? 就是移动一个半圆? 如果我们移动半圆的断点, 这里就涉及到圆弧的改变了,可能变成椭圆弧或者说用二阶、三阶贝塞尔曲线去表达。还有就是移动一个图形和画布上其他图形形成了切割? 是不是也要切割算法?其实可以用Clipper去求交并差,感兴趣的同学可以自行去了解一下,但是这些不在本篇文章所想要阐述中。 本篇文章所有的例子(只支持直线也就是LineSegment)。
OK,我们第一步我们得去重新表达矩形,因为他不够通用准确的是重新表达四边形, 矩形和正方形只是其中的特列。这里我给出原因? 为什么呢一个很简答的case,移动四边形的一个点,他可能变成下面这样:
ok 为了下面好表达我们新建一个Point2d这个类, 将画布上的每一个点都用一个实例去表示。
class Point2d {
constructor(x,y) {
this.x = x || 0;
this.y = y || 0;
}
clone() {
return this.constructor(this.x, this.y);
}
add(v) {
this.x += v.x;
this.y += v.y
return this;
}
random() {
this.x = Math.random() *1800;
this.y = Math.random() * 800;
return this
}
}
接下来我们就随便在画布上一鼠标的位置分别加上矩形的长度和宽度,画出矩形。 代码如下:
function drawFourPolygon(x, y ,width = 50, height = 50) {
ctx.clearRect( 0, 0, 1800, 800 );
ctx.beginPath();
ctx.moveTo(x- width /2, y - height/2)
ctx.lineTo(x+ width / 2, y -height/2 )
ctx.lineTo(x+ width / 2, y + height/2 )
ctx.lineTo(x - width / 2, y + height/2 )
ctx.closePath()
ctx.stroke()
}
为了交互更加完美, 鼠标第一次点击确定移动的开始点, 然后 鼠标不停地移动就是移动的终止点, 这样就确定了一个向量。 这里为了移动的时候更加明显我增加了虚线功能,代码如下
function drawDashLine(start, end) {
if (!start || !end) {
return
}
ctx.strokeStyle = 'red';
ctx.setLineDash( [5, 10] );
ctx.beginPath();
ctx.moveTo( start.x, start.y );
ctx.lineTo( end.x, end.y );
ctx.closePath()
ctx.stroke();
}
这里用到的就是canvas setLineDash 这个api 参数的含义,实线的距离5、空白的距离10 如此往复的走下去形成虚线。start和end 就是鼠标点击确定的就是start 点, 然后鼠标不停的移动就是end点。 这里有一个小提醒就是我鼠标移动的过程中先画了虚线,然后又画了矩形所以呢? 矩形我们还是实线。我们这里对画矩形代码做了修改,还是把虚线还原过来。
代码如下:
ctx.setLineDash([]);
OK整体的交互出来了,我先给大家看下效果:
![Jun-23-2021 22-27-27](/Users/wangzhengfei/Desktop/Jun-23-2021 22-27-27.gif)
是不是有点感觉了哈哈哈?从画面上看这还是整体的移动不是点的移动, 由于我画的图形以鼠标点击的那个点去画矩形的,我的下一篇文章会给大家介绍不规则多边形点的移动,本篇文章我们还是假设我移动的是右上角的那个点。OK我们由移动的开始点和结束点, 可以得到一个移动的向量, 所以我们只要将要移动的点 和这个向量相加。这样我们是不是实现了点的移动。
const moveVec = end.clone().sub(start);
const rightTop = new Point2d(x+ width / 2, y - height/2).clone().add(moveVec)
这里我改变了右上角的点,但是呢有一个问题就是我们点击也是走的同一个函数,所以我们得加个开关去判断下,主要是用来判读是第一次点击还是移动就好了代码如下:
ctx.lineTo(isSelect ? rightTop.x : x+ width / 2, isSelect ? rightTop.y : y height/2)
// 看下click和move 事件 开关就是isSelect这个变量
canvas.addEventListener('mousemove',(e)=>{
if(!isMove) {
return
}
const x = e.clientX;
const y = e.clientY;
clearRect();
end = new Point2d(x,y);
drawDashLine(start,end);
drawFourPolygon(start)
})
canvas.addEventListener('click',(e)=> {
// 这是一个每次清除画布的函数
clearRect()
isMove = !isMove;
const x = e.clientX;
const y = e.clientY;
start = new Point2d(x,y);
drawFourPolygon(start)
isSelect = true;
});
效果图如下:
哈哈哈是不是十分的丝滑和流畅, 发现canvas 的画图的性能还是非常不错的。但是还是有一个问题就是,确定结果,看上面代码我们确定结果是有问题的。 所以我以按住alt键结束为确定结果这就十分完美了,代码就不在这里展现了。
有了点的移动,线的移动就显示的十分简单。 线的移动其实就是对应的点的移动。 我们以右边这条线为例子: 代码改写如下:
function drawFourPolygon( point, width = 50, height = 50) {
if(!point) {
return
}
ctx.strokeStyle = 'black'
ctx.setLineDash([]);
ctx.beginPath();
const { x, y } = point;
const moveVec = end.clone().sub(start);
// 其实就是 右上和右下这两个点同时移动
const rightTop = new Point2d(x+ width / 2, y - height/2).clone().add(moveVec)
const rightBottom = new Point2d(x+ width / 2, y + height/2).clone().add(moveVec)
ctx.moveTo(x- width /2, y - height/2)
ctx.lineTo(isSelect ? rightTop.x : x+ width / 2, isSelect ? rightTop.y : y - height/2)
ctx.lineTo(isSelect ? rightBottom.x : x+ width / 2, isSelect ? rightBottom.y : y + height/2)
ctx.lineTo(x - width / 2, y + height/2 )
ctx.closePath()
ctx.stroke()
}
我们看下效果图:
本篇文章主要介绍的2d图形下最基本的变化移动,无论是线的移动还是面的移动最终都是点的移动。其实移动除了用向量表示还可以用矩阵, 或者说我们旋转移动缩放等等命令都可以用矩阵变化表示。 最后还是感谢大家看到最后,码字不容易,如果看了对你有帮助的, 欢迎点个赞👍和关注。 你的支持是我持续更新好文章的最大动力。 所有代码都在我的github上。欢迎大家star。
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.