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

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

技术博客 admin 1099 浏览

前言

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

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

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

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

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

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

最简单的粒子系统

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

js
复制代码
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 里可以统一设置粒子的颜色和大小。

js
复制代码
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 正二十面体,传入半径和细分数两个参数,细分数越大顶点越多,此时粒子分布很均匀。

js
复制代码
const geometry = new THREE.IcosahedronGeometry(10, 6);

材质换成 ShaderMaterial

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

js
复制代码
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 轴负方向上,所以这里要加个负号,近大远小取倒数,再通过前面的数值调整大小即可。

C#
复制代码
gl_PointSize = 100.0 / -mvPosition.z;

方形粒子变成圆形

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

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

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

C#
复制代码
gl_FragColor = vec4(vUv, 0.0, 1.0);

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

C#
复制代码
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 丢弃、不绘制对应片元/像素。

C#
复制代码
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 默认已经声明,所以直接用就行。

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

js
复制代码
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作为球体半径。

js
复制代码
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 前面。

js
复制代码
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 上即可。

js
复制代码
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 舍弃掉圆圈外围的像素。

C#
复制代码
// 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,此时效果看起来就和原作接近了。

C#
复制代码
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、对颜色的变化效果如何产生影响,我也不太理解,有待高手解答吧,总之先把整体效果跑通再说!另外把粒子大小再调大些。

C#
复制代码
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且会舍弃。这样粒子圆圈就会是模糊朦胧的效果。

C#
复制代码
// 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 这样粒子重叠后的颜色会变白发亮,可以看到球体边缘一圈微微发亮。

js
复制代码
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个数值设置到每个顶点上。

js
复制代码
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 就会出现粒子闪烁的效果。

C#
复制代码
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 坐标。

C#
复制代码
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,这就对不上了。虽然上面粒子也已经动起来,但有必要搞清楚这里代码的逻辑。

C#
复制代码
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 的方式。

js
复制代码
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() 设置半径、角度、高度来随机生成。

js
复制代码
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 的生成稍微多了些步骤。

javascript
复制代码
// 圆盘粒子 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 的效果看起来也差不多,没想象中那么不均匀,可能是粒子足够小的缘故,总之原作里的方式大家也可以学学,万一用得上呢!

js
复制代码
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 轴。

js
复制代码
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 部分的代码,本次复现时也改成了更好里记得方式)

js
复制代码
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,备注「可视化加群」即可。

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

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

如有侵权请联系站点删除!

Technical cooperation service hotline, welcome to inquire!