黑白手绘线稿图变3D彩色粒子,带你用Three.js Shader一步步实现(下)

1,532 阅读16分钟

前言

上一篇文章「黑白手绘线稿图变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,当点击后通过 gsapmaterial.uniforms.uProgressvalue 值变成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」系列文章目录如下:

照例

如果你喜欢本文内容,欢迎以各种方式支持,这也是对古柳输出教程的一种正向鼓励!

最后欢迎加入「可视化交流群」,进群多多交流,对本文任何地方有疑惑的可以群里提问。加古柳微信:xiaoaizhj,备注「可视化加群」即可。

欢迎关注古柳的公众号「牛衣古柳」,并设置星标,以便第一时间收到更新。