在 Web 开发中,使用 Three.js 库结合 WebGPU 来创建炫酷的图形特效是一件非常有趣且富有挑战性的事情。今天我们就来详细解析一段实现火焰特效的代码,让大家深入了解其背后的原理和功能实现。官网示例代码地址:https://github.com/mrdoob/three.js/blob/master/examples/webgpu_tsl_vfx_flames.html
一、代码导入部分
import * as THREE from 'three';
import { PI2, oneMinus, spherizeUV, sin, step, texture, time, Fn, uv, vec2, vec3, vec4, mix, billboarding } from 'three/tsl';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
这段代码首先导入了 Three.js 的核心库,通过import * as THREE from 'three',我们可以使用 Three.js 提供的各种 3D 相关的类和功能,比如创建场景、相机、渲染器以及各种几何体等。
接着,从three/tsl中导入了一系列实用的函数和数据类型,像PI2(可能是表示2 * PI的常量,用于角度相关计算)、oneMinus(可能用于对某个值取反等操作)、spherizeUV(用于处理 UV 坐标使其呈现球形化效果等)等等,这些函数在后续的材质计算等过程中会发挥重要作用。
最后导入OrbitControls,它能方便地让我们实现用鼠标控制相机旋转、缩放和平移等交互操作,增强场景的可操作性和观赏性。
二、初始化函数init()解析
(一)相机设置
camera = new THREE.PerspectiveCamera( 25, window.innerWidth / window.innerHeight, 0.1, 100 );
camera.position.set( 1, 1, 3 );
这里创建了一个透视相机,第一个参数25表示视角角度,它决定了我们看到场景的宽窄程度;window.innerWidth / window.innerHeight是宽高比,根据当前浏览器窗口大小自适应;0.1和100分别是近裁剪面和远裁剪面的距离,用于控制相机可见的范围。然后设置了相机的初始位置在(1, 1, 3)处,确定了观察场景的起点。
(二)场景创建
scene = new THREE.Scene();
scene.background = new THREE.Color( 0x201919 );
创建了一个 Three.js 的场景对象,它是所有 3D 物体的容器。并且将场景的背景颜色设置为十六进制表示的0x201919,也就是一种偏深色的颜色,作为整个场景的底色。
(三)纹理加载
const textureLoader = new THREE.TextureLoader();
const cellularTexture = textureLoader.load( './textures/noises/voronoi/grayscale-256x256.png' );
const perlinTexture = textureLoader.load( './textures/noises/perlin/rgb-256x256.png' );
首先创建了一个纹理加载器对象textureLoader,然后使用它分别加载了两张纹理图片,一张是基于 Voronoi 算法生成的灰度纹理(用于模拟类似细胞结构的噪声效果),另一张是 Perlin 噪声纹理(常用于生成自然、随机的纹理变化效果),这些纹理在后续火焰材质的构建中会作为噪声源等发挥关键作用。
(四)渐变纹理创建(gradient canvas)
const gradient = {};
gradient.element = document.createElement( 'canvas' );
gradient.element.width = 128;
gradient.element.height = 1;
gradient.context = gradient.element.getContext( '2d' );
gradient.colors = [
'#090033',
'#5f1f93',
'#e02e96',
'#ffbd80',
'#fff0db',
];
gradient.texture = new THREE.CanvasTexture( gradient.element );
gradient.texture.colorSpace = THREE.SRGBColorSpace;
gradient.update = () => {
const fillGradient = gradient.context.createLinearGradient( 0, 0, gradient.element.width, 0 );
for ( let i = 0; i < gradient.colors.length; i ++ ) {
const progress = i / ( gradient.colors.length - 1 );
const color = gradient.colors[ i ];
fillGradient.addColorStop( progress, color );
}
gradient.context.fillStyle = fillGradient;
gradient.context.fillRect( 0, 0, gradient.element.width, gradient.element.height );
gradient.texture.needsUpdate = true;
};
gradient.update();
这部分代码创建了一个自定义的渐变纹理。先通过document.createElement('canvas')创建一个 HTML5 的画布元素,设置其宽度为128像素,高度为1像素,并获取其 2D 绘图上下文。定义了一个颜色数组,包含了从深蓝色到浅黄色等多个颜色值,用于构建渐变。
接着创建一个线性渐变对象fillGradient,并通过循环在不同的进度位置添加对应的颜色停止点,最后将这个渐变设置为画布的填充样式并填充整个画布区域。通过将这个画布转换为CanvasTexture并设置其颜色空间等属性,创建出了一个可以在材质中使用的渐变纹理,并且调用update方法初始化这个渐变纹理的绘制内容。
(五)火焰 1 材质创建(flame1Material)
const flame1Material = new THREE.SpriteNodeMaterial( { transparent: true, side: THREE.DoubleSide } );
flame1Material.colorNode = Fn( () => {
// main UV
const mainUv = uv().toVar();
mainUv.assign( spherizeUV( mainUv, 10 ).mul( 0.6 ).add( 0.2 ) ); // spherize
mainUv.assign( mainUv.pow( vec2( 1, 2 ) ) ); // stretch
mainUv.assign( mainUv.mul( 2, 1 ).sub( vec2( 0.5, 0 ) ) ); // scale
// gradients
const gradient1 = sin( time.mul( 10 ).sub( mainUv.y.mul( PI2 ).mul( 2 ) ) ).toVar();
const gradient2 = mainUv.y.smoothstep( 0, 1 ).toVar();
mainUv.x.addAssign( gradient1.mul( gradient2 ).mul( 0.2 ) );
// cellular noise
const cellularUv = mainUv.mul( 0.5 ).add( vec2( 0, time.negate().mul( 0.5 ) ) ).mod( 1 );
const cellularNoise = texture( cellularTexture, cellularUv, 0 ).r.oneMinus().smoothstep( 0, 0.5 ).oneMinus();
cellularNoise.mulAssign( gradient2 );
// shape
const shape = mainUv.sub( 0.5 ).mul( vec2( 3, 2 ) ).length().oneMinus().toVar();
shape.assign( shape.sub( cellularNoise ) );
// gradient color
const gradientColor = texture( gradient.texture, vec2( shape.remap( 0, 1, 0, 1 ), 0 ) );
// output
const color = mix( gradientColor, vec3( 1 ), shape.step( 0.8 ).oneMinus() );
const alpha = shape.smoothstep( 0, 0.3 );
return vec4( color.rgb, alpha );
} )();
创建了一个SpriteNodeMaterial材质用于火焰 1,设置其为透明且双面渲染(方便从不同角度都能正确显示)。
- UV 坐标处理:
首先获取主 UV 坐标mainUv,然后对其进行一系列变换,比如先通过spherizeUV函数进行球形化处理(参数10可能控制球形化的程度等),接着进行拉伸(通过pow函数改变 UV 坐标的幂次方来实现拉伸效果)和缩放(通过乘法和减法运算调整坐标范围)等操作,这些操作都是为了让纹理在后续应用时呈现出特定的形状和分布。
- 梯度计算:
计算两个梯度相关的值gradient1和gradient2,gradient1利用三角函数结合时间和 UV 坐标的y分量来生成随时间变化且基于 UV 位置的波动效果,gradient2则是通过对mainUv.y进行平滑过渡计算得到一个渐变值,然后将gradient1和gradient2结合起来对mainUv.x进行调整,实现颜色等属性在水平方向上基于梯度的变化。
- 细胞噪声应用:
根据mainUv计算出cellularUv用于采样细胞纹理,获取细胞噪声值cellularNoise,并对其进行一系列范围调整和与gradient2的乘法操作,使得细胞噪声能够与之前的梯度效果相结合,影响火焰的形状和颜色变化等。
- 形状计算:
通过对mainUv进行位移、缩放等操作后计算其长度的反向值得到一个基础形状值shape,再减去细胞噪声值进一步调整形状,这个形状值后续会用于决定火焰的轮廓等特征。
- 最终颜色和透明度计算:
根据形状值从渐变纹理中采样得到gradientColor,然后通过mix函数将其与白色(vec3(1))按照一定条件混合得到最终颜色color,同时根据形状值计算出透明度alpha,最后返回一个包含颜色和透明度信息的vec4类型的值作为材质的颜色节点输出,决定了火焰 1 的外观表现。
(六)火焰 2 材质创建(flame2Material)
其创建过程和火焰 1 材质类似,但在具体的噪声应用、坐标变换等细节上有所不同,比如使用了 Perlin 噪声进行更多复杂的干扰效果生成,这里就不再赘述重复逻辑,其整体目的也是通过各种计算来生成一个随时间变化、有着独特外观的火焰材质效果。
(七) Billboarding 设置
flame1Material.vertexNode = billboarding();
flame2Material.vertexNode = billboarding();
这里调用billboarding函数为火焰 1 和火焰 2 的材质设置 Billboarding 效果,也就是让火焰始终面向相机(这里只在水平方向跟随相机旋转),保证火焰在场景中看起来是正对着观察者的,增强视觉效果的真实感。
(八)创建精灵对象并添加到场景
const flame1 = new THREE.Sprite( flame1Material );
flame1.center.set( 0.5, 0 );
flame1.scale.x = 0.5; // optional
flame1.position.x = - 0.5;
scene.add( flame1 );
const flame2 = new THREE.Sprite( flame2Material );
flame2.center.set( 0.5, 0 );
flame2.position.x = 0.5;
scene.add( flame2 );
分别创建了两个基于之前创建的材质的精灵对象(Sprite),它们本质上是一种可以在场景中方便地展示 2D 纹理效果的对象,常用于粒子、特效等的呈现。设置了它们的中心位置、缩放大小以及在场景中的x坐标位置,并将它们添加到场景中,这样它们就能在渲染时显示出来了。
(九)渲染器创建和相关设置
renderer = new THREE.WebGPURenderer( { antialias: true } );
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.setAnimationLoop( animate );
document.body.appendChild( renderer.domElement );
controls = new OrbitControls( camera, renderer.domElement );
controls.enableDamping = true;
controls.minDistance = 0.1;
controls.maxDistance = 50;
window.addEventListener( 'resize', onWindowResize );
创建了一个 WebGPU 渲染器,开启抗锯齿效果,设置其像素比与设备像素比匹配,调整渲染器的尺寸为浏览器窗口大小,并指定了动画循环函数为animate,然后将渲染器的 DOM 元素添加到页面的body标签内,使其能够在页面上显示渲染的内容。
接着创建了OrbitControls实例,关联相机和渲染器的 DOM 元素,开启阻尼效果让相机操作更平滑,同时设置了相机距离的最小和最大值限制,方便用户交互操作相机。最后添加了窗口大小改变的监听器,当窗口大小变化时调用onWindowResize函数来相应地调整相机和渲染器的相关参数,保证场景始终能正确地显示在窗口内。
三、窗口大小改变处理函数onWindowResize()
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize( window.innerWidth, window.innerHeight );
}
当浏览器窗口大小发生改变时,这个函数会被触发。它首先更新相机的宽高比,使其与新的窗口宽高比一致,然后调用updateProjectionMatrix方法让相机的投影矩阵更新,保证视角等渲染效果正确。同时也调整渲染器的尺寸为新的窗口大小,确保场景能完整且正确地渲染在改变后的窗口区域内。
四、动画循环函数animate()
async function animate() {
controls.update();
renderer.render( scene, camera );
}
这是整个程序的动画循环核心函数,在每一帧渲染时被调用。首先调用controls.update()来更新相机的控制状态(比如根据鼠标操作等实时更新相机位置、角度等),然后通过renderer.render(scene, camera)使用渲染器将场景中的内容(包含我们创建的火焰等物体)根据相机的视角渲染到页面上,不断循环这个过程就实现了动画效果,让火焰等特效能够动态地展示在页面中。
通过对这段代码的详细解析,我们可以看到如何利用 Three.js 结合 WebGPU 以及各种数学计算、纹理操作等手段来创建出逼真且炫酷的火焰特效,希望大家能从中学习到相关的知识,并运用到自己的 Web 3D 开发项目中去。