前言
上一篇文章「黑白手绘线稿图变3D彩色粒子,带你用Three.js Shader一步步实现(上)- 牛衣古柳 - 20240519」,古柳带大家开始实现 NONI NONI 这个网站里的彩色图形粒子效果。
讲解了如何从黑白线稿图片里获取像素颜色值,并过滤出黑色部分的坐标位置以用于设置粒子系统,接着使粒子沿z轴分散且颜色深浅变化有层次,分别尝试了 HSL 颜色控制和 RGB 数值依次减小两种实现方式。
本文将继续讲解粒子动画等内容,实现粒子在自身位置上下移动、在随机的无序状态和特定形状的有序状态之间变化、以及不同形状之间切换过渡等效果,这些也是很多实际 shader 网页里很常见、很实用的知识,相信学完会对大家很有帮助!
本文完整源码和效果可见 Codepen,代码后续也会同步到 GitHub。(一些参数因制作配图所需会和 codepen 实际效果有出入,大家自行调整即可)
书接上文
从上篇文章结尾的颜色深浅变化的粒子图形效果开始讲起,核心代码如下。
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
const camera = new THREE.PerspectiveCamera(75, w / h, 0.001, 1000);
camera.position.set(0, 0, 2);
// import img1 from "./assets/fish-03.svg";
const paths = [
"./assets/fish-03.svg",
"./assets/cookie-01.svg",
"./assets/matches-03.svg",
"./assets/triangle-01.svg",
];
function loadImages(paths, whenLoaded) {
const images = [];
paths.forEach((path) => {
const img = new Image();
img.crossOrigin = "Anonymous";
// img.setAttribute('crossOrigin', '');
img.onload = function () {
images.push(img);
if (images.length === paths.length) {
whenLoaded(images);
}
};
img.src = path;
});
}
const size = 100;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
function getImageCoords(img) {
ctx.clearRect(0, 0, size, size);
ctx.drawImage(img, 0, 0, size, size);
const data = ctx.getImageData(0, 0, size, size).data;
const imageCoords = [];
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const red = data[(y * size + x) * 4];
if (red > 0 && red < 50) {
imageCoords.push([x / size - 0.5, 0.5 - y / size]);
}
}
}
return imageCoords;
}
function getRandomValue(data) {
return data[Math.floor(Math.random() * data.length)];
}
const vertexShader = /* GLSL */ `
uniform float uTime;
varying float vDepth;
void main() {
vDepth = position.z;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
gl_PointSize = 12.0;
}
`;
const fragmentShader = /* GLSL */ `
uniform vec3 uColor;
varying float vDepth;
vec3 setColor(vec3 color, float depth) {
float value = depth / -0.3 * 30.0;
float r = clamp(color.r - value * 5.0/255.0, 0.0, 1.0);
float g = clamp(color.g - value * 5.0/255.0, 0.0, 1.0);
float b = clamp(color.b - value * 5.0/255.0, 0.0, 1.0);
return vec3(r, g, b);
}
void main() {
float dist = distance(gl_PointCoord, vec2(0.5));
float mask = smoothstep(0.5, 0.499, dist);
vec3 color = setColor(uColor, vDepth);
gl_FragColor = vec4(color, 1.0 * mask);
}
`;
const count = 13000;
let geometry, material, points;
loadImages(paths, function (images) {
const imageCoords = getImageCoords(0);
geometry = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
const scale = 3;
for (let i = 0; i < count * 3; i += 3) {
const [x, y] = getRandomValue(imageCoords);
const z = Math.random();
positions.set([x * scale, y * scale, z * -0.3], i);
}
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
material = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uColor: { value: new THREE.Color("rgb(3,169,244)") },
},
vertexShader,
fragmentShader,
transparent: true,
depthTest: false,
depthWrite: false,
});
points = new THREE.Points(geometry, material);
scene.add(points);
});
let clock = new THREE.Clock();
function render() {
let time = clock.getElapsedTime();
if (material) material.uniforms.uTime.value = time;
renderer.render(scene, camera);
controls.update();
requestAnimationFrame(render);
}
render();
粒子上下运动
首先带大家实现原作里类似的粒子上下运动的效果。给每个顶点、每个粒子设置一个0到1范围的 aOffset 随机值属性。
const positions = new Float32Array(count * 3);
const offsets = new Float32Array(count);
const scale = 3;
for (let i = 0; i < count * 3; i += 3) {
const [x, y] = getRandomValue(imageCoords);
const z = Math.random();
positions.set([x * scale, y * scale, z * -0.3], i);
offsets.set([Math.random()], i / 3);
}
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("aOffset", new THREE.BufferAttribute(offsets, 1));
在顶点着色器里,将0-1的 aOffset 变化到0-2PI范围作为 sin 函数的不同相位,然后传入 uTime 使数值周期变化,接着改变 newPos.z 数值即可实现每个粒子有先有后的上下移动效果。
attribute float aOffset;
uniform float uTime;
varying float vDepth;
const float PI = 3.141592653589793238;
void main() {
vDepth = position.z;
vec3 newPos = position;
newPos.z += 0.02 * sin(aOffset * PI * 2.0 + uTime * 5.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
gl_PointSize = 12.0;
}
无序到有序
接着教大家实现无序到有序、随机散落到特定形状的粒子动画效果。这种效果很常见,不论传统的数据可视化作品里,还是 shader 效果里都会用到,比如在 CGTN 这个滚动交互数据可视化作品 Who Runs China 里,2D 粒子组成的英文字效果会随着滚动变成随机分布,滚动回来则会复原。
看起来很酷,但实现起来却非常简单。只需提前给定有序、无序两种状态时粒子的坐标,然后用强大的 mix 进行插值就行。
有序状态的坐标是现成的 position,另外设置无序随机时的顶点属性数据 position1 即可。这里基于随机的半径和角度来生成 xy 坐标,半径范围1-2,保持中心空白可能变化时效果更好,用这样随机的圆环形状表示无序状态。下图是靠近粒子系统后的效果。
const positions = new Float32Array(count * 3);
const positions1 = new Float32Array(count * 3);
const offsets = new Float32Array(count);
const scale = 3;
for (let i = 0; i < count * 3; i += 3) {
const [x, y] = getRandomValue(imageCoords);
const z = Math.random();
positions.set([x * scale, y * scale, z * -0.3], i);
const r = Math.random() + 1;
const angle = Math.random() * Math.PI * 2;
const x1 = Math.cos(angle) * r;
const y1 = Math.sin(angle) * r;
const z1 = (Math.random() - 0.5) * 2;
positions1.set([x1, y1, z1], i);
offsets.set([Math.random()], i / 3);
}
geometry.setAttribute(
"position1",
new THREE.BufferAttribute(positions1, 3)
);
在顶点着色器里用 position1 作为 newPos,就能看到无序状态下的粒子分布效果。
attribute float aOffset;
attribute vec3 position1;
uniform float uTime;
varying float vDepth;
const float PI = 3.141592653589793238;
void main() {
vDepth = position.z;
// vec3 newPos = position;
vec3 newPos = position1;
newPos.z += 0.02 * sin(aOffset * PI * 2.0 + uTime * 5.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
gl_PointSize = 12.0;
}
接着通过 mix 里 abs(sin(uTime)) 数值从0到1变化就能使粒子在两个坐标、两种状态之间过渡。就是这么简单!实际项目中还可以通过 GUI、滚动、点击等交互去控制过渡动画进度,大家可自行尝试。
// vec3 newPos = position1;
vec3 newPos = mix(position1, position, abs(sin(uTime/2.)));
颜色变化
我们还可以让两种状态时的粒子颜色不同,形状变化时颜色也同步变化。通过 uniforms 里的 uColor1 再传入一种主色 rgb(139,195,74) 作为无序状态时的粒子颜色。
material = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uColor: { value: new THREE.Color("rgb(3,169,244)") },
uColor1: { value: new THREE.Color("rgb(139,195,74)") },
},
vertexShader,
fragmentShader,
transparent: true,
depthTest: false,
depthWrite: false,
});
将控制动画进度的数值通过 vProgress 传到片元着色器里,以便同步控制颜色变化。
varying float vProgress;
void main() {
vDepth = position.z;
float progress = abs(sin(uTime/2.0));
vProgress = progress;
vec3 newPos = mix(position1, position, progress);
newPos.z += 0.02 * sin(aOffset * PI * 2.0 + uTime * 5.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
gl_PointSize = 12.0;
}
片元着色器里直接用 vProgress 对 uColor1 和 uColor 进行 mix 插值,然后通过 setColor 设置深浅不同即可,同样很简单。注意后来 z 值、vDepth 值一直没特别去管它,怎么简单怎么来,以讲解核心知识为主,大家也不用纠结 vDepth 没有随有序无序变化时而变化等问题,不过最后会进行改正。
uniform vec3 uColor;
uniform vec3 uColor1;
varying float vDepth;
varying float vProgress;
// vec3 setColor(vec3 color, float depth) { ... }
void main() {
float dist = distance(gl_PointCoord, vec2(0.5));
float mask = smoothstep(0.5, 0.499, dist);
// vec3 color = setColor(uColor, vDepth);
vec3 color = mix(uColor1, uColor, vProgress);
color = setColor(color, vDepth);
gl_FragColor = vec4(color, 1.0 * mask);
}
两个图形间变化
我们还可以把 position1 用另一个图形的坐标来替换,从而实现出不同图形间的变化效果。使用第二张图片 images[1] 通过 getImageCoords() 函数过滤出黑线坐标,然后每次随机取其中一个坐标的 xy 值,用于设置顶点粒子的坐标。
loadImages(paths, function (images) {
const imageCoords = getImageCoords(images[0]);
const imageCoords1 = getImageCoords(images[1]);
const positions1 = new Float32Array(count * 3);
// ...
for (let i = 0; i < count * 3; i += 3) {
const [x, y] = getRandomValue(imageCoords);
const z = Math.random();
positions.set([x * scale, y * scale, z * -0.3], i);
const [x1, y1] = getRandomValue(imageCoords1);
const z1 = z * -0.3;
positions1.set([x1 * scale, y1 * scale, z1], i);
offsets.set([Math.random()], i / 3);
}
geometry.setAttribute(
"position1",
new THREE.BufferAttribute(positions1, 3)
);
// ...
}
调整下 mix 时的始末顺序,第一个形状在前、第二个形状在后。其它代码都不用改就能实现不同形状变换的效果。
vec3 newPos = mix(position, position1, progress);
vec3 color = mix(uColor, uColor1, vProgress);
color = setColor(color, vDepth);
点击后再变化
上面的过渡动画都是通过 sin(uTime) 自动进行的。现在让我们添加点击事件,鼠标点击后再使粒子发生变形。在 uniforms 里加个 uProgress 变量,初始值为0,当点击后通过 gsap 将 material.uniforms.uProgress 的 value 值变成1。
// npm i gsap
import gsap from "gsap";
material = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uProgress: { value: 0 },
uColor: { value: new THREE.Color("rgb(3,169,244)") },
uColor1: { value: new THREE.Color("rgb(139,195,74)") },
},
vertexShader,
fragmentShader,
transparent: true,
depthTest: false,
depthWrite: false,
});
let animating = false;
window.addEventListener("click", () => {
if (!animating) {
animating = true;
gsap.to(material.uniforms.uProgress, {
value: 1,
onComplete: () => {
animating = false;
},
});
}
});
shader 里使用 uProgress 控制粒子形状和颜色的变化。
// vertex shader
uniform float uProgress;
void main() {
// vProgress = progress;
// vec3 newPos = mix(position, position1, progress);
vec3 newPos = mix(position, position1, uProgress);
}
// fragment shader
uniform float uProgress;
void main() {
// vec3 color = mix(uColor, uColor1, vProgress);
vec3 color = mix(uColor, uColor1, uProgress);
}
多种形状间点击切换
接下来让我们实现点击后依次切换到下一个图形的效果,上一篇文章里的4张图片现在终于可以用上了。
因为每一次过渡变化都需要知道起始和结束的 position,我们可以提前将4张图片对应的像素位置 position 存到数组里,通过索引 current 在点击时变化来拿到当前图形和下一个图形的 position 然后更新到 geometry 属性。
function getImageCoords(img) {
// ...
return imageCoords;
}
const count = 13000;
function setGeometryAttributes(imageCoords) {
const positions = new Float32Array(count * 3);
for (let i = 0; i < count * 3; i += 3) {
const [x, y] = getRandomValue(imageCoords);
const scale = 3;
const z = Math.random();
positions.set([x * scale, y * scale, z * -0.3], i);
}
return positions;
}
遍历每张图片通过 setGeometryAttributes() 函数设置每张图片的 position 数据,并存储到 geometryAttributes 数组里。遍历结束设置一次 geometry 的 position、position 为 current=0 时的属性数据即可,aOffset 也仅设置一次。uColor、uColor1 用16种颜色里的随机一种。
const COLORS = [
"rgb(244,67,54)",
"rgb(233,30,99)",
"rgb(156,39,176)",
"rgb(103,58,183)",
"rgb(63,81,181)",
"rgb(33,150,243)",
"rgb(3,169,244)",
"rgb(0,188,212)",
"rgb(0,150,136)",
"rgb(76,175,80)",
"rgb(139,195,74)",
"rgb(205,220,57)",
"rgb(255,235,59)",
"rgb(255,193,7)",
"rgb(255,152,0)",
"rgb(255,87,34)",
];
let current = 0;
let geometry, material, points;
let geometryAttributes = [];
loadImages(paths, function (images) {
images.forEach((img) => {
const imageCoords = getImageCoords(img);
geometryAttributes.push(setGeometryAttributes(imageCoords));
});
geometry = new THREE.BufferGeometry();
const offsets = new Float32Array(count);
for (let i = 0; i < count; i++) {
offsets.set([Math.random()], i);
}
geometry.setAttribute(
"position",
new THREE.BufferAttribute(geometryAttributes[current], 3)
);
geometry.setAttribute(
"position1",
new THREE.BufferAttribute(geometryAttributes[current], 3)
);
geometry.setAttribute("aOffset", new THREE.BufferAttribute(offsets, 1));
const color = new THREE.Color(getRandomValue(COLORS));
material = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uProgress: { value: 0 },
uColor: { value: color },
uColor1: { value: color },
},
vertexShader,
fragmentShader,
transparent: true,
depthTest: false,
depthWrite: false,
});
points = new THREE.Points(geometry, material);
scene.add(points);
});
点击后改变 current 数值,依次从 0、1、2、3、0、1、2、3...变化,并且用改变后的 current 对应的顶点数据去马上改变 geometry 的 position1 属性,注意设置 needsUpdate 为 true;同时改变 uColor1 为随机一种颜色;然后通过 gsap 改变 uProgress 数值,使得过渡动画开始发生,因此此时起始状态和结束状态不一样了,所以变形效果会生效;
最后在 onComplete 里等动画时把起始状态 position、uColor 也变成当前的图形的数据,uProgress 变回0,这样下一次点击时,就是上一次结束的状态作为下一次动画的起始状态。这部分相对小复杂点,大家第一次接触可能需要留心下。这类设置很实用,还是必须学会的,其实也不难!
window.addEventListener("click", () => {
if (!animating) {
animating = true;
current++;
current = current % paths.length;
geometry.attributes.position1.array = geometryAttributes[current];
geometry.attributes.position1.needsUpdate = true;
const color = new THREE.Color(getRandomValue(COLORS));
material.uniforms.uColor1.value = color;
gsap.to(material.uniforms.uProgress, {
value: 1,
onComplete: () => {
animating = false;
geometry.attributes.position.array = geometryAttributes[current];
geometry.attributes.position.needsUpdate = true;
material.uniforms.uColor.value = color;
material.uniforms.uProgress.value = 0;
},
});
}
});
此时去点击触发动画后,会发现动画结束时颜色会突变下,还是前面没去管的 vDepth 导致的。之前是因为无序状态时 z 值范围为-1-1、有序时为-0.3-0,而 shader 里插值深浅颜色时用的后者,所以懒得去调整,这里统一都用-0.3-0,就可以重新改下顶点着色器使 vDepth 也随顶点z值变化而变化,同样用 uProgress 插值 position.z 和 position1.z 即可,这样颜色就不会最后突变。
// vDepth = position.z;
vec3 newPos = mix(position, position1, uProgress);
vDepth = mix(position.z, position1.z, uProgress);
newPos.z += 0.03 * sin(aOffset * PI * 2.0 + uTime * 5.0);
这样鼠标点击后粒子在不同图形间变化的效果就实现出来了。大家也可以继续推进,像原作一样再去改变背景色,并且结合 canvas 手绘从而把2D手绘到3D彩色粒子整个链路打通,相应会更有收获。
小结
本篇教程的内容需要大家多结合代码去理解,讲解的可能没特别细致,但也不难(有没搞懂的地方,可以直接问我「xiaoaizhj」,也欢迎群里交流和围观朋友圈最新动态);
文中涉及的有序无序变化、多种状态间过渡等都是 shader 里很常用、很实用的知识点,希望大家都能学会,后续也一定会再应用到。
下一篇文章古柳打算回归下手把手入门系列,离「手把手带你入门 Three.js Shader 系列(八)- 牛衣古柳 - 20240229」一文又过去3个多月,虽然最近这3-4篇实际完整 shader 效果教程的反响还不错,但第九篇及后续系列还得继续输出才行,不知道有多少人看完前八篇在等待下一篇呢?欢迎告诉古柳,让我知道到底多少人真的完整跟着本系列在学习!
另外,大家有对其他 shader 酷炫网页效果的实现感兴趣的,也可将网址发评论区或发我,有机会古柳也去研究下、拆解后出教程教给大家。
最后本文完整源码可见 Codepen。
相关阅读
古柳的「Three.js Shader」系列文章目录如下:
- 「没有前端能抵抗住的酷炫效果,带你用 Three.js Shader 一步步实现) - 牛衣古柳 - 20240427」
- 「手撸一个星系,送给心爱的姑娘!(Three.js Shader 粒子系统实现)- 牛衣古柳 - 20240417」
- 「断更19个月,携 Three.js Shader 归来!(上)- 牛衣古柳 - 20230416」
- 「断更19个月,携 Three.js Shader 归来!(下)- 牛衣古柳 - 20230421」
- 「手把手带你入门 Three.js Shader 系列(八)- 牛衣古柳 - 20240229」
- 「手把手带你入门 Three.js Shader 系列(七)- 牛衣古柳 - 20240206」
- 「手把手带你入门 Three.js Shader 系列(六)- 牛衣古柳 - 20231220」
- 「手把手带你入门 Three.js Shader 系列(五)- 牛衣古柳 - 20231126」
- 「手把手带你入门 Three.js Shader 系列(四)- 牛衣古柳 - 20231121」
- 「手把手带你入门 Three.js Shader 系列(三)- 牛衣古柳 - 20230725」
- 「手把手带你入门 Three.js Shader 系列(二)- 牛衣古柳 - 20230716」
- 「手把手带你入门 Three.js Shader 系列(一)- 牛衣古柳 - 20230515」
照例
如果你喜欢本文内容,欢迎以各种方式支持,这也是对古柳输出教程的一种正向鼓励!
最后欢迎加入「可视化交流群」,进群多多交流,对本文任何地方有疑惑的可以群里提问。加古柳微信:xiaoaizhj,备注「可视化加群」即可。
欢迎关注古柳的公众号「牛衣古柳」,并设置星标,以便第一时间收到更新。