【WebGL】three.js : soft particle

~ この記事はTech KAYAC Advent Calendar 2020の18日目の記事になります ~

こんにちは!クライアントワークチーム・フロントエンドエンジニアの深澤です。web や unity の実装を担当しています。

今日は three.js で soft particle (ソフトパーティクル)を実装する方法を紹介していきたいと思います。

ソフトパーティクルを使って、霧のような雰囲気の中をキツネが駆けるシーンを作ってみました。

この動作デモはこちらから、動作デモのソースはこちらのリポジトリからご覧いただくことができます。

f:id:takumifukasawa:20201217140610g:plain


ソフトパーティクルについて

ソフトパーティクルとは

おおまかに一言でまとめると「深度値の比較をして透過率・色を調整する」方法のことです。

特徴

まずはこちらの画像を比較してみます。

f:id:takumifukasawa:20201216153311p:plain f:id:takumifukasawa:20201216153324p:plain

1枚目はソフトパーティクルを無効にしたもの、2枚目が有効にしたものです。 煙に着目すると1枚目は床(地面)との境界に線が入っており、2枚目は床に馴染んでいるという違いがあります。

煙は、霧感を出すためにキツネが走っている床のあたりに板ポリのビルボードにテクスチャを貼って散りばめています。ほとんどの煙の板ポリは床に突き抜けているような状態 = 板ポリと床は交差している状態にあるのですが、そのままだと1枚目の画像のように床の境界で煙のテクスチャが切れてしまうので不自然な見た目になります。

そこで、「他のオブジェクトとの重なりの境界を馴染ませる方法」としてソフトパーティクルを使います。

実装方針

カメラから見て床と煙の境界付近のピクセルに着目すると、カメラから床・煙それぞれへの距離はかなり近くなります。完全な境界の場合は、それぞれの距離はほぼ同じになっているはずです。この、距離が近い = 深度値が近いことを利用していきます。

まずは床を描画して床の深度値を取得します。その後、煙を描画する際に深度値を計算します。 その際、煙のピクセルシェーダー内で、床の深度値と煙の深度値を比較して近ければ近いほど透過するような処理をつけます。

すると、煙が描画されるピクセルのうち、床に遠い部分から近い部分にフェードアウトするような状態になります。

↓ 赤い枠線の範囲が板ポリゴンの床より上に見えている範囲だとしたら、

f:id:takumifukasawa:20201216201147p:plain

↓ 赤が濃い部分(床に近い部分)ほど透過をかけるようなイメージ

f:id:takumifukasawa:20201216201201p:plain

このように、深度値の比較をして透過率・色を調整することで他のオブジェクトとの重なりを馴染ませることができます。

ソフト「パーティクル」と呼ばれていますが、この後のデモのように、パーティクルのようなたくさん物量をばらまくようなオブジェクトでなくとも同じ見た目を表現することももちろん可能です。

three.jsでの実装

簡易的なデモをこちらに用意しました。重なっている部分にフェードがかかっていることが分かるかなと思います。

赤い箱は赤単色を出力するマテリアルを、白い箱はソフトパーティクルのシェーダーを適用したマテリアルを割り当てています。マテリアルは RawShaderMaterial を使っています。

See the Pen [test] three.js : soft particle by takumifukasawa (@takumifukasawa) on CodePen.

白い箱以外のオブジェクトも試してみました。

↓ ビルボードの白い板ポリ

f:id:takumifukasawa:20201216183214g:plain

↓ 煙のテクスチャを割り当てた板ポリのビルボード: ソフトパーティクル無効

f:id:takumifukasawa:20201216183239g:plain

↓ 煙のテクスチャを割り当てた板ポリのビルボード: ソフトパーティクル有効

f:id:takumifukasawa:20201216183307g:plain

JavaScript

requestAnimationFrame で毎フレーム実行される関数の中身を抜粋してコメントをつけてみます。

// RenderTarget の横幅縦幅は別途更新
const renderTarget = new THREE.WebGLRenderTarget(1, 1);
renderTarget.texture.format = THREE.RGBAFormat;
renderTarget.texture.minFilter = THREE.NearestFilter;
renderTarget.texture.magFilter = THREE.NearestFilter;
renderTarget.texture.generateMipmaps = false;
renderTarget.stencilBuffer = false;
renderTarget.depthBuffer = true;
renderTarget.depthTexture = new THREE.DepthTexture();
renderTarget.depthTexture.type = THREE.UnsignedShortType;
renderTarget.depthTexture.format = THREE.DepthFormat;

...

const tick = () => {
  ...

  const ctx = renderer.getContext();

  // ----------------------------------------------------------------------------
  // 1. ソフトパーティクル以外(= 赤い箱)の深度値を深度テクスチャに描画
  // ----------------------------------------------------------------------------

  // ソフトパーティクルのメッシュを非表示
  softParticleMesh.visible = false;

  // 深度値を描画する renderTarget を割り当て  
  renderer.setRenderTarget(renderTarget);
  
  // depth だけ renderTarget に描画するため、色情報は描画しないようにマスク
  ctx.colorMask(false, false, false, false);
  renderer.render(scene, camera);

  // ----------------------------------------------------------------------------
  // 2. ソフトパーティクルを含め全てのオブジェクトを描画
  // ----------------------------------------------------------------------------

  // 画面に表示させるために renderTarget の指定を解除
  renderer.setRenderTarget(null);

  // ソフトパーティクルのメッシュを表示  
  softParticleMesh.visible = true;

  // 深度値比較用の各種 uniform 値をシェーダーに渡す
  softParticleMesh.material.uniforms.tDepth.value = renderTarget.depthTexture;
  softParticleMesh.material.uniforms.cameraNear.value = camera.near;
  softParticleMesh.material.uniforms.cameraFar.value = camera.far;
  softParticleMesh.material.uniforms.depthFade.value = params.depthFade;
  softParticleMesh.material.uniforms.resolution.value = new THREE.Vector2(
    width * ratio, height * ratio
  );

  // 色情報のマスクを解除し描画
  ctx.colorMask(true, true, true, true);
  renderer.render(scene, camera);
};

流れをまとめると、

ソフトパーティクル以外のオブジェクトのz-bufferを深度テクスチャに焼く → 深度テクスチャなど必要な情報をソフトパーティクルのシェーダーに渡す → 全てのオブジェクトを描画

となっています。

z-buffer を深度テクスチャに焼く際、 gl.colorMask という WebGL API を直接叩いています。この関数は、フレームバッファーに書き込むチャンネルをフラグ形式で指定することができます。

深度情報のみ欲しい場合はRGBA情報は必要ないので、gl.colorMask(false, false, false, false) として色情報を書き込む処理を無効にし、負荷軽減対策をしています。

developer.mozilla.org

GLSL

ソフトパーティクルの処理を入れている白い箱の頂点シェーダーとフラグメントシェーダーの抜粋になります。 こちらもコメントをつけていきます。

// ----------------------------------------------------------------------------
// 頂点シェーダー
// ----------------------------------------------------------------------------

attribute vec3 position;
attribute vec2 uv;

uniform mat4 projectionMatrix;
uniform mat4 modelViewMatrix;

varying vec4 vViewPosition;

void main() {
  // ビュー座標をフラグメントシェーダーに渡す
  vViewPosition = modelViewMatrix * vec4(position, 1.);

  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.);
}
// ----------------------------------------------------------------------------
// ピクセルシェーダー
// ----------------------------------------------------------------------------

precision highp float;
  
#include <packing>
  
varying vec4 vViewPosition;

uniform sampler2D uDepthTexture;
uniform float uCameraNear;
uniform float uCameraFar;
uniform float uDepthFade;
uniform vec2 uResolution;
  
// refs: https://threejs.org/examples/webgl_depth_texture.html
float readDepth(sampler2D depthSampler, vec2 coord) {
  float fragCoordZ = texture2D(depthSampler, coord).x;
  float viewZ = perspectiveDepthToViewZ(fragCoordZ, cameraNear, cameraFar);
  return viewZToOrthographicDepth(viewZ, cameraNear, cameraFar);
} 
  
void main() {
  // スクリーン上の座標を0~1で取得
  vec2 screenCoord = gl_FragCoord.xy / uResolution.xy;

  // 深度テクスチャから、現在のピクセルの他のオブジェクト(= 赤い箱)の深度値を取得
  float sceneDepth = readDepth(uDepthTexture, screenCoord);

  // 現在のピクセルの、これから描画するオブジェクト(= 白い箱)の深度値を取得
  float viewZ = vViewPosition.z;
  float currentDepth = viewZToOrthographicDepth(viewZ, uCameraNear, uCameraFar);

  // 深度値を比較して、一定の値以下の場合はフェードする
  // 0除算しないように調整 
  float eps = .0001;
  float fade = clamp(abs(currentDepth - sceneDepth) / max(uDepthFade, eps), 0., 1.);
 
  gl_FragColor = vec4(vec3(1.), fade);
}

ポイントは readDepth 関数です。この関数自体はthree.jsのサンプルから引用しました。

まず、深度テクスチャには z-buffer の値が入っているのですが、z-buffer の性質の関係で値は非線形ではありません。 そのため、取り出す際には camera の near clip と far clip を元に線形に直す作業が必要になります。それを行っているのが readDepth です。

z-buffer の性質についてはこちらの記事がとてもわかりやすかったです。

marupeke296.com

learnopengl.com

readDepth 関数の中で呼んでいるviewZToOrthographicDepth, perspectiveDepthToViewZはピクセルシェーダー冒頭の#include <packing>で展開される中身に含まれています。

#include <...> は three.js のGLSLのコードを挿入する記述です。fog など three.js の機能に絡むシェーダーを使いたい時に便利です。

float viewZToOrthographicDepth( const in float viewZ, const in float near, const in float far ) {
  return ( viewZ + near ) / ( near - far );
}

float perspectiveDepthToViewZ( const in float invClipZ, const in float near, const in float far ) {
  return ( near * far ) / ( ( far - near ) * invClipZ - far );
}

改善点

  • iOS Safari, Chrome で見るとガタついているようなアーティファクトが発生していました。原因は、深度テクスチャの書き込み時もしくは読み取り時に精度が落ちていることにあるようです。

  • フェードする閾値だけを調整できるようにしているのですが、フェード開始地点・フェードする閾値の2つを調整できるようにするとより細かい調整が可能かなと思います。

霧の表現

キツネが駆けるデモでは霧のような雰囲気を出すためにソフトパーティクルを使っているのですが、煙のポリゴンをたくさん出して霧の雰囲気を出そうとするとその分透過の描画・重なりも増えるので描画が重くなりやすいです。

なので、霧のような雰囲気をより出すためにはパーティクルは控えめにしつつ(数を減らす・サイズを小さくするなど)、他の方法と組み合わせるのがよいと思います。例えば distance fog や height fog ですね。キツネが駆けるデモでは three.js の fog (distance fog) と組み合わせています。

f:id:takumifukasawa:20201217141713g:plain

最後に

ここまで読んでいただきありがとうございました!

明日の担当は、とても頼りになる広島カープ大好きUnityエンジニア @fujisawa-satoshi-carp くんです。

たった一言でエンジニアを怒らせる方法 10 おまけ付き

f:id:goudacheese:20201216222524j:plain
この記事は、Tech KAYAC Advent Calendar 2020 の17日目の記事になります。

こんにちは。はじめまして。クライアントワーク事業部でコピーライターをしている合田ピエール陽太郎と申します。

www.kayac.com

コピーライターとは

普段は、広告を見る人に向けて、どんな言葉を言えば振り向いてくれるか、商品を手に取ってもらえるかを考え続けて全精力を捧げている人間です。いわゆる短い言葉で、人を惹きつけるにはどうすればいいかを常日頃から考えています。たとえば、YouTubeの『好きなことで生きていく』、日産の『やっちゃえ日産』などは一度は目にしたことがあるのではないでしょうか。そういった言葉をディレクターから依頼されて考えることが多いです。

書こうと思ったきっかけ

僕がこれまで数社を渡り歩いてきた中でエンジニアと他の職能の人とが言葉のやりとりで上手くいかずに憤慨しているシーンをいくつか目撃してきました。何を言うと怒るのか、コピーライター的な視点からまとめてみました。全部で10種類あります。ご覧ください。


f:id:goudacheese:20201216222458j:plain
ライブラリなども含めて世の中にたくさん元となるコードがあると思っているために発せられる一言です。どこかにあるとしても、それを探すのは大変なことですし、見つからなかったら自分で一からコードを書かなければなりません。地味な修正でも、プロセスを見るとかなり大変です。他の職能の人には、なかなか見えにくい部分なので、軽く頼まれてしまうのでしょう。どういうプロセスがあるのか理解することで発せられることが減少すると思われます。

f:id:goudacheese:20201216222553j:plain
細かい配置や演出などを決めず、むしろエンジニアに自由にやってもらいたいときに発せられる一言です。自由にやっていいなんて最高だと思われがちですが、こんな言葉があります。

荒々しい自由の海には、波がつきものだ。
〜トーマス・ジェファーソン〜

デザインや文言などを考えるなどのエンジニアの領域を出て荒波にもまれることもしばしば。頼んだ人によって、「よしな」の内容が異なるので、何度もやりとりするより、一度すりあわせる方が効率がいいこともありそうです。

f:id:goudacheese:20201216222620j:plain
自分の頭のイメージを伝えたつもりだったけど、できあがったものがちがったときに発される一言です。コピーでは「伝える」と「伝わった」は全然ちがうと言われますが、それに似たようなことかもしれません。エンジニアに依頼するときは、リファレンスなどを用いて自分の頭の中が伝わるように心がけることも手の一つだと思います。 「よしなに頼むわ」とのコンボは最悪なパターンです。くれぐれも口にしないでくださいね。

f:id:goudacheese:20201216222649j:plain
この一言は、正しく動いてないと認識されたときに発されがちです。

Dir:バグだ
Eng:こういうものです
Dir:いや、バグでしょ
Eng:仕様書通りです

このような押し問答が起こることもあります。このまま会話がバグってしまうので、控えておくべき一言です。

f:id:goudacheese:20201216222714j:plain
提案が通ったと聞き、企画を見ると、複雑な仕様。スケジュールもカツカツ。正直に難しいと伝えたときに発せられる一言です。確かに提案が通った喜びとの落差も相まって口走ってしまう気持ちも理解できます。
この一言の真意は、何か仕様を削るなどして近い体験を得たいのだけど、どうすればいいかということではないでしょうか。また、エンジニアの難しいも、(このままの仕様とスケジュールでは)難しい、ということだと思います。お互いに歩み寄る一言を添えてみれば、憤慨することが減少するかもしれません。

f:id:goudacheese:20201216222746j:plain
もう世の中にあるサービスを引き合いに出して、簡単じゃないのという憶測から発せられる言葉です。しかし、Googleも一朝一夕でできたわけではありません。
「できるっていっちゃったよ」とのコンボが決まると、爆裂憤慨してしまうので、細心の注意が必要です。

f:id:goudacheese:20201216222819j:plain
大きな課題にぶつかったときに発せられる一言です。AIならなんでも、全知全能の神のようになんでも解決してくれる、というよな印象を持たれているのがいけないのかもしれません。
聞いたことがあるからと言って、安易に口にするのは控えた方がよさそうです。

f:id:goudacheese:20201216222846j:plain
修正が反映されていないときに発せられる一言です。言葉だけではニュアンスが絶妙に異なります。ドキュメントに残して、誰でも分かるように心がける必要があります。
「よしなに頼むわ」と「思ってたのとちがう」との三連コンボが炸裂した日には、エンジニアが憤死しかねませんので要注意です。

【憤死】ふん・し
 憤慨して命を落とすこと。

f:id:goudacheese:20201216222917j:plain
簡単にワンクリックで修正できるんじゃないかと思ったときに発せられる一言。
たとえば、「洗濯なんて、洗濯機に入れてボタン押すだけでしょ?」なんて言ってお母さんに怒られた経験はありませんか。泥汚れがひどいものは下洗いしてたり、外で干したり、さまざまな行程があるのです。
さらにエンジニアは、お母さんでもありません。家族以上の思いやりが必要ですね。

f:id:goudacheese:20201216222944j:plain
何度も何度も修正を重ね、何度も最後と言われた後に発せられる一言です。ほとんどの確率で、このあとも修正は来ます。知り合いには、修正が来る度によくできるチャンスを得たと捉えている人がいました。見習いたいですが、やっぱり怒りたくなるときはありますよね。


さいごに

お互い何を思っているのか、考えているのか、を共有することが大切なのかも知れませんね。
こんな言葉は燃やしましょう。
f:id:goudacheese:20201217113025g:plain

というわけで、明日は期待の燃えるエンジニア@takumifukasawaがものすごい何かを発表してくれます! (期待値をあげたパスも怒らせる方法のひとつです)

おまけ

コピーライターを怒らせる一言も紹介します。

f:id:goudacheese:20201216223011j:plain
musicを別の言葉にする以外、何か方法ありますか‥。

f:id:goudacheese:20201216223105j:plain
自動販売機じゃありません‥。

さようなら!