陈年旧事: 基于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..未完待续

相关推荐
伟笑9 分钟前
elementUI 循环出来的表单,怎么做表单校验?
前端·javascript·elementui
确实菜,真的爱33 分钟前
electron进程通信
前端·javascript·electron
魔术师ID2 小时前
vue 指令
前端·javascript·vue.js
Clown953 小时前
Go语言爬虫系列教程 实战项目JS逆向实现CSDN文章导出教程
javascript·爬虫·golang
星空寻流年3 小时前
css3基于伸缩盒模型生成一个小案例
javascript·css·css3
waterHBO4 小时前
直接从图片生成 html
前端·javascript·html
EndingCoder5 小时前
JavaScript 时间转换:从 HH:mm:ss 到十进制小时及反向转换
javascript
互联网搬砖老肖5 小时前
React组件(一):生命周期
前端·javascript·react.js
HCl+NaOH=NaCl+H_2O5 小时前
Quasar组件 Carousel走马灯
javascript·vue.js·ecmascript
℘团子এ5 小时前
vue3中预览Excel文件
前端·javascript