陈年旧事: 基于JS的三维光学引擎

写这篇博客其实是想为大学时候的一段代码做个总结. 跨越许久的时光,这段代码不禁让人感到雀跃

先看一下效果

仓库地址: 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 对象中定义的旋转速度 (弧度/帧)。
    • 旋转角度随着帧数线性增加,使场景以恒定速度旋转。
  • 预先计算正弦和余弦 :
    • 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() 方法更新数据粒子的位置,因为它从一个连接点移动到另一个连接点,并计算其屏幕坐标和颜色。
  • 绘制线框 :
    • 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() 方法绘制一个表示其运动的线段。
  • 调试逻辑 :
    • 在消失点绘制一个红色圆圈,其半径与场景的深度和焦距有关。这可能用于可视化透视投影和 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..未完待续

相关推荐
腾讯TNTWeb前端团队7 小时前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
拉不动的猪10 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪10 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
uhakadotcom12 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom12 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom12 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom12 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试
咖啡教室13 小时前
前端开发日常工作每日记录笔记(2019至2024合集)
前端·javascript
咖啡教室13 小时前
前端开发中JavaScript、HTML、CSS常见避坑问题
前端·javascript·css
市民中心的蟋蟀16 小时前
第五章 使用Context和订阅来共享组件状态
前端·javascript·react.js