本教程将演示如何使用 Three.js 绘制大量粒子,以及使用着色器和屏幕外纹理使粒子对鼠标和触摸输入做出反应的有效方法。
推荐:用 NSDT场景设计器 快速搭建3D场景。
粒子是根据图像的像素创建的。 我们的图像尺寸为 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));
该材质是带有自定义着色器 particle.vert 和 particle.frag 的 RawShaderMaterial。
uniforms说明如下:
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;
// (...)
}
在我们的演示中,我们根据粒子的亮度设置粒子的大小,这意味着暗粒子几乎不可见。 这为一些优化留出了空间。 当遍历图像的像素时,我们可以丢弃那些太暗的像素。 这减少了粒子的数量并提高了性能。
优化在我们创建 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++;
}
有许多不同的方法可以引入与粒子的相互作用。 例如,我们可以给每个粒子一个速度属性,并根据它与光标的接近程度在每一帧更新它。 这是一个经典的技术,效果很好,但如果我们必须循环数万个粒子,它可能有点太重了。
一种更有效的方法是在着色器中进行。 我们可以将光标的位置作为一个 uniform 传递,并根据它们与它的距离来移动粒子。 虽然这会执行得更快,但结果可能非常干燥。 粒子会到达给定的位置,但不会缓入或缓出。
我们在演示中选择的技术是将光标位置绘制到纹理上。 优点是我们可以保留光标位置的历史记录并创建轨迹。 我们还可以对该轨迹的半径应用缓动函数,使其平滑地增长和收缩。 一切都将在着色器中发生,所有粒子并行运行。
为了获得光标的位置,我们使用了一个 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;
// (...)
}
原文链接:Three.js可交互粒子 — BimAnt