milvinae

web & product design.

webGL(Three.js)で、雨が水面に落ちるエフェクトを作る

,

CSSとJavascriptだけでも結構なアニメーション表現ができます。
WebGLを使うことでさらに素敵なアニメーションを表現することが出来ます。

ということで、今回はWebGLを使いやすくするライブラリThree.jsを利用して、水たまりに雨が降るようなエフェクトを作ります。

<参考ページ>
水面シミュレーションサンプル:https://threejs.org/examples/?q=Water#webgl_gpgpu_water
鏡面反射サンプル      :https://threejs.org/examples/#webgl_materials_envmaps
鏡面反射サンプル2     :https://threejs.org/docs/scenes/material-browser.html#MeshPhongMaterial


できたものがこちら

See the Pen rain effect by keiyashi (@keiyashi) on CodePen.


WebGLはデバッグしにくくシステム的にも奥が深いです。
お断りしておきますが、理解していると思い込んでいる部分も多くあるので間違ってたらごめんなさい。
とにかくプログラムは動きます。
ので、参考にできる部分は取り入れてもらえれば幸いです。


まず実装の方向性ですが、素直に”水面マテリアルに環境マップを設定してそれを反射させる”という形で作っていきます。
シェーダ周りは不要なところを削除し、必要なシェーダの中身はいじりません。


ではWebGLの特有のシェーダーから見ていきましょう。
webgl_gpgpu_waterの頂点シェーダwaterVertexShaderとフラグメントシェーダheightmapFragmentShaderを使います。

<div class="backboard">
</div>

<script type="importmap">
  {
    "imports": {
      "three": "https://threejs.org/build/three.module.js",
      "stats":  "https://threejs.org/examples/jsm/libs/stats.module.js",
      "gui"  :"https://threejs.org/examples/jsm/libs/lil-gui.module.min.js",
      "gpucompute":"https://threejs.org/examples/jsm/misc/GPUComputationRenderer.js",
      "orbit":"https://threejs.org/examples/jsm/controls/OrbitControls.js"     
    }
  }
</script>

<script id="heightmapFragmentShader" type="x-shader/x-fragment">

			#include <common>

			uniform vec2 mousePos;
			uniform float mouseSize;
			uniform float viscosityConstant;
			uniform float heightCompensation;

			void main()	{

				vec2 cellSize = 1.0 / resolution.xy;

				vec2 uv = gl_FragCoord.xy * cellSize;

				// heightmapValue.x == height from previous frame
				// heightmapValue.y == height from penultimate frame
				// heightmapValue.z, heightmapValue.w not used
				vec4 heightmapValue = texture2D( heightmap, uv );

				// Get neighbours
				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 ) );

				// https://web.archive.org/web/20080618181901/http://freespace.virgin.net/hugo.elias/graphics/x_water.htm

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

				// Mouse influence
				float mousePhase = clamp( length( ( uv - vec2( 0.5 ) ) * BOUNDS - vec2( mousePos.x, - mousePos.y ) ) * PI / mouseSize, 0.0, PI );
				newHeight += ( cos( mousePhase ) + 1.0 ) * 0.28;

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

				gl_FragColor = heightmapValue;
			}

		</script>

<script id="waterVertexShader" type="x-shader/x-vertex">

			uniform sampler2D heightmap;

			#define PHONG

			varying vec3 vViewPosition;

			#ifndef FLAT_SHADED

				varying vec3 vNormal;

			#endif

			#include <common>
			#include <uv_pars_vertex>
			#include <displacementmap_pars_vertex>
			#include <envmap_pars_vertex>
			#include <color_pars_vertex>
			#include <morphtarget_pars_vertex>
			#include <skinning_pars_vertex>
			#include <shadowmap_pars_vertex>
			#include <logdepthbuf_pars_vertex>
			#include <clipping_planes_pars_vertex>

			void main() {

				vec2 cellSize = vec2( 1.0 / WIDTH, 1.0 / WIDTH );

				#include <uv_vertex>
				#include <color_vertex>

				// # include <beginnormal_vertex>
				// Compute normal from heightmap
				vec3 objectNormal = vec3(
					( texture2D( heightmap, uv + vec2( - cellSize.x, 0 ) ).x - texture2D( heightmap, uv + vec2( cellSize.x, 0 ) ).x ) * WIDTH / BOUNDS,
					( texture2D( heightmap, uv + vec2( 0, - cellSize.y ) ).x - texture2D( heightmap, uv + vec2( 0, cellSize.y ) ).x ) * WIDTH / BOUNDS,
					1.0 );
				//<beginnormal_vertex>

				#include <morphnormal_vertex>
				#include <skinbase_vertex>
				#include <skinnormal_vertex>
				#include <defaultnormal_vertex>

			#ifndef FLAT_SHADED // Normal computed with derivatives when FLAT_SHADED

				vNormal = normalize( transformedNormal );

			#endif

				//# include <begin_vertex>
				float heightValue = texture2D( heightmap, uv ).x;
				vec3 transformed = vec3( position.x, position.y, heightValue );
				//<begin_vertex>

				#include <morphtarget_vertex>
				#include <skinning_vertex>
				#include <displacementmap_vertex>
				#include <project_vertex>
				#include <logdepthbuf_vertex>
				#include <clipping_planes_vertex>

				vViewPosition = - mvPosition.xyz;

				#include <worldpos_vertex>
				#include <envmap_vertex>
				#include <shadowmap_vertex>
			}
</script>
importで重要なのはThreeとGPUComputationRendererですね。
Three.jsは、煩雑な行列計算や手続きを簡略化しよく使われる便利なシェーダを組み込んだWebGLのユーティリティライブラリです。
GPUComputationRendererはThree.jsの拡張モジュールで、GPUにシェーダの計算を個別に委託し結果を変数としてアクセスすることが出来ます。
gpucomputationrender-three
なんだかよくわからない説明ですが、とにかくフラグメントシェーダを独立して計算させることが出来るモジュールだと思ってください。
ちなみに、GPUComputionRenderを使わないとGPUが利用できないわけではなく、WebGL自体もGPU上で動作してます。


その他のimportのstats/gui/orbitは直接必要ではありませんが、あると便利なものなので追加しています。
statsはフレームレートの乱れをチェックし負荷を確認するためのインターフェースで、guiはパラメータを簡単に変更できるようにするためのインターフェースを提供します。
orbitはカメラを注目点を中心に操作するための機能を提供します。
それぞれよく使われるものなので解説は省きます。



続いてシェーダの中身ですが…

すみません。
waterVertexShaderはほぼインライン展開していて読み切れていません。
おそらく、”描画毎の水面のジオメトリを決めているシェーダ”と考えています。
js側で水面のマテリアルにこのシェーダが登録されているのでほぼ間違いないでしょう。


heightmapFragmentShaderは結構複雑ですがなんとなく読めそうです。
宣言のないheightmapを参照して、注目uvの上下左右を合成して半分にして前のフレームの高さを引いて粘性係数をかけて…
なんとなく波の伝搬を記述している感じがします。
ですが、まだ使い方が判然としませんね。

一旦jsのほうへ移りましょう。
// inspired by 
// https://threejs.org/examples/?q=Water#webgl_gpgpu_water
// https://threejs.org/examples/#webgl_materials_envmaps
// https://threejs.org/docs/scenes/material-browser.html#MeshPhongMaterial

import * as THREE from 'three';
import Stats from 'stats';
import { GUI } from 'gui';
import { GPUComputationRenderer } from 'gpucompute';
import { OrbitControls } from "orbit";

// Texture width for simulation
const WIDTH = 128;

// Water size in system units
const BOUNDS = 512;
const BOUNDS_HALF = BOUNDS * 0.5;

let container, stats;
let camera, scene, renderer, controls;
let mouseMoved = false;
const mouseCoords = new THREE.Vector2();
const raycaster = new THREE.Raycaster();

let waterMesh;
let meshRay;
let gpuCompute;
let heightmapVariable;
let waterUniforms;
const waterNormal = new THREE.Vector3();

const NUM_SPHERES = 5;
const spheres = [];
let spheresEnabled = true;

//addition for rain effect
let dropSize;
let dropFreq; // how long msec between drop
const dropCoords = new THREE.Vector2();
let dropped = false;

init();

function init() {
  
  dropSize = 1;
  dropFreq = 50;

  container = document.querySelector(".backboard");

  camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 1, 3000 );
  camera.position.set( 0, 200, 0 );
  camera.lookAt( 0, 0, 0 );

  scene = new THREE.Scene();

  const sun = new THREE.DirectionalLight( 0xFFFFFF, 20.0 );
  sun.position.set( 200, 300, 175 );
  sun.target.position.set(0,0,0);
  scene.add( sun );

  const sun2 = new THREE.DirectionalLight( 0x40A040, 4.0 );
  sun2.position.set( - 100, 350, - 200 );
  scene.add( sun2 );

  renderer = new THREE.WebGLRenderer();
  renderer.setPixelRatio( window.devicePixelRatio );
  renderer.setSize( window.innerWidth, window.innerHeight );
  controls = new OrbitControls(camera, renderer.domElement);
  controls.enableDamping = true;
  container.appendChild( renderer.domElement );

  stats = new Stats();
  container.appendChild( stats.dom );

  container.style.touchAction = 'none';
  container.addEventListener( 'pointermove', onPointerMove );

  document.addEventListener( 'keydown', function ( event ) {

    // W Pressed: Toggle wireframe
    if ( event.keyCode === 87 ) {
      waterMesh.material.wireframe = ! waterMesh.material.wireframe;
      waterMesh.material.needsUpdate = true;
    }
  });

  window.addEventListener( 'resize', onWindowResize );
  const gui = new GUI();
  const effectController = {
    mouseSize: 20.0,
    viscosity: 0.98,
    dropFrequency:dropFreq,
  };

  const valuesChanger = function () {
    heightmapVariable.material.uniforms[ 'mouseSize' ].value = effectController.mouseSize;
    heightmapVariable.material.uniforms[ 'viscosityConstant' ].value = effectController.viscosity;
    dropFreq = effectController.dropFrequency;
  };

  gui.add( effectController, 'mouseSize', 1.0, 100.0, 1.0 ).onChange( valuesChanger );
  gui.add( effectController, 'viscosity', 0.9, 0.999, 0.001 ).onChange( valuesChanger );
  gui.add( effectController, 'dropFrequency', 5, 100, 1.0 ).onChange( valuesChanger );

  loadTexture()
  .then(envTexture => {
    initWater(envTexture);
    setTimeout(calcDropPoint, dropFreq);
    valuesChanger();  
    renderer.setAnimationLoop( animate );
  });
}

function calcDropPoint(){
  dropCoords.set( Math.floor(Math.random() * BOUNDS * 2) - BOUNDS, - (Math.floor(Math.random() * BOUNDS * 2) - BOUNDS));
  dropped = true;  
  setTimeout(calcDropPoint, dropFreq);
}

async function loadTexture(){
  return new Promise(function (resolve,reject) {
    
    const r = "https://threejs.org/examples/textures/cube/Bridge2/";
    const urls = [r + "posx.jpg", r + "negx.jpg", r + "posy.jpg", r + "negy.jpg", r + "posz.jpg", r + "negz.jpg"];

    const cubeTextureLoader = new THREE.CubeTextureLoader();
    const reflectionCube = cubeTextureLoader.load( urls );
    resolve( reflectionCube );
  })
}

function initWater(envTexture) {

  const materialColor = 0x000000;
  const geometry = new THREE.PlaneGeometry( BOUNDS, BOUNDS, WIDTH - 1, WIDTH - 1 );

  // material: make a THREE.ShaderMaterial clone of THREE.MeshPhongMaterial, with customized vertex shader
  
  const material = new THREE.ShaderMaterial( {
    uniforms: THREE.UniformsUtils.merge( [
      THREE.ShaderLib.phong.uniforms,
      {
        'heightmap': { value: null }
      }
    ] ),
    vertexShader: document.getElementById( 'waterVertexShader' ).textContent,
    fragmentShader: THREE.ShaderChunk[ 'meshphong_frag' ],
  } );

  material.lights = true;
  material.envMap = envTexture;
  material.combine = THREE.AddOperation;
  material.reflectivity = 0.6 ;
  
  // Material attributes from THREE.MeshPhongMaterial
  // Sets the uniforms with the material values
  material.uniforms[ 'diffuse' ].value = new THREE.Color( materialColor );
  material.uniforms[ 'emissive' ].value = new THREE.Color( 0x444444 );
  material.uniforms[ 'specular' ].value = new THREE.Color( 0x111111 );
  material.uniforms[ 'shininess' ].value = Math.max( 50, 1e-4 );
  material.uniforms[ 'opacity' ].value = material.opacity;
  material.uniforms[ 'envMap' ].value = envTexture;

  // Defines
  material.defines.WIDTH = WIDTH.toFixed( 1 );
  material.defines.BOUNDS = BOUNDS.toFixed( 1 );

  waterUniforms = material.uniforms;

  waterMesh = new THREE.Mesh( geometry, material );
  waterMesh.rotation.x = - Math.PI / 2;
  waterMesh.matrixAutoUpdate = false;
  waterMesh.updateMatrix();
  scene.add( waterMesh );

  // THREE.Mesh just for mouse raycasting
  const geometryRay = new THREE.PlaneGeometry( BOUNDS, BOUNDS, 1, 1 );
  meshRay = new THREE.Mesh( geometryRay, new THREE.MeshBasicMaterial( { color: 0xFFFFFF, visible: false } ) );
  meshRay.rotation.x = - Math.PI / 2;
  meshRay.matrixAutoUpdate = false;
  meshRay.updateMatrix();
  scene.add( meshRay );

  // Creates the gpu computation class and sets it up
  gpuCompute = new GPUComputationRenderer( WIDTH, WIDTH, renderer );
  const heightmap0 = gpuCompute.createTexture();//initial heigth map
  heightmapVariable = gpuCompute.addVariable( 'heightmap', document.getElementById( 'heightmapFragmentShader' ).textContent, heightmap0 );

  gpuCompute.setVariableDependencies( heightmapVariable, [ heightmapVariable ] );
  heightmapVariable.material.uniforms[ 'mousePos' ] = { value: new THREE.Vector2( 10000, 10000 ) };
  heightmapVariable.material.uniforms[ 'mouseSize' ] = { value: 20.0 };
  heightmapVariable.material.uniforms[ 'viscosityConstant' ] = { value: 0.98 };
  heightmapVariable.material.uniforms[ 'heightCompensation' ] = { value: 0 };
  heightmapVariable.material.defines.BOUNDS = BOUNDS.toFixed( 1 );

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

function onWindowResize() {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();

  renderer.setSize( window.innerWidth, window.innerHeight );
}

function setMouseCoords( x, y ) {
  mouseCoords.set( ( x / renderer.domElement.clientWidth ) * 2 - 1, - ( y / renderer.domElement.clientHeight ) * 2 + 1 );
  mouseMoved = true;
}

function onPointerMove( event ) {
  if ( event.isPrimary === false ) return;

  setMouseCoords( event.clientX, event.clientY );
}

function animate() {
  
  controls.update();
  render();
  stats.update();
}

function render() {

  // Set uniforms: mouse interaction
  const uniforms = heightmapVariable.material.uniforms;
  if ( mouseMoved ) {
    raycaster.setFromCamera( mouseCoords, camera );
    const intersects = raycaster.intersectObject( meshRay );

    if ( intersects.length > 0 ) {
      const point = intersects[ 0 ].point;
      uniforms[ 'mousePos' ].value.set( point.x, point.z );
    } else {
      uniforms[ 'mousePos' ].value.set( 10000, 10000 );
    }
    mouseMoved = false;
  } else {
    uniforms[ 'mousePos' ].value.set( 10000, 10000 );
  }
  
  if( dropped ){
    uniforms[ 'mousePos' ].value.set( dropCoords.x, dropCoords.y );
    dropped = false;
  }

  // Do the gpu computation
  gpuCompute.compute();

  // Get compute output in custom uniform
  waterUniforms[ 'heightmap' ].value = gpuCompute.getCurrentRenderTarget( heightmapVariable ).texture;

  // Render
  renderer.render( scene, camera );
}
ほぼサンプルと同じですが、水面に浮く玉の処理などは削除しています。
また、結果を確認しやすくするためOrbitControlsを追加しています。

サンプルの段階で波の表現はできているので、あとは水面に反射と環境マップを設定してやりました。
ただし、いくつか躓いた点がありました。
1.環境マップを作る際に限らず、画像の読み込みがある時は完了を待つ必要がある
2.環境マップをマテリアルに紐づけるときはマテリアルとUniformの両方に登録する必要がある
3.使用する画像のサイズは2^nの正方形(が推奨される)

1ですが、async/awaitやpromiseで待つのがいいかと思います。(asyncをつけるとpromiseで結果が返されるので意味合い的には一緒です)
ロードは時間がかかり、ロードが完了する前にマテリアルにテクスチャを紐づけてしまうとテクスチャが表示されなくなってしまう場合があるからです。
今回はloadTexture関数を作り、ロード完了をPromiseで待つようにしました。
ロード完了後、マテリアル生成から恒常ループ発行までを実行します。

2は陥りがちなミスで、マテリアル自身とPhongShaderのuniformの両方にEnvmapをセットする箇所があるのです。
solved-how-to-use-envmap-in-shadermaterial-based-on-meshphongmaterial

3はOpenGL ES2.0以降は2^nのサイズ指定は必須ではないそうですが、なぜか正方形でないとうまくいきませんでした。
一応、2^nサイズが推奨だそうです。
Using_textures_in_WebGL



おまけにランダムに雨が落ちるような処理を追加してあげれば、ホラ!
結果、期待通りにできました。





ですが、とりあえず動いたからいいや では今後再び困ってしまうシチュエーションに出会ってしまうでしょう。
やっていることを(可能な限り)理解し、次に備えましょう。

jsとシェーダを細かく見ていきましょう。

・init関数
 → Three.jsの初期化やイベントハンドラーの登録などで特にいうことはありません。

・calcDropPoint関数
 → 波紋が広がる位置をランダムに生成する関数です。
   タイムアウトで再呼び出ししていますが、もっといいやり方がありそうな気がします。

・loadTexture関数
 → Promiseを使って画像のロードを待つ関数です。
   Promiseも奥が深い関数なので後日解説します。

・initWater関数
 → 肝と言ってもいいでしょう。
   水面マテリアルの生成・GPU計算変数をセットアップします。
   注目するのは、水面マテリアルの頂点シェーダにはwaterVertexShaderをセットするのに、フラグメントシェーダにはheightmapFragmentShaderはセットしていない点です。
   heightmapFragmentShaderシェーダはGPUComputionRenderに登録しています。
   水面マテリアルのフラグメントシェーダには、組み込み済みのMeshPhong_fragを使用します。

   なぜ自作のFragmentShaderをGPUComputionRenderに登録するのでしょうか?
   それは、波紋の伝搬のようなTrailを実現するには前のフレームの状態を知る必要があり、GPUComputionは都合がいいからです。

   GPUComputionRenderは自身に登録するシェーダに変数を追加します。
   この変数自身を変数に依存させることで、前のフレームの結果を参照することができます。

・render関数
 → GPUComputionRenderをComputeさせると登録したheightmapFramgentShaderが起動し、ハイトマップを参照しながらgl_FragColorに更新したheightmapを入れ込みます。
   つまるところheightmapFramgentShaderの計算結果はテクスチャ形式のheightmapですから、GPUからそれを取り出して水面マテリアルの頂点シェーダに渡してレンダリング命令を出します。
   
   水面マテリアルのVertexShaderはheightmapから法線の計算をし直していますから、その後のPhongシェーダのレンダリングで最新の状態の鏡面反射が計算されるわけです。


※ 注意したいのは、水面マテリアルのFragmentShaderは組み込み済みのphongShaderです。
  GPUで計算したheightmapをそのまま受け取ることが出来ません。
  なので、マテリアル宣言時にUniformにheightmapをMergeしています。
  


~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~


いやぁ、実に難しい。
Three.jsで使いやすくなっているとはいえ基本はWebGL。
まるで直感的でないうえに、オフィシャルな解説が少ない…。
WebGLの理解にかなり苦しみました。

個人的には以下のサイトがめちゃくちゃ頼りになりました。
https://webglfundamentals.org/webgl/lessons/ja/

あとはもうひたすら生のコードを追っかけるだけです。

最後に偉大なる先人たちに感謝。
ありがとうございました。