【GLSL】モザイク除去装置を作りたい

2022年5月26日GLSL,Three.js

ーナカタの記憶 vol.2ー
初恋の相手は幼稚園で同じ組だった、鼻下のうぶ毛がすごい女の子。

はじめに

世の中にはすごいwebサイトで溢れかえっていますね。その中でも、マウスのところだけモヤモヤして向こう側が透けて見えているような演出に心を打たれてしまいました。以前の僕ならどうやってんのか全く分からなかったと思うけど、今なら「もしかしてこうやってんのかなぁ」と想像できるぐらいにはなれました。そんなワケで似たようなモノを作ってみました↓。

マウスのところだけモザイクが消えているのが分かると思います。今回はコレの作り方を解説していこうと思います。よろしくお願いします。

JavaScript側の説明

オフスクリーンレンダリングを使います。

くるくると回転している女の子。実はこの子は直接ディスプレイ(canvas)に描画されていません。一度オフスクリーン(メモリ)に描き込んで、それをテクスチャとして板ポリに貼り付けているんですね。で、貼り付ける際に、そのテクスチャに対してシェーダーでアレコレと加工するワケです。今回のだったらモザイク加工ですね。

オフスクリーンレンダリングはThree.jsを使えば割と簡単にできてしまいます。今回はシェーダーの説明がメインになりますので、JavaScript側の説明はさらーっとした感じにさせて頂きます。↓。

/*
* シェーダーに渡すデータ
*/
const uniforms = {
  uResolution: {value: [0, 0]}, // ウィンドウの幅と高さ
  uMouse: {value: [0, 0]}, // マウス座標
  uTexture0: {value: null} // テクスチャ
};

JavaScriptからシェーダーに渡したいデータは、こんな風にオブジェクトの形にしてあげると管理しやすいですね。続いて初期化処理です↓。

/*
* 初期化
*/
function init(fov, aspRatio) {

  // オフスクリーンレンダリングには欠かせないレンダーターゲットを作ります
  renderTarget = new THREE.WebGLRenderTarget(window.innerWidth, window.innerHeight);

  // レンダラーを作ります
  webGLRenderer = new THREE.WebGLRenderer();
  webGLRenderer.setSize(window.innerWidth, window.innerHeight);  
  document.getElementById("screen").appendChild(webGLRenderer.domElement);

  // =======================
  // ベースとなるシーンの設定
  // =======================
  scene = new THREE.Scene();  
  camera = new THREE.PerspectiveCamera(fov, aspRatio, 1, 10000);

  // ベースとなるシーンには板ポリ1枚だけしかありません
  // シェーダーを使うのでShaderMaterialを使います
  const geo = new THREE.PlaneGeometry(2, 2); // 板ポリをウィンドウぴったりにするために2×2
  const mat = new THREE.ShaderMaterial({
    uniforms: uniforms, // シェーダーに渡すデータ
    vertexShader: document.getElementById('vs').textContent, // 頂点シェーダー
    fragmentShader: document.getElementById('fs').textContent, // フラグメントシェーダー
    glslVersion: THREE.GLSL3,
    side: THREE.DoubleSide
  });
  const plane = new THREE.Mesh(geo, mat);
  scene.add(plane);

  // =========================================
  // オフスクリーン(メモリ)に描き込むシーンの設定
  // =========================================
  offScene = new THREE.Scene(fov, aspRatio);
  offCamera = new THREE.PerspectiveCamera(fov, aspRatio, 1, 10000);
  offCamera.position.set(0, 0, 5);
  
  // あらかじめ読み込んでおいた3Dモデル(original)から女の子(五十嵐景子)を作ります
  keiko = original.clone();
  keiko.position.set(0, 0, 0);
  offScene.add(keiko);

  // ライトを2種類作ります
  const ambientLight = new THREE.AmbientLight(0xaaaaaa);
  offScene.add(ambientLight);

  const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0);
  directionalLight.position.set(-1, 2, 2);
  offScene.add(directionalLight);

  // =====================
  // イベントリスナーの設定
  // =====================
  window.addEventListener("resize", resizeWindow);
  window.addEventListener("mousemove", mouseMove);

  // ====
  // 描画
  // ====
  renderScene();
}

コメント見てもらえばなんとなく分かるかなぁと思います。まずはレンダーターゲットを作りましょう。これがないとお話になりません。あとは、ベース用のシーンとカメラ、オフスクリーン用のシーンとカメラ、これらを別々に作っておくんですね。次はマウス座標を取得する処理を見てみましょう↓。

/*
* マウス座標を取得(-1~1で正規化)
*/
function mouseMove(e) {
  mx = (e.offsetX * 2 - window.innerWidth) / Math.min(window.innerWidth, window.innerHeight);
  my = -(e.offsetY * 2 - window.innerHeight) / Math.min(window.innerWidth, window.innerHeight);
}

ウィンドウの中央が原点になるように正規化しています。正規化については下記のサイトに詳しい説明が載っていますので、そちらをご覧ください。ちなみに、僕はこの連載記事を読んでGLSLの勉強を始めました。とても分かりやすいです。

[連載]やってみれば超簡単! WebGL と GLSL で始める、はじめてのシェーダコーディング(4)

最後は描画処理です↓。

/*
* 描画
*/
function renderScene() {

  requestAnimationFrame(renderScene);

  // ===========================
  // 五十嵐景子をくるくる回します
  // ===========================
  count++;
  rad = utils.getRad(count % 360);
  keiko.rotation.set(rad, rad, rad);
  
  // ==================================
  // オフスクリーン(メモリ)に描き込みます
  // ==================================
  webGLRenderer.setRenderTarget(renderTarget); // レンダラーにレンダーターゲットをセットします
  webGLRenderer.setClearColor(0xf5f542); // 背景色
  webGLRenderer.render(offScene, offCamera); // オフスクリーン用のシーンとカメラをセットしてメモリに描き込みます

  // ================================
  // シェーダーに渡すデータを更新します
  // ================================
  uniforms.uResolution.value = [window.innerWidth, window.innerHeight]; // ウィンドウの幅と高さ
  uniforms.uMouse.value = [mx, my]; // マウス座標
  uniforms.uTexture0.value = renderTarget.texture; // オフスクリーンに描き込んだものをテクスチャとしてシェーダーに渡します

  // =========================
  // ベースシーンを描き込みます
  // =========================
  webGLRenderer.setRenderTarget(null); // レンダーターゲットを解除します
  webGLRenderer.setClearColor(0x000000); // 背景色(今回は無くてもいい)
  webGLRenderer.render(scene, camera); // ベース用のシーンとカメラをセットしてディスプレイ(canvas)に描き込みます
}

こちらも非常に丁寧なコメント。特に言う事ありませんが、renderTarget.textureでオフスクリーン(メモリ)に描き込んだものをテクスチャとして取得できるという事がポイントになるでしょうか。

以上がJavaScript側の主な処理になります。次はシェーダー側の説明です。

シェーダー側の説明

まずは頂点シェーダーです↓。

<!-- 頂点シェーダー -->
<script id="vs" type="x-shader/x-vertex">
  precision mediump float;

  out vec2 vUv;

  void main(void) {
    vUv = uv; // フラグメントシェーダーにテクスチャ座標を渡す
    gl_Position = vec4(position, 1.0); // 座標変換しない
  }
</script>

ポイントはgl_positionにデータを渡すところ。座標変換しないんです。そうするとウィンドウサイズぴったりの板ポリを作ることができます。詳しい説明は下の絵をご覧ください↓。

お次はフラグメントシェーダーです↓。ここからが本題。

<!-- フラグメントシェーダー -->
<script id="fs" type="x-shader/x-fragment">
  precision mediump float;

  uniform vec2 uResolution; // ウィンドウの幅と高さ
  uniform vec2 uMouse; // マウス座標
  uniform sampler2D uTexture0; // オフスクリーン(メモリ)に描き込んだテクスチャ
  in vec2 vUv; // テクスチャ座標
  out vec4 fragColor; // 最終的なピクセルの色

  void main(void) {

    // 色をつけるピクセルの座標を正規化(-1~1)します
    vec2 p = (gl_FragCoord.xy * 2.0 - uResolution) / min(uResolution.x, uResolution.y);

    // モザイク係数を作ります
    float mosaic = (1.0 - step(0.3, length(p - uMouse))) * max(uResolution.x, uResolution.y) + 50.0;

    // モザイク係数を使ってテクスチャ座標を変換します(ここでモザイク加工をしています)
    vec2 uv = floor(vUv * mosaic) / mosaic;

    // テクスチャから色を取り出してピクセルの色とします
    fragColor = texture(uTexture0, uv);
    
  }
</script>

ポイントはモザイク係数を作っているところと、モザイク加工をしているところです。最初にモザイク加工をしている処理から説明していきましょう↓。

// モザイク係数をテクスチャ座標を変換します(ここでモザイク加工をしています)
vec2 uv = floor(vUv * mosaic) / mosaic;

変数mosaicの値を変えることで、モザイクをかけたりかけなかったり、濃くかけたり薄くかけたりを自在に操ることができます。ちょっと試してみましょう。

vec2 uv = floor(vUv * 40.0) / 40.0;

↑40.0にすると…

↑こうなります。

vec2 uv = floor(vUv * 100.0) / 100.0;

↑100.0にすると…

↑こうなりますね。なぜこうなるのかmosaicが40.0のケースで説明します。

vec2 uv = floor(vUv * 40.0) / 40.0;

vUvは0~1に正規化されています(たぶんThree.jsで勝手に正規化してくれています)。

例えばvUvの値が0.41だったとしましょう。0.41×40.0を計算すると16.4になります。floorはGLSLにもともと用意されている関数で、小数点以下を切り捨ててくれます。16.4は16.0になり、それを40.0で割るので計算結果は0.40になりますね。

次はvUvの値が0.42だった場合を考えてみます。0.42×40.0=16.8を切り捨てて16.0。16.0÷40.0=0.40。uVuが0.41の時と同じ結果になりましたね。

つまり0.41と0.42の位置にあるピクセルには、0.40の位置の色が塗られるワケです。計算すると分かりますが、vUvの値が0.40以上0.425未満なら全て0.40に変換されます。ある範囲内のピクセルが全て同じ色で塗られるのでカクカクして、モザイクがかかっているように見えるのです。

そして、これもアレコレ計算すると分かることですが、mosaicの値を大きくすればする程、モザイクは薄くなり、mosaicの値をウィンドウの幅(もしくは高さ)と同じにするとモザイクは極限まで薄く、というか全く無い状態になります。

今回作ったものをもう一度見てみましょう↓。

ご覧のように、マウスの周りだけモザイクがかかっていない状態になっています。つまりですね、マウスの周りはmosaicの値がウィンドウの幅の値と同じになっているということです。今回はマウスからの距離が0.3未満だとモザイクがかからないようになっています。

以上のことを踏まえて、今度はmosaic係数を作っている処理を見てみましょう↓。

// モザイク係数を作ります
float mosaic = (1.0 - step(0.3, length(p - uMouse))) * max(uResolution.x, uResolution.y) + 50.0;

長いので分割して見ていきましょう。

前半部分の(1.0 – step(0.3, length(p – uMouse)))までを説明します。
length(p – uMouse)で、「今から色を塗るピクセル」と「マウス」との距離を取得します。
step(0.3, length(p – uMouse))は、先程取得した距離が0.3以上なら1.0を、0.3未満なら0.0を返してくれます。
step関数で取得した値を1.0から引きます。
前半部分計算結果マウスとの距離が0.3以上なら0.0になり、マウスとの距離が0.3未満なら1.0になります。

次は後半部分のmax(uResolution.x, uResolution.y)です。
uResolution.xはウィンドウの幅、uResolution.yはウィンドウの高さです。max(uResolution.x, uResolution.y)で幅と高さのうち、大きい方の値を取得することができます。パソコンで見ていればウィンドウの幅が返ってくるでしょう。ここではウィンドウの幅を仮に1000とします。
後半部分の計算結果は、1000になったと思ってください。

前半部分と後半部分を掛け算すると、0.0もしくは1000.0になります。最後に50.0を足すと、50.0もしくは1050.0(1050.0ってウィンドウの幅を越えちゃってますけど、大きい分には結果は変わらないのでヨシとします)になります。これがモザイク係数になります。

まとめると、マウスとの距離が0.3以上ならモザイク係数が50.0になってカクカクになり、マウスとの距離が0.3未満ならモザイク係数が1050.0になってモザイクがかかっていない状態になります。お分かり頂けたでしょうか?

ちなみに

if文使うともっと簡単にモザイク係数を求めることができます。

float mosaic;
if (length(p - uMouse) >= 0.3) {
  mosaic = 50.0;
} else {
  mosaic = max(uResolution.x, uResolution.y);
}

しかしですね、シェーダーでif文使うとパフォーマンスが落ちるらしいです。インターネッツ曰く、ピクセルに対して一斉に同じ処理をする事で処理速度を高めるのがシェーダーの強みなのに、if文で処理を分岐させちゃダメでしょ。とかナントカ。難しくてよく分からないけど、とにかくシェーダーでif文使うのは負けみたいな気がしたので、使わない方法で頑張りました。

あとがき

いろんな関数使って狙った値を算出するのって、Excelで複雑な数式組むのに似てるなぁと思いました。今回は以上です。ありがとうございました。

おしゃれ度

★★★☆☆

Posted by ナカタ