使用Three.js绘制快速而逼真的水

本文将利用GPUComputationRenderer来实现水波纹的绘制,相似的案例可以看threejs官方的GPGPU Water示例。更多精彩内容尽在数字孪生平台

image.png

什么是 GPGPU

GPGPU代表通用图形处理单元(General-Purpose Graphic Processing Unit),意思是用GPU计算图形以外的计算任务。在 Three.js 里,指的是我们使用片段着色器来计算其他内容(例如粒子的位置或速度)的技术。通过使用 GPU(GLSL 着色器)而不是 CPU(Javascript)来进行这些计算,我们可以从并行计算中获得巨大的性能提升。

但是我们在 Three.js 中并不经常看到这个术语,更常见的是“帧缓冲区对象”或 FBO 一词。我们字面上称其为 FBO 技术,因为它使用额外的渲染目标来保存你希望 GPU 执行的额外计算,而“渲染目标”本质上是一个“帧缓冲区”。帧缓冲区通常是在底层中使用的功能,请参阅文档

让我们看看 GPUComputationRenderer 实际上做了什么(threejs的源代码)。它是一个帮助类,可让我们创建数据纹理,通过自定义片段着色器将计算结果存储到每个纹素中的 4 个浮点数 (xyzw) 中。换句话说,每个通道 32 位,每个纹素 16 字节。

这里需要注意的是,与每个颜色通道仅存储 8 位的普通图像纹理相比,使用浮点数的数据纹理能够以更高的精度存储更大的数字。

而且 GPUComputationRenderer中定义的数据纹理(称为“变量”)可以依赖其前一帧的结果来计算下一帧;我们甚至可以设置多个相互依赖的数据纹理。

以“GPGPU Water”示例为例,它只定义了一个变量,命名为heightmap,因为它的唯一功能是计算每一帧中水波的高度图。在第一帧中,高度图具有初始状态(我们偏好的随机值),但当片段着色器完成计算后,输出数据纹理将被分配回高度图变量本身以供下一帧使用,并且依此类推。
下一帧沿用上一帧的数据

具体实现

首先我们为场景添加

  • 方向光,用来照亮场景
  • 水的平面mesh,我们将水波高度图应用于此
  • raycaster,可以将鼠标数据传递到 FBO 中的计算片段着色器
  • 创建 FBO 的 GPUComputationRenderer

水面Mesh

首先创建几何和材质。

const plane = new THREE.PlaneGeometry( GEOM_WIDTH, GEOM_HEIGHT, FBO_WIDTH - 1, FBO_HEIGHT - 1 );
this.waterMat = new THREE.MeshPhongMaterial({
  color: new THREE.Color( 0x0040C0 )
})

然后定义了四个常量:

const FBO_WIDTH = 128
const FBO_HEIGHT = 128
const GEOM_WIDTH = 512
const GEOM_HEIGHT = 512

这里我们将平面几何体的宽度/高度的段数指定为相应的 FBO 的宽度 - 1。这是因为我们希望几何体的顶点数与 FBO 中的纹素数完全相同。

接下来我们通过 onBeforeCompile 扩展 Phong 材质:

this.waterMat.userData.heightmap = { value: null }
    
this.waterMat.onBeforeCompile = (shader) => {
  shader.uniforms.heightmap = this.waterMat.userData.heightmap
  shader.vertexShader = shader.vertexShader.replace('#include <common>', `
    uniform sampler2D heightmap;
    #include <common>
  `)
  shader.vertexShader = shader.vertexShader.replace('#include <beginnormal_vertex>', `
    // Compute normal from heightmap
    vec2 cellSize = vec2( 1.0 / (${FBO_WIDTH.toFixed( 1 )}), 1.0 / ${FBO_HEIGHT.toFixed( 1 )} );
    vec3 objectNormal = vec3(
      ( texture2D( heightmap, uv + vec2( - cellSize.x, 0 ) ).x - texture2D( heightmap, uv + vec2( cellSize.x, 0 ) ).x ) * ${FBO_WIDTH.toFixed( 1 )} / ${GEOM_WIDTH.toFixed( 1 )},
      ( texture2D( heightmap, uv + vec2( 0, - cellSize.y ) ).x - texture2D( heightmap, uv + vec2( 0, cellSize.y ) ).x ) * ${FBO_HEIGHT.toFixed( 1 )} / ${GEOM_HEIGHT.toFixed( 1 )},
      1.0 );
  `)
  shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>', `
    float heightValue = texture2D( heightmap, uv ).x;
    vec3 transformed = vec3( position.x, position.y, heightValue );
  `)
}

我们在 this.waterMat.userData.heightmapShader.uniforms.heightmap 之间建立了链接,这样每当从 FBO 计算出新的高度图值时,我们就可以将其保存到材质的 userData 中,然后更新shader中的高度图数据uniform。

可以看到,我们将网格上的 z 位置替换为高度图的值。它是 Z 轴而不是 Y 轴,是因为我们将网格旋转了 90 度,因此它水平面朝上。我们从高度图纹理中获取 x 值,这是我们存储每个点的当前高度的槽。然后因为我们改变了高度位置,所以我们还需要重新计算法线。

最后,我们构建网格并将其添加到场景中:

this.waterMesh = new THREE.Mesh( plane, this.waterMat )
this.waterMesh.rotation.x = - Math.PI / 2
// as the mesh is static, we can turn auto update off: https://threejs.org/docs/#manual/en/introduction/Matrix-transformations
this.waterMesh.matrixAutoUpdate = false
this.waterMesh.updateMatrix()
scene.add( this.waterMesh )

Raycasting

如果我们想让鼠标移动触发涟漪效果,则需要Raycasting将鼠标的屏幕坐标投射到场景中,求出相交的世界坐标。

首先,我们初始化一些变量和 Raycaster

this.mouseMoved = false
this.pointer = new THREE.Vector2()
this.raycaster = new THREE.Raycaster()

然后我们定义绑定到pointermove事件的函数:

onPointerMove( event ) {
    if ( event.isPrimary === false ) return
    // converting mouse coordinates into -1 to +1 space
    this.pointer.x = ( event.clientX / window.innerWidth ) * 2 - 1
    this.pointer.y = - ( event.clientY / window.innerHeight ) * 2 + 1
    this.mouseMoved = true
}

this.container.addEventListener( 'pointermove', this.onPointerMove.bind(this) )

在每帧调用的更新函数中,我们添加以下代码:

const hmUniforms = this.heightmapVariable.material.uniforms
if ( this.mouseMoved ) {
  this.raycaster.setFromCamera( this.pointer, camera )
  const intersects = this.raycaster.intersectObject( this.waterMesh )

  if ( intersects.length > 0 ) {
    const point = intersects[ 0 ].point
    // point是世界坐标
    hmUniforms[ 'mousePos' ].value.set( point.x, point.z )
  } else {
    hmUniforms[ 'mousePos' ].value.set( 10000, 10000 )
  }

  this.mouseMoved = false
} else {
  hmUniforms[ 'mousePos' ].value.set( 10000, 10000 )
}

hmUniforms 是 FBO 使用的计算片段着色器中的uniform变量。当鼠标移动时,我们计算鼠标和水网格的交点,并将该点的世界坐标保存到 hmUniforms[‘mousePos’] 中。由于计算片段着色器负责计算波高,因此我们可以很自然地将鼠标与水的交点传递给它。当没有鼠标移动时,我们只需将其设置到画布外的某个位置即可。

设置 FBO

最后是这个示例的核心部分。我们将计算片段着色器中的波浪计算位留到最后。
首先引入文件:

import { GPUComputationRenderer } from "three/examples/jsm/misc/GPUComputationRenderer"
import { SimplexNoise } from "three/examples/jsm/math/SimplexNoise"
import HeightmapFragment from "./shaders/heightmapFragment.glsl"

我们需要 SimplexNoise 来生成水网格的初始高度位置。

接下来我们定义一些用户可更改的参数:

const params = {
  mouseSize: 20.0,
  viscosity: 0.98,
  waveHeight: 0.3,
}

为了初始化 GPUComputationRenderer,我们需要传入用于存储计算的高度图的数据纹理的尺寸,以及我们用于场景绘制的渲染器。

this.gpuCompute = new GPUComputationRenderer( FBO_WIDTH, FBO_HEIGHT, renderer )
if ( renderer.capabilities.isWebGL2 === false ) {
  this.gpuCompute.setDataType( THREE.HalfFloatType )
}

我们的数据纹理的分辨率为 128 x 128,这样每个纹素都存储水网格上相应顶点的高度值。这也是为什么之前我们将宽度/高度段数设置为 127,因为总顶点数将为 128 x 128。

接下来我们需要创建真正的 FBO,即 this.heightmapVariable

const heightmap0 = this.gpuCompute.createTexture()
this.fillTexture( heightmap0 )
this.heightmapVariable = this.gpuCompute.addVariable( 'heightmap', HeightmapFragment, heightmap0 )
this.gpuCompute.setVariableDependencies( this.heightmapVariable, [ this.heightmapVariable ] )

this.heightmapVariable主要存储3个东西:

  • 使用计算片段着色器渲染的虚拟/离屏网格的材质
  • 此离屏渲染的渲染目标
  • 对其他 FBO 的依赖

这里我们将 FBO 设置为依赖于自身,这样我们就可以将最后一帧的高度图位置作为输入来计算下一帧的高度图位置。

我们调用 this.fillTexture 方法使用 Simplex Noise 生成初始高度数据:

fillTexture( texture ) {
  const waterMaxHeight = 2;
  const simplex = new SimplexNoise()

  function layeredNoise( x, y ) {
    let multR = waterMaxHeight;
    let mult = 0.025;
    let r = 0;
    for ( let i = 0; i < 10; i ++ ) {
      r += multR * simplex.noise( x * mult, y * mult );
      multR *= 0.5;
      mult *= 2;
    }

    return r;
  }

  const pixels = texture.image.data;

  let p = 0;
  for ( let j = 0; j < FBO_HEIGHT; j ++ ) {
    for ( let i = 0; i < FBO_WIDTH; i ++ ) {
      const x = i * 128 / FBO_WIDTH;
      const y = j * 128 / FBO_HEIGHT;

      pixels[ p + 0 ] = layeredNoise( x, y );
      pixels[ p + 1 ] = 0;
      pixels[ p + 2 ] = 0;
      pixels[ p + 3 ] = 1;

      p += 4;
    }
  }
}

然后我们设置要在计算片段着色器中使用的uniform和define:

this.heightmapVariable.material.uniforms[ 'mousePos' ] = { value: new THREE.Vector2( 10000, 10000 ) }
this.heightmapVariable.material.uniforms[ 'mouseSize' ] = { value: params.mouseSize }
this.heightmapVariable.material.uniforms[ 'viscosityConstant' ] = { value: params.viscosity }
this.heightmapVariable.material.uniforms[ 'waveheightMultiplier' ] = { value: params.waveHeight }
this.heightmapVariable.material.defines.GEOM_WIDTH = GEOM_WIDTH.toFixed( 1 )
this.heightmapVariable.material.defines.GEOM_HEIGHT = GEOM_HEIGHT.toFixed( 1 )

调用init来完成初始化!

const error = this.gpuCompute.init()
if ( error !== null ) {
  console.error( error )
}

添加可调整参数的 GUI 控件:

const gui = new dat.GUI()
gui.add(params, "mouseSize", 1.0, 100.0, 1.0 ).onChange((newVal) => {
  this.heightmapVariable.material.uniforms[ 'mouseSize' ].value = newVal
})
gui.add(params, "viscosity", 0.9, 0.999, 0.001 ).onChange((newVal) => {
  this.heightmapVariable.material.uniforms[ 'viscosityConstant' ].value = newVal
})
gui.add(params, "waveHeight", 0.1, 2.0, 0.05 ).onChange((newVal) => {
  this.heightmapVariable.material.uniforms[ 'waveheightMultiplier' ].value = newVal
})

最后,在每帧调用的更新函数中,我们必须手动告诉 this.gpuCompute 计算新的高度图数据。然后我们可以立即将计算结果传递给水网格的着色器:

this.gpuCompute.compute()
this.waterMat.userData.heightmap.value = this.gpuCompute.getCurrentRenderTarget( this.heightmapVariable ).texture

如何模拟波浪

现在我们进入本教程的最后部分,先来看下 heightmapFragment.glsl 里有什么。

#define PI 3.1415926538

uniform vec2 mousePos;
uniform float mouseSize;
uniform float viscosityConstant;
uniform float waveheightMultiplier;

void main() {
  vec2 cellSize = 1.0 / resolution.xy;

  vec2 uv = gl_FragCoord.xy * cellSize;

  // heightmapValue.x 为倒数第一帧的高度
  // heightmapValue.y 为倒数第二帧的高度
  vec4 heightmapValue = texture2D( heightmap, uv );

  // 获取相邻值
  vec4 north = texture2D( heightmap, uv + vec2( 0.0, cellSize.y ) );
  vec4 south = texture2D( heightmap, uv + vec2( 0.0, - cellSize.y ) );
  vec4 east = texture2D( heightmap, uv + vec2( cellSize.x, 0.0 ) );
  vec4 west = texture2D( heightmap, uv + vec2( - cellSize.x, 0.0 ) );

  float newHeight = ( ( north.x + south.x + east.x + west.x ) * 0.5 - heightmapValue.y ) * viscosityConstant;

  // 鼠标影响
  float mousePhase = clamp( length( ( uv - vec2( 0.5 ) ) * vec2(GEOM_WIDTH, GEOM_HEIGHT) - vec2( mousePos.x, - mousePos.y ) ) * PI / mouseSize, 0.0, PI );
  newHeight += ( cos( mousePhase ) + 1.0 ) * waveheightMultiplier;

  heightmapValue.y = heightmapValue.x;
  heightmapValue.x = newHeight;

  gl_FragColor = heightmapValue;

}
  • resolution 变量定义为数据纹理的分辨率,来自我们构建 GPUComputationRenderer 时传入的宽度和高度参数。
  • 在我们定义了 this.heightmapVariable 的自依赖之后,heightmap 变量也是在这里自动定义的。它应该与 this.heightmapVariable.name 具有相同的名称。

至于水波算法暂时先不讲述了。

  • 13
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

搞GIS图形的sky.

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值