更多精彩内容尽在 dt.sim3d.cn ,关注公众号【sky的数孪技术】,技术交流、源码下载请添加VX:digital_twin123
此处接上文:Threejs中的WebGPU实践(1-1)
顶点着色器设置
现在我们已经对材质系统和 TSL 着色器有了一点熟悉,接下来我们再创建一个更有趣的场景。我们只使用基本的立方体和两个灯光,但利用顶点着色器的强大功能可以将这个基本的立方体转变为由色彩缤纷的旋转立方体组成的场景。
首先,我们需要定义一些常量,例如我们想要创建多少个同心圆,以及我们想要在场景中添加多少个立方体。本次实验我们来使用放置在四个同心圆内的八十个立方体网格实例填充场景。
// 要创建的立方体网格实例的数量
const instanceCount = 80;
// 场景中同心圆的数量
const numCircles = 4;
// 将实例数量平均分配到各个圆圈中
const meshesPerCircle = instanceCount / numCircles
const material = new THREE.MeshStandardNodeMaterial();
接下来,缩小立方体几何体的比例,删除网格的默认旋转,并将网格从标准网格切换为实例化网格。实例化网格利用 WebGPU 图形 API 中的特定功能,允许应用程序在一次绘制调用中绘制同一网格的多个实例,从而提高整体渲染性能。
// const geometry = new THREE.BoxGeometry( 1, 1, 1);
// mesh = new THREE.Mesh( geometry, material );
const geometry = new Three.BoxGeometry( 0.1, 0.1, 0.1 );
mesh = new THREE.InstancedMesh( geometry, material, instanceCount );
// mesh.rotation.y += MATH.PI / 4;
最后,我们将从 Three.js 库中导入所需的所有必需的节点功能到我们的项目中,创建一组uniform,并使这些uniform可供 GUI 访问。
import * as THREE from 'three';
// 创建本实验中出现的效果所需的所有节点功能
import { positionGeometry, cameraProjectionMatrix, modelViewProjection, modelScale, positionView, modelViewMatrix, storage, attribute, float, timerLocal, uniform, tslFn, vec3, vec4, rotate, PI2, sin, cos, instanceIndex, negate, texture, uv, vec2, positionLocal, int } from 'three/tsl';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import GUI from 'three/addons/libs/lil-gui.module.min.js';
function init() {
const effectController = {
// uniform() 函数创建一个保存统一值的 UniformNode
uCircleRadius: uniform( 1.0 ),
uCircleSpeed: uniform( 0.5 ),
uSeparationStart: uniform( 1.0 ),
uSeparationEnd: uniform( 2.0 ),
uCircleBounce: uniform( 0.02 ),
};
// ...
const gui = new GUI();
gui.add( effectController.uCircleRadius, 'value', 0.1, 3.0, 0.1 ).name( 'Circle Radius' );
gui.add( effectController.uCircleSpeed, 'value', 0.1, 3.0, 0.1 ).name( 'Circle Speed' );
gui.add( effectController.uSeparationStart, 'value', 0.5, 4, 0.1 ).name( 'Separation Start' );
gui.add( effectController.uSeparationEnd, 'value', 1.0, 5.0, 0.1 ).name( 'Separation End' );
gui.add( effectController.uCircleBounce, 'value', 0.01, 0.2, 0.001 ).name( 'Circle Bounce' );
}
编写顶点着色器
使用 Three.js WebGPURenderer 时,用户可以通过将 TSL 函数分配给材质的 positionNode
或 vertexNode
属性,将顶点着色器应用到网格的 NodeMaterial
。为 positionNode
编写TSL 函数仍将遵循已应用于网格的标准模型-视图-投影(MVP) 转换。本质上,这意味着对网格体顶点或网格体整体位置的复杂转换可以像在 Javascript 中执行一样执行。此外,由于这些操作将在材质的顶点着色器中并行执行,因此它们的性能将比同等的 CPU 操作高得多。
使用 vertexNode
时,函数的行为略有不同。该节点将绕过标准 MVP 转换,将 TSL 函数返回的原始值直接输出到顶点着色器。因此,如果将 TSL 函数分配给 vertexNode,则必须在该函数内手动应用 MVP 转换。
为了演示其中的差异,我在下面编写了两个函数,一个用于 vertexNode
,另一个用于 positionNode
。每个着色器执行相同的操作:沿 x 轴移动网格的位置。
const material = new THREE.MeshStandardNodeMaterial();
// Position 节点方法
material.positionNode = tslFn(() => {
const position = positionLocal;
// 沿x轴来回摆动
const moveX = sin( timerLocal() );
// 相当于普通 JavaScript 中的 mesh.position.x += Math.sin(time)
position.x.addAssign( moveX );
return positionLocal;
})();
// Vertex 节点方法
material.vertexNode = tslFn(() => {
const position = positionLocal;
position.x.addAssign( sin( timerLocal() ) );
// 需要应用变换矩阵以使输出相同
return cameraProjectionMatrix.mul( modelViewMatrix ).mul( position );
})();
请注意,在两个着色器中,我们如何使用 positionLocal
来访问局部空间中的网格顶点。有多个方便的属性可以访问网格顶点的预转换版本,包括
- **positionWorld:**由
modelWorldMatrix
转换的网格几何体的位置,它可以缩放、旋转和平移网格顶点。 - **positionView:**由
modelViewMatrix
转换的网格几何体的位置,它将网格带入视图空间。 - **modelViewProjection:**对作为参数传递的位置执行标准 MVP 转换。
有了这些额外的节点,我们可以修改 vertexNode
着色器以输出无数网格的顶点,而无需对着色器的视觉输出进行任何更改。
// 用于渲染网格的顶点节点方法
material.vertexNode = tslFn(() => {
// 方法 1
return cameraProjectionMatrix.mul( modelViewMatrix ).mul( positionLocal );
// 方法 2
return cameraProjectionMatrix.mul( positionWorld );
// 方法 3
return modelViewProjection( positionLocal );
})();
由于我们不想扰乱标准 MVP 投影过程,因此我们将为材质的 positionNode
编写顶点着色器。让我们首先删除我们创建的任何示例位置或顶点着色器,然后创建一个新的着色器,稍后将其分配给我们的positionNode
。在这个着色器中,让我们提取uniform并创建一些我们将在着色器中重复使用的变量。
const positionTSL = tslFn(() => {
// uniform可以被解构,因为它们只是 Javascript 中代表uniform的对象变量
const { uCircleRadius, uCircleSpeed, uSeparationStart, uSeparationEnd, uCircleBounce } = effectController;
// 访问自着色器创建以来经过的时间
const time = timerLocal();
const circleSpeed = time.mul( uCircleSpeed );
})
然后,我们需要访问position着色器中的一些实例数据,以正确协调立方体网格每个实例的移动。这意味着我们必须访问当前顶点所属的实例索引。为此,我们所要做的就是访问之前导入的 instanceIndex
值。在分配给positionNode
或vertexNode
的功能块中,instanceIndex
值将表示当前顶点所属的网格实例的索引。如果你想知道为什么我明确区分这是它在顶点着色器上下文中的值,那是因为 instanceIndex
是一个上下文节点,其值根据使用它的上下文而变化。虽然目前了解并不重要,但 instanceIndex
可以表示的其他值在以后将变得至关重要。现在,让我们继续将 instanceIndex
添加到我们的position着色器中,并从它的值中导出其他索引。
const positionTSL = tslFn(() => {
const { uCircleRadius, uCircleSpeed, uSeparationStart, uSeparationEnd, uCircleBounce } = effectController;
const time = timerLocal();
const circleSpeed = time.mul( uCircleSpeed );
// 立方体在其各自同心圆内的索引。
// 注意:instanceWithinCircle 使用从 0 开始的索引。
const instanceWithinCircle = instanceIndex.remainder( meshesPerCircle );
// 立方体网格所属圆的索引。
// 注意:circleIndex 使用从 1 开始的索引。
const circleIndex = instanceIndex.div( meshesPerCircle ).add( 1 );
// Example Values when meshesPerCircle === 20
// instanceIndex: 0 ---> instance is cube 0 of circle 1.
// instanceIndex: 16 --> instance is cube 16 of circle 1.
// instanceIndex: 22 --> instance is cube 2 of circle 1.
// instanceIndex: 47 --> instance is cube 7 of circle 2.
})
有了这些索引,我们将使用它们根据这些值来分离和偏移每个网格实例。下面,我们将在函数中添加一小段代码来演示这些值的工作原理。
const positionTSL = tslFn(() => {
// ...
const newPosition = positionLocal;
// 将范围 [0, meshesPerCircle) 的 instanceWithinCircle 归一化到范围 [-1, 1)
const range = float( instanceWithinCircle ).sub( meshesPerCircle / 2 ).div( meshesPerCircle / 2 )
// 偏移网格 x.
newPosition.x.addAssign( range.mul( 2 ) );
// 按circleIndex 偏移网格 y
newPosition.y.addAssign( int(circleIndex).sub( 2 ) );
})
material.positionNode = positionTSL();
从结果上看试验成功,那么接下来我们就要完成自己的着色器了。首先,将场景的透视相机的位置向后设置为 15 个单位。然后,删除上面块中的示例代码,并将其替换为这一行,该行根据数字的奇偶校验返回负值或正值。
// 圆索引偶数 = 1,圆索引奇数 = -1.
// Examples:
// 0 -> 0 % 2 = 0 * 2 = 0 - 1 = -1
// 3 -> 3 % 2 = 1 * 2 = 2 - 1 = 1
const evenOdd = circleIndex.remainder( 2 ).mul( 2 ).oneMinus();
接下来,创建一个代表同心圆之一的半径的变量。随着circleIndex 的增加,每个连续圆的半径也会增加。它增加的程度是由我们之前创建并应用于 GUI 的圆半径统一驱动的。
// 当我们进入下一个圆时增加半径
const circleRadius = uCircleRadius.mul( circleIndex );
我们现在需要将立方体网格的每个实例移动到其各自的位置。为此,需要计算从立方体中心到其圆周的每个可能的角度,并将立方体沿着该角度移动到其圆中。此外,我们将缩放外圈中的立方体,使它们逐渐变得比内圈中的立方体更大。
material.positionNode = Fn(() => {
// ...
// 将 instanceWithinCircle 置于范围 [0, 2*PI] 以获得 'meshesPerCircle' 从原点到圆周长的角度数
const angle = float( instanceWithinCircle ).div( meshesPerCircle ).mul( PI2 ).add( circleSpeed );
// 圆的半径是从位于原点的圆心到它的边缘的距离。
// 我们所要做的就是用这个半径来缩放角度的x和y方向,将网格实例放置在圆的圆周上。
// 相反方向旋转偶数圈和奇数圈。
const circleX = sin( angle ).mul( circleRadius ).mul( evenOdd );
const circleY = cos( angle ).mul( circleRadius );
// 将后面的同心圆中的立方体缩放得更大.
const scalePosition = positionLocal.mul( circleIndex );
const newPosition = scalePosition.add( vec3( circleX, circleY, 0.0 ));
return newPosition;
})();
缩放操作后,让我们随着时间的推移旋转每个单独的立方体。
// 将后面的同心圆中的立方体缩放得更大.
const scalePosition = positionLocal.mul( circleIndex );
// 旋转形成同心圆的各个立方体.
const rotatePosition = rotate( scalePosition, vec3( time, time, time ) );
const newPosition = rotatePosition.add( vec3( circleX, circleY, 0.0 ) );
最后,我们可以通过向每个立方体的位置添加额外的偏移来完成position着色器的完善。
// 最终的 Postion Shader
const positionTSL = tslFn(() => {
const { uCircleRadius, uCircleSpeed, uSeparationStart, uSeparationEnd, uCircleBounce } = effectController;
const time = timerLocal();
const circleSpeed = time.mul( uCircleSpeed );
const instanceWithinCircle = instanceIndex.remainder( meshesPerCircle );
const circleIndex = instanceIndex.div( meshesPerCircle ).add( 1 );
const evenOdd = circleIndex.remainder( 2 ).mul( 2 ).oneMinus();
const circleRadius = uCircleRadius.mul( circleIndex );
const angle = float( instanceWithinCircle ).div( meshesPerCircle ).mul( PI2 ).add( circleSpeed );
const circleX = sin( angle ).mul( circleRadius ).mul( evenOdd );
const circleY = cos( angle ).mul( circleRadius );
const scalePosition = positionLocal.mul( circleIndex );
const rotatePosition = rotate( scalePosition, vec3( time, time, time ) );
// 控制圆圈垂直弹跳的程度.
const bounceOffset = cos( time.mul( 10 ) ).mul( uCircleBounce );
const bounce = circleIndex.remainder( 2 ).equal( 0 ).cond( bounceOffset, negate( bounceOffset ) );
const separationDistance = uSeparationEnd.sub( uSeparationStart );
const sinRange = ( sin( time ).add( 1 ) ).mul( 0.5 );
const separation = uSeparationStart.add( sinRange.mul( separationDistance ) );
const newPosition = rotatePosition.add( vec3( circleX, circleY.add( bounce ), float( circleIndex ).mul( separation ) ) );
return newPosition;
});
material.positionNode = positionTSL();
现在剩下要做的就是将随机颜色应用于网格的每个实例,我们的效果就完成了!
material.positionNode = positionTSL();
//material.colorNode = texture( crateTexture, uv().add( vec2( timerLocal(), negate( timerLocal()) ) ));
const r = sin( timerLocal().add( instanceIndex ) );
const g = cos( timerLocal().add( instanceIndex ) );
const b = sin( timerLocal() );
material.fragmentNode = vec4( r, g, b, 1.0 );
结论
现在我们算是刚刚迈出了进入 WebGPURenderer 世界的第一步。在下一个教程中,我们将探索 Three.js 的新计算功能,编写一个计算着色器来并行计算多个粒子的速度。