canvas中的矩阵变换

480 阅读6分钟

前言

源码

github.com/buglas/canv…

学习目标

  • canvas 内置变换方法里的矩阵逻辑

知识点

  • translate(x,y)
  • rotate(ang)
  • scale(x,y)
  • transform(e0,e1,e3,e4,e6,e7)

前情回顾

之前我们完成了图案编辑器的整体架构,创建了vue+vite+ts+vitest 项目,并准备好了必备的数学方法。

接下来我们还得再做一项很重要的准备工作:搞明白canvas 里的矩阵变换。

1-canvas 内置矩阵

我们之前在canvas基础课程里说过canvas 内置的矩阵变换,现在我们在回顾一下。

canvas 内置了针对三种矩阵变换状态的方法:

  • translate(x,y) :位移
  • rotate(ang): 旋转
  • scale(x,y) :缩放

这三个方法都是相对变换方法。

canvas 还内置了两个直接基于矩阵变换的方法:

  • setTransform(e0,e1,e3,e4,e6,e7):绝对变换
  • transform(e0,e1,e3,e4,e6,e7):相对变换

其参数里的序号对应的是列主序矩阵里矩阵因子的索引位置。

接下来,我们考虑一个问题:translate()、rotate()和scale() 这三个方法是按照怎样的矩阵逻辑运行的。

2-矩阵测试

先在当前项目中建立一个测试页。

1.把/src/examples/HelloWorld.vue 文件复制一份,名称为MatrixOfCanvas.vue

2.配置好路由。

  • /src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
    {
        path: '/',
        component: () => import('../examples/HelloWorld.vue'),
    },
    {
        path: '/MatrixOfCanvas',
        component: () => import('../examples/MatrixOfCanvas.vue'),
    },
]

const router = createRouter({
    history: createWebHistory(),
    routes,
})

export default router

3.添加相应导航。

  • /src/App.vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'
/* canvas 容器 */
const contRef = ref<HTMLDivElement>()
const size = ref<{
    width: number | undefined
    height: number | undefined
}>({
    width: 0,
    height: 0,
})

onMounted(() => {
    // 获取canvas 容器的尺寸
    const cont = contRef.value
    size.value.width = cont?.clientWidth
    size.value.height = cont?.clientHeight
})
</script>

<template>
    <!-- 路由出口 -->
    <div id="cont" ref="contRef">
        <!-- 将canvas容器的尺寸传给子组件 -->
        <router-view :size="size"></router-view>
    </div>
    <!-- 导航栏 -->
    <nav>
        <div class="nav-tit">测试</div>
        <router-link to="/">HelloWorld</router-link>
        <router-link to="/MatrixOfCanvas">canvas内置矩阵</router-link>
    </nav>
</template>

<style scoped>
……
</style>

2-1-translate(),rotate(),scale()测试

接下来我要通过translate(),rotate(),scale() 这三个内置方法绘制一个矩形。

  • /src/examples/MatrixOfCanvas.vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Matrix3 } from '../lmm/math/Matrix3'
import { Vector2 } from '../lmm/math/Vector2'

// 获取父级属性
defineProps({
    size: { type: Object, default: { width: 0, height: 0 } },
})

// 对应canvas 画布的Ref对象
const canvasRef = ref<HTMLCanvasElement>()

/* 矩形的初始顶点 */
const vertices = [0, 0, 100, 0, 100, 50, 0, 50]

/* 矩形的模型矩阵数据 */
const position = new Vector2(300, 200)
const rotate = 0.4
const scale = new Vector2(1, 2)

/* 矩阵测试 */
function matrixTest(ctx: CanvasRenderingContext2D) {
    /* translate(),rotate(),scale()测试 */
    ctx.save()
    ctx.translate(position.x, position.y)
    ctx.rotate(rotate)
    ctx.scale(scale.x, scale.y)
    drawRect(ctx, vertices, 'black', 40)
    ctx.restore()
}

/* 绘制矩形 */
function drawRect(
    ctx: CanvasRenderingContext2D,
    vertices: number[],
    color: string = 'black',
    lineWidth: number = 0
) {
    ctx.fillStyle = color
    ctx.strokeStyle = color
    ctx.lineWidth = lineWidth
    ctx.beginPath()
    ctx.moveTo(vertices[0], vertices[1])
    for (let i = 2, len = vertices.length; i < len; i += 2) {
        ctx.lineTo(vertices[i], vertices[i + 1])
    }
    ctx.closePath()
    ctx.stroke()
    ctx.fill()
}

onMounted(() => {
    const canvas = canvasRef.value
    const ctx = canvas?.getContext('2d')
    ctx && matrixTest(ctx)
})
</script>

<template>
    <canvas ref="canvasRef" :width="size.width" :height="size.height"></canvas>
</template>

<style scoped>
</style>

效果如下:

image-20230312185425398

解释一下其绘制过程。

1.声明矩形的4个初始顶点。

const vertices = [0, 0, 100, 0, 100, 50, 0, 50]

两个数字为一组,对应顶点的x,y。

2.建立矩形的绘制方法。

function drawRect(
    ctx: CanvasRenderingContext2D,
    vertices: number[],
    color: string = 'black',
    lineWidth: number = 0
) {
    ctx.fillStyle = color
    ctx.strokeStyle = color
    ctx.lineWidth = lineWidth
    ctx.beginPath()
    ctx.moveTo(vertices[0], vertices[1])
    for (let i = 2, len = vertices.length; i < len; i += 2) {
        ctx.lineTo(vertices[i], vertices[i + 1])
    }
    ctx.closePath()
    ctx.stroke()
    ctx.fill()
}

这个比较简单,我就不再多说,看不懂的话就得看我的canvas基础课了。

3.声明矩形的模型矩阵数据。

const position = new Vector2(300, 200)
const rotate = 0.4
const scale = new Vector2(1, 2)

4.onMounted时,绘制矩形。

function matrixTest(ctx: CanvasRenderingContext2D) {
    /* translate(),rotate(),scale()测试 */
    ctx.save()
    ctx.translate(position.x, position.y)
    ctx.rotate(rotate)
    ctx.scale(scale.x, scale.y)
    drawRect(ctx, vertices, 'black', 40)
    ctx.restore()
}

onMounted(() => {
    const canvas = canvasRef.value
    const ctx = canvas?.getContext('2d')
    ctx && matrixTest(ctx)
})

在绘制矩形时,我是按照translate>rotate>scale的顺序变换的。

我这个顺序不是瞎排的,有矩阵基础的同学应该知道:

  • translate,rotate,scale 各自可以看成一个具备单一状态的矩阵变换。
  • rotate和scale 不会影响本地坐标系的基点,translate会改变本地坐标系的基点。
  • 当scale 非等比缩放时,先scale再rotate会让物体发生倾斜。

比如,我把之前的rotate和scale顺序颠倒一下:

ctx.save()
ctx.translate(position.x, position.y)
/* ctx.rotate(rotate)
ctx.scale(scale.x, scale.y) */

//rotate和rotate变换顺序测试
ctx.scale(scale.x, scale.y)
ctx.rotate(rotate)

drawRect(ctx, vertices, 'black', 40)
ctx.restore()

之前的矩形会变成平行四边形:

image-20230312185755842

  • translate,rotate,scale,合在一起便是一个完整的矩阵变换。

与此同时,我们还要知道,矩阵有两种变换方式:

  • 相对变换:第1次变换是基于世界坐标系变换的,之后的都是基于本地坐标系相对变换。
  • 绝对变换:所有的变换都是基于世界坐标系的。

相对变换和绝对变换的变换方式是互逆的,它们其实就是对同一事物的两种观察角度。

我们可以用数学里的矩阵乘法来写这个变换逻辑:

模型矩阵=位移矩阵*旋转矩阵*缩放矩阵

接下来我们通过canvas内置的transform()方法测试一下这个矩阵算法。

2-2-transform()测试

1.把之前声明的position,rotate,scale 转为矩阵。

const [sm, rm, pm] = [
    new Matrix3().makeScale(scale.x, scale.y),
    new Matrix3().makeRotation(rotate),
    new Matrix3().makeTranslation(position.x, position.y),
]

2.把上面的三个矩阵合成一个完整矩阵。

const matrix = pm.multiply(rm).multiply(sm)

上面矩阵乘法的顺序就是:位移矩阵旋转矩阵缩放矩阵

3.把上面的矩阵因子,作为transform()方法的参数,变换矩阵。

const { elements: e } = matrix
ctx.save()
ctx.transform(e[0], e[1], e[3], e[4], e[6], e[7])
drawRect(ctx, vertices, '#00acec', 20)
ctx.restore()

matrix.elements 是一个列主序的矩阵,transform() 方法便是从这个列主序的矩阵里获取矩阵因子。

至于为什么是按照0,1,3,4,6,7 的顺序来获取的,那是canvas 的API规则,我们记住即可。

最终效果如下:

image-20230312215207491

其黑色部分是我第一次画的矩形,蓝色部分是我用transform() 方法画的矩形,两者的填充区域是重合的。

这说明我之前用translate(),rotate()和scale() 做的矩阵变换,与我用transform()做的矩阵变换是一样的。

扩展

之前计算矩阵matrix时,我们用的是pm.multiply(rm).multiply(sm) 方法,我们也可以换一种写法的。

const matrix = new Matrix3()
  .scale(scale.x,scale.y)
  .rotate(rotate)
  .translate(position.x, position.y)

这种写法便是绝对变换,在世界坐标系缩放,在世界坐标系旋转,在世界坐标系位移。

大家若不理解,推荐看看源码。

接下来,我们也可以用matrix直接变换矩形的初始顶点,画出像上面一样的图形。

2-3-顶点变换

1.先声明一个用矩阵变换顶点的方法。

function tranformVertices(vertices: number[], matrix: Matrix3) {
    const worldVertives: number[] = []
    for (let i = 0, len = vertices.length; i < len; i += 2) {
        const { x, y } = new Vector2(vertices[i], vertices[i + 1]).applyMatrix3(
            matrix
        )
        worldVertives.push(x, y)
    }
    return worldVertives
}

2.变换顶点,然后绘图。

ctx.save()
drawRect(ctx, tranformVertices(vertices, matrix), '#acec00', 0)
ctx.restore()

效果如下:

image-20230312222838348

这三个颜色的矩形的填充区域是完全重合的。

总结

这一章所说的矩阵变换是我们之后在开发图形组件时的重要知识支撑,需要彻底理解。