写这篇博客其实是想为大学时候的一段代码做个总结. 跨越许久的时光,这段代码不禁让人感到雀跃
先看一下效果
仓库地址: github.com/kaori-seaso...
核心思想
生成一系列在三维空间中相互连接的节点,并在这些节点之间移动数据粒子,模拟出一种抽象的光学网络
核心概念与数据结构
-
Connection(连接):
- 表示三维空间中的一个节点。
- 包含其三维坐标(
x
,y
,z
)、大小(size
)以及屏幕坐标(screen
)。 - 维护一个连接列表(
links
),用于存储与其相连的其他节点。 - 通过
link()
方法递归生成子节点,形成三维网络结构。 setScreen()
方法将3D坐标转换为2D屏幕坐标。
kotlin
// 节点构造函数
function Connection(x, y, z, size) {
this.x = x; this.y = y; this.z = z; this.size = size;
this.screen = {}; // 存储屏幕坐标
this.links = []; // 存储连接的节点
this.probabilities = []; // 连接概率 this.isEnd = false; // 是否为末端节点
this.glowSpeed = opts.baseGlowSpeed + opts.addedGlowSpeed * Math.random(); // 发光速度
}
-
Data(数据):
- 表示在节点之间移动的数据粒子。
- 维护当前节点(
connection
)和下一个节点(nextConnection
)的信息。 - 通过
step()
方法在两个节点之间进行线性插值移动,模拟数据的流动。 setScreen()
方法将3D坐标转换为2D屏幕坐标.
javascript
// 数据粒子构造函数
function Data(connection) {
// 设置数据粒子的发光速度,随机增加一些变化
this.glowSpeed = opts.baseGlowSpeed + opts.addedGlowSpeed * Math.random();
// 设置数据粒子的移动速度,同样随机增加一些变化
this.speed = opts.baseSpeed + opts.addedSpeed * Math.random();
// 初始化数据粒子的屏幕坐标对象
this.screen = {};
// 设置数据粒子初始连接的节点
this.setConnection(connection);
}
markdown
- **`this.glowSpeed`:**
- 用于控制数据粒子的发光效果的速度。
- 通过将基础发光速度 `opts.baseGlowSpeed` 与一个随机增加的量相加来创建变化,使得每个粒子的发光速度略有不同。
- **`this.speed`:**
- 控制数据粒子在节点之间移动的速度。
- 与发光速度类似,它也通过随机增加一些变化,使得每个粒子的移动速度略有不同。
- **`this.screen`:**
- 一个空对象,用于存储数据粒子在屏幕上的坐标和其他相关信息。
- 在此对象中添加 `x`、`y`、`z` 等属性,用于将三维坐标转换为二维屏幕坐标。
- **`this.setConnection(connection)`:**
- 用于设置数据粒子初始连接的节点。
- 它接收一个 `connection` 参数,表示数据粒子要连接的 `Connection` 对象。
- 此方法将初始化数据粒子的位置和移动目标。
-
全局变量:
opts
:存储了各种视觉效果和行为的配置参数。connections
:存储所有的Connection对象。data
:存储所有的Data对象。all
:存储所有的connection和data对象。tick
:动画帧计数器,用于控制动画速度。
初始化与配置
javascript
window.onload = function () {
var c = document.getElementById('c'), // 获取canvas元素
w = c.width = window.innerWidth, // 设置canvas宽度为窗口宽度
h = c.height = window.innerHeight, // 设置canvas高度为窗口高度
ctx = c.getContext('2d'), // 获取2D渲染上下文
// 配置参数
opts = {
range: 180, // 节点生成的范围
// ... 其他配置参数 ...
};
// ... 其他初始化代码 ...
};
三维节点网络生成
javascript
function Connection(x, y, z, size) {
// ... 节点构造函数 ...
}
Connection.prototype.link = function () {
// ... 生成子节点 ...
};
// ... 初始化节点网络 ...
-
Connection
构造函数用于创建三维节点对象,包含坐标、大小、连接列表等属性。 -
Connection.prototype.link
方法递归生成子节点,形成三维网络结构。
通过随机生成角度和长度,计算子节点坐标,并确保它们在指定范围内,且与其他节点保持一定距离。
数据粒子流动
javascript
function Data(connection) {
// ... 数据粒子构造函数 ...
}
Data.prototype.step = function () {
// ... 更新粒子位置 ...
};
// ... 创建数据粒子 ...
三维到二维投影
javascript
Connection.prototype.setScreen = Data.prototype.setScreen = function () {
// ... 三维到二维坐标转换 ...
};
setScreen
方法将三维坐标转换为二维屏幕坐标,实现透视投影效果。应用旋转变换和透视投影公式,计算出屏幕坐标和缩放比例。
动画循环与渲染
javascript
function anim() {
window.requestAnimationFrame(anim);
// ... 渲染逻辑 ...
}
// ... 启动动画循环 ...
-
anim
函数使用requestAnimationFrame
创建动画循环。 -
在每一帧中,它更新节点和粒子位置,并使用Canvas 2D API绘制它们。
-
通过调整
globalCompositeOperation
实现发光效果,使用sort
方法实现深度效果。
交互与事件处理
javascript
window.addEventListener('resize', function () {
// ... 窗口大小改变处理 ...
});
window.addEventListener('click', init);
- 监听窗口大小改变事件,调整Canvas大小和消失点。以及监听点击事件,重新初始化动画。
这其中我们会发现粒子总是会流动到各个连接的边上,这是怎么做到的呢?
首先,物体的每一次旋转是不可能一直保存上一次描绘的点的,这对渲染的压力太大. 所以需要一个函数负责生成当前节点的子节点,并确保这些子节点在三维空间中的分布合理。
连接点与边构建的逻辑
分为四个部分:末端节点检查,生成连接点,创建子节点,递归生成
-
末端节点检查
- 如果当前节点的大小已经小于
opts.minSize
,则说明它已经足够小,不需要生成子节点。将其标记为末端节点,并返回。
- 如果当前节点的大小已经小于
-
生成连接点
- 通过随机生成角度和长度,计算出新连接点的三维坐标。
- 使用
squareDist
函数检查新连接点是否与现有节点和已生成的连接点太近,以避免节点过于密集。 - 如果新连接点通过了所有检查,则将其添加到
links
数组中。
-
创建子节点
- 如果成功生成了连接点,则为每个连接点创建一个新的
Connection
对象。 - 将新创建的子节点添加到
this.links
数组、all
数组和connections
数组中。 - 将新创建的子节点添加到
toDevelop
数组中,以便在后续的循环中继续生成它们的子节点。
- 如果成功生成了连接点,则为每个连接点创建一个新的
-
递归生成
- 通过将子节点添加到
toDevelop
数组,实现了递归生成节点的效果。
- 通过将子节点添加到
ini
Connection.prototype.link = function () {
// 如果当前节点的大小小于最小允许大小,则将其标记为末端节点并返回
if (this.size < opts.minSize)
return this.isEnd = true;
// 初始化一些局部变量
var links = [], // 存储新生成的连接点坐标
connectionsNum = opts.baseConnections + Math.random() * opts.addedConnections | 0, // 计算连接数量(基础连接数 + 随机额外连接数)
attempt = opts.connectionAttempts, // 连接尝试次数
alpha, beta, len, // 用于生成新连接点的角度和长度
cosA, sinA, cosB, sinB, // 预先计算的正弦和余弦值
pos = {}, // 存储新连接点的坐标
passedExisting, passedBuffered; // 用于检查新连接点是否有效
// 循环尝试生成新连接点,直到达到连接数量或尝试次数上限
while (links.length < connectionsNum && --attempt > 0) {
// 生成随机角度和长度
alpha = Math.random() * Math.PI; // 垂直角度
beta = Math.random() * Tau; // 水平角度
len = opts.baseDist + opts.addedDist * Math.random(); // 连接长度
// 预先计算正弦和余弦值
cosA = Math.cos(alpha);
sinA = Math.sin(alpha);
cosB = Math.cos(beta);
sinB = Math.sin(beta);
// 计算新连接点的坐标
pos.x = this.x + len * cosA * sinB;
pos.y = this.y + len * sinA * sinB;
pos.z = this.z + len * cosB;
// 检查新连接点是否在允许的范围内
if (pos.x * pos.x + pos.y * pos.y + pos.z * pos.z < squareRange) {
// 初始化通过检查标志
passedExisting = true;
passedBuffered = true;
// 检查新连接点是否与现有节点太近
for (var i = 0; i < connections.length; ++i)
if (squareDist(pos, connections[i]) < squareAllowed)
passedExisting = false;
// 如果新连接点通过了现有节点检查,则检查是否与已缓冲的连接点太近
if (passedExisting)
for (var i = 0; i < links.length; ++i)
if (squareDist(pos, links[i]) < squareAllowed)
passedBuffered = false;
// 如果新连接点通过了所有检查,则将其添加到连接点列表
if (passedExisting && passedBuffered)
links.push({ x: pos.x, y: pos.y, z: pos.z });
}
}
// 如果没有生成任何连接点,则将其标记为末端节点
if (links.length === 0)
this.isEnd = true;
else {
// 创建新的连接节点
for (var i = 0; i < links.length; ++i) {
var pos = links[i], // 获取连接点坐标
connection = new Connection(pos.x, pos.y, pos.z, this.size * opts.sizeMultiplier); // 创建新节点
// 将新节点添加到连接列表和全局节点列表
this.links[i] = connection;
all.push(connection);
connections.push(connection);
}
// 将新节点添加到待建立列表,以便进一步生成子节点
for (var i = 0; i < this.links.length; ++i)
toDevelop.push(this.links[i]);
}
}
渲染逻辑
- window.requestAnimationFrame(anim); :
- 这是动画循环的核心。它告诉浏览器在下一次重绘之前调用 anim 函数。
- 浏览器会优化这个调用,使其与显示器的刷新率同步,从而实现流畅的动画,并在页面不可见时节省资源。
- 这创建了一个递归循环:anim 函数自身,由浏览器的渲染周期驱动。
- Canvas 准备工作 :
- ctx.globalCompositeOperation = 'source-over';: 设置 Canvas 的合成模式为默认的 'source-over'。这意味着新的像素会覆盖在旧的像素之上,确保每一帧都干净地绘制。
- ctx.fillStyle = opts.repaintColor;: 设置 Canvas 的填充颜色。opts.repaintColor 变量通常存储一个深色值 (例如 #111),用作背景色。
- ctx.fillRect(0, 0, w, h);: 使用当前填充颜色清空整个 Canvas。这会擦除上一帧的画面,为绘制新的一帧做准备。
- 帧计数器 :
- ++tick;: 递增 tick 变量,它用来记录已经渲染了多少帧。这个计数器用于控制 3D 场景的旋转。
- 计算旋转角度 :
- var rotX = tick * opts.rotVelX, rotY = tick * opts.rotVelY;: 计算 X 轴和 Y 轴的旋转角度。
- tick: 当前帧数。
- opts.rotVelX, opts.rotVelY: 在 opts 对象中定义的旋转速度 (弧度/帧)。
- 旋转角度随着帧数线性增加,使场景以恒定速度旋转。
- var rotX = tick * opts.rotVelX, rotY = tick * opts.rotVelY;: 计算 X 轴和 Y 轴的旋转角度。
- 预先计算正弦和余弦 :
- cosX = Math.cos(rotX); sinX = Math.sin(rotX); cosY = Math.cos(rotY); sinY = Math.sin(rotY);: 计算旋转角度的正弦和余弦值。
- 这些值对于执行 3D 旋转变换至关重要。 在这里计算它们是为了提高效率,避免在 setScreen 函数 (它会被每个节点和粒子调用) 中重复计算。
- 添加数据粒子 :
- if (data.length < connections.length * opts.dataToConnections) { ... }: 检查场景中的数据粒子数量是否少于期望的数量。粒子数量与连接点 (节点) 的数量相关。
- var datum = new Data(connections[0]);: 创建一个新的 Data 对象 (数据粒子)。 它被初始化为连接到某个连接点 (可能是根节点)。
- data.push(datum); all.push(datum);: 将新粒子添加到 data 数组 (专门用于存储粒子) 和 all 数组 (存储所有需要渲染的物体) 中。
- 准备绘制线框 :
- ctx.globalCompositeOperation = 'lighter';: 将合成模式设置为 'lighter'。 这种模式会将新像素的颜色与 Canvas 上已有的颜色相加。 当多个线条或形状重叠时,重叠区域会变得更亮,产生发光效果。 这用于使 3D 结构的线框更加突出。
- ctx.beginPath();: 开始一条新的绘制路径。路径是由一系列绘图命令 (线段、弧形等) 组成的序列。
- ctx.lineWidth = opts.wireframeWidth; ctx.strokeStyle = opts.wireframeColor;: 设置将要绘制的线条的样式:粗细和颜色。
- 更新所有物体 :
- all.map(function (item) { item.step(); });: 这是关键的一步。 它遍历 all 数组中的每个物体 (包括 Connection 和 Data 对象),并调用每个物体的 step() 方法。
- 对于 Connection 对象,step() 方法计算其在 2D 屏幕上的坐标 (使用之前计算的 cosX、sinX、cosY、sinY 进行旋转),并设置其颜色。它还会准备绘制到其子节点的线条。
- 对于 Data 对象,step() 方法更新数据粒子的位置,因为它从一个连接点移动到另一个连接点,并计算其屏幕坐标和颜色。
- all.map(function (item) { item.step(); });: 这是关键的一步。 它遍历 all 数组中的每个物体 (包括 Connection 和 Data 对象),并调用每个物体的 step() 方法。
- 绘制线框 :
- ctx.stroke();: 这个命令实际地绘制线框。 Connection 对象的 step() 方法已经定义了要绘制的线条 (从每个节点到其子节点)。 ctx.stroke() 使用之前设置的 lineWidth 和 strokeStyle 绘制这些线条,从而创建线框效果。
- 准备深度排序 :
- ctx.globalCompositeOperation = 'source-over';: 将合成模式恢复为默认值 'source-over'。 'lighter' 模式只用于绘制线框。 现在,我们需要按照正确的顺序绘制物体,使近处的物体遮挡住远处的物体。
- all.sort(function (a, b) { return b.screen.z - a.screen.z; });: 根据物体在屏幕上的 z 坐标对 all 数组中的物体进行排序。
- a.screen.z, b.screen.z: 物体投影到 2D 屏幕后的 Z 坐标。 较小的 z 值表示物体离观察者更近。
- b.screen.z - a.screen.z: 这个排序函数将物体按 z 坐标降序 排列。 z 值较大的物体 (更远) 会排在数组的前面,这意味着它们会首先被绘制。 这可能与你最初的直觉相反,但它是正确的,可以实现Painter's Algorithm。
- 绘制所有物体 (按深度排序) :
- all.map(function (item) { item.draw(); });: 这个命令遍历排序后 的 all 数组,并调用每个物体的 draw() 方法。
- 因为数组是按 Z 坐标排序的 (从远到近),所以物体会按照正确的顺序绘制,从而产生 3D 空间的感觉。 较远的物体先绘制,然后较近的物体在它们之上绘制,遮挡住较远的物体。
- 对于 Connection 对象,draw() 方法绘制一个表示节点的圆形。
- 对于 Data 对象,draw() 方法绘制一个表示其运动的线段。
- all.map(function (item) { item.draw(); });: 这个命令遍历排序后 的 all 数组,并调用每个物体的 draw() 方法。
- 调试逻辑 :
- 在消失点绘制一个红色圆圈,其半径与场景的深度和焦距有关。这可能用于可视化透视投影和 3D 空间的范围。
- window.addEventListener('resize', function () { ... }); :
- 这会设置一个事件监听器,在浏览器窗口大小改变时被调用。
- 在监听器内部:
- opts.vanishPoint.x = (w = c.width = window.innerWidth) / 2;: 将消失点的 X 坐标更新为新的窗口宽度的一半。 w 变量会被更新为新的宽度。
- opts.vanishPoint.y = (h = c.height = window.innerHeight) / 2;: 将消失点的 Y 坐标更新为新的窗口高度的一半。 h 变量会被更新为新的高度。
- ctx.fillRect(0, 0, w, h);: 使用背景色清空整个 Canvas。 这是必要的,以便在窗口大小改变后正确地重绘场景。
- window.addEventListener('click', init); :
- 这会设置一个事件监听器,在用户点击浏览器窗口中的任何位置时被调用。
- 在监听器内部:
- init();: 调用 init 函数。 init 函数会初始化整个 3D 场景,创建连接点 (节点) 并设置初始状态。 这允许用户通过单击窗口来重置动画。
scss
function anim() {
//请求下一个动画帧
window.requestAnimationFrame(anim);
// 准备 Canvas 以绘制新的一帧
ctx.globalCompositeOperation \= 'source-over'; // 重置 Canvas 的合成模式
ctx.fillStyle \= opts.repaintColor; // 设置填充颜色为背景色
ctx.fillRect(0, 0, w, h); // 使用背景色清空整个 Canvas
// 更新帧计数器
\++tick;
// 计算这一帧的旋转角度
var rotX \= tick \* opts.rotVelX, // 绕 X 轴旋转的角度
rotY \= tick \* opts.rotVelY; // 绕 Y 轴旋转的角度
// 计算旋转角度的正弦和余弦值 (用于 3D 变换)
cosX \= Math.cos(rotX);
sinX \= Math.sin(rotX);
cosY \= Math.cos(rotY);
sinY \= Math.sin(rotY);
// 可能会添加新的数据粒子
if (data.length \< connections.length \* opts.dataToConnections) {
var datum \= new Data(connections\[0\]); // 创建一个新的数据粒子
data.push(datum); // 将其添加到数据数组中
all.push(datum); // 并添加到包含所有物体的数组中
}
// 准备绘制 3D 结构
ctx.globalCompositeOperation \= 'lighter'; // 设置合成模式为 'lighter',实现发光效果
ctx.beginPath(); // 开始一条新的绘制路径
ctx.lineWidth \= opts.wireframeWidth; // 设置线条宽度
ctx.strokeStyle \= opts.wireframeColor; // 设置线条颜色
// 更新场景中所有物体的状态
all.map(function (item) {
item.step(); // 调用每个物体的 step() 方法来更新其状态 (位置、颜色等)
});
// 绘制 3D 结构的线框
ctx.stroke();
// 准备按深度排序物体
ctx.globalCompositeOperation \= 'source-over'; // 将合成模式恢复为默认值
all.sort(function (a, b) {
return b.screen.z \- a.screen.z; // 按 Z 坐标 (深度) 降序排列物体 (远处的物体在前)
});
// 绘制所有物体 (节点和粒子)
all.map(function (item) {
item.draw(); // 调用每个物体的 draw() 方法来在 Canvas 上绘制它
});
// 用于调试,绘制一个圆形
ctx.beginPath();
ctx.strokeStyle \= 'red';
ctx.arc(opts.vanishPoint.x, opts.vanishPoint.y, opts.range \* opts.focalLength / opts.depth, 0, Tau);
ctx.stroke();
\*/
}
// 窗口大小改变事件的监听器
window.addEventListener('resize', function () {
opts.vanishPoint.x \= (w \= c.width \= window.innerWidth) / 2; // 更新消失点的 X 坐标
opts.vanishPoint.y \= (h \= c.height \= window.innerHeight) / 2; // 更新消失点的 Y 坐标
ctx.fillRect(0, 0, w, h); // 使用背景色重新填充整个 Canvas
});
// 鼠标点击事件的监听器
window.addEventListener('click', init); // 点击窗口时重新初始化场景
Continue..未完待续