图形学 & canvas2d,实现跨窗口球体动画

1,094 阅读13分钟

但行好事 莫问前程

前言 🎀

去年国外某大佬在推特发表的 Entangled 动画(纠缠的球体?)广受好评:

image.png

在其中,动画、图形学、web 得到了完美的融合,淋漓尽致的展示着艺术和程序之美,令人心神向往。

工作之余写了个简单的demo,才疏学浅还望大家见谅!🙊

相似的文章在掘金已经有几篇了,本文主要梳理:

  1. 数学图形学 中的一些概念(坐标系、矩阵),并结合 canvas 在web中进行实践
  2. 前端跨窗口通信 的各种方式和优缺点(localStorage、postMessage、Broadcast Channel、sharedWorker...)

代码已上传 github,最终实现效果如下:

(可以拖动本窗口尝试) jcode2

如果有收获还望 点赞+收藏 🌹

简介

实现动画的主要步骤为:

  1. 在窗口的画布(canvas)中 绘制球体,并添加 旋转动画
  2. 存储窗口的相关参数,并在窗口变化时 通知更新,绘制窗口范围内的球体
  3. 增加缓动函数让动画更平滑

代码中抽象出三个类:

  1. 粒子类Particle, 球体类Globe, 视口类ViewPort
  2. ParticleGlobe用于 生成&存储 模型数据
  3. ViewPort用于 创建场景 和 摆放相机

Particle: 记录粒子坐标、颜色、大小、比例,提供绘制方法draw、更新方法update

Globe: 记录球体的旋转角度、旋转速度、球体半径、粒子数组,提供旋转方法rotate、填充方法fillParticles

ViewPort: 记录目标画布、球体数组、视口宽高、相机位置,提供渲染方法render、添加球体方法addGlobe

实现

- 绘制球体

建模

建立物体模型,采用模型坐标系,以webgl右手坐标系为基准。

假设以球体中心为原点坐标 O(0, 0, 0),球体半径为r,随机生成方位角θ、仰角φ

3f74f366d662914b4375c2ab9991faa6.png

根据球坐标的计算公式: x = r * cosθ * sinΦy = r * sinθ * sinΦz = r * cosΦ,已知r、θ、φ,可得x、y、z。

利用极坐标的便利性 通过两个互相垂直的极坐标 取一个点P(r, θ, φ),然后通过计算公式转换为 笛卡尔坐标系 点P(x, y, z)

for (let i = 0; i < 2000; i++) {
  const theta = Math.random() * 2 * Math.PI;  // 方位角θ:随机0 - 360°
  const phi = Math.acos(Math.random() * 2 - 1);  // 仰角φ:随机-90 - 90°
  // P(r, φ, θ) -> P(x, y, z)
  // x = r * cosθ * sinΦ; y = r * sinθ * sinΦ; z = rcosΦ
  const x = this.radius * Math.sin(phi) * Math.cos(theta);
  const y = this.radius * Math.sin(phi) * Math.sin(theta);
  const z = this.radius * Math.cos(phi);  // [-r, r]
  this.Particles.push(new Particle(x, y, z, color, particleRadius));
}

场景

创建场景,提供一个全局的环境,用于摆放不同的模型,使用世界坐标系。

文中暂时默认模型坐标原点和世界坐标原点重合(球心重叠),坐标、方向相同无需额外计算。

下图 用于理解世界坐标系

每个茶壶模型(=球体) 都有各自的模型坐标系

世界坐标系给模型提供了一个通用的环境(=场景)

通过旋转、平移、缩放...等一系列坐标转换,可以把它们放到一个坐标系(场景)中

image.png

相机 & 投影

摆放相机 ,投影(透视投影)计算可见内容,3D坐标 -> 2D 坐标

设置相机,摆放在世界坐标系的Z轴上 正对原点,调整视距(Z轴相对原点的距离)

image.png
// 手动调整视距 
canvasInstance.cameraPosition = canvasInstance.width * 1;

Q:为什么是 canvasInstance.width * 1
A:因为坐标的最大值为±canvasInstance.width * 1,并且在世界坐标系中摆放球体时未经缩放,这样设置我们能看到所有球体的完整形态,当然你也可以调整 * 1: 缩小/放大 = 相机 前进/后退

以相机视角进行观察,使用观察坐标系(与世界坐标系XYZ轴方向相同 原点的Z坐标不同),世界坐标系中顶点的Z轴坐标需要转换(Z坐标平移从[-r, r]变为了[0, -2r])

view-space.png

投影,计算可视内容,3D坐标转换为2D坐标,Z轴通过大小和透明度来模拟

image.png

// class Globe
this.Particles.forEach(particleItem => {
  // 根据场景中心,绘制球体粒子
  const [centerX, centerY] = viewPortItem.center;
  const { x, y, z} = particleItem;
  const size = (z + viewItem.cameraPosition) / (2 * viewItem.cameraPosition);
  const xProjection = x * size + centerX;
  const yProjection = y * size + centerY;
  particleItem.update(xProjection, yProjection, Math.max(size, 0.1))
});
// 根据视距重排,远的粒子先绘制 近的粒子最后绘制,解决粒子层级的冲突
this.Particles.sort((particle1, particle2) => particle1.size - particle2.size);
this.Particles.forEach(particleItem => particleItem.draw(viewItem.ctx));

视口

最后在web中创建一个 画布canvas 作为 视口ViewPort,用于展示内容。

<canvas id="canvas_particle_1" width="500" height="500"></canvas>

const canvasElement = document.getElementById('view_port_1') as HTMLCanvasElement;
const canvasInstance = new ViewPort(canvasElement);

此时已能直观看到球体:

image.png

- 旋转模型

模型数据不变,相机位置不变,旋转场景中的球体,根据 三维坐标 & 旋转角度 & 矩阵转换公式,重新计算投影后的坐标。

球体绕Y轴旋转,Y轴坐标不变:

[cos(β)0sin(β)00100sin(β)0cos(β)00001]×[xyz1]=[xcos(β)+zsin(β)yxsin(β)+zcos(β)1] \left[ \begin{matrix} cos(β) & 0 & sin(β) & 0\\ 0 & 1 & 0 & 0\\ -sin(β) & 0 & cos(β) & 0 \\ 0 & 0 & 0 & 1 \end{matrix} \right] × \left[ \begin{matrix} x \\ y \\ z \\ 1 \end{matrix} \right]= \left[ \begin{matrix} x·cos(β) + z·sin(β) \\ y \\ -x·sin(β) + z·cos(β) \\ 1 \end{matrix} \right]
const rotX = x * cosineRotation + z * sineRotation;
const rotZ = -x * sineRotation + z * cosineRotation;

此时已初具雏形了:

- 跨窗口通信

为此新增一个 窗口类WindowMessage 让数据更清晰。

class WindowMessage {
  left: number;
  right: number;
  top: number;
  bottom: number;
  globe: Globe
}

在窗口打开时、窗口移动时 和 球体偏移时,通知其他窗口当前球体的位置。

借助BroadcastChannel API 实现窗口之间的通信。

// 创建、连接频道
const broadcastChannel = new BroadcastChannel("cross-window-broadcast");
const portId = Date.now();
// 监听频道
broadcastChannel.addEventListener('message', (event) => {
  const { data } = event;
  if (!data?.globe) return;
  receiveChnnaelMessage(data);
});
// 发送数据
function sendChannelMessage(data: WindowMessage) {
  broadcastChannel.postMessage(data);
}
// 接收数据
function receiveChnnaelMessage(data: WindowMessage) {
  // 计算窗口之间的偏移量
  if (msgSet.has(id)) {
    // 更新共享球体的数据
  } else {
    // 新增球体,同时共享自身数据,避免新窗口没有记录本窗口的球体
  }
}

- 缓动函数

球体位置的变化并不是突然启动或者停止,为了让移动更加平滑,我们在拖动时和停止拖动时增加缓动函数。

效果类似于:

动画1.gif

easings.net/zh-cn#

// 定义缓动函数
function easeOutExpo(t: number) {
  return (t === 1) ? 1 : 1 * (-Math.pow(2, -10 * t / 1) + 1);
}

function easeOutBack(t: number) {
  let s = 2.70158;
  return 1 * ((t = t / 1 - 1) * t * ((s + 1) * t + s) + 1);
}

// 监听窗口移动
function watchWindowScreen() {
  // 窗口偏移量的变化值
  let leftUpdate = endLeft - newLeft;
  let topUpdate = endTop - newTop;
  // 窗口移动时,自身球体逐渐偏离场景中心
  if (leftUpdate || topUpdate) {
    // 窗口移动时,非自身球体的偏移量跟随变化
    for (let globeItem of canvasInstance.Globes) {}
    // 跟随窗口移动不断更新起始位置
    animateGlobePosition(Date.now(), offsetX, offsetY);
  } else if (animateFrameId) {
    // 窗口停止移动时,取消偏离动画,球体逐渐回归场景中心
    cancelAnimationFrame(revertFrameId);
    cancelAnimationFrame(animateFrameId);
    revertGlobePosition(Date.now(), offsetX, offsetY);
  }
  requestAnimationFrame(watchWindowScreen);
}

requestAnimationFrame(watchWindowScreen);

图形与动画

计算机图形学,是一种使用数学算法 将 2D、3D 模型 渲染到 2D 计算机屏幕 的科学,主要应用在游戏、数据可视化、地图导航、虚拟现实(AR) ...

图形学与数学密切相关,涉及到 坐标系、点、向量、矩阵等等,最后能帮助我们实现图形的 旋转、缩放、平移和投影等效果

- 坐标系

真实世界中每个物体都有自己的位置,类似以地心为原点用经纬度表示一个地点。

而在程序中为了精确地模拟现实场景,并描述物体的位置,我们需要引入坐标系的概念。

WebGL 沿用了 OpenGL坐标系的体系,多数使用右手坐标系,XY轴表示水平、垂直坐标,Z 轴正向轴朝向屏幕外 —— 表示前后、深浅

image.png

webgl的绘制需要进行多次 坐标系变换,目的是为了将模拟场景中的物体(3D)渲染到屏幕(2D)上,即 render 3D in 2D

坐标系转换的计算过程比较复杂(旋转、缩放、z轴、投影...),我们需要一种快速执行复杂计算的工具:矩阵

我们的学习通过 向量 开始。

- 点、向量、矩阵

在 3D 领域通常指顶点坐标,是3D世界的基本元素,3D世界由模型组成,模型由面组成,面又由点组成。

点在坐标系中的位置用坐标来表示。

WebGL 的渲染过程,接收模型的顶点 交给GPU,GPU执行流程:顶点着色器绘制 -> 图元装配连线 -> 光栅化填格 -> 片段着色器填色,最后将顶点渲染成模型 呈现到屏幕上。

向量

向量 是既有大小,又有方向的量,在物理和工程学中又称为 矢量

向量在坐标系中通常用一根带箭头的线段来表示。

我们经常会使用 三维向量 来表示 顶点坐标,然后 变换矩阵 左乘 顶点坐标 代表对这个顶点执行 坐标转换

矩阵

矩阵是由行列排列的一系列数值组成的集合,以简单的方式代替大量的运算,不用再使用三角函数、加减乘除等繁杂的数学公式来完成坐标转换。

M=(123456789)M =\begin{pmatrix} 1 & 2 & 3 \\ 4 & 5 & 6 \\ 7 & 8 & 9 \end{pmatrix}

根据行列数,称作m×nm \times n阶 或者 nn阶矩阵。

向量可以理解为一个特殊的矩阵,四维向量既可以理解为一个 1 行 4 列矩阵,也可以理解为一个 4 行 1 列矩阵,所以向量可以根据转换的矩阵被称为 行/列向量

a\vec{a} = (1, 2, 3, 4)

=[1234]=\begin{bmatrix} 1 & 2 & 3 & 4\\ \end{bmatrix}
=[1234]=\begin{bmatrix} 1\\ 2\\ 3\\ 4\\ \end{bmatrix}

一个矩阵代表一种变换,多个矩阵相乘就是多个变换。

将一个变换矩阵左乘一个列向量(既顶点坐标),代表了对原始顶点执行了某种变换,比如旋转、缩放、平移等。

- 坐标系变换

坐标系变换:模型坐标系 --> 世界坐标系 --> 观察坐标系(又称相机坐标系、视图坐标系) --> 裁剪坐标系 --> 规范化设备(NDC)坐标系 --> 屏幕坐标系

  1. 模型坐标系,模型自身的局部坐标系,用于描述模型本身的位置关系,坐标原点通常在模型中心。
  • 例如一个人物模型,我们以它两脚之间的中心为原点建立坐标系,然后就可以用坐标描述它手部、腿部、眼睛。。。一系列部位顶点所在的位置
  1. 世界坐标系,提供整个场景中所有物体使用的 全局、公共、统一的坐标系
  • 每个物体/模型都有自己的坐标系,我们不可能在一个场景中观察多个坐标系
  • 类似于地心相对于地球,我们也以一个原点构建贯穿整个场景(世界)的坐标系,然后将物体逐个放进去
  • 模型变换,模型坐标 -> 世界坐标,本质上是将模型的坐标通过平移,旋转,缩放等等操作,放到场景中合适的位置
  1. 观察坐标系,为方便 观察场景中物体而建立的坐标系,又称相机坐标系。
  • 三维场景(世界坐标)中摆放了各种物体,为了方便观察,假设我们再摆放一个相机
  • 相机模拟人眼(视点)视角进行观察,重新计算各个物体相对于相机的坐标
  • 本质上是在世界坐标系中摆放一个相机,并 以相机为原点、相机的朝向为+Z轴 建立坐标系
  • 视图变换,世界坐标 -> 观察坐标,与世界坐标系的转换类似,以相机为原点,对物体进行平移、旋转、缩放等操作,变换为相对于相机的坐标
  1. 裁剪坐标系,计算观察坐标系中 可见的内容裁剪的内容,提高渲染效率、节省渲染资源。
  • 裁剪坐标系与观察坐标系原点相同,z轴相反,且进一步限制了坐标范围
  • 投影矩阵根据给定的 上下左右边界(限制xy坐标) + 远近平面(限制z坐标,透视投影下协助限制xy坐标),形成 可视范围(正交-立方体,透视-视椎体)
  • 范围内的物体将被渲染,范围外的物体将被裁剪
  • 投影变换,观察坐标 -> 裁剪坐标,3D -> 2D的第一步,决定以何种方式成像,主要有两种:
    1. 正交投影 orthographic projection,投影线垂直于观察平面,投影的结果与原物体的大小相等,常用于工程绘图
    2. 透视投影 perspective projection,影线相交于一点,符合人眼视觉,投影效果为近大远小
  1. NDC坐标系,与设备平台无关的一套三维坐标系,方便后续映射到不同设备的屏幕上,又称规范化设备坐标系。
  • NDC坐标通常位于一个单位立方体内,其中所有的坐标值都在 [-1, 1] 的范围内
  • 齐次除法(透视除法),裁剪坐标 -> NDC坐标,会对顶点的XYZ坐标除以W分量,所有坐标分量的范围都在[-1, 1]之间,Z轴越小离我们眼睛越近
  1. 屏幕坐标系,与最终图形显示设备相关的 二维平面坐标系,3D -> 2D的最后一步。
  • 通常以给定的视口左上角为原点,建立二维坐标系(类似canvas坐标系),NDC 坐标系原点在 屏幕坐标系的中心 [X/2, Y/2]
  • 视口变换,NDC坐标 -> 屏幕坐标,据视口将NDC坐标映射到屏幕中,转换为真正的2D坐标

前三步,仿佛是在 创建模型 -> 建立场景&摆放模型 -> 设置相机&指定视线

后两步,根据相机视角、可视范围和设备视口,将内容渲染到我们眼前(投影变换)

坐标系的转换比较抽象,篇幅有限,欢迎关注 后续更新

跨窗口通信

为了用户打开的网页窗口之间能够互相感知,我们使用各种方式让同源、非同源的 窗口能够互相共享数据、发送通知

本节会介绍多种纯前端的通信方式并提供简单示例。

本文内容有点多啦,且重点不在跨窗口通信,所以相关内容后文再更吧~ (删去了1000+字)

总结

本文首先介绍了一些数学、图形学的知识、概念,并结合web中的canvas进行实践,实现了绘制球体和旋转动画。

图形学第一定律: “if it looks right,it is right”,在计算机中模拟现实生活,只能达到近似模拟,而不能精确模拟。数学算法只要能满足视觉上的近似效果就可以了。

然后罗列了多种前端跨窗口通信方法的优缺点和使用。

通过对相关数据的分享、存储,监听窗口距离变化绘制出重叠的球体,简单的实现了前言中的动画效果。

webgl和threejs实现这个动画效果肯定会更好和方便~但本人还在学习中🙊

对图形、动画感兴趣的同学欢迎查阅另外几篇文章:

  1. # 使用【贝塞尔曲线】实现更好的动画效果和图形
  2. # 【Canvas实战】仿明日方舟Logo粒子动画 vue3+ts
  3. # 一文入坑【Canvas】多图与案例详解

结语 🎉

写作本文属实不易,如果有收获还望 点赞+收藏 🌹

不要光看不实践哦,希望能对你有所帮助~

持续更新前端知识,脚踏实地不水文,真的不关注一下吗~

才疏学浅,如有问题或建议还望指教!

参考 ⛓

# How to render 3D in 2D canvas
# webgl入门与实践