<Three.jsとシェーダースクリプト>マウスオンで波打って違う画像を表示するサンプル
何かGLSLを使ってWebサイトに応用できそうなものを作りたかったので、マウスオーバーで、画像が波打って変わるようなのを作りました。
デモ
https://cumak.github.io/three-wave-image-change/
納得いくまで調整するのが結構大変で…(今も不満なとこあるし)
しかも、これを実践でどう組み入れるかというのが全く頭に思い浮かばないのですが。
でも、こういうアニメーションは、できないからやらないのと、できるけどやらないのとでは違うと信じている。
アニメーション好きとして、WebGLはなんか避けてはいけない気がするのでやってみましたが(ほぼ強迫観念に近い)難しい…。
せっかく作ったのでポイントを書いていきます。
全コードはこちら
https://github.com/cumak/three-wave-image-change
環境構築
以前、
glslとThree.jsのためのesbuild + TypeScript環境を作る
という記事を書きましたが、今回はテストしたかっただけなので、簡単にviteを使っています。
とりあえずビルド環境が必要。
HTML
canvasタグを用意します。
<canvas class="webglGallery"></canvas>
あとは好きなように。
CSSは省略します。canvasを画面いっぱいにするだけです。
コード概要
Three.jsでシーンやメッシュを作成する
Three.jsで、シーン・パースペクティブカメラ・レンダラーを作ります。
(このあたりは特別なことはしていないので省略)
メインオブジェクトは、プレーンジオメトリ(板ポリ)にテクスチャを貼り付けています。
マテリアルはShaderMaterial
を使い、オプションを定義して生成します。
const geometry = new THREE.PlaneGeometry(1.5, 1, 512, 512);
const material = new THREE.ShaderMaterial({
vertexShader: vertexShader,
fragmentShader: fragmentShader,
side: THREE.DoubleSide,
transparent: true,
uniforms: {
uTextureImage: { value: textureImage },
uTextureImage2: { value: textureImage2 },
uTime: { value: START_TIME },
uFreqency: { value: 9.25 },
uWaveLength: { value: 0.082 },
uFreqency2: { value: 1.13 },
uWaveLength2: { value: 0.674 },
uTopDown: { value: -0.403 },
uSpeed: { value: 2.3 },
uPosAddZ: { value: 7.31 },
uDispHandle: { value: -2 },
}
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
マテリアルのオプションについて
vertexShader
受け取った頂点の位置を変更できる。ここでは頂点シェーダープログラムを文字列で渡している。vertexShader
は「fragmentShader.glsl」に記述し、importしています。
fragmentShader
受け取ったピクセルの色を設定。ここではフラグメントシェーダープログラムを文字列で渡している。
fragmentShaderは「fragmentShader.glsl」に記述し、importしています。
side
デフォルトは表面だけ表示される。DoubleSide
を指定するとカメラが裏側にまわっても表示される。
transparent
透過の設定
uniforms
頂点シェーダーとフラグメントシェーダーの両方に情報を送信できる。グローバル変数を作ることができる。
uTextureImage
とuTextureImage2
は、テクスチャ(メインの画像)です。
fragmentShaderで使います。
uFreqency
・uWaveLength
・uFreqency2
・uWaveLength2
は、波の具合を調整するために自分で作ったパラメータです。
uTopDown
:・uSpeed
・uPosAddZ
・uDispHandle
も自分で作ったパラメータです。
それぞれvertexShaderで使います。
uTime
は、経過時間をいれるために作ったパラメータです。Date.now()
を使って、「時間と共に増え続ける数字」を取得し、それを利用してピクセルの座標を移動させます。
テクスチャの読み込みと板ポリへの適用
uniformsでfragmentShader.glslと共有します。
const textureLoader = new THREE.TextureLoader();
const textureImage = textureLoader.load(image);
const textureImage2 = textureLoader.load(image2);
fragmentShader.glslでは、ロードした画像をテクスチャにして、mix関数に入れます。
mix関数は、2つの色やテクスチャを混ぜることができます。第三引数に0〜1の数字を指定し、例えば0.2なら第一引数の色が20%、第二引数の色が80%の割合で混ざります。
テクスチャも混ぜることができます。
vec4 textureColor = texture2D(uTextureImage, vUv);
vec4 textureColor2 = texture2D(uTextureImage2, vUv);
vec4 mixTexture = mix(textureColor, textureColor2, vDispVal);
gl_FragColor = vec4(mixTexture);
vUvには、vertexShaderから共有されたUV情報が入っています。
UVには、特定の面にテクスチャのどの部分を対応させるかの情報が入っていて、Three.jsでジオメトリを作ったときに自動的にいい感じに作られています。
※UVは展開図みたいなものです。分かりにくければ、Blender(モデリングソフト)で何か作品を作ってみたら、理解しやすいです。
マウスを乗せたときの判定
マウスの位置を取得します。
const mouse = new THREE.Vector2();
canvas.addEventListener('mousemove', (event: MouseEvent) => {
const element = event.currentTarget as HTMLElement;
const x = event.clientX - element.offsetLeft;
const y = event.clientY - element.offsetTop;
const w = element.offsetWidth;
const h = element.offsetHeight;
mouse.x = (x / w) * 2 - 1;
mouse.y = -(y / h) * 2 + 1;
});
Three.jsでは、3Dオブジェクトにマウスが乗っているかどうかは
レイキャスターという機能で判定することができます。
イメージ的には、カメラからビームが発射されて、オブジェクトと交差したらその要素が取得できるといった感じです。
光線とぶつかったかどうかはintersectObjects
メソッドを使い、引数に、光線との交差をチェックするオブジェクトをいれます。シーン内のものをすべて対象にするため、scene.children
を引数に指定しています。
animate関数のなかで光線を発射し、フレームごとにオブジェクトの位置を把握 → アニメーションします。
const raycaster = new THREE.Raycaster();
const animate = () => {
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children);
if(intersects.length && intersects[0].object.name === 'picture'){
マウスが「picture」というnameのメッシュに乗ったときの処理
}
renderer.render(scene, camera);
window.requestAnimationFrame(animate);
}
animate();
マウスオーバーの処理
変数を用意します。
const START_TIME = 1
let startTime = Date.now();
let firstflag = true;
const animate = () => {
〜
const topDowntarget = material.uniforms.uTopDown;
const dispHandle = material.uniforms.uDispHandle;
〜
}
START_TIME
は、波が起き始める時間に合わせます。再生しながら調整して、最終的に1になりました。
startTime
… マウスが乗った時の時間を記憶する
firstflag
… gsapの処理(最初に波が持ち上がったり最後に下がったりなど)はフレームごとではなく一度でいいので、最初のフレームの処理を判定するためのフラグを用意
マウスが乗ったときの処理は以下です。
if (intersects.length === 1 && intersects[0].object.name === 'picture') {
const time = (Date.now() - startTime) / 1000 - START_TIME;
const maxTime = 1.4;
if((Date.now() - startTime) / 1000 - START_TIME > maxTime){
material.uniforms.uTime.value = maxTime;
}else{
material.uniforms.uTime.value = time;
}
if(firstflag){
gsap.to(topDowntarget,{value:-0.403,duration:1,ease:"power1.out"})
gsap.to(dispHandle,{value: 4.5 ,duration:1.9, delay:0.8})
firstflag = false;
}
} else {
firstflag = true;
gsap.to(topDowntarget,{value:-1.0,duration:1,ease:"power1.out",onComplete:()=>{material.uniforms.uTime.value = START_TIME}})
gsap.to(dispHandle,{value: -2 ,duration:1})
startTime = Date.now();
}
animate関数の中なので、フレームごとに上記の処理が高速ではしっていることになります。startTime
はマウスオフしてからもstartTime = Date.now()
で更新され、マウスオーバーの時に更新がとまるようにします。
マウスオーバーの間は Date.now() - startTime
でtime
を更新し続けます。つまりtime
にはマウスオーバーしてから現在までの時間が入ります。
最初は、Three.jsのclock.getDelta()でフレーム間の時間を取得し、それをマウスオーバーの間足していくことでtime変数を作っていました…が、マウスオーバーしたばかりの時はtime += clock.getDelta()
が機能しない!!…フレーム更新が速すぎて計算がついていけない模様…。いろんなパフォーマンス上の要因はあるかもしれませんが。
JSは非同期なので、足されないままtimeがvertexShaderを更新し続け、アニメーションの挙動がおかしくなるという現象に出くわしました。
上記の方法ならマウスオーバーイベント直後でもスムーズに値を取得できます。
ここでのポイントは、フレームごとにuTimeを更新することです。
material.uniforms.uTime.value = maxTime;
material.uniforms.uTime.value
を更新することで、vertexShaderでアニメーションに必要な「増え続ける数字」を取得することができます。
gsap.toのあたりは、マウスを乗せたときに波がふわっと持ち上がる処理と、マウスをはなしたときにふわっと波がなくなる処理です。
また、波の後に画像を切り替えるためのパラメータをアニメーションしています。
gsapを使うと、徐々に数字を増やしたいとか減らしたいということが簡単にでき、easingも設定できるのでめちゃくちゃ便利です。(ここではあまりeasingは効果的に使えてないですが)
GLSLで波を作る
下記で、z軸に対して波を作っています。
vec4 modelPosition = modelMatrix * vec4(position,1.0);
float elevation =
max(
(sin(((-modelPosition.x - modelPosition.y) + uTime * uSpeed) * uFreqency)) * uWaveLength
+ (cos(((-modelPosition.x - modelPosition.y) + uTime * uSpeed) * uFreqency2) * uWaveLength2)
+ uTopDown
, 0.0)
;
modelPosition.z += elevation;
sin関数を使って波の形を作ります。
今回は二つの波を組み合わせているので、sinの波とcosの波を作りました。
何度理解しようとして勉強しても、後で見ると「どういう意味だっけ…」となるので、ゆるく公式化しました。
<うねる波を作る公式>
<波立たせたい縦方向(modelPosition.z)> += sin(<波立たせたい横方向(modelPosition.x)> * 周波数の指数 + 経過時間) * 振幅の指数;
(-modelPosition.x - modelPosition.y)
で、斜め方向に設定。
uTime(経過時間)をsinの中で足すと、波の形を保ったまま横スライドしてくれます。(外で足すとうねる)
三角関数はGLSLを勉強するまで学生の時に習ったきりで、何のためにあるんだろうと思っていましたが、とにかく三角関数のグラフ(sinやcosだと波、tanだと上下に長い等高線(?)みたいな)の形をそのまま見える形で表現できるのだ。とわかり、「そのためにいちいちグラフ化してたのか」と思いました。
uTopDown
は、sin波の上の方だけ利用したかったので作ったパラメータです。
lil-gui(画面右上にスライダーをつけられるプログラム)で値を調整しました。
さらに、max関数でzのpositionが0未満にならないようにして、波の上のほうだけ使ってそれ以外の谷の部分はすべて0ポジションにしました
波の上の方は次の画像が表示されるようにする
vDispVal
は、fragmentShaderでmix関数の第三引数に指定するため自分で作ったパラメータです。
vec4 mixTexture = mix(textureColor, textureColor2, vDispVal);
これをz軸に連動させ、z軸が高いほどvDispVal
も高くなるように調整します。
0〜1の間に収めないといけないので、min関数とmax関数で1より大きい値と0未満をカットします。
そのままではうまく上のほうだけ変わるようにはならないので、波の高さの範囲でちゃんと画像がかわるよう、uPosAddZ
というパラメータをかけて、うまくみえる状態をまたlil-guiで探しました。
vDispVal = min(max(0.0,sin(modelPosition.z) * uPosAddZ + disp) , 1.0);
vec4 viewPosition = viewMatrix * modelPosition;
vec4 projectionPosition = projectionMatrix * viewPosition;
gl_Position = projectionPosition;
波のあとから追うように次の画像に切り替わる
画像を切り替えるのは、上のほうでかいたfragmentShader.glslのmix関数です。
右下から順番に変わるようにするため、x軸とy軸を計算に組み込みます。
これを増やすと次の画像に切り替わるみたいなパラメータがほしかったので、uDispHandle
という変数を作りました。
float disp = (-modelPosition.x - modelPosition.y) + 0.6 * uDispHandle;
このuDispHandle
は、jsのgsapでアニメーションさせます。
0.6は、あとから調整するために加えた適当な数字です。
こうやって作ったdisp
変数を、vDispVal
を作成する式に組み込む(というより組み込んでから変数の値を調整する ←この考え方がいる)と、波がおきたあとに画像がかわるようにできました。
vDispVal = min(max(0.0,sin(modelPosition.z) * uPosAddZ + disp) , 1.0);
まとめ
シェーダープログラムについては、基礎は勉強しましたが、まだなんとなくかけたり足したりして、lil-guiで具合を見ながらやっている状態です。
今までは単純な要素のCSSアニメーションだったり、Adobe AnimateやAfterEffectsで作ったものをJSライブラリで動かすといった感じで実装したこともありましたが、要素に高度なエフェクトをかけたような見栄えのものを作ることはできませんでした。
GLSLなら、他の方法では不可能なアニメーションが実現できます。
また、今まで例えばメインビジュアルにimgタグのスライダーを設置していたところを、WebGLのスライダーに変えると、解像度の高い画像を何枚も使って高速に表示できるようになったりします。
Webサイトに応用するには、たくさんの知識が必要になりますが、徐々にレベルアップしていきたいと思っています。