大厂技术 高级前端 Node进阶
点击上方 程序员成长指北,关注公众号
回复1,加入高级Node交流群
前言
之前在小红书上刷到上海「深空未来」展的图片,看到这个宇宙星球的粒子效果觉得挺酷的,也很多人喜欢。
于是古柳想起曾经见过的这个 Three.js Shader 实现的粒子系统星系效果,它的形状、颜色、动画令人难忘,可惜当初水平有限,有些地方没有理解,这次重新勾起兴趣看了下源码,发现又搞懂不少地方,可以讲解下,因此想带大家一起手撸一个星系。
链接:https://actium.co.jp/
链接:https://codepen.io/prisoner849/pen/RwyzrVj
当然像本文这样实现一个具体完整的 shader 效果的文章,和前面八篇「手把手带你入门 Three.js Shader 系列」
教程按部就班讲解一个个知识点还是不太一样,并且本文涉及的粒子系统、BufferGeometry、顶点上设置属性等也都是系列教程里还未涉及的(当然也不难),理想情况下在系列教程讲完那些内容后,再紧跟着来这么一篇完整效果的文章最好。
但有时看到酷炫 shader 的效果、起了兴致就想和大家分享,就也顾不上许多(何况老是犯懒,等系列教程更新完基础内容还不知道要到什么时候)。
言归正传,复现完这个星系效果后照旧套了下之前的 AR 模板,欢迎大家用手机 Google Chrome 浏览器访问看看
(必须!电脑或手机其他浏览器均不行)。不过由于很多手机不支持 ARCore 可能不少人看不了,大家可以通过第二个链接看看自己的手机型号是否在支持列表里。
链接:https://desertsx.github.io/galaxy-particles-nova/
链接:https://developers.google.com/ar/devices?hl=zh-cn
另外,本文代码已放到 Codepen 并将同步 GitHub,欢迎大家学习:
链接:https://codepen.io/GuLiu/pen/WNWaNdJ
链接:https://github.com/DesertsX/threejs-shader-tutorial
最简单的粒子系统
我们从显示一个白色、线框模式下的球体开始讲起。可以看到球体表面相交的位置就是一个个顶点。
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 里可以统一设置粒子的颜色和大小。
链接:https://threejs.org/docs/api/en/materials/PointsMaterial.html
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
正二十面体,传入半径和细分数两个参数,细分数越大顶点越多,此时粒子分布很均匀。
链接:https://threejs.org/docs/#api/en/geometries/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 顶点坐标,这样想在哪放粒子就能在哪放。
链接:https://threejs.org/docs/#api/en/core/BufferGeometry
下面演示用圆圈范围内随机出的顶点坐标组成几何体、再组成粒子系统的流程。
在半径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、设置顶点属性等内容,因此有必要简单讲下,对齐一下颗粒度。
有了上面的基础,接下来就可以进入正题,开始复现原作、手撸一个星系了。
链接:https://codepen.io/prisoner849/pen/RwyzrVj
观察原作会发现星系由中心的球体和外面的圆盘/圆柱两部分组成。
中心球体
首先生成中心球体的顶点坐标。在 for 循环里分别生成5万个粒子的球体坐标、10万个粒子的圆盘坐标,统一放到 positions 数组里,再设置到一个 BufferGeometry 上,这里没有分成两个设置。
原作里用 THREE.Vector3().randomDirection()
生成球体上的单位向量长度的顶点,然后设置向量长度到9.5-10作为球体半径。
链接:https://threejs.org/docs/api/en/math/Vector3.html
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 轴。
链接:https://threejs.org/docs/#api/en/math/Euler.order
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 看效果),大家还可以根据自己需要去调整参数、改改配色等。
链接:https://codepen.io/GuLiu/pen/WNWaNdJ
虽然源码里仍有几处设置古柳没完全吃透,但不妨碍我们整体跑通整个流程。
记得最初不理解源码里的顶点设置和粒子怎么运动的、不懂 theta/phi/moveS/moveT/cos/sin 球坐标等用途、不知道 material 里的 onBeforeCompile 是什么东西和一般自己写 shader 有什么区别......(下面就是源码里 material 部分的代码,本次复现时也改成了更好里记得方式)
链接:https://codepen.io/Gu-Liu/pen/YzMOWNp
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 效果,很是欣慰。希望看完本文大家也能有所收获。最后完整源码附上。
链接:https://codepen.io/GuLiu/pen/WNWaNdJ
import * as THREE from "https://esm.sh/three";
import { OrbitControls } from "https://esm.sh/three/examples/jsm/controls/OrbitControls";
let w = window.innerWidth;
let h = window.innerHeight;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, w / h, 0.001, 1000);
camera.position.set(0, 3, 24);
camera.lookAt(scene.position);
const renderer = new THREE.WebGLRenderer({
antialias: true,
// alpha: true,
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(w, h);
renderer.setClearColor(0x160016, 1);
document.getElementById("app").appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
// controls.enableDamping = true;
// controls.enablePan = false;
// const geometry = new THREE.SphereGeometry(1, 64, 64);
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.random() * Math.PI;
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
shifts.push(theta, phi, angle, strength);
let size = Math.random() * 1.5 + 0.5; // 0.5-2.0
sizes.push(size);
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.sin(phi) * Math.sin(theta);
// let z = r * Math.cos(phi);
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); // 通过 rand=0-1 数值去线性插值 R^2 和 r^2 大概是按圆圈面积采样粒子分布更均匀
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);
}
}
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(positions, 3)
);
geometry.setAttribute("aSize", new THREE.Float32BufferAttribute(sizes, 1));
geometry.setAttribute("aShift", new THREE.Float32BufferAttribute(shifts, 4));
const vertexShader = /* GLSL */ `
attribute float aSize;
attribute vec4 aShift;
uniform float uTime;
varying vec3 vColor;
const float PI = 3.141592653589793238;
void main() {
// float d = abs(position.y) / 10.0;
float d = length(abs(position) / vec3(40., 10., 40.)); // 中间黄色、外面紫色
d = clamp(d, 0., 1.);
// rgb(227, 155, 0)
// rgb(100, 50, 255)
vec3 color1 = vec3(227., 155., 0.);
vec3 color2 = vec3(100., 50., 255.);
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(transformed, 1.0);
gl_PointSize = aSize * 50.0 / -mvPosition.z;
gl_Position = projectionMatrix * mvPosition;
}
`;
const fragmentShader = /* GLSL */ `
varying vec3 vColor;
void main() {
float d = length(gl_PointCoord.xy - 0.5);
if (d > 0.5) discard;
// gl_FragColor = vec4(vColor, step(0.5, 1.0 - d));
gl_FragColor = vec4(vColor, smoothstep(0.5, 0.1, d));
}
`;
const material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
uTime: { value: 0 },
},
transparent: true,
blending: THREE.AdditiveBlending,
depthTest: false,
});
// const mesh = new THREE.Mesh(geometry, material);
const mesh = new THREE.Points(geometry, material);
mesh.rotation.order = "ZYX";
mesh.rotation.z = 0.2;
scene.add(mesh);
// let time = 0;
let clock = new THREE.Clock();
function render() {
// time += 0.05;
let time = clock.getElapsedTime();
mesh.rotation.y = time * 0.01;
material.uniforms.uTime.value = time;
renderer.render(scene, camera);
controls.update();
requestAnimationFrame(render);
}
render();
function resize() {
w = window.innerWidth;
h = window.innerHeight;
renderer.setSize(w, h);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
window.addEventListener("resize", resize);
相关阅读
「手把手带你入门 Three.js Shader 系列」目录如下:
照例
Node 社群
我组建了一个氛围特别好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你对Node.js学习感兴趣的话(后续有计划也可以),我们可以一起进行Node.js相关的交流、学习、共建。下方加 考拉 好友回复「Node」即可。
“分享、点赞、在看” 支持一下