webgl 结合three.js 粒子波图

393 阅读8分钟

最近沉迷于WebGL的学习,每天不是在跟GLSL语法的各种报错作斗争,就是在各种三角函数矩阵向量的数学海洋里挣扎。为了调剂一下紧绷的神经,我决定到three.js官网找些有趣的粒子效果示例来放松学习——毕竟看着绚丽的粒子飞舞。

chrome-capture-2025-5-18.gif

查看链接: www.yanhuangxueyuan.com/threejs/exa…

一、项目介绍

这是基于 Three.js 的WebGL 粒子动画示例,代码通过Shader 材质和顶点动画,模拟了粒子在二维网格中的上下波动。并结合了鼠标交互控制相机视角,最终呈现出流动的波浪视觉效果。

二、代码实现具体介绍

1.着色器

定义顶点着色器和片元着色器代码。如果学习过WebGL,这部分代码应该很容易看懂。

(1) 顶点着色器

modelViewMatrix: 模型视图矩阵

  • 包含模型变换(平移、旋转、缩放)
  • 包含视图变换 (相机位置和朝向)
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);

增加齐次坐标w,拓展为思维矩阵。因为gl_Position是四维矩阵

gl_PointSize = scale * (300.0 / -mvPosition.z);

gl_PointSize 是控制粒子大小的。粒子大小由scale 和 300.0 / -mvPosition.z) 共同调节控制。

(2) 片元着色器

通过 uniform 生命的color变量,是用来控制像素颜色的。这个color 变量在后面会被js或者并进行改写值。

if ( length( gl_PointCoord - vec2( 0.5, 0.5 ) ) > 0.475 ) discard;

length:webgl内置函数,可以计算两点的距离
gl_PointCoord: vec2() 类型,取值范围是[0,1]。是webGL 的内置变量,仅在点精灵渲染模式下有效,用于获取当前片段在点精灵中的相对位置。
上面代码作用: 将距离中心点超过 0.475 时丢弃像素,形成圆形粒子。
discard: 一个关键字,用于在片元着色器中丢弃当前正在处理的片段,使其不被写入帧缓冲区。

<script type="x-shader/x-vertex" id="vertexshader">

			attribute float scale;

			void main() {

				vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );

				gl_PointSize = scale * ( 300.0 / - mvPosition.z );

				gl_Position = projectionMatrix * mvPosition;

			}

		</script>

		<script type="x-shader/x-fragment" id="fragmentshader">

			uniform vec3 color;

			void main() {

				if ( length( gl_PointCoord - vec2( 0.5, 0.5 ) ) > 0.475 ) discard;

				gl_FragColor = vec4( color, 1.0 );

			}

		</script>

后面主要介绍的都是js逻辑部分。

2. init 初始化方法

const SEPARATION = 100, AMOUNTX = 50, AMOUNTY = 50; // 粒子网格间距和行列数

事先声明几个常量,SEPARATION 粒子间隔是100, AMOUNTX渲染50行, AMOUNTY 渲染50列。

function init() {
        // 创建容器DOM
        container = document.createElement('div');
        document.body.appendChild(container);

        // 创建透视相机
        camera = new THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 1, 10000);
        camera.position.z = 1000; // 相机初始位置

        // 创建场景
        scene = new THREE.Scene();

        // ---------------------- 创建粒子系统 ----------------------
        const numParticles = AMOUNTX * AMOUNTY; // 总粒子数
        const positions = new Float32Array(numParticles * 3); // 顶点坐标数组(x,y,z)
        const scales = new Float32Array(numParticles); // 缩放数组

        // 初始化粒子网格坐标
        let i = 0, j = 0;
        for (let ix = 0; ix < AMOUNTX; ix++) {
                for (let iy = 0; iy < AMOUNTY; iy++) {
                // x/z轴形成二维网格,y轴初始为0
                positions[i] = ix * SEPARATION - (AMOUNTX * SEPARATION)/2; // x轴居中
                positions[i+1] = 0; // y轴初始高度
                positions[i+2] = iy * SEPARATION - (AMOUNTY * SEPARATION)/2; // z轴居中
                scales[j] = 1; // 初始缩放值
                i += 3;
                j++;
                }
        }

        // 创建缓冲几何体
        const geometry = new THREE.BufferGeometry();
        geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
        geometry.setAttribute('scale', new THREE.BufferAttribute(scales, 1)); // 绑定scale属性

        // 创建着色器材质
        const material = new THREE.ShaderMaterial({
                uniforms: { color: { value: new THREE.Color(0xffffff) } }, // 统一变量
                vertexShader: document.getElementById('vertexshader').textContent, // 顶点着色器代码
                fragmentShader: document.getElementById('fragmentshader').textContent // 片元着色器代码
        });

        // 创建点精灵对象(Points)并添加到场景
        particles = new THREE.Points(geometry, material);
        scene.add(particles);

        // ---------------------- 初始化渲染器 ----------------------
        renderer = new THREE.WebGLRenderer({ antialias: true });
        renderer.setPixelRatio(window.devicePixelRatio); // 高清屏适配
        renderer.setSize(window.innerWidth, window.innerHeight); // 设置画布尺寸
        renderer.setAnimationLoop(animate); // 使用动画循环(requestAnimationFrame)
        container.appendChild(renderer.domElement);

        // 性能监控(Stats.js)
        stats = new Stats();
        container.appendChild(stats.dom);

        // 鼠标事件与窗口Resize监听
        container.addEventListener('pointermove', onPointerMove);
        window.addEventListener('resize', onWindowResize);
    }
  • positions : 存储了50个顶点坐标,每三个数据代表一个点(x, y, z);
  • scales: 50个缩放数据,对应每个坐标的缩放比例
  • BufferGeometry:几个缓冲区,通过BufferAttribute直接操作顶点数据,适合大量粒子(性能优于Geometry
  • ShaderMaterial: 自定义着色器实现复杂效果(如动态缩放、波浪动画)。
  • Points 对象: 用于渲染大量点精灵,比 Mesh 更高效。

3. 鼠标事件和resize窗口事件

// 窗口resize 事件
function onWindowResize() {

    windowHalfX = window.innerWidth / 2;
    windowHalfY = window.innerHeight / 2;

    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();

    renderer.setSize( window.innerWidth, window.innerHeight );

}

//鼠标事件

function onPointerMove( event ) {

    if ( event.isPrimary === false ) return;  // 仅处理主指针(鼠标左键)

    mouseX = event.clientX - windowHalfX; // 鼠标位置相对于窗口中心点的便宜
    mouseY = event.clientY - windowHalfY;

}

注意: mouseX 和 mouseY 变量,后面有用到。

4. render 方法(难点 !!)

// 渲染函数
function render() {
        // 相机跟随鼠标移动
        camera.position.x += (mouseX - camera.position.x) * .05;
        camera.position.y += (-mouseY - camera.position.y) * .05;
        camera.lookAt(scene.position);

        // 更新粒子位置形成波浪
        const positions = particles.geometry.attributes.position.array;
        const scales = particles.geometry.attributes.scale.array;

        for (let ix = 0; ix < AMOUNTX; ix++) {
                for (let iy = 0; iy < AMOUNTY; iy++) {
                // 使用正弦函数计算y位置,形成波浪
                positions[i + 1] = (Math.sin((ix + count) * 0.3) * 50) + 
                                                        (Math.sin((iy + count) * 0.5) * 50);

                // 粒子大小也随时间变化
                scales[j] = (Math.sin((ix + count) * 0.3) + 1) * 20 + 
                                        (Math.sin((iy + count) * 0.5) + 1) * 20;

                i += 3; j++;
                }
        }

        // 标记属性需要更新
        particles.geometry.attributes.position.needsUpdate = true;
        particles.geometry.attributes.scale.needsUpdate = true;

        renderer.render(scene, camera);
        count += 0.1; // 时间计数器
}

在阅读这段代码之前,我们必须了解一些数学知识:正弦函数。

(1)正弦函数

学习webgl时候,我们需要经常用到熟悉知识,所以我们平常应该习惯上去温故高中数学知识和大学学过的熟悉知识。
正弦函数是周期函数,公式 : y=A⋅sin(ω⋅x+ϕ)+k

  • 振幅: A 控制波峰和波谷的高度
  • 频率: ω 控制周期数量(频率越高,波越密集)
  • 相位:ϕ 控制波的水平偏移
  • 垂直偏移: k 控制波的中心位置

联系到我们demo代码:

Math.sin((ix + count) * 0.3) * 50
  • A=50(振幅为 50,决定波浪高度)
  • ω=0.3(频率为 0.3,控制波浪密度)
  • ϕ=count(相位随时间变化,实现动画)
  • k=0(垂直偏移为 0,波浪中心在 y=0 平面)

(2)二维波浪的叠加原理

positions[i + 1] = (Math.sin((ix + count) * 0.3) * 50) + 
										(Math.sin((iy + count) * 0.5) * 50);

positions 的y轴坐标,是使用了两个方向的正弦波叠加。
这种叠加产生了 二维网格上的复合波浪, 其数学原理是: y(x,z,t)=A1​⋅sin(ω1​⋅x+ϕ(t))+A2​⋅sin(ω2​⋅z+ϕ(t))
其中:
**不同频率: **

  • x方向频率ω1=0.3
  • z 方向频率 ω2=0.5
  • 频率差异导致波浪在两个方向呈现不同的疏密程度

相同相位:

  • ϕ(t)=count(时间变量)
  • 两个方向的波浪同步变化,形成协调的运动模式

振幅相同

  • A1 =A2 = 50
  • 保证两个方向的波浪对最终高度贡献均等

(3)scale 动态缩放的熟悉原理

scales[j] = 
  (Math.sin((ix + count) * 0.3) + 1) * 20 + 
  (Math.sin((iy + count) * 0.5) + 1) * 20;

观察到粒子的大小,也引用了正弦函数公式。其熟悉原理公式为:
scale(x,z,t)=[sin(ω1x+ϕ(t))+1]⋅C1+[sin(ω2z+ϕ(t))+1]⋅C2
回忆下中学数学知识,可知:

  • sin(θ) 的范围是 [−1,1]
  • sin(θ)+1 的范围变为 [0,2]
  • 两个叠加的正弦函数都乘以 20, 则总的范围是 [0,80]

也就是说scales[j] 最大值可放大到80, 最小是0。 其变化频率和position的频率是同步的。

(4)时间参数与动画原理

变量 count 是动画的核心驱动:

count += 0.1; // 每帧递增0.1

在数学上,这相当于相位随时间线性变化:
ϕ(t)=ϕ0+v⋅t

  • ϕ0 =0(初始相位)
  • v=0.1(相位变化速度)
  • t 由帧数隐式表示

这种变化使得波浪模式随时间平滑移动,实现动画效果。若以时间为第四维度,波浪函数可表示为:
y(x,z,t)=A⋅sin(ωx+v⋅t)+A⋅sin(ωz+v⋅t)

(5)更新position和scale

particles.geometry.attributes.position.needsUpdate = true;	particles.geometry.attributes.scale.needsUpdate = true;

以上代码一定不能忘,需要设置了才会在每帧都更新position 和 scale

三、完整代码

<!DOCTYPE html>
<html lang="en">
	<head>
		<title>three.js webgl - particles - waves</title>
		<meta charset="utf-8">
		<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
		<link type="text/css" rel="stylesheet" href="main.css">
	</head>
	<body>

		<div id="info">
			<a href="https://threejs.org" target="_blank" rel="noopener">three.js</a> - webgl particles waves example
		</div>

		<script type="x-shader/x-vertex" id="vertexshader">

			attribute float scale;

			void main() {

				vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );

				gl_PointSize = scale * ( 300.0 / - mvPosition.z );

				gl_Position = projectionMatrix * mvPosition;

			}

		</script>

		<script type="x-shader/x-fragment" id="fragmentshader">

			uniform vec3 color;

			void main() {

				if ( length( gl_PointCoord - vec2( 0.5, 0.5 ) ) > 0.475 ) discard;

				gl_FragColor = vec4( color, 1.0 );

			}

		</script>

		<script type="importmap">
			{
				"imports": {
					"three": "../build/three.module.js",
					"three/addons/": "./jsm/"
				}
			}
		</script>

		<script type="module">

			import * as THREE from 'three';

			import Stats from 'three/addons/libs/stats.module.js';

			const SEPARATION = 100, AMOUNTX = 50, AMOUNTY = 50;

			let container, stats;
			let camera, scene, renderer;

			let particles, count = 0;

			let mouseX = 0, mouseY = 0;

			let windowHalfX = window.innerWidth / 2;
			let windowHalfY = window.innerHeight / 2;

			init();

			function init() {
				// 创建容器DOM
				container = document.createElement('div');
				document.body.appendChild(container);

				// 创建透视相机
				camera = new THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 1, 10000);
				camera.position.z = 1000; // 相机初始位置

				// 创建场景
				scene = new THREE.Scene();

				// ---------------------- 创建粒子系统 ----------------------
				const numParticles = AMOUNTX * AMOUNTY; // 总粒子数
				const positions = new Float32Array(numParticles * 3); // 顶点坐标数组(x,y,z)
				const scales = new Float32Array(numParticles); // 缩放数组

				// 初始化粒子网格坐标
				let i = 0, j = 0;
				for (let ix = 0; ix < AMOUNTX; ix++) {
					for (let iy = 0; iy < AMOUNTY; iy++) {
					// x/z轴形成二维网格,y轴初始为0
					positions[i] = ix * SEPARATION - (AMOUNTX * SEPARATION)/2; // x轴居中
					positions[i+1] = 0; // y轴初始高度
					positions[i+2] = iy * SEPARATION - (AMOUNTY * SEPARATION)/2; // z轴居中
					scales[j] = 1; // 初始缩放值
					i += 3;
					j++;
					}
				}

				// 创建缓冲几何体
				const geometry = new THREE.BufferGeometry();
				geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
				geometry.setAttribute('scale', new THREE.BufferAttribute(scales, 1)); // 绑定scale属性

				// 创建着色器材质
				const material = new THREE.ShaderMaterial({
					uniforms: { color: { value: new THREE.Color(0xffffff) } }, // 统一变量
					vertexShader: document.getElementById('vertexshader').textContent, // 顶点着色器代码
					fragmentShader: document.getElementById('fragmentshader').textContent // 片元着色器代码
				});

				// 创建点精灵对象(Points)并添加到场景
				particles = new THREE.Points(geometry, material);
				scene.add(particles);

				// ---------------------- 初始化渲染器 ----------------------
				renderer = new THREE.WebGLRenderer({ antialias: true });
				renderer.setPixelRatio(window.devicePixelRatio); // 高清屏适配
				renderer.setSize(window.innerWidth, window.innerHeight); // 设置画布尺寸
				renderer.setAnimationLoop(animate); // 使用动画循环(requestAnimationFrame)
				container.appendChild(renderer.domElement);

				// 性能监控(Stats.js)
				stats = new Stats();
				container.appendChild(stats.dom);

				// 鼠标事件与窗口Resize监听
				container.addEventListener('pointermove', onPointerMove);
				window.addEventListener('resize', onWindowResize);
			}

			function onWindowResize() {

				windowHalfX = window.innerWidth / 2;
				windowHalfY = window.innerHeight / 2;

				camera.aspect = window.innerWidth / window.innerHeight;
				camera.updateProjectionMatrix();

				renderer.setSize( window.innerWidth, window.innerHeight );

			}

			//

			function onPointerMove( event ) {

				if ( event.isPrimary === false ) return;

				mouseX = event.clientX - windowHalfX;
				mouseY = event.clientY - windowHalfY;

			}

			//

			function animate() {

				render();
				stats.update();

			}

			// 渲染函数
			function render() {
				// 相机跟随鼠标移动
				camera.position.x += (mouseX - camera.position.x) * .05;
				camera.position.y += (-mouseY - camera.position.y) * .05;
				camera.lookAt(scene.position);
				
				// 更新粒子位置形成波浪
				const positions = particles.geometry.attributes.position.array;
				const scales = particles.geometry.attributes.scale.array;
				
				for (let ix = 0; ix < AMOUNTX; ix++) {
					for (let iy = 0; iy < AMOUNTY; iy++) {
					// 使用正弦函数计算y位置,形成波浪
					positions[i + 1] = (Math.sin((ix + count) * 0.3) * 50) + 
										(Math.sin((iy + count) * 0.5) * 50);
					
					// 粒子大小也随时间变化
					scales[j] = (Math.sin((ix + count) * 0.3) + 1) * 20 + 
								(Math.sin((iy + count) * 0.5) + 1) * 20;
					
					i += 3; j++;
					}
				}
				
				// 标记属性需要更新
				particles.geometry.attributes.position.needsUpdate = true;
				particles.geometry.attributes.scale.needsUpdate = true;
				
				renderer.render(scene, camera);
				count += 0.1; // 时间计数器
			}

		</script>
	</body>
</html>