Threejs: 利用实例着色器实现炫酷的烟花效果

职场   2024-11-13 17:00   北京  
弧线
飞来飞去的弧线是最重要的组成部分。每条弧线都代表实时下达的订单,它们从商家飞向买家所在地。
这些可以定义为具有 4 个控制点的贝塞尔曲线。每个点都是一个三维矢量。
P0:位于起始位置
P1:位于起点和终点之间 25% 的位置
P2:位于起点和终点之间 75% 的位置
P3:位于末端位置

可以通过将控制点 P1 和 P2 移离表面来调整弧高。
为了渲染弧线,我们创建了一个网格,该网格是沿着曲线移动的三角形条带。这使我们能够控制厚度,并可以更灵活地使用着色器对其进行样式化。

然而,你会注意到,当你移动网格时,它会从某些角度消失。我们需要一种方法让它始终面向观察者。解决方案出奇的简单。
曲线上的红线表示这些点的切线。将切线与相机方向的叉积相乘可得到一个新的向量。顶点着色器会使用该向量来偏移位置,使网格始终面向观察者。
// the tangents are computed when the mesh is created, and passed as an attributeattribute vec3 tangent;
vec3 lookDirection = normalize(position.xyz - cameraPosition);vec3 offset = cross(lookDirection, tangent);vec3 adjustedPosition = position.xyz + normalize(offset) * thickness;


为了给弧线添加纹理,网格的 UV 定义为v从起点的 0 到终点的 1。因此,弧线中间的顶点应v为 0.5。
这里用 color = uv.y 表示

要为弧线制作动画,必须计算其当前时间。计算公式为:

# Number of seconds since start of pageuniform float uTime;# Duration in seconds for arc to travel to destination and disappearuniform float duration;# Time when the arc animation should start (relative to page load)attribute float startTime;
age = clamp((uTime - startTime) / duration, 0.0, 2.0);


时间标准化为0至2的范围。
age = 0:弧线开始
age = 1:弧已经到达目的地
age = 2:整个弧线轨迹动画已完全播放完毕
为了实现我们想要的溶解类型效果,我们使用 UV 和弧的年龄并将其传递给噪声函数。
float rand(vec2 n) {    return fract(sin(dot(n, vec2(12.9898, 4.1414))) * 43758.5453);}
float noise(vec2 n) { const vec2 d = vec2(0.0, 1.0); vec2 b = floor(n), f = smoothstep(vec2(0.0), vec2(1.0), fract(n)); return mix(mix(rand(b), rand(b + d.yx), f.x), mix(rand(b + d.xy), rand(b + d.yy), f.x), f.y);}
float ramp(float t) { float v = step(0.0, t) * (1.0 - step(1.0, t)); return smoothstep(0.0, 1.0, (1.0 - t)) * v;}
void main() { if(age <= 0.0 || age >= 2.0) { discard; }
// adjustedAge corresponds to the age of each vertex since the time the arc started float adjustedAge = age - vUv.y;
float r = ramp(adjustedAge); vec2 uvScaled = vUv * vNoiseScale; float n = noise(uvScaled);
float alpha = step(1.0 - r, n);
gl_FragColor = vec4(vColor, alpha);}

烟花

使用 Three.js 的 IcosahedronGeometry 生成基础网格,并将三角形条带从中心连接到每个顶点。

为了让网格看起来像烟花绽放,我们沿着每条轨迹逐渐降低顶点以模拟重力。

每条轨迹上的 UV 处理方式与弧线相同,v起点为 0,终点为 1。这使我们能够轻松制作向外爆发的轨迹动画。

if (vUv.y < 0.0 || vUv.y > t) discard;

我们还利用电弧产生的噪声效应来模拟突发随时间消散的过程。这与延迟因子相结合,以便突发轨迹在开始消失之前有一点时间。

float fadeThreshold = smoothstep(0.0, t, vUv.y - delayFactor);if (noise(vUv) < fadeThreshold) discard;

一旦添加了光晕,整个效果就会真正融合在一起。这是我们第一次添加后期处理效果的地球仪,它增加了我们想要的额外视觉效果。

虽然爆炸本身看起来确实很令人满意,但它们缺少从地面升起的发射轨迹。我们不想为此引入第二个网格,因此我们有一个额外的长三角形带,从原点开始一直到爆炸的中心。
我们添加了一个名为的几何属性isBurstTrail,对于发射轨迹的任何顶点部分,该属性为 false,对于爆发轨迹,该属性为 true。这让我们可以驱动每个部分的动画。
if (burstTrail) {  // delayDuration is how long between the launch trail finishing and the burst starting  t = (elapsedTime - launchDuration - delayDuration) / burstDuration;} else {  t = elapsedTime / launchDuration;}

线性动画 t 从 0 到 1 感觉有点太慢,所以我们尝试了不同的缓动值。立方缓动可以达到我们想要的速度。

float easeOutCubic(float t) {    t = t - 1.0;    return (t * t * t + 1.0);}
t = easeOutCubic(t);

我们还为发射轨迹高度、爆发大小和旋转偏移添加了一些着色器统一体。这让我们能够以有趣的方式将烟花组合在一起。

最好的部分是,这仍然是一个单一的绘制调用。每个单独的烟花都有自己的开始时间属性,类似于弧线,因此它们可以独立地动画化。

最终结果:

完整的顶点着色器:

// vertexShader.glslvarying float vUvY;uniform float elapsedTime;uniform float launchDuration;uniform float burstDuration;uniform float delayDuration;uniform bool isBurstTrail;
void main() { vec3 pos = position;
float t; if (isBurstTrail) { t = (elapsedTime - launchDuration - delayDuration) / burstDuration; } else { t = elapsedTime / launchDuration; }
// 线性动画 t 从 0 到 1 t = clamp(t, 0.0, 1.0); t = easeOutCubic(t);
// 模拟重力 pos.y -= t * 0.1;
// UV 处理 vUvY = position.y;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);}
float easeOutCubic(float t) { t = t - 1.0; return (t * t * t + 1.0);}


片元着色器:

// fragmentShader.glslvarying float vUvY;uniform float t;uniform float delayFactor;
void main() { // UV 处理 if (vUvY < 0.0 || vUvY > t) discard;
// 模拟突发随时间消散的过程 float fadeThreshold = smoothstep(0.0, t, vUvY - delayFactor); if (noise(vUvY) < fadeThreshold) discard;
// 设置颜色 gl_FragColor = vec4(1.0, 1.0, 0.0, 1.0); // 黄色}


烟花组件:

// src/Fireworks.jsimport React, { useRef, useEffect } from 'react';import { Canvas, useFrame } from '@react-three/fiber';import { ShaderMaterial, IcosahedronGeometry } from 'three';import vertexShader from './vertexShader.glsl';import fragmentShader from './fragmentShader.glsl';
const Firework = ({ elapsedTime, launchDuration, burstDuration, delayDuration }) => { const meshRef = useRef(); const isBurstTrail = useRef(false); useFrame(() => { if (meshRef.current) { meshRef.current.material.uniforms.elapsedTime.value = elapsedTime; meshRef.current.material.uniforms.launchDuration.value = launchDuration; meshRef.current.material.uniforms.burstDuration.value = burstDuration; meshRef.current.material.uniforms.delayDuration.value = delayDuration; meshRef.current.material.uniforms.isBurstTrail.value = isBurstTrail.current; } });
return ( <mesh ref={meshRef}> <icosahedronGeometry args={[1, 0]} /> <shaderMaterial vertexShader={vertexShader} fragmentShader={fragmentShader} uniforms={{ elapsedTime: { value: 0 }, launchDuration: { value: launchDuration }, burstDuration: { value: burstDuration }, delayDuration: { value: delayDuration }, isBurstTrail: { value: isBurstTrail.current }, }} /> </mesh> );};
const FireworksCanvas = () => { const [elapsedTime, setElapsedTime] = React.useState(0); useEffect(() => { const interval = setInterval(() => { setElapsedTime((prev) => prev + 0.1); }, 100); return () => clearInterval(interval); }, []);
return ( <Canvas> <ambientLight /> <pointLight position={[10, 10, 10]} /> <Firework elapsedTime={elapsedTime} launchDuration={2} burstDuration={1} delayDuration={0.5} /> </Canvas> );};
export default FireworksCanvas;


文章来源:

https://shopify.engineering/how-we-built-shopifys-bfcm-2023-globe


有相同兴趣爱好的可通过加星球的方式添加作者微信。加入后查看置顶评论可加微信交流。
关于作者

做一只爬的最久的乌龟,保持学习保持好奇,即使慢一点,遇到一点困难,只要最后能到达终点,又有什么关系呢。
毕竟人生没有白走的路,每一步都算数。


前端程序设计
专注前端最前沿技术,数据可视化,web3d。偶尔插播生活和艺术。
 最新文章