canvas 入门 [2023-03-01]
抖音:
关键词
ts
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
ctx.beginPath() // 开始准备路径
ctx.moveTo(100, 20) // 移动到某个点, 坐标以左上角为原点
ctx.lineTo(200, 40) // 从上一个点, 连接到某个点
ctx.lineTo(240, 100) // 从上一个点, 连接到某个点
ctx.closePath() // 使终点连接到起点
ctx.strokeStyle = '#fff' // 线的样式
ctx.stroke() // 画的第一种方式, 表示描边, 把路径的线画出来
ctx.fillStyle = '#fff' // 填充样式
ctx.fill() // 填充
ctx.arc(100, 20, 6, 0, 2 * Math.PI) // 画一个圆, 圆心坐标(100,20) 半径6 起始弧度0 结束弧度2 PI 默认顺时针
ctx.fillStyle = '#fff' // 填充样式
ctx.font = '24px JetBrainsMono'
ctx.fillText('hello world!', 100, 100)图像清晰度
- 自然尺寸(原始尺寸)
${img}.naturalWidth${img}.naturalHeight - 样式尺寸(展示到页面尺寸)
style="width: 200px; height: 200px" - 缩放倍率
devicePixelRatio - 保持清晰
自然尺寸 = 样式尺寸 * 缩放倍率
canvas 基本绘图说明
所有的绘图都必须在上下文中完成
同一个 canvas 只能产生唯一的上下文
上下文类型可以是:
2d: 绘制2d图形bitmaprender:绘制位图上下文webgl: 绘制3d的上下文,只在实现WebGL 2.0的浏览器上可用webgl2: 绘制3d的上下文,只在实现WebGL 3.0的浏览器上可用
1. 基础画图
根据最后一节,尝试拓展了,鼠标在内部移动,有一个跟随移动的点并连线
查看代码
vue
<template>
<div>
<canvas id="canvas-base" />
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
const getRandom = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1) + min)
onMounted(() => {
const canvas = document.querySelector('#canvas-base') as HTMLCanvasElement
canvas.width = (canvas.parentElement?.clientWidth || 500) * devicePixelRatio
canvas.height = 500 * devicePixelRatio
/** 上下文 */
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
class Point {
x: number
y: number
/** 半径 */
r: number
/** 每个点的x方向移动速度,随机 */
xSpeed: number
/** 每个点的y方向移动速度,随机 */
ySpeed: number
/** 记录小球上一次运动的时间 */
lastDrawTime?: number
/** 是否固定,不随机改变位置 */
fixed?: boolean
constructor() {
this.r = 4 * devicePixelRatio
this.x = getRandom(0, canvas.width - this.r / 2)
this.y = getRandom(0, canvas.height - this.r / 2)
this.xSpeed = getRandom(-50, 50)
this.ySpeed = getRandom(-50, 50)
this.lastDrawTime = undefined
}
draw() {
// 更新坐标
if (this.lastDrawTime && !this.fixed) {
// 如果上次有时间则更新坐标, 如果没有表示第一次
const duration = (Date.now() - this.lastDrawTime) / 1000
// 根据时间计算出移动的距离
const xDis = duration * this.xSpeed
const yDis = duration * this.ySpeed
let x = this.x + xDis
let y = this.y + yDis
/*
如果x移动超过宽度-半径, 则将x点设置到这个位置,并反向移动
x小于0+半径; y大于高度-半径; y 小于高度+半径, 同理
*/
if (x > canvas.width - this.r / 2) {
x = canvas.width - this.r / 2
this.xSpeed = -this.xSpeed // 反向移动
}
if (x < this.r / 2) {
x = this.r / 2
this.xSpeed = -this.xSpeed
}
if (y > canvas.height - this.r / 2) {
y = canvas.height - this.r / 2
this.ySpeed = -this.ySpeed
}
if (y < this.r / 2) {
y = this.r / 2
this.ySpeed = -this.ySpeed
}
this.x = x
this.y = y
}
ctx.beginPath()
ctx.arc(this.x, this.y, this.r, 0, 2 * Math.PI)
ctx.fillStyle = 'rgb(200, 200, 200)'
ctx.fill()
this.lastDrawTime = Date.now()
}
}
class Graph {
points: Point[]
constructor(pointNumber = 40, /** 最大距离 */ readonly maxDis = 200) {
this.points = new Array(pointNumber).fill(0).map(() => new Point())
}
draw() {
requestAnimationFrame(() => {
this.draw() // 浏览器每次渲染时重画
})
ctx.clearRect(0, 0, canvas.width, canvas.height) // 清空画布
this.points.forEach((point, i) => {
point.draw()
for (let j = i + 1; j < this.points.length; j++) {
const targetPoint = this.points[j]
// 根据勾股定理计算直线距离
const d = Math.sqrt((point.x - targetPoint.x) ** 2 + (point.y - targetPoint.y) ** 2)
ctx.beginPath() // 开始准备路径
ctx.moveTo(point.x, point.y) // 移动到某个点, 坐标以左上角为原点
ctx.lineTo(targetPoint.x, targetPoint.y) // 从上一个点, 连接到某个点
ctx.closePath() // 使终点连接到起点
ctx.strokeStyle = `rgba(200, 200, 200, ${1 - d / this.maxDis})` // 根据最大距离设置透明度
ctx.stroke()
}
})
}
}
const graph = new Graph()
graph.draw()
/**
* 拓展,鼠标在canvas中移动,加一个点并连线
*/
let mousePoint!: Point
canvas.onmouseover = e => {
if (!mousePoint) {
mousePoint = new Point()
mousePoint.fixed = true
}
!graph.points.includes(mousePoint) && graph.points.push(mousePoint)
canvas.onmousemove = e => {
const rect = canvas.getBoundingClientRect()
mousePoint.x = e.clientX - rect.left
mousePoint.y = e.clientY - rect.top
}
}
canvas.onmouseout = () => {
let index = graph.points.findIndex(point => point === mousePoint)
if (index > -1 && mousePoint) {
graph.points.splice(index, 1)
}
}
const observer = new ResizeObserver(() => {
canvas.width = canvas.parentElement?.clientWidth || 500
})
observer.observe(canvas.parentElement as HTMLDivElement)
onUnmounted(() => {
observer.unobserve(canvas.parentElement as HTMLDivElement)
})
})
</script>
<style lang="less" scoped>
#canvas-base {
background-color: black;
border: 1px solid #f0f2f5;
width: 100%;
height: 500px;
}
</style>2. 文字也可以很酷炫
查看代码
vue
<template>
<div>
<canvas id="canvas-text" />
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
const getRandomChar = () => {
const str = '1234567890qwertyuiopasdfghjkllzxcvbnm'
return str[Math.floor(Math.random() * str.length)]
}
onMounted(() => {
const canvas = document.querySelector('#canvas-text') as HTMLCanvasElement
canvas.width = (canvas.parentElement?.clientWidth || 500) * devicePixelRatio
canvas.height = 500 * devicePixelRatio
/** 上下文 */
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
const fontSize = 16 * devicePixelRatio
const columnCount = Math.floor(canvas.width / fontSize)
const charIndex = new Array(columnCount).fill(0)
ctx.textBaseline = 'top' // 调整与y轴对齐基线
const draw = () => {
ctx.font = `${fontSize}px JetBrainsMono`
ctx.fillStyle = 'rgba(0,0,0,0.1)' // 每次给一个快透明的矩形,以达到慢慢消失
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = 'green' // 填充样式
for (let i = 0; i < charIndex.length; i++) {
const x = i * fontSize
const y = charIndex[i] * fontSize
ctx.fillText(getRandomChar(), x, y)
if (y > canvas.height && Math.random() > 0.99) {
// 如果超过高度然后通过随机数模拟延迟
charIndex[i] = 0
} else {
charIndex[i]++
}
}
}
draw()
let timer = setInterval(draw, 50)
const observer = new ResizeObserver(() => {
canvas.width = canvas.parentElement?.clientWidth || 500
})
observer.observe(canvas.parentElement as HTMLDivElement)
onUnmounted(() => {
clearInterval(timer)
observer.unobserve(canvas.parentElement as HTMLDivElement)
})
})
</script>
<style lang="less" scoped>
#canvas-text {
background-color: black;
border: 1px solid #f0f2f5;
width: 100%;
height: 500px;
}
</style>3. 用 canvas 玩转图片
关键信息
TIP
index = (y * width + x) * 4 y 行 + x * 4 是要取的像素点的索引,因为是 rgba,所以 4 个元素为一个点
点击图中某个地方,周围相近的颜色变绿
查看代码
vue
<template>
<div>
<canvas id="canvas-image" />
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import ImageFile from './assets/1.jpg'
/**
* 简单对比
* 分别将ragb相减,然后求绝对值,再相加
* 如果完全没差异的话,求出来肯定是0
* 如果有差异求出来肯定是正数
*/
const diffColor = (color1: Uint8ClampedArray, color2: Uint8ClampedArray) => {
return (
Math.abs(color1[0] - color2[0]) +
Math.abs(color1[1] - color2[1]) +
Math.abs(color1[2] - color2[2]) +
Math.abs(color1[3] - color2[3])
)
}
onMounted(() => {
const canvas = document.querySelector('#canvas-image') as HTMLCanvasElement
canvas.width = 400 * devicePixelRatio
canvas.height = 400 * devicePixelRatio
/** 上下文 */
const ctx = canvas.getContext('2d', {
//如果需要反复读取canvas中的数据,浏览器建议加上这个,浏览器会执行一些优化
willReadFrequently: true
}) as CanvasRenderingContext2D
const img = new Image()
img.onload = () => {
ctx.drawImage(img, 0, 0)
}
img.src = ImageFile
const point2Index = (x: number, y: number) => {
return (y * canvas.width + x) * 4
}
const getColor = (x: number, y: number, imageData: ImageData) => {
const index = point2Index(x, y)
return imageData.data.slice(index, index + 4)
}
const resetColor = new Uint8ClampedArray([0, 255, 0, 255])
canvas.addEventListener('click', e => {
// 点击拿x和y坐标
const x = e.offsetX,
y = e.offsetY
// canvas所有像素点的颜色
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const clickColor = getColor(x, y, imageData)
// 当图片较大时,递归应改为循环
// const _changeColor = (x, y) => {
// // 如果超出边界,返回
// if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.width) return
// const targetColor = getColor(x, y, imageData)
// // 如果求出来点击颜色和目标颜色差值大于100则不改变
// if (diffColor(targetColor, clickColor) > 100) return
// // 如果目标颜色已经是要设置的颜色也不改变,防止一直递归
// if (diffColor(targetColor, resetColor) === 0) return
// // 修改图片像素点信息,只是内存中修改了,canvas并未修改
// imageData.data.set(Array.from(resetColor), point2Index(x, y))
// // 周围的点
// // _changeColor(x + 1, y)
// // _changeColor(x, y + 1)
// // _changeColor(x - 1, y)
// // _changeColor(x, y - 1)
// }
// _changeColor(x, y)
// 周围的点 递归改循环 参考的,自己写不出来
function _changeColor(x: number, y: number) {
const pixelStack = [[x, y]]
while (pixelStack.length) {
const [x, y] = pixelStack.pop() || []
if (!x && !y) return
if (x >= 0 && y >= 0 && x < canvas.width && y < canvas.height) {
const targetColor = getColor(x, y, imageData)
// 如果求出来点击颜色和目标颜色差值大于100则不改变
if (diffColor(targetColor, clickColor) > 100) continue
// 如果目标颜色已经是要设置的颜色也不改变,防止一直递归
if (diffColor(targetColor, resetColor) === 0) continue
// 修改图片像素点信息,只是内存中修改了,canvas并未修改
imageData.data.set(Array.from(resetColor), point2Index(x, y))
pixelStack.push([x + 1, y])
pixelStack.push([x - 1, y])
pixelStack.push([x, y + 1])
pixelStack.push([x, y - 1])
}
}
}
_changeColor(x, y)
// 通过此方法将修改后的像素点信息更新到canvas中
ctx.putImageData(imageData, 0, 0)
})
})
</script>
<style lang="less" scoped>
#canvas-image {
// background-color: black;
border: 1px solid #f0f2f5;
width: 400px;
margin: 0 auto;
height: 400px;
}
</style>4. 绘制与拖动
关键词
ctx.lineCap指定如何绘制每一条线段末端的属性butt线段末端以方形结束。round线段末端以圆形结束。square线段末端以方形结束,但是增加了一个宽度和线段相同,高度是线段厚度一半的矩形区域。
查看代码
vue
<template>
<div>
<input type="color" name="" id="color-picker" />
<canvas id="canvas-darg" />
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
onMounted(() => {
const colorPicker = document.querySelector('#color-picker') as HTMLInputElement
const canvas = document.querySelector('#canvas-darg') as HTMLCanvasElement
canvas.width = (canvas.parentElement?.clientWidth || 500) * devicePixelRatio
console.log('[ devicePixelRatio ]', canvas.parentElement?.clientWidth, devicePixelRatio)
canvas.height = 500 * devicePixelRatio
/** 上下文 */
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
/** 所有需要画的矩形的数组 */
const shapes: Rectangle[] = []
/** 通过坐标判断是哪个shape,倒序,因为后画的可能在之前画的上面 */
const getShape = (x: number, y: number) => {
for (let i = shapes.length - 1; i >= 0; i--) {
const shape = shapes[i]
if (x >= shape.minX && x <= shape.maxX && y >= shape.minY && y <= shape.maxY) {
return shape
}
}
return null
}
class Rectangle {
color: string
startX: number
startY: number
endX: number
endY: number
constructor(color: string, startX: number, startY: number) {
this.color = color
this.startX = startX
this.startY = startY
this.endX = startX
this.endY = startY
}
/**
* 由于可能正着拖和反着拖,所以开始位置不一定比结束位置小,所以最小值作为矩形左上顶点
*/
get minX() {
return Math.min(this.startX, this.endX)
}
get maxX() {
return Math.max(this.startX, this.endX)
}
get minY() {
return Math.min(this.startY, this.endY)
}
get maxY() {
return Math.max(this.startY, this.endY)
}
draw() {
ctx.beginPath()
ctx.moveTo(this.minX * devicePixelRatio, this.minY * devicePixelRatio)
ctx.lineTo(this.minX * devicePixelRatio, this.minY * devicePixelRatio)
ctx.lineTo(this.maxX * devicePixelRatio, this.minY * devicePixelRatio)
ctx.lineTo(this.maxX * devicePixelRatio, this.maxY * devicePixelRatio)
ctx.lineTo(this.minX * devicePixelRatio, this.maxY * devicePixelRatio)
ctx.closePath()
ctx.fillStyle = this.color
ctx.fill()
ctx.strokeStyle = '#000'
ctx.lineWidth = 2 * devicePixelRatio
ctx.lineCap = 'square'
ctx.stroke()
}
}
canvas.onmousedown = e => {
const rect = canvas.getBoundingClientRect()
const clickX = e.clientX - rect.left
const clickY = e.clientY - rect.top
const shape = getShape(clickX, clickY)
if (shape) {
document.body.style.cursor = 'move'
// 拖动
const { startX, startY, endX, endY } = shape // 初始位置
window.onmousemove = e => {
const disX = e.clientX - rect.left - clickX // 移动的距离
const disY = e.clientY - rect.top - clickY
shape.startX = startX + disX
shape.startY = startY + disY
shape.endX = endX + disX
shape.endY = endY + disY
}
} else {
const shape = new Rectangle(colorPicker.value, clickX, clickY)
shapes.push(shape)
document.body.style.cursor = 'crosshair'
window.onmousemove = e => {
shape.endX = e.clientX - rect.left
shape.endY = e.clientY - rect.top
}
}
window.onmouseup = () => {
window.onmousemove = null
window.onmouseup = null
document.body.style.cursor = ''
}
}
function draw() {
requestAnimationFrame(draw)
ctx.clearRect(0, 0, canvas.width, canvas.height)
shapes.forEach(shape => shape.draw())
}
draw()
const observer = new ResizeObserver(() => {
canvas.width = canvas.parentElement?.clientWidth || 500
})
observer.observe(canvas.parentElement as HTMLDivElement)
onUnmounted(() => {
observer.unobserve(canvas.parentElement as HTMLDivElement)
})
})
</script>
<style lang="less" scoped>
#canvas-darg {
background-color: #eee;
width: 100%;
height: 500px;
}
</style>