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

2,181 阅读20分钟

前言

前两篇文章「手撸一个星系,送给心爱的姑娘!(Three.js Shader 粒子系统实现)- 牛衣古柳 - 20240417」「没有前端能抵抗住的酷炫效果,带你用 Three.js Shader 一步步实现) - 牛衣古柳 - 20240427」反响不错,很多人喜欢,还没看的朋友可以看看,相信一定会有所收获。

本文接着前两篇的话题继续讲讲粒子系统,将实现图片像素粒子相关的效果。五一之前古柳在群里说过,按照计划假期结束后会讲解 NONI NONI 这个网站的粒子效果如何实现,现在终于写完可以分享给大家。(欢迎➕我「xiaoaizhj」,备注「可视化加群」,一起交流和关注最新动态)。

根据介绍可知,该网站是由一位叫 Jongmin Kim 的韩国奶爸从爱画画的2岁女儿身上得到的灵感而为孩子们所做的。这里也有提及用到的一些技术。(因为用到谷歌 API 大家最好挂上梯子去体验)

这个网站会将大家鼠标绘制的字符或图案自动生成相应的3D粒子效果,在手绘图案、涂鸦后,会调用神经网络相关 API 给出所绘草图的一些相关预测的词条,并罗列在页面左侧。

词条背后对应黑白线稿的 svg 图片,点击对应词条就能自动生成相应的彩色粒子效果。

本文的目的就是教大家如何将类似这样的黑白“小鱼”图片,通过 Three.js Shader 做出原作那样的3D彩色粒子效果。其中粒子颜色的深浅层次变化,这个是古柳一开始就好奇的,原作是如何实现的也将在本文揭秘。

本文完整源码和效果可见 Codepen,代码后续也会同步到 GitHub。(一些参数因制作配图所需会和 codepen 实际效果有出入,大家自行调整即可)

Plane 平面 + texture 纹理图片

闲言少叙,进入正题。让我们从1:1的平面几何体开始讲起,并在 shader 里用红色显示。

import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";

let w = window.innerWidth;
let h = window.innerHeight;

const scene = new THREE.Scene();

const camera = new THREE.PerspectiveCamera(75, w / h, 0.01, 1000);
camera.position.set(0, 0, 1.5);
camera.lookAt(new THREE.Vector3());

const renderer = new THREE.WebGLRenderer({
  antialias: true,
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(w, h);
renderer.setClearColor(0xe6fcf5, 1);
document.body.appendChild(renderer.domElement);

const controls = new OrbitControls(camera, renderer.domElement);

const geometry = new THREE.PlaneGeometry(1, 1);

const vertexShader = /* GLSL */ `
  uniform float uTime;
  varying vec2 vUv;

  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

const fragmentShader = /* GLSL */ `
  varying vec2 vUv;
  
  void main() {
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
    // gl_FragColor = vec4(vUv, 0.0, 1.0);
  }
`;

const material = new THREE.ShaderMaterial({
  vertexShader,
  fragmentShader,
  uniforms: {
    uTime: { value: 0 },
  },
  // wireframe: true,
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

const clock = new THREE.Clock();
function render() {
  const time = clock.getElapsedTime();
  material.uniforms.uTime.value = time;
  renderer.render(scene, camera);
  requestAnimationFrame(render);
}

render();

使用 uv 作为颜色,就是熟悉的青红颜色。

gl_FragColor = vec4(vUv, 0.0, 1.0);

有了 uv 就能用来显示纹理图片,这里用古柳自己的头像——可爱的浅草绿 Asakusa Midori——进行演示,图片尺寸1:1和 plane 保持一致。通过 TextureLoader 加载图片,取名 uTexture 借助 uniforms 传给 shader。

import img from "./assets/avatar.png";

const material = new THREE.ShaderMaterial({
  vertexShader,
  fragmentShader,
  uniforms: {
    uTime: { value: 0 },
    uTexture: {
      value: new THREE.TextureLoader().load(img),
    },
  },
});

const mesh = new THREE.Points(geometry, material);

在片元着色器里,使用 texture2D 函数通过 uv 对 sampler2D 数据类型的 uTexture 采样颜色并设置到 gl_FragColor 上,就能显示出纹理图。这是 shader 里显示纹理图的固定步骤,之前的入门系列教程里还没讲过,但其实很简单。

uniform sampler2D uTexture;

varying vec2 vUv;

void main() {
    // gl_FragColor = vec4(vUv, 0.0, 1.0);
    vec4 color = texture2D(uTexture, vUv);
    gl_FragColor = color;
}

我们知道图片放大看就是由一个个像素组成,100x100尺寸的图片有10000个像素点,前面我们学过粒子系统,那么这里就可以用粒子来模拟像素从而组成图片,这样图片就不再是铁板一块,每个粒子/像素都能通过 shader 去灵活操作。

我们通过 BufferGeometry() 设置粒子的 position 和 uv 属性。粒子 xy 坐标在-0.5-0.5之间,z=0 这样就是正方形排列且居中显示;再手动设置 uv 值到0-1之间,就能用来采样纹理,当粒子数较多、粒子较小时看起来就像完整的图片。

// const geometry = new THREE.PlaneGeometry(1, 1);
const row = 100; // 200
const column = row;
const count = row * column;
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
const uvs = new Float32Array(count * 2);
for (let j = 0; j < row; j++) {
  for (let i = 0; i < column; i++) {
    const x = i / column - 0.5; // -0.5-0.5
    const y = j / row - 0.5; // -0.5-0.5
    const u = x + 0.5; // 0-1
    const v = y + 0.5; // 0-1
    positions.set([x, y, 0], (i + j * column) * 3);
    uvs.set([u, v], (i + j * column) * 2);
  }
}
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("uv", new THREE.BufferAttribute(uvs, 2));

顶点着色器里可以控制粒子大小 gl_PointSize。当 row 值/宽高尺寸减小、粒子数减小,且粒子变大时,就能明显看出图片由一个个粒子方块组成。

varying vec2 vUv;

void main() {
    vUv = uv;
    vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
    gl_Position = projectionMatrix * mvPosition;
    gl_PointSize = 10.0 / -mvPosition.z;
}

虽然像素粒子动画不是本篇的重点,但这里讲到了,那古柳和大家再分享一个非常酷炫的图片粒子动画效果——chromeography,点击下方小图片可以使海量粒子切换变化到对应图片,背后原理就是借助 shader 使每个像素粒子按想要的方式移动,从而实现一般前端做不出的效果。以后古柳会讲解如何一步步实现这个效果。

黑白图片

不过本次我们并不需要在图片的每个位置放置粒子,因为用到的图片是白色背景、黑色线描的图案(svg 文件大家可自行下载到本地),我们只需在黑线部分放上粒子即可,这样粒子数更少、性能更好。

import img from "./assets/fish-03.svg";

const material = new THREE.ShaderMaterial({
  vertexShader,
  fragmentShader,
  uniforms: {
    uTime: { value: 0 },
    uTexture: {
      value: new THREE.TextureLoader().load(img),
    },
  },
});

此时需要把图片绘制到另一个离屏 canvas 画布上,接着获取画布像素数据,对每个像素的颜色值依次判断,从而过滤出黑色对应的坐标位置。

图片加载

在将图片绘制到 canvas 画布之前,我们先需要确保图片加载成功,可以通过 Image 对象来实现,当 onload 图片加载后将图片放到 images 数组里并且判断所有图片都加载完再执行回调函数 whenLoaded。paths 里是多张图片(大家自行下载到本地即可),方便后续演示图片间过渡切换变化的效果。

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.onload = function () {
      images.push(img);
      if (images.length === paths.length) {
        whenLoaded(images);
      }
    };
    img.src = path;
  });
}

使用 loadImages 函数,能看到加载完的所有图片。

loadImages(paths, function (images) {
  console.log(images);
});

将图片绘制到 canvas 画布上

图片加载完后就可以依次绘制到 canvas 画布上并获取像素数据,这里只需要一个 canvas 画布和一个 ctx “画笔”,所以声明在函数外面。设置画布尺寸为100x100,可按需调整。实际中 canvas 并不需要添加到 HTML 里,这里定位到左上角进行显示只是确保绘制成功。

const size = 100;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
// document.body.appendChild(canvas);
// canvas.classList.add("test-canvas");
// canvas.style.position = "fixed";
// canvas.style.top = 0;
// canvas.style.left = 0;

在 getImageCoords 函数里先通过 ctx.drawImage 将图片绘制到整个画布上,再通过 ctx.getImageData 获取所有像素的 rgba 值,因此 data 里会有100x100x4=40000个值,每4个值对应一个像素上的 rgba 值。

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;
  console.log(data);
}

loadImages(paths, function (images) {
  getImageCoords(images[0]);
});

过滤出黑色位置的坐标

从上到下、从左到右对图片像素进行遍历,y*size+x 就是每个像素的0-9999索引位置,乘4就是数组里 rgba 的间隔。因为图片是黑白的,用 rgb 里任一个数值去判断在0-50之间即可视为黑色位置,由于该svg图片四周区域是透明的,rgb 均为0,所以必须 red>0 过滤掉四周的;red<50 是后续结合粒子显示时的稀疏情况而确定的,具体大家也可自行调整。

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];
      // const green = data[(y * size + x) * 4 + 1];
      // const blue = data[(y * size + x) * 4 + 2];
      // const alpha = data[(y * size + x) * 4 + 3];
      if (red > 0 && red < 50) {
        imageCoords.push([x / size - 0.5, 0.5 - y / size]);
      }
    }
  }
  return imageCoords;
}

loadImages(paths, function (images) {
  console.log(getImageCoords(images[0]));
});

把筛选出来的(x,y)坐标变化到-0.5-0.5范围后存到 imageCoords 数组里,毕竟后续在3D空间里作为粒子坐标时数值不宜过大。

另外注意y值在2D画布/图片里由上到下增大,而在3D里y轴由下到上增大,所以这里要变成0.5-y/size而非y/size-0.5,否则后续直接用时图案会上下颠倒。

粒子系统

我们基于第一张图片的数据来添加粒子,先跑通粒子效果后再去实现多张图片的过渡变化效果。count=10000,每次从 imageCoords 里随机挑选一组坐标放到 positions 里。顶点着色器里 gl_PointSize 粒子大小用固定值在这里效果更好,而不用离相机的距离 mvPosition.z 来使粒子近大远小。

function getRandomValue(data) {
  return data[Math.floor(Math.random() * data.length)];
}

const vertexShader = /* GLSL */ `
  void main() {
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    gl_PointSize = 12.0;
  }
`;

const fragmentShader = /* GLSL */ `
  void main() {
    vec3 color = vec3(0.0);
    gl_FragColor = vec4(color, 1.0);
  }
`;

const count = 10000;
let geometry, material, points;

loadImages(paths, function (images) {
  const imageCoords = getImageCoords(images[0]);

  geometry = new THREE.BufferGeometry();
  const positions = new Float32Array(count * 3);
  const scale = 3.0;
  for (let i = 0; i < count * 3; i += 3) {
    const [x, y] = getRandomValue(imageCoords);
    positions.set([x * scale, y * scale, 0], i);
  }
  geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));

  material = new THREE.ShaderMaterial({
    uniforms: {
      uTime: { value: 0 },
    },
    vertexShader,
    fragmentShader,
    transparent: true,
    depthTest: false,
    depthWrite: false,
  });
  points = new THREE.Points(geometry, material);
  scene.add(points);
});

function render() {
  const time = clock.getElapsedTime();
  if (material) material.uniforms.uTime.value = time;
  renderer.render(scene, camera);
  requestAnimationFrame(render);
}

不同图片通过 getImageCoords 函数过滤出来的位置数组长度不一,通过固定值 count 来随机取值,能确保后续粒子系统里粒子数量一致,这样等图片过渡变化时也更方便。

粒子变圆形

接着和原作一样粒子圆形显示。用粒子离自身中心的距离 dist 来计算 mask 数值,<0.499的值为1,>0.5的值为0,中间平滑过渡,这样乘到 alpha 上就是中间圆圈不透明,外面透明。注意前面 ShaderMaterial 已经搭配设置 transparent、depthTest、depthWrite。

void main() {
    vec3 color = vec3(0.0);
    // gl_FragColor = vec4(color, 1.0);
    
    float dist = distance(gl_PointCoord, vec2(0.5));
    float mask = smoothstep(0.5, 0.499, dist);
    // float mask = step(dist, 0.5);
    gl_FragColor = vec4(color, 1.0 * mask);
}

放大后看圆形更明显。

原作是通过这张背景透明、中间为白色圆圈的 particle.png 图片贴图实现。这里粒子变成圆圈很简单在片元着色器里直接实现也行。

粒子沿 z 轴分散

接着和原作一样使粒子在 z 轴上分散开,一来形状更为立体,二来方便下一步设置 z 轴上颜色的深浅变化。

用0-1的随机值来设置粒子在z轴的分布,因为相机在z=1的位置,所以这里把粒子放z轴负轴处,并且乘以一个较小的数-0.3,使粒子间距离收紧。

camera.position.set(0, 0, 1);

const scale = 3.0;
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);
}

粒子颜色深浅变化

目前粒子还是黑不溜秋的,看起来不够酷,让我们像原作一样使粒子颜色在 z 轴上深浅变化。

古柳一开始就很好奇这里的颜色深浅变化是如何实现的。如果不看源码,我首先能想到的是通过 HSL 颜色模式来实现深浅明暗效果。

首先,原作里粒子颜色每次是从这16种颜色里随机挑选一个作为主色,然后深浅基于一定的方式衍生出来。

HSL 与 RGB

我们不妨先按照自己的想法用 HSL 颜色试试看。任选一种颜色用 uColor 传入 shader,这里用的蓝色 rgb(3,169,244)

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,
});

在片元着色器里将 uColor 设置到 color 上,粒子就都是单一颜色。

uniform vec3 uColor;

void main() {
    float dist = distance(gl_PointCoord, vec2(0.5));
    float mask = smoothstep(0.5, 0.499, dist);
    // vec3 color = vec3(0.0);
    vec3 color = uColor;
    gl_FragColor = vec4(color, 1.0 * mask);
}

接着把粒子坐标的 z 值从顶点着色器传到片元着色器,用于后续控制 HSL 里的数值,注意z值范围是-0.3到0。

varying float vDepth;

void main() {
    vDepth = position.z;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    gl_PointSize = 12.0;
}

「手把手带你入门 Three.js Shader 系列(七)- 牛衣古柳 - 20240206」一文里也讲到过 HSL 颜色。通过降低 lightness 值就能使颜色变黑,从而衍生出更深的颜色。

谷歌搜索 glsl rgb2hsl functionglsl hsl2rgb function 拷贝现成的函数到片元着色器里。

// https://gist.github.com/yiwenl/745bfea7f04c456e0101
vec3 rgb2hsl(vec3 color) {
vec3 hsl; // init to 0 to avoid warnings ? (and reverse if + remove first part)

float fmin = min(min(color.r, color.g), color.b); //Min. value of RGB
float fmax = max(max(color.r, color.g), color.b); //Max. value of RGB
float delta = fmax - fmin; //Delta RGB value

hsl.z = (fmax + fmin) / 2.0; // Luminance

if (delta == 0.0) //This is a gray, no chroma...
{
    hsl.x = 0.0; // Hue
    hsl.y = 0.0; // Saturation
} else //Chromatic data...
{
    if (hsl.z < 0.5)
        hsl.y = delta / (fmax + fmin); // Saturation
    else
        hsl.y = delta / (2.0 - fmax - fmin); // Saturation

    float deltaR = (((fmax - color.r) / 6.0) + (delta / 2.0)) / delta;
    float deltaG = (((fmax - color.g) / 6.0) + (delta / 2.0)) / delta;
    float deltaB = (((fmax - color.b) / 6.0) + (delta / 2.0)) / delta;

    if (color.r == fmax)
        hsl.x = deltaB - deltaG; // Hue
    else if (color.g == fmax)
        hsl.x = (1.0 / 3.0) + deltaR - deltaB; // Hue
    else if (color.b == fmax)
        hsl.x = (2.0 / 3.0) + deltaG - deltaR; // Hue

    if (hsl.x < 0.0)
        hsl.x += 1.0; // Hue
    else if (hsl.x > 1.0)
        hsl.x -= 1.0; // Hue
}

return hsl;
}

// https://github.com/Experience-Monks/glsl-hsl2rgb/blob/master/index.glsl
float hue2rgb(float f1, float f2, float hue) {
    if (hue < 0.0)
        hue += 1.0;
    else if (hue > 1.0)
        hue -= 1.0;
    float res;
    if ((6.0 * hue) < 1.0)
        res = f1 + (f2 - f1) * 6.0 * hue;
    else if ((2.0 * hue) < 1.0)
        res = f2;
    else if ((3.0 * hue) < 2.0)
        res = f1 + (f2 - f1) * ((2.0 / 3.0) - hue) * 6.0;
    else
        res = f1;
    return res;
}

vec3 hsl2rgb(vec3 hsl) {
    vec3 rgb;
    
    if (hsl.y == 0.0) {
        rgb = vec3(hsl.z); // Luminance
    } else {
        float f2;
        
        if (hsl.z < 0.5)
            f2 = hsl.z * (1.0 + hsl.y);
        else
            f2 = hsl.z + hsl.y - hsl.y * hsl.z;
            
        float f1 = 2.0 * hsl.z - f2;
        
        rgb.r = hue2rgb(f1, f2, hsl.x + (1.0/3.0));
        rgb.g = hue2rgb(f1, f2, hsl.x);
        rgb.b = hue2rgb(f1, f2, hsl.x - (1.0/3.0));
    }   
    return rgb;
}

vec3 hsl2rgb(float h, float s, float l) {
    return hsl2rgb(vec3(h, s, l));
}

接着将 uColor 通过 rgb2hsl 函数变成 hsl 格式,然后加上 vDepth 来改变其第三个值的大小,因为 vDepth 本身就是负数,所以这样 lightness 就会减小。因为 hsl 变量是 vec3 格式,各数值可以通过 .xyz 或 .rgb 访问,所以第三个值用 hsl.b 表示。然后将颜色通过 hsl2rgb 函数变回 rgb 格式即可。

uniform vec3 uColor;

varying float vDepth;

void main() {
    float dist = distance(gl_PointCoord, vec2(0.5));
    float mask = smoothstep(0.5, 0.499, dist);

    // vec3 color = uColor;
    vec3 hsl = rgb2hsl(uColor);
    hsl.b += vDepth * 1.5;
    vec3 color = hsl2rgb(hsl);
    
    gl_FragColor = vec4(color, 1.0 * mask);
}

此时颜色确实深浅变化。但古柳眼拙对颜色不那么敏感,看不出和原作相比的差异与优劣。

RGB 各数值一起不断减小

不过古柳还是好奇原作里到底怎么衍生出的颜色,于是研究了下源码,找到了突破口。在下面摘录的源码片段里,f 数组里就是16种主色,虽然格式不是一般的 RGB 或16进制略显奇怪,但后面还是会转成 RGB 格式;k 是遍历颜色数组时的任意一种颜色;q 从0到30遍历,每次通过 a(k, -q*5) 函数对主色的 r/g/b 数值都减去 -q*5 从而得到衍生颜色,然后根据 z 值位置的远近作为索引使用这30种衍生颜色即可。

f = [
  16007990, 15277667, 10233776, 6765239, 4149685, 2201331, 240116, 48340,
  38536, 5025616, 9159498, 13491257, 16771899, 16761095, 16750592, 16733986,
];

this.COLORS = [];
var p, l = f.length, q;
for (p = 0; p < l; p++) {
  var k = f[p];
  this.COLORS[p] = [];
  for (q = 0; q < 30; q++) this.COLORS[p][q] = a(k, -5 * q);
}

function a(a, c) {
  a = Math.floor(a);
  a = [(a >> 16) & 255, (a >> 8) & 255, a & 255];
  var b;
  for (b = 3; b--; a[b] = 0 > f ? 0 : 255 < f ? 255 : f | 0)
    var f = a[b] + c;
  return new THREE.Color(
    "rgb(" + a[0] + ", " + a[1] + ", " + a[2] + ")"
  ).getHex();
}

于是古柳验证了下,确实30种颜色会逐渐变深。

没想到通过将某一颜色的 rgb 值都不断减5就能衍生出一组相关的、深浅不一的颜色。这种方法古柳之前从未接触过,觉得挺有趣的,因而在本文和大家分享下!

知道了原作里深浅颜色的实现原理,我们就可以在片元着色器里实现下。在 setColor 函数里,先把 depth/-0.3 变回0-1范围,再乘30变到0-30范围,然后分别对 r/g/b 值减去0-155/255的值,因为 glsl 里颜色值是0-1而非0-255,所以除以255,再通过 clamp 确保最小值为0即可。

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);
}

此时的效果和 HSL 实现版本确实有所不同,孰好孰坏留给大家评判,我们的主要目的还是尽可能学习新知识,大家都能有所收获即可。

下一篇更精彩

文章已经很长了,但想讲的东西却还不少,不妨留到下一篇继续,一方面古柳也可以休息下,以免一篇文章写太长自己也累(虽然已经很长,但为了细致讲解让大家都能轻松学到很多干货,也只能如此),一方面大家也能有时间可以先消化消化本文的内容,再进入下一阶段的学习。

下一篇文章里古柳将先带大家实现出原作里类似的粒子上下运动的效果。

接着教大家实现这种很常见的无序到有序、随机散落到特定形状的粒子动画效果,其实非常简单。

最后将多张图片用起来,实现粒子切换过渡变形的动画效果,并且在改变形状的同时改变粒子的颜色。

小结

本篇文章一开始古柳先教大家如何基于图片来设置粒子系统,这样图片不再是铁板一块而是由一个个单独的像素粒子组成,方便灵活操控;

接着由于黑白线稿图片比较简单、我们只需在黑线部分放置粒子,所以通过将图片绘制到 canvas 画布上并根据像素颜色值过滤出黑色部分的坐标位置,用这些坐标设置粒子系统,再将粒子变成圆形、且沿z轴分散开;

最后为使颜色深浅变化有层次感,尝试了两种方法,即通过 HSL 颜色进行控制和将 RGB 数值依次减小的方式来实现。

本文是继浪漫星系粒子和酷炫 Pepyaka 效果教程之后的第三篇讲解具体 shader 例子的文章,同样是非常细致地剖析、非常认真地制作配图,所以背后耗费的时间精力也是难以计量,但还是那句话一切都是为了让大家能尽可能地学会学懂、少些疑惑,大家觉得有所帮助的话勿忘多多点赞等支持。若对古柳的文章有任何疑问,欢迎➕我「xiaoaizhj」,备注「可视化」,可以一起交流)。

另外,大家有对其他 shader 酷炫网页效果的实现感兴趣的,也可将网址发评论区或发我,有机会古柳也去研究下、拆解后出教程教给大家。

最后本文完整源码可见 Codepen。

相关阅读

古柳的「Three.js Shader」系列文章目录如下:

照例

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

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

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