但行好事 莫问前程
前言 🎀
去年国外某大佬在推特发表的 Entangled 动画(纠缠的球体?)广受好评:
在其中,动画、图形学、web 得到了完美的融合,淋漓尽致的展示着艺术和程序之美,令人心神向往。
工作之余写了个简单的demo,才疏学浅还望大家见谅!🙊
相似的文章在掘金已经有几篇了,本文主要梳理:
- 数学、图形学 中的一些概念(坐标系、矩阵),并结合 canvas 在web中进行实践
- 前端跨窗口通信 的各种方式和优缺点(localStorage、postMessage、Broadcast Channel、sharedWorker...)
代码已上传 github,最终实现效果如下:
(可以拖动本窗口尝试) jcode2
如果有收获还望 点赞+收藏 🌹
简介
实现动画的主要步骤为:
- 在窗口的画布(canvas)中 绘制球体,并添加 旋转动画
- 存储窗口的相关参数,并在窗口变化时 通知更新,绘制窗口范围内的球体
- 增加缓动函数让动画更平滑
代码中抽象出三个类:
- 粒子类
Particle, 球体类Globe, 视口类ViewPort Particle和Globe用于 生成&存储 模型数据ViewPort用于 创建场景 和 摆放相机
Particle: 记录粒子坐标、颜色、大小、比例,提供绘制方法draw、更新方法update
Globe: 记录球体的旋转角度、旋转速度、球体半径、粒子数组,提供旋转方法rotate、填充方法fillParticles
ViewPort: 记录目标画布、球体数组、视口宽高、相机位置,提供渲染方法render、添加球体方法addGlobe
实现
- 绘制球体
建模
建立物体模型,采用模型坐标系,以webgl右手坐标系为基准。
假设以球体中心为原点坐标 O(0, 0, 0),球体半径为r,随机生成方位角θ、仰角φ。
根据球坐标的计算公式: 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));
}
场景
创建场景,提供一个全局的环境,用于摆放不同的模型,使用世界坐标系。
文中暂时默认模型坐标原点和世界坐标原点重合(球心重叠),坐标、方向相同无需额外计算。
下图 用于理解世界坐标系
每个茶壶模型(=球体) 都有各自的模型坐标系
世界坐标系给模型提供了一个通用的环境(=场景)
通过旋转、平移、缩放...等一系列坐标转换,可以把它们放到一个坐标系(场景)中
相机 & 投影
摆放相机 ,投影(透视投影)计算可见内容,3D坐标 -> 2D 坐标
设置相机,摆放在世界坐标系的Z轴上 正对原点,调整视距(Z轴相对原点的距离)
// 手动调整视距
canvasInstance.cameraPosition = canvasInstance.width * 1;
Q:为什么是 canvasInstance.width * 1?
A:因为坐标的最大值为±canvasInstance.width * 1,并且在世界坐标系中摆放球体时未经缩放,这样设置我们能看到所有球体的完整形态,当然你也可以调整 * 1: 缩小/放大 = 相机 前进/后退
以相机视角进行观察,使用观察坐标系(与世界坐标系XYZ轴方向相同 原点的Z坐标不同),世界坐标系中顶点的Z轴坐标需要转换(Z坐标平移从[-r, r]变为了[0, -2r])
投影,计算可视内容,3D坐标转换为2D坐标,Z轴通过大小和透明度来模拟
// 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);
此时已能直观看到球体:
- 旋转模型
模型数据不变,相机位置不变,旋转场景中的球体,根据 三维坐标 & 旋转角度 & 矩阵转换公式,重新计算投影后的坐标。
球体绕Y轴旋转,Y轴坐标不变:
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 {
// 新增球体,同时共享自身数据,避免新窗口没有记录本窗口的球体
}
}
- 缓动函数
球体位置的变化并不是突然启动或者停止,为了让移动更加平滑,我们在拖动时和停止拖动时增加缓动函数。
效果类似于:
// 定义缓动函数
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 轴正向轴朝向屏幕外 —— 表示前后、深浅。
webgl的绘制需要进行多次 坐标系变换,目的是为了将模拟场景中的物体(3D)渲染到屏幕(2D)上,即 render 3D in 2D。
坐标系转换的计算过程比较复杂(旋转、缩放、z轴、投影...),我们需要一种快速执行复杂计算的工具:矩阵。
我们的学习通过 点 和 向量 开始。
- 点、向量、矩阵
点
点 在 3D 领域通常指顶点坐标,是3D世界的基本元素,3D世界由模型组成,模型由面组成,面又由点组成。
点在坐标系中的位置用坐标来表示。
WebGL 的渲染过程,接收模型的顶点 交给GPU,GPU执行流程:顶点着色器绘制 -> 图元装配连线 -> 光栅化填格 -> 片段着色器填色,最后将顶点渲染成模型 呈现到屏幕上。
向量
向量 是既有大小,又有方向的量,在物理和工程学中又称为 矢量。
向量在坐标系中通常用一根带箭头的线段来表示。
我们经常会使用 三维向量 来表示 顶点坐标,然后 变换矩阵 左乘 顶点坐标 代表对这个顶点执行 坐标转换。
矩阵
矩阵是由行列排列的一系列数值组成的集合,以简单的方式代替大量的运算,不用再使用三角函数、加减乘除等繁杂的数学公式来完成坐标转换。
根据行列数,称作阶 或者 阶矩阵。
向量可以理解为一个特殊的矩阵,四维向量既可以理解为一个 1 行 4 列矩阵,也可以理解为一个 4 行 1 列矩阵,所以向量可以根据转换的矩阵被称为 行/列向量
= (1, 2, 3, 4)
一个矩阵代表一种变换,多个矩阵相乘就是多个变换。
将一个变换矩阵左乘一个列向量(既顶点坐标),代表了对原始顶点执行了某种变换,比如旋转、缩放、平移等。
- 坐标系变换
坐标系变换:模型坐标系 --> 世界坐标系 --> 观察坐标系(又称相机坐标系、视图坐标系) --> 裁剪坐标系 --> 规范化设备(NDC)坐标系 --> 屏幕坐标系
- 模型坐标系,模型自身的局部坐标系,用于描述模型本身的位置关系,坐标原点通常在模型中心。
- 例如一个人物模型,我们以它两脚之间的中心为原点建立坐标系,然后就可以用坐标描述它手部、腿部、眼睛。。。一系列部位顶点所在的位置
- 世界坐标系,提供整个场景中所有物体使用的 全局、公共、统一的坐标系。
- 每个物体/模型都有自己的坐标系,我们不可能在一个场景中观察多个坐标系
- 类似于地心相对于地球,我们也以一个原点构建贯穿整个场景(世界)的坐标系,然后将物体逐个放进去
- 模型变换,模型坐标 -> 世界坐标,本质上是将模型的坐标通过平移,旋转,缩放等等操作,放到场景中合适的位置
- 观察坐标系,为方便 观察场景中物体而建立的坐标系,又称相机坐标系。
- 三维场景(世界坐标)中摆放了各种物体,为了方便观察,假设我们再摆放一个相机
- 相机模拟人眼(视点)视角进行观察,重新计算各个物体相对于相机的坐标
- 本质上是在世界坐标系中摆放一个相机,并 以相机为原点、相机的朝向为+Z轴 建立坐标系
- 视图变换,世界坐标 -> 观察坐标,与世界坐标系的转换类似,以相机为原点,对物体进行平移、旋转、缩放等操作,变换为相对于相机的坐标
- 裁剪坐标系,计算观察坐标系中 可见的内容 和 裁剪的内容,提高渲染效率、节省渲染资源。
- 裁剪坐标系与观察坐标系原点相同,z轴相反,且进一步限制了坐标范围
- 投影矩阵根据给定的 上下左右边界(限制xy坐标) + 远近平面(限制z坐标,透视投影下协助限制xy坐标),形成 可视范围(正交-立方体,透视-视椎体)
- 范围内的物体将被渲染,范围外的物体将被裁剪
- 投影变换,观察坐标 -> 裁剪坐标,3D -> 2D的第一步,决定以何种方式成像,主要有两种:
- 正交投影 orthographic projection,投影线垂直于观察平面,投影的结果与原物体的大小相等,常用于工程绘图
- 透视投影 perspective projection,影线相交于一点,符合人眼视觉,投影效果为近大远小
- NDC坐标系,与设备平台无关的一套三维坐标系,方便后续映射到不同设备的屏幕上,又称规范化设备坐标系。
- NDC坐标通常位于一个单位立方体内,其中所有的坐标值都在 [-1, 1] 的范围内
- 齐次除法(透视除法),裁剪坐标 -> NDC坐标,会对顶点的XYZ坐标除以W分量,所有坐标分量的范围都在[-1, 1]之间,Z轴越小离我们眼睛越近
- 屏幕坐标系,与最终图形显示设备相关的 二维平面坐标系,3D -> 2D的最后一步。
- 通常以给定的视口左上角为原点,建立二维坐标系(类似canvas坐标系),NDC 坐标系原点在 屏幕坐标系的中心 [X/2, Y/2]
- 视口变换,NDC坐标 -> 屏幕坐标,据视口将NDC坐标映射到屏幕中,转换为真正的2D坐标
前三步,仿佛是在 创建模型 -> 建立场景&摆放模型 -> 设置相机&指定视线
后两步,根据相机视角、可视范围和设备视口,将内容渲染到我们眼前(投影变换)
坐标系的转换比较抽象,篇幅有限,欢迎关注 后续更新
跨窗口通信
为了用户打开的网页窗口之间能够互相感知,我们使用各种方式让同源、非同源的 窗口能够互相共享数据、发送通知。
本节会介绍多种纯前端的通信方式并提供简单示例。
本文内容有点多啦,且重点不在跨窗口通信,所以相关内容后文再更吧~
(删去了1000+字)
总结
本文首先介绍了一些数学、图形学的知识、概念,并结合web中的canvas进行实践,实现了绘制球体和旋转动画。
图形学第一定律: “if it looks right,it is right”,在计算机中模拟现实生活,只能达到近似模拟,而不能精确模拟。数学算法只要能满足视觉上的近似效果就可以了。
然后罗列了多种前端跨窗口通信方法的优缺点和使用。
通过对相关数据的分享、存储,监听窗口距离变化绘制出重叠的球体,简单的实现了前言中的动画效果。
webgl和threejs实现这个动画效果肯定会更好和方便~但本人还在学习中🙊
对图形、动画感兴趣的同学欢迎查阅另外几篇文章:
结语 🎉
写作本文属实不易,如果有收获还望 点赞+收藏 🌹
不要光看不实践哦,希望能对你有所帮助~
持续更新前端知识,脚踏实地不水文,真的不关注一下吗~
才疏学浅,如有问题或建议还望指教!