手撸一个星系,送给心爱的姑娘!(Three.js Shader 粒子系统实现)

17,574 阅读21分钟

前言

之前在小红书上刷到上海「深空未来」展的图片,看到这个宇宙星球的粒子效果觉得挺酷的,也很多人喜欢。

于是古柳想起曾经见过的这个 Three.js Shader 实现的粒子系统星系效果,它的形状、颜色、动画令人难忘,可惜当初水平有限,有些地方没有理解,这次重新勾起兴趣看了下源码,发现又搞懂不少地方,可以讲解下,因此想带大家一起手撸一个星系。

当然像本文这样实现一个具体完整的 shader 效果的文章,和前面八篇「手把手带你入门 Three.js Shader 系列」教程按部就班讲解一个个知识点还是不太一样,并且本文涉及的粒子系统、BufferGeometry、顶点上设置属性等也都是系列教程里还未涉及的(当然也不难),理想情况下在系列教程讲完那些内容后,再紧跟着来这么一篇完整效果的文章最好。

但有时看到酷炫 shader 的效果、起了兴致就想和大家分享,就也顾不上许多(何况老是犯懒,等系列教程更新完基础内容还不知道要到什么时候)。再者之前说过后续会在本公众号出个24篇付费进阶系列类似这样讲完整效果的文章(欢迎➕我「xiaoaizhj」方便获取最新消息,也可进交流群),这样想写什么有趣的内容提笔就能写,因此不妨以这篇作为开篇让大家看看这类文章是啥样子的。

言归正传,复现完这个星系效果后照旧套了下之前的 AR 模板,欢迎大家用手机 Google Chrome 浏览器访问看看(必须!电脑或手机其他浏览器均不行)。不过由于很多手机不支持 ARCore 可能不少人看不了,大家可以通过第二个链接看看自己的手机型号是否在支持列表里。

另外,本文代码已放到 Codepen 并将同步 GitHub,欢迎大家学习:

最简单的粒子系统

我们从显示一个白色、线框模式下的球体开始讲起。可以看到球体表面相交的位置就是一个个顶点。

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, 24);
camera.lookAt(new THREE.Vector3());

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

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

const geometry = new THREE.SphereGeometry(10);
const material = new THREE.MeshBasicMaterial({
  color: 0xffffff,
  wireframe: true,
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

const clock = new THREE.Clock();
function render() {
  const time = clock.getElapsedTime() * 0.5;
  mesh.rotation.y = time;
  renderer.render(scene, camera);
  requestAnimationFrame(render);
}

render();

想在 Three.js 里实现粒子系统,最简单的就是用现成的几何体如 SphereGeometry 搭配 PointsMaterial 材质,再丢给 Points 来替代 Mesh,即可在几何体顶点处放置粒子,默认粒子为方形。其中在 PointsMaterial 里可以统一设置粒子的颜色和大小。

const geometry = new THREE.SphereGeometry(10);
const material = new THREE.PointsMaterial({
  size: 0.4,
  color: 0xffffff,
});

const points = new THREE.Points(geometry, material);
scene.add(points);

function render() {
  // ...
  // mesh.rotation.y = time;
  points.rotation.y = time;
}

不过使用 SphereGeometry 有个很大的问题,粒子在球体两极密集、中间分散,空间上分布不均匀。

一种解决办法是用 IcosahedronGeometry 正二十面体,传入半径和细分数两个参数,细分数越大顶点越多,此时粒子分布很均匀。

const geometry = new THREE.IcosahedronGeometry(10, 6);

材质换成 ShaderMaterial

为了更灵活的控制粒子效果,可以把材质换成 ShaderMaterial,和此前系列文章里的 shader 不同之处在于这里可通过 gl_PointSize 另外设置粒子大小,如果用一个固定数值的话粒子都一样大。

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

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

const fragmentShader = /* GLSL */ `
  varying vec2 vUv;

  void main() {
    gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
    // gl_FragColor = vec4(vUv, 0.0, 1.0);
  }
`;

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

function render() {
  // ...
  material.uniforms.uTime.value = time;
}

想要使靠近相机的粒子大、远离相机的粒子小,就需要对 mvPosition.z 值取倒数。经过 modelViewMatrix 后相机在原点处,3D物体顶点都在 z 轴负方向上,所以这里要加个负号,近大远小取倒数,再通过前面的数值调整大小即可。

gl_PointSize = 100.0 / -mvPosition.z;

方形粒子变成圆形

我们还可以在 shader 里将粒子变成圆形。在「手把手带你入门 Three.js Shader 系列(三) - 牛衣古柳 - 20230725」一文里,我们借助 uv 就能在一个 plane 上绘制圆形。

粒子系统看起来像由许多小 plane 组成,如果每个粒子有自己单独的 uv 坐标事情就好办了。

先直接用 uv 作为颜色看看,此时 uv 还是几何体上面的坐标而不是每个粒子单独的。

gl_FragColor = vec4(vUv, 0.0, 1.0);

幸运的是粒子系统里 gl_PointCoord 就是每个粒子上的(0,0)到(1,1)坐标,直接拿来替代 uv 就行,此时每个粒子上都是熟悉的青绿色。

gl_FragColor = vec4(gl_PointCoord, 0.0, 1.0);

对 gl_PointCoord 减去0.5将坐标范围变化到(-0.5,-0.5)到(0.5,0.5)进行居中,接着通过 length 计算离粒子中心的距离,再通过 step 使得距离小于0.5半径的值为1.0,大于0.5的为0.0,然后作为颜色即可绘制出圆形,但此时粒子半径之外是黑色的而不是透明的,可以通过 discard 丢弃、不绘制对应片元/像素。

void main() {
  float mask = step(length(gl_PointCoord - 0.5), 0.5);
  if(mask < 0.5) discard;
  gl_FragColor = vec4(vec3(mask), 1.0);
}

自定义几何体顶点坐标

除了用 Three.js 现成的几何体外,我们还能通过 BufferGeometry 来自定义几何体的 position 顶点坐标,这样想在哪放粒子就能在哪放。

下面演示用圆圈范围内随机出的顶点坐标组成几何体、再组成粒子系统的流程。

在半径0-10、角度0-2xPI范围内随机出一个个顶点的 xy 坐标,将 z 统一设成0,依次放到数组里,再用 geometry.setAttribute 设置到顶点属性上,命名为 position,且通过 Float32BufferAttribute 表示该数组数据是三个为一组,组成 vec3,这样在顶点着色器里用 attribute vec3 position 就能声明和使用,只不过 ShaderMaterial 里 position 默认已经声明,所以直接用就行。

这是设置顶点属性的惯用方式,后续还会用到。

const geometry = new THREE.BufferGeometry();

const positions = [];
for (let i = 0; i < 5000; i++) {
  const radius = 10 * Math.random();
  const angle = Math.PI * 2 * Math.random();
  const x = Math.sin(angle) * radius;
  const y = Math.cos(angle) * radius;
  positions.push(x, y, 0);
}

geometry.setAttribute(
  "position",
  new THREE.Float32BufferAttribute(positions, 3)
);

const material = new THREE.ShaderMaterial({ ... });
const points = new THREE.Points(geometry, material);
scene.add(points);

// 适当调小粒子大小
// gl_PointSize = 30.0 / -mvPosition.z;

开始复现原作

以上,古柳带大家简单入门粒子系统,对于本身就会的朋友来说很简单,但肯定有人此前没接触过这块内容,而且目前更新的八篇「手把手带你入门 Three.js Shader 系列」教程里也还没讲到粒子系统、BufferGeometry、设置顶点属性等内容,因此有必要简单讲下,对齐一下颗粒度。

有了上面的基础,接下来就可以进入正题,开始复现原作、手撸一个星系了。

观察原作会发现星系由中心的球体和外面的圆盘/圆柱两部分组成。

中心球体

首先生成中心球体的顶点坐标。在 for 循环里分别生成5万个粒子的球体坐标、10万个粒子的圆盘坐标,统一放到 positions 数组里,再设置到一个 BufferGeometry 上,这里没有分成两个设置。

原作里用 THREE.Vector3().randomDirection() 生成球体上的单位向量长度的顶点,然后设置向量长度到9.5-10作为球体半径。

const count1 = 50000;
const count2 = 100000;
const geometry = new THREE.BufferGeometry();
const positions = [];
for (let i = 0; i < count1 + count2; i++) {
  // 球体部分
  if (i < count1) {
    let { x, y, z } = new THREE.Vector3()
      .randomDirection()
      .multiplyScalar(Math.random() * 0.5 + 9.5);
    positions.push(x, y, z);
  } else {
    // 圆盘/圆柱部分
  }
}

geometry.setAttribute(
  "position",
  new THREE.Float32BufferAttribute(positions, 3)
);

// gl_PointSize = 30.0 / -mvPosition.z;

但后续在 shader 里会让粒子沿球体表面运动,原作的实现方式我觉得有些地方蛮困惑,因此自己用更好理解的方式去“改进”下。

具体来说就是,球体顶点是由半径 r,方位角 theta 和极角 phi 的球坐标计算得到 xyz,并且后续会将 theta、phi 也设置到顶点属性上、传入 shader 里,这样每个顶点沿球体表面运动时,只需在 shader 里分别给 theta、phi 加上一定角度,再对新的 theta、phi 用球坐标算出新的 xyz,就是偏移后的顶点 position......

和原作的关键区别就是这里的 theta 和 phi 串起了 position 顶点坐标和 shader 里运动,这样理解起来也更容易(后续写到粒子运动时才逐渐搞懂原作运动实现的逻辑,其实这里根本不需要 theta、phi 和初始 position.xyz 对应、新 position 也不是这么计算的,自己的方式还是有些问题但先保留,等粒子运动时再进行更正)。如果你不知道我在说些什么,不急,跟着文章看下去并结合代码理解即可。

我们用0-2xPI 的方位角 theta、0-PI 的极角 phi、9.5-10的半径 r 计算出球体上的任意顶点坐标 xyz,这里无需纠结 xyz 坐标系和上面配图不一样、哪个用 sin cos 等,直接按代码这么写效果ok就行。theta、phi 圆盘坐标里也用到所以写在 if 前面。

const count1 = 50000;
const count2 = 100000;
const geometry = new THREE.BufferGeometry();
const positions = [];
for (let i = 0; i < count1 + count2; i++) {
  let theta = Math.random() * Math.PI * 2;
  // let phi = Math.random() * Math.PI; // 两极密集
  let phi = Math.acos(Math.random() * 2 - 1); // 分布更均匀
  if (i < count1) {
    // let r = 10;
    let r = Math.random() * 0.5 + 9.5;
    let x = r * Math.sin(phi) * Math.cos(theta);
    let y = r * Math.cos(phi);
    let z = r * Math.sin(phi) * Math.sin(theta);
    positions.push(x, y, z);
  } 
   else {
    // 圆盘/圆柱部分
  }
}

需要注意的是 phi 是通过反余弦函数 acos 对-1-1求出角度得到,这样顶点分布更均匀,直接通过 Math.random() * Math.PI 的话会不均匀、两极更密集。

粒子大小更随机

目前中心球体的粒子效果大致出来了,但靠近球体表面细看时会发现粒子大小都差不多大,此时粒子大小仅取决于离相机的距离,而粒子在球体半径范围9.5-10之间和相机距离差别不大,所以大小也差不多。

为了使粒子大小更随机,可以给每个顶点设置一个随机值属性,这样在顶点着色器里就能使用。这里 size 值为0.5-2(具体范围可自行调整),对于球体和圆盘上的顶点都生成一个数值,通过 setAttribute 设置到几何体顶点属性上,在 Float32BufferAttribute 里表明一个顶点一个数值。然后在顶点着色器里通过 attribute float aSize 就能拿到数值,乘到 gl_PointSize 上即可。

const positions = [];
const sizes = [];
for (let i = 0; i < count1 + count2; i++) {
  let theta = Math.random() * Math.PI * 2;
  let phi = Math.acos(Math.random() * 2 - 1); // 分布更均匀

  let size = Math.random() * 1.5 + 0.5; // 0.5-2.0
  sizes.push(size);
  // ...
}

geometry.setAttribute("aSize", new THREE.Float32BufferAttribute(sizes, 1));

const vertexShader = /* GLSL */ `
  attribute float aSize;
  uniform float uTime;
  
  void main() {
    vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
    // gl_PointSize = 30.0 / -mvPosition.z;
    gl_PointSize = aSize * 30.0 / -mvPosition.z;
    gl_Position = projectionMatrix * mvPosition;
  }
`;

咋看起来可能变化并不明显,但所以小的细节累加起来才能达到漂亮、令人满意的效果。

应用颜色

中心球体形状确定后,我们接着应用颜色让效果更出彩。原作里用顶点离中心距离去 mix 插值下面两种颜色。

目前球体上下 position.y 的范围是-10-10,我们不妨将其除以10变到-1-1,再乘0.5加0.5变到0-1,然后在上下方向插值不同颜色。将颜色传给片元着色器并进行使用,此时 mask 仅用于 discard 舍弃掉圆圈外围的像素。

// vertexShader
attribute float aSize;
uniform float uTime;
varying vec3 vColor;

void main() {
  // rgb(227, 155, 0) #E39B00
  // rgb(100, 50, 255) #6432FF
  vec3 color1 = vec3(227., 155., 0.);
  vec3 color2 = vec3(100., 50., 255.);

  float d = position.y / 10.0 * 0.5 + 0.5;
  vColor = mix(color1, color2, d) / 255.;

  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
  gl_PointSize = aSize * 30.0 / -mvPosition.z;
  gl_Position = projectionMatrix * mvPosition;
}

// fragmentShader
varying vec3 vColor;

void main() {
  float mask = step(length(gl_PointCoord - 0.5), 0.5);
  if(mask < 0.5) discard;
  // gl_FragColor = vec4(vec3(mask), 1.0);
  gl_FragColor = vec4(vColor, 1.0);
}

也可以 abs 取绝对值后,使得中间0、上下1,此时效果看起来就和原作接近了。

float d = abs(position.y) / 10.0;
vColor = mix(color1, color2, d) / 255.;

原作的设置

虽然接近,但还是不同。我们不妨改成原作的设置方式,原作里对顶点坐标除以一个 vec3(40.,10.,40.) 再用 length 计算距离 d,其中这里的10是中心球体的半径,也是圆盘的内半径,40是圆盘的外半径;通过 clamp 截取到0-1,超过1的都为1,小于0的都为0,再 mix 插值两种颜色。具体这里为什么要除以这个 vec3、对颜色的变化效果如何产生影响,我也不太理解,有待高手解答吧,总之先把整体效果跑通再说!另外把粒子大小再调大些。

float d = length(abs(position) / vec3(40., 10., 40.));
d = clamp(d, 0., 1.);
vColor = mix(color1, color2, d) / 255.;

gl_PointSize = aSize * 50.0 / -mvPosition.z;

在片元着色器里,计算每个顶点上的像素离自身中心的距离,然后大于0.5的舍弃,通过 smoothstep 设置透明度,距离小于0.1的取1,0.1-0.5的从1平滑过渡到到0,大于0.5的为0且会舍弃。这样粒子圆圈就会是模糊朦胧的效果。

// fragmentShader
varying vec3 vColor;

void main() {
  float d = length(gl_PointCoord - 0.5);
  if (d > 0.5) discard;
  gl_FragColor = vec4(vColor, smoothstep(0.5, 0.1, d));
}

此时颜色很怪,因为透明度没生效,设置 transparent 为 true 颜色就正常了;设置 blending 为 THREE.AdditiveBlending 这样粒子重叠后的颜色会变白发亮,可以看到球体边缘一圈微微发亮。

 const material = new THREE.ShaderMaterial({
    uniforms: {
      uTime: { value: 0 },
    },
    vertexShader,
    fragmentShader,
    transparent: true,
    blending: THREE.AdditiveBlending,
    depthTest: false,
  });

另外设置 depthTest 为 false 以避免左侧粒子黑边的效果,最终放大后的粒子效果如右图所示,圆圈朦胧、重叠变白发亮。

让粒子动起来(纠正错误)

中心球体的效果更加漂亮了,现在让粒子动起来。在2D里想让粒子在圆圈上运行,需要不断改变角度 angle,同样3D里想让粒子在球体上运动,需要改变 theta 和 phi 两个角度,就像地球仪上从一点到另一点要改变经度和纬度一般。

让我们再给顶点属性上设置和运动相关的数值。theta 和 phi 可以定位出粒子初始位置,angle 为很小的随机角度值表示移动的角度大小或速率,strength 为0.1-1类似运动幅度,将这4个数值设置到每个顶点上。

const positions = [];
const sizes = [];
const shifts = [];
for (let i = 0; i < count1 + count2; i++) {
  let theta = Math.random() * Math.PI * 2;
  let phi = Math.acos(Math.random() * 2 - 1);
  let angle = (Math.random() * 0.9 + 0.1) * Math.PI * 0.1;
  let strength = Math.random() * 0.9 + 0.1; // 0.1-1
  shifts.push(theta, phi, angle, strength);

  let size = Math.random() * 1.5 + 0.5;
  sizes.push(size);
  // ...
}

geometry.setAttribute("aShift", new THREE.Float32BufferAttribute(shifts, 4));

在顶点着色器里可以通过 xyzw 分别拿到 aShift 里的4个值。aShift.x 是原始 theta,加上 aShift.z * uTime 就是角度不断变化,mod 对 2xPI 取余数使角度不断在 0-2xPI 之间变化,从而得到新的 theta 角度;同理得到新的 phi 角度,注意这里 phi 也是要对 2xPI 取余数,虽然不太理解,但换成 PI 就会出现粒子闪烁的效果。

attribute float aSize;
attribute vec4 aShift;
uniform float uTime;
varying vec3 vColor;

const float PI = 3.1415925;

void main() {
  vec3 color1 = vec3(227., 155., 0.);
  vec3 color2 = vec3(100., 50., 255.);

  float d = length(abs(position) / vec3(40., 10., 40.));
  d = clamp(d, 0., 1.);
  vColor = mix(color1, color2, d) / 255.;

  vec3 transformed = position;
  float theta = mod(aShift.x + aShift.z * uTime, PI * 2.);
  float phi = mod(aShift.y + aShift.z * uTime, PI * 2.);
  transformed += vec3(sin(phi) * cos(theta), cos(phi), sin(phi) * sin(theta)) * aShift.w;

  // vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
  vec4 mvPosition = modelViewMatrix * vec4(transformed, 1.0);
  gl_PointSize = aSize * 50.0 / -mvPosition.z;
  gl_Position = projectionMatrix * mvPosition;
}

这是原作粒子运动逻辑的代码,如上所说,一开始古柳以为要让粒子在球体表面运动,是需要更新 theta、phi 后像 JS 里设置顶点坐标时一样根据球坐标算出新的 position/transformed 坐标。

vec3 transformed = position;
float theta = mod(aShift.x + aShift.z * uTime, PI * 2.);
float phi = mod(aShift.y + aShift.z * uTime, PI * 2.);
transformed += vec3(sin(phi) * cos(theta), cos(phi), sin(phi) * sin(theta)) * aShift.w;

vec4 mvPosition = modelViewMatrix * vec4(transformed, 1.0);

那么新的顶点坐标这里应该用 = 而不是 +=,然后 aShift.w 应该是半径 9.5-10.0,而不是0.1-1,这就对不上了。虽然上面粒子也已经动起来,但有必要搞清楚这里代码的逻辑。

transformed = vec3(sin(phi) * cos(theta), cos(phi), sin(phi) * sin(theta)) * aShift.w; // * 10.0

一番思索后古柳逐渐明白是之前自己的理解出了偏差,被粒子要在球体表面运动然后就得通过更新 theta phi 来计算新顶点这一想法所“遮蔽”。

其实运动的逻辑并非如此,对于一维的点如x=10,加减一个速度值如0.1,然后乘时间就是 x+0.1*t 点就能运动起来;二维的点如 (x=10,y=20) 可以沿自身为中心周围一圈的任意方向去移动,可以通过(cos(a), sin(a))单位向量表示方向,同样乘时间就是 (x,y)+(cos(a), sin(a))*t 点就能运动起来;三维的点如 (x=10,y=20,z=30) 可以沿自身为中心周围一圈球体的任意方向去移动,可以通过 sin(phi) * cos(theta), cos(phi), sin(phi) * sin(theta) 单位向量表示方向,同样乘时间就是 (x,y,z)+(sin(phi) * cos(theta), cos(phi), sin(phi) * sin(theta))*t 点就能运动起来。

所以这里的 theta、phi 其实是每个顶点处单位球体上的运动方向,而不是一开始中心球体的两个角度,两者根本不需要对齐、不需要相关,甚至不相关可能更好。shader 里直接对每个顶点坐标加上自己的运动方向乘以 aShift.w 运动幅度0.1-1,只不过因为该值较小,所以看起来粒子还像是在球体上运动,这就是运动的逻辑。因而 JS 里生成中心球体坐标的代码切换回原来 randomDirection 的方式。

if (i < count1) {
  let r = Math.random() * 0.5 + 9.5;
  // let x = r * Math.sin(phi) * Math.cos(theta);
  // let y = r * Math.cos(phi);
  // let z = r * Math.sin(phi) * Math.sin(theta);

  let { x, y, z } = new THREE.Vector3()
    .randomDirection()
    .multiplyScalar(r);
  positions.push(x, y, z);
} 

圆盘粒子

粒子的颜色和运动都搞定后,最后把外围的圆盘粒子也补全,幸运的是上述颜色和运动都能沿用,所以很方便。

圆盘粒子在半径10-40之间,通过 THREE.Vector3().setFromCylindricalCoords() 设置半径、角度、高度来随机生成。

const count1 = 50000;
const count2 = 100000;
const geometry = new THREE.BufferGeometry();
const positions = [];
const sizes = [];
const shifts = [];
for (let i = 0; i < count1 + count2; i++) {
  let theta = Math.random() * Math.PI * 2;
  let phi = Math.acos(Math.random() * 2 - 1);
  let angle = (Math.random() * 0.9 + 0.1) * Math.PI * 0.1;
  let strength = Math.random() * 0.9 + 0.1; // 0.1-1.0 radius
  shifts.push(theta, phi, angle, strength);

  let size = Math.random() * 1.5 + 0.5;
  sizes.push(size);
  
  if (i < count1) {
    // 中心球体粒子
    let r = Math.random() * 0.5 + 9.5;
    let { x, y, z } = new THREE.Vector3()
      .randomDirection()
      .multiplyScalar(r);
    positions.push(x, y, z);
  } else {
    // 圆盘粒子
    let r = 10;
    let R = 40;
    let rand = Math.pow(Math.random(), 1.5);
    let radius = Math.sqrt(R * R * rand + (1 - rand) * r * r);
    let { x, y, z } = new THREE.Vector3().setFromCylindricalCoords(
      radius,
      Math.random() * 2 * Math.PI,
      (Math.random() - 0.5) * 2
    );
    positions.push(x, y, z);
  }
}

唯一需要注意的是这里半径 radius 的生成稍微多了些步骤。

// 圆盘粒子
let r = 10;
let R = 40;
let rand = Math.pow(Math.random(), 1.5);
let radius = Math.sqrt(R * R * rand + (1 - rand) * r * r); 
let { x, y, z } = new THREE.Vector3().setFromCylindricalCoords(
  radius, // 半径
  Math.random() * 2 * Math.PI, // 角度
  (Math.random() - 0.5) * 2 // 高度y -1-1
);
positions.push(x, y, z);

用 random=0-1 取 pow,再作为0-1的数值去插值内外半径的平方,取平方根后作为最后的半径,这里大概是为了让粒子在圆盘上分布更均匀,半径平方相当于按面积大小来采样,不至于越靠近中心粒子越多。

但似乎直接用 random 10-40 的效果看起来也差不多,没想象中那么不均匀,可能是粒子足够小的缘故,总之原作里的方式大家也可以学学,万一用得上呢!

let radius = Math.random() * 30 + 10;
let { x, y, z } = new THREE.Vector3().setFromCylindricalCoords(
  radius,
  Math.random() * 2 * Math.PI,
  (Math.random() - 0.5) * 2
);
positions.push(x, y, z);

最后优化细节

最后调整相机角度;使粒子系统沿z轴的稍微倾斜,并随时间不断沿y轴旋转,这里还更改旋转顺序为 ZYX 轴。

camera.position.set(0, 3, 24);

const points = new THREE.Points(geometry, material);
points.rotation.order = "ZYX";
points.rotation.z = 0.2;
scene.add(points);

const clock = new THREE.Clock();
function render() {
  const time = clock.getElapsedTime();
  points.rotation.y = time * 0.01;
  material.uniforms.uTime.value = time;
}

小结

最终我们手撸出了一个非常漂亮的粒子系统星系效果(当然受限 GIF 导出后上传文章里的文件大小所限上面看着有些糊,大家可去 Codepen 看效果),大家还可以根据自己需要去调整参数、改改配色等。

虽然源码里仍有几处设置古柳没完全吃透,但不妨碍我们整体跑通整个流程。

记得最初不理解源码里的顶点设置和粒子怎么运动的、不懂 theta/phi/moveS/moveT/cos/sin 球坐标等用途、不知道 material 里的 onBeforeCompile 是什么东西和一般自己写 shader 有什么区别......(下面就是源码里 material 部分的代码,本次复现时也改成了更好里记得方式)

let m = new THREE.PointsMaterial({
  size: 0.125,
  transparent: true,
  depthTest: false,
  blending: THREE.AdditiveBlending,
  onBeforeCompile: shader => {
    shader.uniforms.time = gu.time;
    shader.vertexShader = `
      uniform float time;
      attribute float sizes;
      attribute vec4 shift;
      varying vec3 vColor;
      ${shader.vertexShader}
    `.replace(
      `gl_PointSize = size;`,
      `gl_PointSize = size * sizes;`
    ).replace(
      `#include <color_vertex>`,
      `#include <color_vertex>
        float d = length(abs(position) / vec3(40., 10., 40));
        d = clamp(d, 0., 1.);
        vColor = mix(vec3(227., 155., 0.), vec3(100., 50., 255.), d) / 255.;
      `
    ).replace(
      `#include <begin_vertex>`,
      `#include <begin_vertex>
        float t = time;
        float moveT = mod(shift.x + shift.z * t, PI2);
        float moveS = mod(shift.y + shift.z * t, PI2);
        transformed += vec3(cos(moveS) * sin(moveT), cos(moveT), sin(moveS) * sin(moveT)) * shift.w;
      `
    );
    //console.log(shader.vertexShader);
    shader.fragmentShader = `
      varying vec3 vColor;
      ${shader.fragmentShader}
    `.replace(
      `#include <clipping_planes_fragment>`,
      `#include <clipping_planes_fragment>
        float d = length(gl_PointCoord.xy - 0.5);
        //if (d > 0.5) discard;
      `
    ).replace(
      `vec4 diffuseColor = vec4( diffuse, opacity );`,
      `vec4 diffuseColor = vec4( vColor, smoothstep(0.5, 0.1, d)/* * 0.5 + 0.5*/ );`
    );
    //console.log(shader.fragmentShader);
  }
});

幸运地是时过境迁后,终于能大致搞懂并复现以前看过的 shader 效果,很是欣慰。希望看完本文大家也能有所收获。最后完整源码附上。

相关阅读

「手把手带你入门 Three.js Shader 系列」目录如下:

照例

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

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

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