NSDT工具推荐Three.js AI纹理开发包 - YOLO合成数据生成器 - GLTF/GLB在线编辑 - 3D模型格式在线转换 - 可编程3D场景编辑器 - REVIT导出3D模型插件 - 3D模型语义搜索引擎 - AI模型在线查看 - Three.js虚拟轴心开发包 - 3D模型在线减面 - STL模型在线切割 - 3D道路快速建模

本教程将演示如何使用 Three.js 绘制大量粒子,以及使用着色器和屏幕外纹理使粒子对鼠标和触摸输入做出反应的有效方法。

1、实例化几何体

粒子是根据图像的像素创建的。 我们的图像尺寸为 320×180,即 57,600 像素。

但是,我们不需要为每个粒子创建一个几何体。 我们可以只创建一个并使用不同的参数渲染它 57,600 次。 这称为几何实例化。 在 Three.js 中,我们使用 InstancedBufferGeometry 来定义几何形状,BufferAttribute 用于每个实例保持相同的属性,InstancedBufferAttribute 用于在实例之间变化的属性(即颜色,大小)。

我们粒子的几何形状是一个简单的四边形,由 4 个顶点和 2 个三角形组成。

const geometry = new THREE.InstancedBufferGeometry();

// positions
const positions = new THREE.BufferAttribute(new Float32Array(4 * 3), 3);
positions.setXYZ(0, -0.5, 0.5, 0.0);
positions.setXYZ(1, 0.5, 0.5, 0.0);
positions.setXYZ(2, -0.5, -0.5, 0.0);
positions.setXYZ(3, 0.5, -0.5, 0.0);
geometry.addAttribute('position', positions);

// uvs
const uvs = new THREE.BufferAttribute(new Float32Array(4 * 2), 2);
uvs.setXYZ(0, 0.0, 0.0);
uvs.setXYZ(1, 1.0, 0.0);
uvs.setXYZ(2, 0.0, 1.0);
uvs.setXYZ(3, 1.0, 1.0);
geometry.addAttribute('uv', uvs);

// index
geometry.setIndex(new THREE.BufferAttribute(new Uint16Array([ 0, 2, 1, 2, 3, 1 ]), 1));

接下来,我们遍历图像的像素并分配我们的实例化属性。 由于单词 position已经被占用,我们使用单词 offset来存储每个实例的位置。 偏移量将是图像中每个像素的 x,y。 我们还想存储粒子索引和一个随机角度,稍后将用于动画。

const indices = new Uint16Array(this.numPoints);
const offsets = new Float32Array(this.numPoints * 3);
const angles = new Float32Array(this.numPoints);

for (let i = 0; i < this.numPoints; i++) {
	offsets[i * 3 + 0] = i % this.width;
	offsets[i * 3 + 1] = Math.floor(i / this.width);

	indices[i] = i;

	angles[i] = Math.random() * Math.PI;
}

geometry.addAttribute('pindex', new THREE.InstancedBufferAttribute(indices, 1, false));
geometry.addAttribute('offset', new THREE.InstancedBufferAttribute(offsets, 3, false));
geometry.addAttribute('angle', new THREE.InstancedBufferAttribute(angles, 1, false));

2、粒子材质

该材质是带有自定义着色器 particle.vert 和 particle.frag 的 RawShaderMaterial。

uniforms说明如下:

  • uTime:经过的时间,每帧更新
  • uRandom:用于在 x,y 中置换粒子的随机因子
  • uDepth:粒子在 z 方向的最大振荡
  • uSize:粒子的基本尺寸
  • uTexture:图像纹理
  • uTextureSize:纹理的尺寸
  • uTouch:触摸纹理
const uniforms = {
	uTime: { value: 0 },
	uRandom: { value: 1.0 },
	uDepth: { value: 2.0 },
	uSize: { value: 0.0 },
	uTextureSize: { value: new THREE.Vector2(this.width, this.height) },
	uTexture: { value: this.texture },
	uTouch: { value: null }
};

const material = new THREE.RawShaderMaterial({
	uniforms,
	vertexShader: glslify(require('../../../shaders/particle.vert')),
	fragmentShader: glslify(require('../../../shaders/particle.frag')),
	depthTest: false,
	transparent: true
});

一个简单的顶点着色器会直接根据粒子的偏移属性输出粒子的位置。 为了让事情更有趣,我们使用随机和噪声来置换粒子。 粒子的大小也是如此。

// particle.vert

void main() {
	// displacement
	vec3 displaced = offset;
	// randomise
	displaced.xy += vec2(random(pindex) - 0.5, random(offset.x + pindex) - 0.5) * uRandom;
	float rndz = (random(pindex) + snoise_1_2(vec2(pindex * 0.1, uTime * 0.1)));
	displaced.z += rndz * (random(pindex) * 2.0 * uDepth);

	// particle size
	float psize = (snoise_1_2(vec2(uTime, pindex) * 0.5) + 2.0);
	psize *= max(grey, 0.2);
	psize *= uSize;

	// (...)
}

片段着色器从原始图像中采样 RGB 颜色,并使用亮度方法 (0.21 R + 0.72 G + 0.07 B) 将其转换为灰度。

Alpha 通道由到 UV 中心的线性距离确定,这实际上创建了一个圆。 可以使用 smoothstep 模糊圆的边界。

// particle.frag

void main() {
	// pixel color
	vec4 colA = texture2D(uTexture, puv);

	// greyscale
	float grey = colA.r * 0.21 + colA.g * 0.71 + colA.b * 0.07;
	vec4 colB = vec4(grey, grey, grey, 1.0);

	// circle
	float border = 0.3;
	float radius = 0.5;
	float dist = radius - distance(uv, vec2(0.5));
	float t = smoothstep(0.0, border, dist);

	// final color
	color = colB;
	color.a = t;

	// (...)
}

3、优化

在我们的演示中,我们根据粒子的亮度设置粒子的大小,这意味着暗粒子几乎不可见。 这为一些优化留出了空间。 当遍历图像的像素时,我们可以丢弃那些太暗的像素。 这减少了粒子的数量并提高了性能。

优化在我们创建 InstancedBufferGeometry 之前开始。 我们创建一个临时画布,在上面绘制图像并调用 getImageData() 来检索颜色数组 [R, G, B, A, R, G, B … ]。 然后我们定义一个阈值——十六进制 #22 或十进制 34——并针对红色通道进行测试。 红色通道是任意选择,我们也可以使用绿色或蓝色,甚至是所有三个通道的平均值,但红色通道使用起来很简单。

// discard pixels darker than threshold #22
if (discard) {
	numVisible = 0;
	threshold = 34;

	const img = this.texture.image;
	const canvas = document.createElement('canvas');
	const ctx = canvas.getContext('2d');

	canvas.width = this.width;
	canvas.height = this.height;
	ctx.scale(1, -1); // flip y
	ctx.drawImage(img, 0, 0, this.width, this.height * -1);

	const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
	originalColors = Float32Array.from(imgData.data);

	for (let i = 0; i < this.numPoints; i++) {
		if (originalColors[i * 4 + 0] > threshold) numVisible++;
	}
}

我们还需要更新定义偏移量、角度和 pindex 的循环,以将阈值考虑在内。

for (let i = 0, j = 0; i < this.numPoints; i++) {
	if (originalColors[i * 4 + 0] <= threshold) continue;

	offsets[j * 3 + 0] = i % this.width;
	offsets[j * 3 + 1] = Math.floor(i / this.width);

	indices[j] = i;

	angles[j] = Math.random() * Math.PI;

	j++;
}

4、互动性的注意事项

有许多不同的方法可以引入与粒子的相互作用。 例如,我们可以给每个粒子一个速度属性,并根据它与光标的接近程度在每一帧更新它。 这是一个经典的技术,效果很好,但如果我们必须循环数万个粒子,它可能有点太重了。

一种更有效的方法是在着色器中进行。 我们可以将光标的位置作为一个 uniform 传递,并根据它们与它的距离来移动粒子。 虽然这会执行得更快,但结果可能非常干燥。 粒子会到达给定的位置,但不会缓入或缓出。

5、选择的交互方法

我们在演示中选择的技术是将光标位置绘制到纹理上。 优点是我们可以保留光标位置的历史记录并创建轨迹。 我们还可以对该轨迹的半径应用缓动函数,使其平滑地增长和收缩。 一切都将在着色器中发生,所有粒子并行运行。

为了获得光标的位置,我们使用了一个 Raycaster 和一个简单的 PlaneBufferGeometry,其大小与我们的主要几何体相同。 飞机是看不见的,但却是互动的。

Three.js 中的交互性本身就是一个主题。 请参阅此示例以供参考。

当光标与平面有交点时,我们可以使用交点数据中的 UV 坐标来检索光标的位置。 然后将位置存储在数组(轨迹)中并绘制到屏幕外的画布上。 画布作为纹理通过统一的 uTouch 传递给着色器。

在顶点着色器中,粒子根据触摸纹理中像素的亮度进行位移。

// particle.vert

void main() {
	// (...)

	// touch
	float t = texture2D(uTouch, puv).r;
	displaced.z += t * 20.0 * rndz;
	displaced.x += cos(angle) * t * 20.0 * rndz;
	displaced.y += sin(angle) * t * 20.0 * rndz;

	// (...)
}

原文链接:Interactive Particles with Three.js

BimAnt翻译整理,转载请标明出处