threejs——没什么意义的大屏

7,470 阅读46分钟

前言

本文是一次尝试性的创新,代码是作者写的,但是下面的文章内容是把代码扔给AI,让AI写的,作者整理的,感觉比作者之前的文章写的细致,但是有点太细了,不知各位看官是否习惯这样的文章,欢迎大家提意见。五万多个字呢,比以往所有的内容加起来都多

效果图

image.png

线上演示地址,点击体验

源码下载地址,点击下载

视频演示

局部动图

1 局部动图.gif

文件目录

📁content
│  │  ├─ 📄GUI.ts-------------------控制器
│  │  ├─ 📄constData.ts-------------告警信息坐标
│  │  ├─ 📄grid.ts------------------网格辅助线
│  │  ├─ 📄light.ts-----------------灯光
│  │  ├─ 📄main.ts------------------入口文件
│  │  ├─ 📄map.ts-------------------绘制线条形状
│  │  ├─ 📄materials.ts-------------材质
│  │  ├─ 📄request.ts---------------线条形状点位请求
│  │  ├─ 📄scene.ts-----------------场景必要元素
│  │  ├─ 📄tag.ts-------------------告警标签
│  │  ├─ 📄tagLine.ts---------------告警标签连接线
│  │  ├─ 📄tagPanel.ts--------------告警面板
│  │  └─ 📄tornado.ts---------------粒子运动(龙卷风)

│  ├─ 📁utils ----------------------工具库
│  │  ├─ 📄IntervalTime.ts----------定时器class类
│  │  ├─ 📄index.ts-----------------常用工具
│  │  └─ 📄unreal.ts----------------场景发光

技术栈

  • "three": "0.167.0",
  • "typescript": "^5.0.2",
  • "vite": "^4.3.2"

正文

辅助线

创建坐标格辅助线,当你的场景背景太单调时,可以用做于背景图,接受四个参数,很简单,官网有详细案例。grid.userData.isLight = true用于标记是否接受发光场景的影响,如不需要发光,可不进行标记,后续需要发光的元素都会进行标记,不在后文赘述。

const grid = new THREE.GridHelper(200, 500, 0x1c252d, 0x1c252d);
grid.userData.isLight = true
scene.add(grid);

形状及线条

https://geo.datav.aliyun.com/areas_v3/bound/330000_full.json从该网站获取到形状的顶点信息,过滤编号为330100的形状,因为这个形状要做挤压缓冲几何体,也是为了减少浏览器的渲染压力,接口返回的顶点信息为2d。x和y的坐标,所以需要处理一下,将顶点信息改为x和z,y设置为0。

绘制线条

  • 请求数据并进行绘制
// 获取杭州市区地图数据
fetchHZJSapData().then((data) => {
    const features = data.features
    features.forEach((feature: any) => {
        const arcs = feature.geometry.coordinates[0][0];
        // 排除adcode为330100
        if (feature.properties.adcode !== 330100) {
            const positions = getPositions(arcs)
            // 创建较暗的边界线
            const line = createLine(positions, 0x323748, .8);
            cityGroup.add(line)
        }
    })
})

首先调用fetchHZJSapData()函数来获取数据,该函数返回一个Promise。当Promise成功 resolve 后,会将获取到的数据传入后续的回调函数中进行处理。

在回调函数中,从获取到的数据对象中提取出features属性值,并将其赋值给features变量,以便后续对每个特征数据进行遍历操作。

处理数据getPositions方法
// 将二维坐标转换为三维坐标数组
export const getPositions = (arcs: number[][]) => {
    let positions: number[] = [];
    arcs.forEach((v2Arr: number[]) => {
        const x = v2Arr[0];
        const z = v2Arr[1];
        positions.push(x, 0, z); // y坐标设为0
    });
    return positions;
}
创建Line2,并添加到组中createLine

首先创建线条几何体(LineGeometry)对象,通过调用new LineGeometry()完成,该对象用于定义线条的几何形状,随后使用geometry.setPositions(positions)设置线条的顶点位置,即将传入的顶点坐标数组应用到几何体上。

接着创建线条对象,通过new Line2(geometry, lineMaterial(color, opacity, width, dashed))来实现,其中Line2是用于渲染线条的类,而lineMaterial函数用于生成线条的材质,材质的属性由传入的coloropacitywidthdashed等参数确定。

如果dashed参数为true,即线条是虚线的情况下,会调用line.computeLineDistances()来计算线段的距离,以实现虚线效果。

// 创建线条对象
/**
 * 创建线条对象
 * @param positions 顶点坐标数组
 * @param color 线条颜色
 * @param opacity 线条透明度
 * @param width 线条宽度
 * @param isLight 是否为光源
 * @param dashed 是否为虚线
 * @returns 
 */
export function createLine(positions: number[], color: number, opacity = 1, width = 1, isLight = false, dashed = false) {
    // 创建线条几何体
    const geometry = new LineGeometry(); // LineGeometry用于定义线条的几何形状
    geometry.setPositions(positions); // 设置线条的顶点位置,positions是一个包含坐标的数组
    // 创建线条对象,使用lineMaterial函数生成材质
    const line = new Line2(geometry, lineMaterial(color, opacity, width, dashed)); // Line2是用于渲染线条的类
    line.userData.isLight = isLight; // 将isLight属性存储在userData中,方便后续使用
    if (dashed) { // 如果线条是虚线
        line.computeLineDistances(); // 计算线段的距离,用于虚线效果
    }
    return line; // 返回创建的线条对象
}
线条材质

通过调用new LineMaterial({...})创建一个线条材质对象,传入一个包含多个属性的配置对象。

在配置对象中,设置了以下属性:

  • color:传入的线条颜色值。

  • linewidth:传入的线条宽度值。

  • opacity:传入的线条透明度值。

  • transparent:设置为true,表示线条是透明的,结合opacity属性可以实现不同程度的透明效果。

  • vertexColors:设置为false,表示不使用顶点颜色,可能是用于控制线条颜色的一种方式(具体效果取决于渲染引擎的实现)。

  • dashed:传入的布尔值,决定线条是否为虚线。

  • dashSize:当线条为虚线时,设置虚线的线段长度。

  • gapSize:当线条为虚线时,设置虚线的间隔长度。

函数返回值

最后,函数返回创建好的LineMaterial对象,这个对象可以在创建线条对象时被使用,以设置线条的材质属性,从而实现特定的线条渲染效果。


export const lineMaterial = (color: number, opacity: number, width = 1,dashed=false) => {
    const material = new LineMaterial({
        color,
        linewidth: width,
        opacity,
        transparent: true,
        vertexColors: false,
        dashed,
        dashSize: 0.05,
        gapSize: 0.05
    })
    
    return material
};

接下来所有的线条都基于此方法进行创建。

效果图

4 装饰线条.jpg

挤压缓冲几何体

fetchHZSMapData().then(...),用同样的方法请求到需要制作挤压缓冲几何体的顶点信息,或者直接用刚才过滤出来的顶点信息制作缓冲几何体

const createShape = (positions: number[]) => {
    const shape = new THREE.Shape();
    // 绘制形状轮廓
    shape.moveTo(positions[0], positions[2]);
    for (let i = 3; i < positions.length; i += 3) {
        shape.lineTo(positions[i], positions[i + 2]);
    }
    shape.lineTo(positions[0], positions[2]); // 闭合路径

    // 设置挤压参数
    const extrudeSettings = {
        steps: 1, // 挤压的分段数
        depth: 0.04, // 挤压的深度
        bevelEnabled: false, // 是否启用斜角
    };

    // 创建挤压几何体和网格
    const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
     const shapeMesh = new THREE.Mesh(geometry, [extrudeMaterial(0x1c212c), extrudeMaterial(0x12141c)]);
    shapeMesh.rotation.x = Math.PI / 2; // 旋转使其与路径方向一致
    shapeMesh.userData.isLight = false
    return shapeMesh
}

定义一个createShape的函数,其主要目的是基于传入的顶点坐标数组创建一个三维形状的网格对象。这个三维形状是通过先绘制二维形状轮廓,再对其进行挤压操作得到的,最后返回设置好相关属性(如旋转角度、用户自定义数据等)的三维网格对象,可用于三维图形渲染等相关场景。

positions:从请求中获取的二维顶点信息并通过前文提到的getPositions方法处理过的数组,数组中的元素按每三个一组的方式代表各个顶点在三维空间中的坐标(这里实际使用时主要关注每组中的第一个和第三个元素来绘制二维形状)。

首先创建一个THREE.Shape对象,用于绘制二维形状。

通过shape.moveTo(positions[0], positions[2])将绘制起点设置为传入坐标数组中第一个顶点的xz坐标(这里似乎是在xz平面上绘制二维形状)。

接着使用for循环,从索引为3开始,每次递增3遍历坐标数组。在循环中,通过shape.lineTo(positions[i], positions[i + 2])依次连接各个顶点,绘制出二维形状的轮廓。

最后通过shape.lineTo(positions[0], positions[2])将路径闭合,形成完整的二维形状。

创建一个名为extrudeSettings的对象,用于设置挤压操作的相关参数。

其中steps属性设置为1,表示挤压过程的分段数为1depth属性设置为0.04,确定了挤压的深度;bevelEnabled属性设置为false,表示不启用斜角效果。

利用之前创建的二维形状对象shape和设置好的挤压参数extrudeSettings,通过调用new THREE.ExtrudeGeometry(shape, extrudeSettings)创建一个挤压几何体。

然后创建一个THREE.Mesh对象,即三维网格对象。将创建好的挤压几何体作为其几何属性,同时传入两个材质对象,作为其材质属性。第一个材质为表面材质,第二个为侧面材质。

  • 挤压缓冲几何体材质
 export const extrudeMaterial = (color: number) => new THREE.MeshLambertMaterial({
    color,
});
效果图

5 挤压缓冲几何体.jpg

在开发过程中由于3d场景的复杂性,以及灯光和环境对模型颜色的影响,如果直接应用UI提供设计稿的颜色,并不能完全的复刻UI稿,所以就需要一个GUI参数来实时调整材质的颜色,手动将颜色调为和UI稿一致

所以我们现在创建一个GUI并添加到dom中。

图形用户界面(GUI

创建一个图形用户界面(GUI)来配置与挤压缓冲结合体相关的一些参数,并提供了对几何体材质颜色的可调节功能。通过这个 GUI,用户可以直观地修改诸如形状的顶面颜色、侧面颜色等参数,以便实时看到挤压体相关元素外观的变


const gui = new GUI({ container: document.getElementById('gui') as HTMLElement, width: 300, title: '地图配置' });
export const guiParams = {
    shapeColor: 0xffffff,
    shapeColor2: 0xffffff,
   ...
};

const shapeColor = gui.addColor(guiParams, 'shapeColor')
shapeColor.name('顶面颜色')
export { shapeColor }
const shapeColor2 = gui.addColor(guiParams, 'shapeColor2')
shapeColor2.name('侧面颜色')
export { shapeColor2 }

创建一个名为guiParams的对象,并将其导出,以便在其他模块中也能使用这个对象来获取或修改相关参数。

添加颜色调节控件(针对顶面颜色)

使用gui.addColor方法(这是 GUI 库提供的用于添加颜色调节控件的方法),从guiParams对象中获取shapeColor属性的值,并在 GUI 上创建一个颜色调节控件。这个控件允许用户修改guiParams对象中shapeColor属性的值。

该方法返回一个对象(赋值给shapeColor变量),这个对象可以进一步进行一些设置操作,比如设置控件的名称等

下面是调整挤压缓冲几何体材质颜色的方法

// 设置GUI控制挤压体的颜色
guiParams.shapeColor = shape.material[0].color.getHex();
shapeColor.updateDisplay()
shapeColor.onChange(function (val) {
    shape.material[0].color.setHex(val)
});
guiParams.shapeColor2 = shape.material[1].color.getHex();
shapeColor2.updateDisplay()
shapeColor2.onChange(function (val) {
    shape.material[1].color.setHex(val)
});
设置 GUI 参数的初始值
guiParams.shapeColor = shape.material[0].color.getHex();
shapeColor.updateDisplay();

color.getHex() 是调用该材质对象的 color 属性的 getHex() 方法,其作用是获取当前材质颜色的十六进制表示值。然后将这个值赋给 guiParams 对象的 shapeColor 属性,guiParams 是用于集中管理 GUI 相关参数的对象,这样就将挤压体第一种材质的当前颜色设置为了 GUI 中对应颜色控制参数的初始值。

定义颜色修改的回调函数
shapeColor.onChange(function (val) {
    shape.material[0].color.setHex(val)
});

shapeColor.onChange() 是为 shapeColor 对象在 GUI 中的颜色控制组件注册一个 onChange 回调函数。当用户在 GUI 界面上通过与该颜色控制组件相关的操作,修改颜色值时,这个回调函数就会被触发。

调节颜色效果图

6 改变表面颜色.gif

通过前面绘制线条的方法,如法炮制绘制出其他的装饰线条并在GUI中修改到合适的颜色

效果图

7 添加其他装饰线条.jpg

告警标记

下面代码以最外层带动画的线条举例,从创建到动画的过程。从前文效果图可以看出 这里的线组成了一个六边形,并在运动过程中通过改变六边形的角,而向外扩散。

 warn && (() => {
    const length = 0.018; // 假设每条线的长度为0.018
    const distance = length * 2; // 移动的距离
    const maxDistance = distance * 2.5; // 移动的距离
    const { group: tagLineGroup, tween: tagLineTween } = tagLine(color, distance, maxDistance, length, 4, warn)
    group.add(tagLineGroup)
    tweenGroup.add(tagLineTween)
})();

这里使用了逻辑与(&&)运算符的短路特性。如果 warn 变量的值为 false,那么整个表达式就会立即返回 false,后面的匿名函数就不会被执行;只有当 warn 为 true 时,才会执行后面的匿名函数,进入到具体的操作流程中。

首先定义了一个常量 length,并将其值设置为 0.018

然后根据 length 计算出 distance,它是线初始距离,通过 length 乘以 2 得到。最终组成一个完整而不重叠的六边形

最后计算出 maxDistance,移动的距离,通过 distance 乘以 2.5 得到。移动后的效果即展开六边形的角

tagLine 方法


const tagLine = (color: number, distance: number, maxDistance: number, length: number, width: number, warn = false) => {

    const group = new THREE.Group()
    const angle = Math.PI / 6; // 60度的弧度值

    const x2 = 0;
    const z2 = 0;
    const x1 = length * Math.cos(angle);
    const z1 = length * Math.sin(angle);
    const x3 = -length * Math.cos(angle);
    const z3 = length * Math.sin(angle);

    const positions = [x1, 0, z1, x2, 0, z2, x3, 0, z3];

    const line = createLine(positions, color, 1, width, true);

    for (let i = 0; i < 6; i++) {
        const newLine = line.clone()
        newLine.rotation.y = i * Math.PI / 3 + Math.PI / 2
        newLine.position.x -= distance * Math.cos(i * Math.PI / 3);
        newLine.position.z += distance * Math.sin(i * Math.PI / 3);
        newLine.userData.warn = warn
        group.add(newLine)
    }

    const tween = new TWEEN.Tween({ distance })
        .to({ distance: maxDistance }, 1000)
        .easing(TWEEN.Easing.Quadratic.Out)
        .onUpdate((value) => {
            // 更新位置
            group.children.forEach((child: any, i) => {
                child.position.x = -value.distance * Math.cos(i * Math.PI / 3);
                child.position.z = value.distance * Math.sin(i * Math.PI / 3);
            })
        })
        .onComplete(() => {
            // 动画完成后的操作
        })
        .repeat(Infinity) // 添加重复动画
        .yoyo(true) // 使动画往返
    return { group, tween }
}

代码介绍

const group = new THREE.Group();
const angle = Math.PI / 6; // 60度的弧度值

首先创建了一个 THREE.Group 对象,将其赋值给变量 group。这个对象将作为一个容器,用于存放后续创建的所有线条对象,以便对它们进行统一的管理和操作,比如整体的移动、旋转等。

接着定义了一个常量 angle,其值为 Math.PI / 6,也就是将角度值 60 度转换为弧度值,这个角度值在后续计算线条端点坐标等操作中会用到。

计算线条端点坐标并创建初始线条对象

const x2 = 0;
const z2 = 0;
const x1 = length * Math.cos(angle);
const z1 = length * Math.sin(angle);
const x3 = -length * Math.cos(angle);
const z3 = length * Math.sin(angle);

const positions = [x1, 0, z1, x2, 0, z2, x3, 0, z3];

const line = createLine(positions, color, 1, width, true);

先分别定义了几个变量 x2z2x1z1x3z3,并通过三角函数(根据前面定义的角度 angle)结合 length 参数计算出它们的值。这些变量的值将作为线条端点的坐标值,其中 x2 和 z2 都设置为 0,而 x1z1x3z3 则是根据三角函数计算得到的与 length 和 angle 相关的坐标值。

然后将这些坐标值按照一定顺序(每三个一组,分别代表 xyz 坐标)组成一个数组 positions,这个数组将作为创建线条对象的顶点坐标数据。

最后调用 createLine 函数,与前文创建线条方法相同,传入 positionscolor、透明度值 1width 以及布尔值 true作为参数,创建出一个初始的线条对象,并将其赋值给变量 line

克隆并设置多条线条对象的属性及添加到组中

for (let i = 0; i < 6; i++) {
    const newLine = line.clone();
    newLine.rotation.y = i * Math.PI / 3 + Math.PI / 2;
    newLine.position.x -= distance * Math.cos(i * Math.PI / 3);
    newLine.position.z += distance * 3 * Math.sin(i * Math.PI / 3);
    newLine.userData.warn = warn;
    group.add(newLine);
}

使用 for 循环,循环次数为 6 次。以此作为六边形的六个角

首先通过调用 line.clone() 方法克隆出一个新的线条对象,并将其赋值给变量 newLine。这样就得到了与初始线条对象 line 具有相同属性(除了后续要设置的新属性外)的新线条对象。

接着设置新线条对象 newLine 的旋转角度,通过 newLine.rotation.y = i * Math.PI / 3 + Math.PI / 2 将其绕 y 轴旋转一定的角度,这个角度是根据循环变量 i 以及固定的角度值(Math.PI / 3 和 Math.PI / 2)计算得到的,使得每条新线条对象都有不同的旋转角度。

然后设置新线条对象的位置,通过 newLine.position.x -= distance * Math.cos(i * Math.PI / 3) 和 newLine.position.z += distance * Math.sin(i * Math.PI / 3) 来调整其在 x 和 z 轴方向上的位置,这里的位置调整也是根据循环变量 i 和初始的 distance 参数来计算的,使得每条新线条对象都有不同的位置。

最后将 warn 参数的值赋给新线条对象的 userData.warn 属性,即设置每条新线条对象的警告状态,以便后续根据这个状态进行相关操作。

在完成上述属性设置后,将新线条对象 newLine 添加到之前创建的线条组对象 group 中,通过 group.add(newLine) 实现,这样就将所有克隆并设置好属性的线条对象都收集到了 group 这个容器中。

创建动画控制对象并设置动画相关属性

const tween = new TWEEN.Tween({ distance })
.to({ distance: maxDistance }, 1000)
.easing(TWEEN.Easing.Quadratic.Out)
.onUpdate((value) => {
    // 更新位置
    group.children.forEach((child: any, i) => {
        child.position.x = -value.distance * Math.cos(i * Math.PI / 3);
        child.position.z = value.distance * Math.sin(i * Math.PI / 3);
    })
})
.onComplete(() => {
    // 动画完成后的操作
})
.repeat(Infinity) // 添加重复动画
.yoyo(true) // 使动画往返

首先创建一个 TWEEN.Tween 对象,传入一个包含 distance 属性的对象作为初始状态。这个 TWEEN.Tween 对象将用于控制前面创建的线条组对象 group 的动画效果。

接着通过 .to({ distance: maxDistance }, 1000) 设置动画的目标状态,即要将 distance 属性的值在 1000 毫秒(也就是 1 秒)内变化到 maxDistance 的值,从而实现线条在动画过程中的位置变化。

通过 .easing(TWEEN.Easing.Quadratic.Out) 选择了一种缓动函数,这里选择的是二次函数的输出型缓动函数(Quadratic.Out),它决定了动画过程中速度的变化规律,使得动画效果更加自然。

定义了一个 .onUpdate 回调函数,当动画在更新过程中(也就是在 1000 毫秒的动画时间内),这个回调函数会被调用。在回调函数内部,通过遍历 group.children(也就是线条组对象 group 的所有子对象,即之前创建的所有线条对象),并根据当前动画的 distance 值(通过 value.distance 获取)以及循环变量 i(与之前设置线条位置时的循环变量一致),重新计算并设置每条线条对象的位置,即 child.position.x = -value.distance * Math.cos(i * Math.PI / 3) 和 child.position.z = value.distance * Math.sin(i * Math.PI / 3),从而实现了在动画过程中线条位置的实时更新。

定义了一个 .onComplete 回调函数,当动画完成(也就是 distance 的值达到 maxDistance)后,这个回调函数会被调用,这里虽然没有具体的操作内容,但可以在后续根据具体需求添加相关操作,比如重置动画状态等。

通过 .repeat(Infinity) 设置动画为无限重复,使得动画会不断地循环播放,呈现出持续的动态效果。

通过 .yoyo(true) 设置动画为往返式动画,即动画到达目标状态后会按照相反的顺序返回初始状态,然后再重复播放,这样可以增加动画的趣味性和动态性。

函数返回值解析

最后,函数返回一个包含两个属性的对象,即 { group, tween }。其中 group 是包含了所有创建好的线条对象的 THREE.Group 对象,可以在其他地方对这个组对象进行进一步的操作,比如添加到三维场景中、进行整体的移动或旋转等。

而 tween 是用于控制这些线条对象动画的 TWEEN.Tween 对象,将动画导出,并添加到TWEEN.Group中进行统一操作。比如开始,暂停,重置等等。并且可以根据需要进一步修改动画的相关属性。

开始动画

tweenGroup.getAll().forEach((tween) => {
    tween.start()
})

通过以上代码即可实现一个六边形沿着六个角的方向进行运动的动画

效果图

8 六角动画.gif

有了这一个例子,其他也就都可以做出来了,通过计算顶点信息,存入positions中,再通过createLine创建出线条并添加到场景中即可。

告警2d标记

通过createTagText创建一个2d标记,并添加到告警标记的组中,通过调整y轴位置,让标记显示到合适的位置。

 if (warn) {
    const panel = createTagPanel();
    group.add(panel)
}

createTagPanel 方法

创建一个简单的 HTML div 元素,为其添加特定的样式类,将该 div 元素封装成一个 CSS2DObject,设置其内部 HTML 内容,最后返回这个 CSS2DObject 对象,以便在其他代码部分可能用于在三维场景中以二维平面的形式展示相关信息或元素。

export const createTagPanel = () => {
    const div = document.createElement('div');
    div.classList.add('tag-panel');
    const label = new CSS2DObject(div);
    div.innerHTML = `
    <div >
     ...
    </div>
    `
    const {size} = getMeshInfo(label)
    console.log('size',size);
    
    label.position.set(0,0.3,0);
    return label;
}

首先,通过 new CSS2DObject(div) 创建了一个 CSS2DObject 对象,并将其赋值给变量 labelCSS2DObject 是一种特殊的对象类型,常用于在三维场景中以二维平面的方式展示与之关联的 HTML 元素。在这里,就是将前面创建的 div 元素与这个 CSS2DObject 相关联,使得后续可以在三维场景的特定位置展示该 div 元素及其包含的内容。

渲染2d元素

css2dObject需要CSS2DRenderer进行渲染。

const labelRenderer = new CSS2DRenderer();
labelRenderer.setSize(width, height);
labelRenderer.domElement.classList.add('label-renderer');
const main = document.querySelector('main')
main && main.appendChild( labelRenderer.domElement );

这里通过 new CSS2DRenderer() 创建了一个新的 CSS2DRenderer 对象,并将其赋值给变量 labelRendererCSS2DRenderer 能够将 CSS 样式应用到相关的 DOM 元素上,使得这些元素可以按照设定的样式在页面或者特定的场景中展示出来。

使用 labelRenderer.setSize(width, height) 方法为刚刚创建的 CSS2DRenderer 对象设置尺寸。这里的 width 和 height 为3d场景容器的尺寸,分别代表要设置的宽度和高度值。通过设置尺寸,确定了这个渲染器在页面或者相关场景中所占据的空间大小,以便后续能够准确地展示相关的 CSS 样式内容。

labelRenderer.domElement 可以获取到与 CSS2DRenderer 对象关联的 DOM 元素。然后,通过 classList.add('label-renderer') 方法为这个 DOM 元素添加了一个名为 label-renderer 的样式类。在项目的 CSS 样式表中,应该对这个样式类有相应的样式定义,用于设置该 DOM 元素的外观特征,比如大小、颜色、边框样式等,使得这个 DOM 元素在页面上能够呈现出特定的视觉效果。

标记动画

这里标记用tween也可以实现,文中效果使用css关键帧动画实现的,相对于tween来说,css消耗的资源更少。


@keyframes tag-panel-blink {
    0%,
    18%,
    45%,
    60% {
        opacity: 0;
    }
    5%,
    30%,
    71%,
    100% {
        opacity: 1;
    }
}

.tag-panel {
    animation: tag-panel-blink 3s infinite;
}

定义一个名为 tag-panel-blink 的动画关键帧序列。

在这个关键帧序列中,指定了不同百分比阶段下元素的不透明度(opacity)值的变化:

在动画的起始点(0%)、以及动画进行到 18%45% 和 60% 这些时刻,将元素的不透明度设置为 0,这意味着在这些时间点上,具有该动画应用的元素将会是完全透明的,不可见的状态。

而在动画进行到 5%30%71% 和动画结束点(100%)时,将元素的不透明度设置为 1,此时元素将会是完全不透明的,呈现出正常可见的状态。

通过这样的关键帧设置,就定义了一个元素不透明度在动画过程中来回变化的规律,从而实现闪烁效果

效果图

9 2d标记.gif

告警连接线

无动画效果

10 贝塞尔曲线.jpg

通过创建一个贝塞尔曲线,并获取它的点位信息,再绘制一个line2,同样使用createLine方法。下面是具体方法

// 将警告位置转换为THREE.Vector3对象
const vector3s = warningPos.map((item) => new THREE.Vector3().fromArray(item.position));

// 定义贝塞尔曲线的两个控制手柄
const handle1 = new THREE.Vector3(0.352343, 0.02, -0.14313); // 控制贝塞尔曲线的手柄1
const handle2 = new THREE.Vector3(-0.352343, 0.02, 0.14313); // 控制贝塞尔曲线的手柄2

// 创建三次贝塞尔曲线
const curve = new THREE.CubicBezierCurve3(vector3s[0], handle1, handle2, vector3s[2]);

// 获取曲线上的点
export const points = curve.getPoints(50);

// 获取点的位置数组
const array = getPointsFromPosition(points.map((item, index) => index === 0 ? item : points[0]))

// 创建线条对象
const line = createLine(array, 0xff0000, 1, 4, true, true);
line.geometry.setPositions(array);

// 获取所有点的位置数组
 const allArray = getPointsFromPosition(points)

格式转换

贝塞尔曲线的两端为告警信息的位置,手柄为自定义,也可以结合告警1和告警2的位置通过三角函数计算出想要的位置,文中只是模拟了一个手柄的位置,这两个向量对象通过指定的 xyz 坐标值来确定其在三维空间中的位置。这些控制手柄将在后续创建贝塞尔曲线时起到调整曲线形状的作用。

// 将警告位置转换为THREE.Vector3对象
const vector3s = warningPos.map((item) => new THREE.Vector3().fromArray(item.position));

warningPos 是一个包含多个对象的数组,每个对象都有一个 position 属性,该属性的值是一个数字数组,包含[x、y、z]三个信息,

通过 map 方法遍历 warningPos 数组中的每个元素 item。对于每个元素,创建一个新的 THREE.Vector3 对象,并使用 fromArray 方法将 item.position 数组中的值作为坐标来初始化这个向量对象。最终将所有创建好的 THREE.Vector3 对象组成一个新的数组 vector3s。这样就将原始的警告位置数据转换为了适合在三维场景中使用的 THREE.Vector3 对象形式。

创建三次贝塞尔曲线

// 定义贝塞尔曲线的两个控制手柄
const handle1 = new THREE.Vector3(0.352343, 0.02, -0.14313); 
const handle2 = new THREE.Vector3(-0.352343, 0.02, 0.14313); 

// 创建三次贝塞尔曲线
const curve = new THREE.CubicBezierCurve3(vector3s[0], handle1, handle2, vector3s[2]);

使用 new THREE.CubicBezierCurve3 构造函数来创建一条三次贝塞尔曲线。这个构造函数需要传入四个参数,分别是曲线的起始点、第一个控制手柄、第二个控制手柄和曲线的终点。

起始点取的是之前转换得到的 vector3s 数组中的第一个元素 vector3s[0],即第一个警告位置对应的 THREE.Vector3 对象;终点取的是 vector3s 数组中的第三个元素 vector3s[2];中间传入了前面定义好的两个控制手柄 handle1 和 handle2。通过这样的设置,就创建出了一条基于给定的警告位置和控制手柄的三次贝塞尔曲线。

获取曲线上的点

// 获取曲线上的点
export const points = curve.getPoints(50);

// 获取点的位置数组
const array = getPointsFromPosition(points.map((item, index) => index === 0? item : points[0]))

首先对 points 数组进行了一次 map 操作。在这个 map 操作中,对于 points 数组中的每个元素 item,当索引 index 等于 0 时,就直接返回该元素 item;否则返回 points 数组中的第一个元素 points[0]

定义打开动画

// 定义打开动画
const tweenOpen = new TWEEN.Tween({
    index: 0
}).to({
    index: allArray.length
}, timeLine * 1000).onUpdate(({ index }) => {
    update(Math.floor(index));
})
   .onComplete(() => {
        // 重新开始关闭动画
        tweenClose.start();
    })
   .start();
创建TWEEN.Tween对象

首先创建了一个TWEEN.Tween对象,传入一个初始状态对象{ index: 0 },这里的index变量将用于在动画过程中跟踪进度,初始值设置为0

设置动画目标状态和时长

通过.to({ index: allArray.length }, timeLine * 1000)设置了动画的目标状态,即要在timeLine * 1000毫秒(也就是前面定义的1秒,因为timeLine = 1)内将index的值从初始的0变化到allArray.length,这个过程控制了动画的持续时间和进度范围。

定义动画更新回调函数

.onUpdate(({ index }) => { update(Math.floor(index)); })定义了一个在动画更新过程中每次被调用的回调函数。在每次更新时,会获取当前的index值(通过解构赋值得到),并将其向下取整后传入update函数(后续会详细解析update函数),用于根据当前动画进度处理点的位置数据并更新线条位置。

定义动画完成回调函数

.onComplete(() => { tweenClose.start(); })定义了一个在动画完成(即index达到allArray.length)时被调用的回调函数。在这个回调函数中,会启动tweenClose动画(后续会详细解析tweenClose动画),实现打开动画完成后紧接着开始关闭动画的效果,从而形成一个循环的动画序列。

启动动画

最后通过.start()方法启动了tweenOpen动画,使其开始按照设定的规则进行播放。

定义关闭动画

// 定义关闭动画
const tweenClose = new TWEEN.Tween({
    index: 0
}).to({
    index: allArray.length
}, timeLine * 1000).onUpdate(({ index }) => {
    update2(Math.floor(index));
}).onComplete(() => {
    // 重新开始打开动画
    tweenOpen.start();
})

与定义打开动画类似,这里也创建了一个TWEEN.Tween对象,初始状态为{ index: 0 },设置了在timeLine * 1000毫秒内将index值从0变化到allArray.length的目标状态。

在动画更新过程中,通过.onUpdate(({ index }) => { update2(Math.floor(index)); })定义了一个回调函数,每次更新时将当前index值向下取整后传入update2函数(后续会详细解析update2函数),用于根据当前动画进度处理点的位置数据并更新线条位置。

在动画完成时,通过.onComplete(() => { tweenOpen.start(); })定义了一个回调函数,当动画完成后会启动tweenOpen动画,实现关闭动画完成后紧接着开始打开动画的效果,与打开动画形成循环播放的关系。

定义用于动画更新的update函数(打开动画更新逻辑)

// 更新函数,用于动画更新
const update = (index: number) => {
    if (index % 3 === 0) {
        const newArray = [...allArray];
        const newArr = newArray.slice(0, index);
        const lastArr = newArr.slice(-3);
        const oldArr = array.slice(index);
        for (let i = 0; i < oldArr.length; i++) {
            if (i % 3 === 0) {
                newArr.push(...lastArr);
            }
        }
        // 更新线条位置
        if (newArr.length === allArray.length) {
            updateLine(newArr);
        }
    }
}
条件判断

首先通过if (index % 3 === 0)进行条件判断,只有当index除以3的余数为0时才会执行后续的操作,每三个点及是点位信息的x,y,z,以实现特定的动画效果。

数据处理

创建了一个新数组newArray,并通过[...allArray]allArray中的所有元素复制到newArray中,作为后续处理的基础数据。

通过newArray.slice(0, index)获取newArray中从开头到index位置(不包括index)的子数组,并赋值给newArr

通过newArr.slice(-3)获取newArr的最后三个元素,赋值给lastArr,这三个元素后续的处理中起到关键作用。

通过array.slice(index)获取从index位置开始的array数组中的元素赋值给oldArr

然后通过一个循环,遍历oldArr数组,当i % 3 === 0时,将lastArr中的元素添加到newArr中,这样就根据特定规则对新 Arr 进行了扩展处理。

更新线条位置

最后通过if (newArr.length === allArray.length)进行条件判断,当处理后的newArr数组长度与allArray数组长度相等时,调用updateLine函数(后续会详细解析updateLine函数),并传入newArr,用于更新线条的位置,实现根据动画进度调整线条位置的效果。

定义用于动画更新的update2函数(关闭动画更新逻辑)
// 更新函数,用于动画更新
const update2 = (index: number) => {
    if (index % 3 === 0) {
        const oldArr = allArray.slice(index);
        const lastArr = oldArr.slice(0, 3);
        const newArr = allArray.slice(0, index);
        const preArr = [];
        for (let i = 0; i < newArr.length; i++) {
            if (i % 3 === 0) {
                preArr.push(...lastArr);
            }
        }

        const points = [...preArr,...oldArr];
        if (points.length === allArray.length) {
            updateLine(points);
        }
    }
}

条件判断同样通过if (index % 3 === 0)进行条件判断,只有满足该条件时才执行后续操作。

数据处理

通过allArray.slice(index)获取从index位置开始的allArray数组中的元素,赋值给oldArr

通过oldArr.slice(0, 3)获取oldArr的前三个元素,赋值给lastArr

通过allArray.slice(0, index)获取从开头到index位置(不包括index)的allArray数组中的元素,赋值给newArr

创建一个空数组preArr,然后通过一个循环遍历newArr数组,当i % 3 === 0时,将lastArr中的元素添加到preArr中,这样就构建了一个新的数组preArr

通过[...preArr,...oldArr]preArroldArr中的元素合并成一个新数组points

更新线条位置

最后通过if (points.length === allArray.length)进行条件判断,当合并后的points数组长度与allArray数组长度相等时,调用updateLine函数,传入points,用于更新线条的位置,实现根据动画进度调整线条位置的效果。

定义用于更新线条点位信息的updateLine函数
// 更新线条点位信息
const updateLine = (points: number[]) => {
    line.geometry.setPositions(points);
    line.computeLineDistances();
}

这个函数接收一个包含数字的数组points作为参数,它的主要作用是更新线条对象的位置信息。

通过line.geometry.setPositions(points)将传入的points数组设置为线条对象,从而改变线条的形状。

通过line.computeLineDistances()计算线条的距离相关信息,进一步完善线条的相关属性更新,以确保线条在动画过程中的正确显示。

在更新动画中一定要保证组成线的点位至少为两个,不然会报缓存不足,感兴趣的童鞋可以查看一下源码。node_modules/three/examples/jsm/lines/LineGeometry.js

报错详情

11 空间不足.jpg

效果图

12 连接线动画.gif

模拟龙卷风

创建一个粒子系统来模拟类似龙卷风的效果,包括生成粒子的初始位置、速度和颜色信息,设置粒子的材质以便正确渲染颜色,通过更新函数实现粒子的动态运动(如向上移动、围绕中心旋转等),以及利用 TWEEN.Tween 实现龙卷风位置的动其画效果,使能够在一系列给定的位置间移动并带有倾斜等姿态变化。

const group = new THREE.Group();
scene.add(group);
const particleCount = 5000;
const particles = new THREE.BufferGeometry();
const positions = new Float32Array(particleCount * 3);
const velocities = new Float32Array(particleCount * 3);
const colors = new Float32Array(particleCount * 3); // 新增颜色数组

const xzExpansionRange = 0.6; // 提取x和z的扩散范围变量
const maxHeight = 0.8; // 提取高度变量

const curveFactor = 1.2; // 提取曲线弧度变量
const curveCalculationFactor = Math.PI; // 提取曲线弧度计算方式为变量

const whiteColor = new THREE.Color(0xffffff); // 设置粒子颜色为白色

for (let i = 0; i < particleCount; i++) {
    const theta = Math.random() * 2 * Math.PI;
    const height = Math.random() * maxHeight / 2;
    const expansionFactor = height / maxHeight + Math.random() / 5; // 根据y值计算扩散因子

    const xs = height * Math.random();
    // 使用曲线差值计算半径,增加曲线弧度
    const radius = Math.sin(height / maxHeight * curveCalculationFactor * curveFactor * xs) * xzExpansionRange * expansionFactor;

    positions[i * 3] = radius * Math.cos(theta);
    positions[i * 3 + 1] = height;
    positions[i * 3 + 2] = radius * Math.sin(theta);

    velocities[i * 3] = -Math.sin(theta) * 0.01;
    velocities[i * 3 + 1] = 0.02;
    velocities[i * 3 + 2] = Math.cos(theta) * 0.01;

    // 设置颜色为白色
    colors[i * 3] = whiteColor.r; // R
    colors[i * 3 + 1] = whiteColor.g; // G
    colors[i * 3 + 2] = whiteColor.b; // B
}

particles.setAttribute('position', new THREE.BufferAttribute(positions, 3));
particles.setAttribute('velocity', new THREE.BufferAttribute(velocities, 3));
particles.setAttribute('color', new THREE.BufferAttribute(colors, 3)); // 设置颜色属性

const material = new THREE.ShaderMaterial({
    vertexShader: `
            varying vec3 vColor;
            attribute vec3 color; // 声明颜色属性
            void main() {
                vColor = color; // 使用传入的颜色
                vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
                gl_PointSize = 5.0; // 固定点大小
                gl_Position = projectionMatrix * mvPosition;
            }
        `,
    fragmentShader: `
            varying vec3 vColor;
            void main() {
                gl_FragColor = vec4(vColor, 1.0);
            }
        `,
    blending: THREE.NormalBlending, // 取消扩散小托
    depthTest: true,
    transparent: true
});

const particleSystem = new THREE.Points(particles, material);
group.add(particleSystem);

let rotationFactor = 0.09; // 控制转动速度的因子,可以根据需要调整
rotationFactorValue.onChange((value: number) => {
    rotationFactor = value;
})
export const animateParticles = () => {
    const time = Date.now() * 0.00001;

    const centerX = 0; // 龙卷风中心在x轴的位置,这里设为0,可根据实际需求调整
    const centerZ = 0; // 龙卷风中心在z轴的位置,这里设为0,可根据实际需求调整

    for (let i = 0; i < particleCount; i++) {
        const index = i * 3;

        // 获取当前粒子的位置和速度信息
        const x = positions[index];
        const z = positions[index + 2];

        // 更新粒子的y坐标,使其向上移动
        // positions[index + 1] = y + velocities[index + 1]; // 向上移动

        // 如果粒子超过maxHeight/2,则重置到底部
        if (positions[index + 1] > maxHeight / 2) {
            positions[index + 1] = 0; // 重置y坐标
        }

        // 计算粒子相对于龙卷风中心的位置
        const relativeX = x - centerX;
        const relativeZ = z - centerZ;

        // 根据时间和一些参数来更新粒子位置,实现向上缠绕的效果
        const newRelativeX = relativeX * Math.cos(rotationFactor * time) - relativeZ * Math.sin(rotationFactor * time);
        const newRelativeZ = relativeX * Math.sin(rotationFactor * time) + relativeZ * Math.cos(rotationFactor * time);

        // 更新粒子的x和z坐标
        positions[index] = newRelativeX + centerX;
        positions[index + 2] = newRelativeZ + centerZ;
    }

    // 标记位置属性需要更新
    particles.attributes.position.needsUpdate = true;
};

export const animateTornado = (positions: THREE.Vector3[]) => {
    new TWEEN.Tween({ index: 0 })
        .to({ index: positions.length - 1 }, 5000) // 动画持续时间为5000毫秒
        .onUpdate(({ index }) => {
            const currentIndex = Math.floor(index);
            const nextIndex = currentIndex + 1 < positions.length ? currentIndex + 1 : 0;

            // 更新龙卷风的位置
            particleSystem.position.copy(positions[currentIndex]);
            particleSystem.lookAt(positions[nextIndex]); // 使粒子系统朝向下一个位置
            const tiltAngle = Math.PI / 18; // 10度的倾斜角度
            const tiltAxis = new THREE.Vector3(1, 0, 0); // 沿着x轴倾斜
            const quaternion = new THREE.Quaternion().setFromAxisAngle(tiltAxis, tiltAngle);
            particleSystem.quaternion.multiplyQuaternions(quaternion, particleSystem.quaternion);
        })
        .repeat(Infinity) // 添加重复动画
        .start();
};

初始化粒子相关参数和数据结构

const particleCount = 5000;
const particles = new THREE.BufferGeometry();
const positions = new Float32Array(particleCount * 3);
const velocities = new Float32Array(particleCount * 3);
const colors = new Float32Array(particleCount * 3); // 新增颜色数组

const xzExpansionRange = 0.6; // 提取x和z的扩散范围变量
const maxHeight = 0.8; // 提取高度变量

const curveFactor = 1.2; // 提取曲线弧度变量
const curveCalculationFactor = Math.PI; // 提取曲线弧度计算方式为变量

const whiteColor = new THREE.Color(0xffffff); // 设置粒子颜色为白色

首先定义了一系列常量和数组来准备生成粒子系统所需的数据:

particleCount:指定了要生成的粒子数量为 5000 个。

particles:创建了一个 THREE.BufferGeometry 对象,用于存储粒子的几何信息,后续会将粒子的位置、速度、颜色等属性设置到这个对象中。

positionsvelocitiescolors:分别创建了长度为 particleCount * 3 的 Float32Array 数组,用于存储每个粒子的三维位置坐标、速度向量以及颜色信息(每个粒子的颜色信息也是以三维向量的形式存储,分别对应红、绿、蓝三个颜色通道)。

xzExpansionRangemaxHeightcurveFactorcurveCalculationFactor:这些常量分别定义了与粒子分布和运动相关的一些参数,如 x 和 z 方向的扩散范围、粒子所能达到的最大高度、用于计算曲线弧度的因子以及曲线弧度的计算方式(这里以 Math.PI 为基础)。

whiteColor:创建了一个表示白色的 THREE.Color 对象,用于后续设置粒子的颜色。

生成粒子的初始位置、速度和颜色信息

for (let i = 0; i < particleCount; i++) {
    const theta = Math.random() * 2 * Math.PI;
    const height = Math.random() * maxHeight / 2;
    const expansionFactor = height / maxHeight + Math.random() / 5; // 根据y值计算扩散因子

    const xs = height * Math.random();
    // 使用曲线差值计算半径,增加曲线弧度
    const radius = Math.sin(height / maxHeight * curveCalculationFactor * curveFactor * xs) * xzExpansionRange * expansionFactor;

    positions[i * 3] = radius * Math.cos(theta);
    positions[i * 3 + 1] = height;
    positions[i * 3 + 2] = radius * Math.sin(theta);

    velocities[i * 3] = -Math.sin(theta) * 0.01;
    velocities[i * 3 + 1] = 0.02;
    velocities[i * 3 + 2] = Math.cos(theta) * 0.01;

    // 设置颜色为白色
    colors[i * 3] = whiteColor.r; // R
    colors[i * 3 + 1] = whiteColor.g; // G
    colors[i * 3 + 2] = whiteColor.b; // B
}

通过一个循环遍历 particleCount 次,为每个粒子生成初始的位置、速度和颜色信息:

位置信息生成

首先生成一个随机角度 theta,范围是从 0 到 2 * Math.PI,用于确定粒子在水平面上的角度位置。

随机生成一个粒子的高度 height,范围是从 0 到 maxHeight / 2

根据 height 计算一个扩散因子 expansionFactor,它与粒子在 x 和 z 方向的扩散程度有关。

通过一些计算(涉及到曲线弧度相关参数)得到粒子在水平面上的半径 radius,然后根据三角函数计算出粒子的三维位置坐标,并分别存储到 positions 数组中对应的位置(每三个元素一组,分别对应 xyz 坐标)。

速度信息生成

同样基于 theta 以及一些固定的速度系数(如 0.01 和 0.02),计算出粒子在三维空间中的速度向量,并存储到 velocities 数组中对应的位置。

颜色信息设置

将之前创建的白色 THREE.Color 对象的红、绿、蓝三个颜色通道的值分别赋给 colors 数组中对应粒子的颜色信息位置,从而将所有粒子的颜色都设置为白色。

设置粒子的属性到 BufferGeometry 对象
particles.setAttribute('position', new THREE.BufferAttribute(positions, 3));
particles.setAttribute('velocity', new THREE.BufferAttribute(velocities, 3));
particles.setAttribute('color', new THREE.BufferAttribute(colors, 3)); // 设置颜色属性

使用 particles.setAttribute 方法将之前生成的粒子位置、速度和颜色信息数组分别设置为 particles 对象的对应属性。每个属性都通过创建一个新的 THREE.BufferAttribute 对象,并传入相应的数组和每个属性的维度(这里都是 3,因为是三维空间的信息)来完成设置。

创建粒子的材质并设置相关属性

const material = new THREE.ShaderMaterial({
    vertexShader: `
            varying vec3 vColor;
            attribute vec3 color; // 声明颜色属性
            void main() {
                vColor = color; // 使用传入的颜色
                vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
                gl_PointSize = 5.0; // 固定点大小
                gl_Position = projectionMatrix * mvPosition;
            }
        `,
    fragmentShader: `
            varying vec3 vColor;
            void main() {
                gl_FragColor = vec4(vColor, 1.0);
            }
        `,
    blending: THREE.NormalBlending, // 取消扩散小托
    depthTest: true,
    transparent: true
});

创建了一个 THREE.ShaderMaterial 对象作为粒子的材质,并设置了相关的属性:

顶点着色器(vertexShader)

声明了一个 varying 变量 vColor,用于在顶点着色器和片段着色器之间传递颜色信息。

声明了一个 attribute 变量 color,用于接收从 BufferGeometry 对象传递过来的粒子颜色属性。

在 main 函数中,将接收到的 color 属性值赋给 vColor,以便后续在片段着色器中使用。同时,进行了一些常规的坐标变换操作(如通过 modelViewMatrix 和 projectionMatrix 对粒子的位置进行变换),并设置了固定的点大小为 5.0

片段着色器(fragmentShader)

接收从顶点着色器传递过来的 vColor 变量,并将其转换为 gl_FragColor,用于设置最终渲染的颜色,这里将颜色的透明度设置为 1.0,表示完全不透明(因为在材质的其他属性中已经设置了 transparent: true,所以这里可以根据需要调整透明度)。

其他材质属性

blending: THREE.NormalBlending:设置了混合模式为正常混合,用于处理粒子之间以及粒子与背景之间的颜色混合情况,这里可能是为了避免一些不必要的颜色扩散效果(具体效果需结合实际场景确定)。

depthTest: true:开启深度测试,确保粒子在三维空间中的渲染顺序正确,即离观察者近的粒子会遮挡住离观察者远的粒子。

transparent: true:设置材质为透明的,这与片段着色器中设置的颜色透明度可以配合使用,以便在需要时实现半透明等效果。

设置粒子系统转动速度因子及更新函数

let rotationFactor = 0.09; // 控制转动速度的因子,可以根据需要调整
rotationFactorValue.onChange((value: number) => {
    rotationFactor = value;
})
export const animateParticles = () => {
    const time = Date.now() * 0.00001;

    const centerX = 0; // 龙卷风中心在x轴的位置,这里设为 0,可根据实际需求调整
    const centerZ = 0; // 龙卷风中心在z轴的位置,这里设为 0,可根据实际需求调整

    for (let i = 0; i < particleCount; i++) {
        const index = i * 3;

        // 获取当前粒子的位置和速度信息
        const x = positions[index];
        const z = positions[index + 2];

        // 更新粒子的y坐标,使其向上移动
        // positions[index + 1] = y + velocities[index + 1]; // 向上移动

        // 如果粒子超过maxHeight/2,则重置到底部
        if (positions[index + 1] > maxHeight / 2) {
            positions[index + 1] = 0; // 重置y坐标
        }

        // 计算粒子相对于龙卷风中心的位置
        const relativeX = x - centerX;
        const relativeZ = z - centerZ;

        // 根据时间和一些参数来更新粒子位置,实现向上缠绕的效果
        const newRelativeX = relativeX * Math.cos(rotationFactor * time) - relativeZ * Math.sin(rotationFactor * time);
        const newRelativeZ = relativeX * Math.sin(rotationFactor * time) + relativeZ * Math.cos(rotationFactor * time);

        // 更新粒子的x和z坐标
        positions[index] = newRelativeX + centerX;
        positions[index + 2] = newRelativeZ + centerZ;
    }

    // 标记位置属性需要更新
    particles.attributes.position.needsUpdate = true;
};
粒子动画更新函数(animateParticles)

首先获取当前时间 time,并进行了一定的单位换算(乘以 0.00001),以便在后续的计算中使用合适的时间尺度。

定义了龙卷风中心在 x 轴和 z 轴的位置,这里都设置为 0,可根据实际需求调整。

通过一个循环遍历所有粒子,对于每个粒子:

获取当前粒子的位置信息(x 和 z 坐标),并根据粒子的 y 坐标情况进行处理:

如果粒子的 y 坐标超过了 maxHeight / 2,则将其 y 坐标重置为 0,实现粒子在达到一定高度后重新回到底部的效果。

计算粒子相对于龙卷风中心的位置(relativeX 和 relativeZ)。

根据时间 time 和转动速度因子 rotationFactor,通过三角函数计算出更新后的粒子相对于龙卷风中心的位置(newRelativeX 和 newRelativeZ),从而实现粒子围绕龙卷风中心转动并向上缠绕的效果。

更新粒子的 x 和 z 坐标,将其设置为相对于龙卷风中心更新后的位置加上龙卷风中心的坐标。

最后,为了确保粒子的位置属性更新能够被正确渲染,将 particles.attributes.position.needsUpdate 设置为 true,告诉渲染引擎需要更新粒子的位置信息。

龙卷风位置动画函数

export const animateTornado = (positions: THREE.Vector3[]) => {
    new TWEEN.Tween({ index: 0 })
       .to({ index: positions.length - 1 }, 5000) // 动画持续时间为5000毫秒
       .onUpdate(({ index }) => {
            const currentIndex = Math.floor(index);
            const nextIndex = currentIndex + 1 < positions.length? currentIndex + 1 : 0;

            // 更新龙卷风的位置
            particleSystem.position.copy(positions[currentIndex]);
            particleSystem.lookAt(positions[nextIndex]); // 使粒子系统朝向下一个位置
            const tiltAngle = Math.PI / 18; // 10度的倾斜角度
            const tiltAxis = new THREE.Vector3(1, 0, 0); // 沿着x轴倾斜
            const quaternion = new THREE.Quaternion().setFromAxisAngle(tiltAxis, tiltAngle);
            particleSystem.quaternion.multiplyQuaternions(quaternion, particleSystem.quaternion);
        })
       .repeat(Infinity) // 添加重复动画
       .start();
};

这个函数接受一个包含 THREE.Vector3 对象的数组 positions 作为参数,用于实现龙卷风在一系列给定位置间移动的动画效果:

创建了一个 TWEEN.Tween 对象,初始状态设置为 { index: 0 },并设置目标状态为 { index: positions.length - 1 },动画持续时间为 5000 毫秒。这意味着在 5000 毫秒内,index 的值会从 0 变化到 positions.length - 1,用于控制动画的进度。

在 onUpdate 回调函数中:

获取当前的 index 值(通过向下取整得到 currentIndex),并根据 currentIndex 确定下一个位置的索引 nextIndex,如果当前索引已经是最后一个位置,则下一个位置索引设置为 0,实现循环遍历给定位置的效果。

更新粒子系统的位置,将其设置为当前位置 positions[currentIndex]

使粒子系统朝向下一个位置 positions[nextIndex],通过 lookAt 方法实现粒子系统的朝向调整。

设置一个倾斜角度 tiltAngle 为 Math.PI / 18(即 10 度),并定义倾斜轴为沿着 x 轴方向的 THREE.Vector3(1, 0, 0)。通过创建一个 THREE.Quaternion 对象,并使用 setFromAxisAngle 方法根据倾斜轴和倾斜角度设置其值,然后将这个四元数与粒子系统现有的四元数通过 multiplyQuaternions 方法相乘,实现粒子系统在移动过程中的倾斜效果。

通过 repeat(Infinity) 设置动画为无限重复,使得龙卷风会在给定的位置序列中不断循环移动并带有倾斜等姿态变化。最后通过 start() 启动动画。

效果图

13 龙卷风效果图.gif

发光体

import * as THREE from "three";
import { EffectComposer, OutputPass, RenderPass, ShaderPass, UnrealBloomPass } from "three/examples/jsm/Addons.js";
import { scene, camera, width, height, renderer } from "../content/scene";
import { guiParams } from "../content/GUI";

 const darkMaterial = new THREE.MeshBasicMaterial({ color: 'black',opacity: 0,transparent: true });

const createParams = {
    threshold: 0,
    strength: 0.39, // 强度
    radius: 0.1,// 半径
    exposure: 0.5 // 扩散
};

const materials: any = {}

const bloomLayer = new THREE.Layers();
const BLOOM_SCENE = 1;
bloomLayer.set(BLOOM_SCENE);

// 渲染器通道,将场景全部加入渲染器
const renderScene = new RenderPass(scene, camera);
// 添加虚幻发光通道
const bloomPass = new UnrealBloomPass(new THREE.Vector2(width, height), 1.5, 0.4, 0.85);
bloomPass.threshold = createParams.threshold;
bloomPass.strength = createParams.strength;
bloomPass.radius = createParams.radius;

// 创建合成器
const bloomComposer = new EffectComposer(renderer);
bloomComposer.renderToScreen = false;
// 将渲染器和场景结合到合成器中
bloomComposer.addPass(renderScene);
bloomComposer.addPass(bloomPass);

// 着色器通道
const mixPass = new ShaderPass(
    // 着色器
    new THREE.ShaderMaterial({
        uniforms: {
            baseTexture: { value: null },
            bloomTexture: { value: bloomComposer.renderTarget2.texture }
        },
        vertexShader: `
            
            varying vec2 vUv;
    
            void main() {
    
                vUv = uv;
    
                gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
    
            }
            
            `,
        fragmentShader: `
            
            uniform sampler2D baseTexture;
            uniform sampler2D bloomTexture;
    
            varying vec2 vUv;
    
            void main() {
    
                gl_FragColor = ( texture2D( baseTexture, vUv ) + vec4( 1.0 ) * texture2D( bloomTexture, vUv ) );
    
            }
    
            `,
        defines: {}
    }), 'baseTexture'
);
mixPass.needsSwap = true;

// 合成器输出通道
const outputPass = new OutputPass();

const finalComposer = new EffectComposer(renderer);
finalComposer.addPass(renderScene);
finalComposer.addPass(mixPass);
finalComposer.addPass(outputPass);


function darkenNonBloomed(obj: any) {
    if (bloomLayer) {
        if (!obj.userData.isLight && bloomLayer.test(obj.layers) === false) {
            materials[obj.uuid] = obj.material;
            obj.material = darkMaterial;
        }
    }

}

function restoreMaterial(obj: any) {
    if (materials[obj.uuid]) {
        obj.material = materials[obj.uuid];
        // 用于删除没必要的渲染
        delete materials[obj.uuid];
    }
}

const render = () => {

    if (guiParams.isLight) {
        if (bloomComposer) {
            scene.traverse(darkenNonBloomed);
            bloomComposer.render();
        }

        if (finalComposer) {
            scene.traverse(restoreMaterial);
            finalComposer.render();
        }
    }
}



export {
    render as unrealRender
}

这段代码可以直接复制引用,并在render中调用unrealRender 方法,如果需要场景中某个物体发光,设置mesh.userData.isLight = true,即可,下面是详细讲解

定义发光效果相关参数和图层设置

const createParams = {
    threshold: 0,
    strength: 0.39, // 强度
    radius: 0.1,// 半径
    exposure: 0.5 // 扩散
};

const materials: any = {}

const bloomLayer = new THREE.Layers();
const BLOOM_SCENE = 1;
bloomLayer.set(BLOOM_SCENE);
定义发光效果参数

创建了一个名为 createParams 的对象,其中包含了用于控制虚幻发光效果的几个参数,如 threshold(发光阈值,决定哪些部分开始发光)、strength(发光强度)、radius(发光半径,影响发光范围)、exposure(扩散程度,可能与发光的整体扩散效果有关)。

创建并设置图层

创建了一个 THREE.Layers 对象 bloomLayer,并设置了一个图层标识 BLOOM_SCENE 为 1,通过 bloomLayer.set(BLOOM_SCENE) 将该图层标记为与发光效果相关的特定图层。这个图层将在后续用于区分场景中的物体是否应该产生发光效果。

创建渲染通道和设置发光效果通道参数

// 渲染器通道,将场景全部加入渲染器
const renderScene = new RenderPass(scene, camera);
// 添加虚幻发光通道
const bloomPass = new UnrealBloomPass(new THREE.Vector2(width, height), 1.5, 0.4, 0.85);
bloomPass.threshold = createParams.threshold;
bloomPass.strength = createParams.strength;
bloomPass.radius = createParams.radius;

创建渲染场景通道

创建了一个 RenderPass 对象 renderScene,它的作用是将整个场景(由传入的 scene 和 camera 确定)渲染到某个目标(通常是后续合成器中的一个中间目标),这是整个渲染流程的基础步骤,确保场景能够被正确渲染。

创建并设置虚幻发光通道

创建了一个 UnrealBloomPass 对象 bloomPass,用于实现虚幻发光效果。在创建时传入了一些参数,如根据场景的宽度和高度创建的 THREE.Vector2(width, height),以及一些默认发光效果相关的参数。然后,将之前定义的 createParams 对象中的相关参数(thresholdstrengthradius)分别设置给 bloomPass,以定制化发光效果。

创建合成器并添加渲染通道

// 创建合成器
const bloomComposer = new EffectComposer(renderer);
bloomComposer.renderToScreen = false;
// 将渲染器和场景结合到合成器中
bloomComposer.addPass(renderScene);
bloomComposer.addPass(bloomPass);
创建发光效果合成器

创建了一个 EffectComposer 对象 bloomComposer,并将渲染器(renderer)作为参数传入,它的作用是将多个渲染通道组合在一起,实现更复杂的渲染效果。通过设置 bloomComposer.renderToScreen = false,表示这个合成器的输出不会直接显示到屏幕上,而是作为后续进一步处理的中间结果。

添加渲染通道到合成器

将之前创建的 renderScene 和 bloomPass 两个渲染通道添加到 bloomComposer 中,这样在后续渲染时,会先通过 renderScene 将场景渲染到中间目标,然后再通过 bloomPass 在这个基础上添加虚幻发光效果。

创建着色器通道并设置相关参数

// 着色器通道
const mixPass = new ShaderPass(
    // 着色器
    new THREE.ShaderMaterial({
        ......
    }), 'baseTexture'
);
mixPass.needsSwap = true;
创建着色器通道对象

创建了一个 ShaderPass 对象 mixPass,它用于应用一个自定义的着色器来处理渲染结果。

设置着色器相关参数

在创建 ShaderPass 时,传入了一个 THREE.ShaderMaterial 对象作为着色器的具体实现。这个着色器材料有以下特点:

定义 uniform 变量

在 uniforms 部分,定义了两个 uniform 变量,baseTexture和 bloomTexture(值为 bloomComposer.renderTarget2.texture,即之前创建的 bloomComposer 合成器的第二个渲染目标的纹理,这个纹理包含了经过发光效果处理后的内容)。

顶点着色器(vertexShader)

主要进行了一些常规的坐标变换操作,将顶点的 uv 坐标赋值给 vUv 变量,并通过 modelViewMatrix 和 projectionMatrix 对顶点位置进行变换,以确定在屏幕上的最终位置。

片段着色器(fragmentShader)

在这个着色器中,通过 texture2D 函数分别获取 baseTexture 和 bloomTexture 在当前 vUv 坐标下的纹理颜色值,并将它们相加(其中 bloomTexture 的值还乘以了 vec4(1.0),可能是为了调整发光效果在最终颜色中的权重),得到最终的片段颜色 gl_FragColor

通过设置 mixPass.needsSwap = true,表示在渲染过程中需要进行纹理交换操作

创建合成器输出通道并构建最终合成器

// 合成器输出通道
const outputPass = new OutputPass();

const finalComposer = new EffectComposer(renderer);
finalComposer.addPass(renderScene);
finalComposer.addPass(mixPass);
finalComposer.addPass(outputPass);
创建输出通道

创建了一个 OutputPass 对象 outputPass,它的作用是将最终的渲染结果输出到屏幕上。

构建最终合成器

创建了一个新的 EffectComposer 对象 finalComposer,同样将渲染器(renderer)作为参数传入。然后将之前创建的 renderScenemixPass 和 outputPass 三个渲染通道添加到 finalComposer 中,这样在渲染时,会先通过 renderScene 将场景渲染到中间目标,然后通过 mixPass 应用着色器处理,最后通过 outputPass 将最终结果输出到屏幕上,实现了整个复杂的渲染流程,包括场景渲染、发光效果处理以及最终的颜色混合等操作。

定义处理非发光物体材质的函数
function darkenNonBloomed(obj: any) {
    if (bloomLayer) {
        if (!obj.userData.isLight && bloomLayer.test(obj.layers) === false) {
            materials[obj.uuid] = obj.material;
            obj.material = darkMaterial;
        }
    }

}

function restoreMaterial(obj: any) {
    if (materials[obj.uuid]) {
        obj.material = materials[obj.uuid];
        // 用于删除没必要的渲染
        delete materials[obj.uuid];
    }
}

这个函数用于处理场景中那些不应该产生发光效果的物体材质。它首先检查 bloomLayer 是否存在,然后判断物体(由传入的 obj 表示)是否满足两个条件:一是物体的 userData.isLight 属性为 false(可能表示物体本身不是光源相关的物体),二是通过 bloomLayer.test(obj.layers) 判断物体所在的图层不属于之前设置的发光相关图层。如果满足这两个条件,就将物体当前的材质保存到 materials 数组中(以物体的 uuid 作为索引),然后将物体的材质替换为 darkMaterial,这样就实现了将非发光物体的材质变暗的效果。

定义渲染函数并控制渲染流程

这个函数用于恢复之前被替换材质的物体的原始材质。它首先检查 materials 数组中是否存在以物体的 uuid 为索引的元素,如果存在,就将物体的材质设置回原来的材质,并删除 materials 数组中对应的元素,以避免不必要的内存占用和后续可能出现的问题。

const render = () => {

    if (guiParams.isLight) {
        if (bloomComposer) {
            scene.traverse(darkenNonBloomed);
            bloomComposer.render();
        }

        if (finalComposer) {
            scene.traverse(restoreMaterial);
            finalComposer.render();
        }
    }
}

定义了一个名为 render 的函数,它用于控制整个渲染流程。首先判断 guiParams.isLight 的值,如果为 true,则进行以下操作:

如果 bloomComposer 存在,就通过 scene.traverse(darkenNonBloomed) 遍历整个场景中的物体,并调用 darkenNonBloomed 函数对每个物体进行材质处理,将非发光物体的材质变暗。然后调用 bloomComposer.render() 进行发光效果的渲染,即在经过材质处理后的场景基础上添加虚幻发光效果。

如果 finalComposer 存在,就通过 scene.traverse(restoreMaterial) 遍历整个场景中的物体,并调用 restoreMaterial 函数对之前被替换材质的物体进行恢复操作。然后调用 finalComposer.render() 进行最终的渲染,将经过发光效果处理、材质恢复等操作后的场景最终输出到屏幕上。

render中渲染

导出的方法unrealRender 可直接在render函数中调用

renderer.setAnimationLoop(animate);

function animate() {
    renderer.render(scene, camera);
    TWEEN.update();
    intervalTime.update()
    controls.update(); // 更新控制器
    // 更新虚幻系统
    unrealRender()
    ...
}

效果图

14 发光效果关闭开启.gif

通过点击GUI控制器中的是否发光选项来观察虚幻引擎对场景和物体的影响

可算看完了,感觉还可以不?欢迎提意见,兄弟们,撤!

image.png