AR.jsとthree.jsでポストプロセスをかける

AR.jsとthree.jsでAR表示した3Dオブジェクトにカメラから取得した映像も含めて、ポストプロセスをかけてみたいと思います。

AR.jsとthree.jsでMarker Based ARを行う方法については[以前の記事](https://qiita.com/aa_debdeb/items/4edf6a2e053e02305ef5)で書きました。この記事で書いたようにAR.jsでは`video`HTML要素でカメラから取得した映像を表示し、その上に`canvas`HTML要素を重ねてAR表示を実現しています。そのためカメラ映像も含めてポストプロセスをかけるためには、カメラ映像をthree.js側に持ってくる必要があります。

サンプルコードは以下のようになっています。以下のようにARでキューブを表示した後に、グリッチ系のエフェクトをかけています。[前回](https://qiita.com/aa_debdeb/items/4edf6a2e053e02305ef5)と同じように`data`というディレクトリを作成して、その中に[camera_para.dat](https://github.com/AR-js-org/AR.js/blob/3.1.0/data/data/camera_para.dat)と[patt.hiro](https://github.com/AR-js-org/AR.js/blob/3.1.0/data/data/patt.hiro)を配置する必要があります。
![arjs-postprocessing.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61569/8f5a203d-5741-c041-d539-db25712b1492.gif)

```html
<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
    <title>Post-processing with AR.js and Three.js</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r110/three.min.js"></script>
    <script src="https://unpkg.com/three@0.110.0/examples/js/postprocessing/EffectComposer.js"></script>
    <script src="https://unpkg.com/three@0.110.0/examples/js/postprocessing/ShaderPass.js"></script>
    <script src="https://unpkg.com/three@0.110.0/examples/js/postprocessing/RenderPass.js"></script>
    <script src="https://unpkg.com/three@0.110.0/examples/js/postprocessing/GlitchPass.js"></script>
    <script src="https://unpkg.com/three@0.110.0/examples/js/postprocessing/HalftonePass.js"></script>
    <script src="https://unpkg.com/three@0.110.0/examples/js/shaders/CopyShader.js"></script>
    <script src="https://unpkg.com/three@0.110.0/examples/js/shaders/DigitalGlitch.js"></script>
    <script src="https://unpkg.com/three@0.110.0/examples/js/shaders/HalftoneShader.js"></script>
    <script src="https://raw.githack.com/AR-js-org/AR.js/3.1.0/three.js/build/ar.js"></script>
  </head>
  <body style='margin: 0px; overflow: hidden;'>
    <script>
      const renderer = new THREE.WebGLRenderer({
        antialias: true,
        alpha: true
      });
      renderer.setClearColor(new THREE.Color(), 0);
      renderer.setSize(640, 480);
      renderer.domElement.style.position = 'absolute';
      renderer.domElement.style.top = '0px';
      renderer.domElement.style.left = '0px';
      document.body.appendChild(renderer.domElement);

      const scene = new THREE.Scene();
      const camera = new THREE.Camera();
      scene.add(camera);

      const arToolkitSource = new THREEx.ArToolkitSource({
        sourceType: 'webcam'
      });

      let cameraPlane;
      arToolkitSource.init(() => {
        setTimeout(() => {
          onResize();
        }, 2000);

        const video = document.querySelector('video');
        const videoTexture = new THREE.VideoTexture(video);
        videoTexture.minFilter = THREE.LinearFilter;
        videoTexture.magFilter = THREE.LinearFilter;
        videoTexture.format = THREE.RGBFormat;

        cameraPlane = new THREE.Mesh(
          new THREE.PlaneBufferGeometry(2, 2),
          new THREE.RawShaderMaterial({
            uniforms: {
              videoTexture: {value: videoTexture},
              videoScale: {value: 1.0},
            },
            vertexShader: `
              precision highp float;
              
              attribute vec3 position;
              attribute vec2 uv;
              
              varying vec2 v_uv;

              uniform float videoScale;
              
              void main() {
                v_uv = uv;
                gl_Position = vec4(position.x * videoScale, position.y, position.z, 1.0);
              }
            `,
            fragmentShader: `
              precision highp float;
              
              varying vec2 v_uv;
              
              uniform sampler2D videoTexture;
              
              void main() {
                gl_FragColor = vec4(texture2D(videoTexture, vec2(v_uv.x, v_uv.y)).rgb, 1.0);
              }
            `,
            depthTest: false,
            depthWrite: false,
          })
        );
        cameraPlane.renderOrder = -1;
        cameraPlane.frustumCulled = false;
        scene.add(cameraPlane);
      });

      addEventListener('resize', () => {
        onResize();
      });

      function onResize() {
        arToolkitSource.onResizeElement();
        arToolkitSource.copyElementSizeTo(renderer.domElement);
        if (arToolkitContext.arController !== null) {
          arToolkitSource.copyElementSizeTo(arToolkitContext.arController.canvas);
        }
        const video = document.querySelector('video');
        if (video !== null) {
          cameraPlane.material.uniforms.videoScale.value = (video.videoWidth / video.videoHeight) / 1.33333;
        }
      };

      const arToolkitContext = new THREEx.ArToolkitContext({
        cameraParametersUrl: 'data/camera_para.dat',
        detectionMode: 'mono'
      });

      arToolkitContext.init(() => {
        camera.projectionMatrix.copy(arToolkitContext.getProjectionMatrix());
      });

      const marker = new THREE.Group();
      scene.add(marker);

      const arMarkerControls = new THREEx.ArMarkerControls(arToolkitContext, marker, {
        type: 'pattern',
        patternUrl: 'data/patt.hiro',
        changeMatrixMode: 'modelViewMatrix'
      });

      const cube = new THREE.Mesh(
        new THREE.CubeGeometry(1, 1, 1),
        new THREE.MeshNormalMaterial(),
      );
      cube.position.y = 1.0;
      marker.add(cube);

      const composer = new THREE.EffectComposer(renderer);
      const renderpass = new THREE.RenderPass(scene, camera);
      composer.addPass(renderpass);
      composer.addPass(new THREE.GlitchPass());
      composer.addPass(new THREE.HalftonePass());

      const clock = new THREE.Clock();
      requestAnimationFrame(function animate(){
        requestAnimationFrame(animate);
        if (arToolkitSource.ready) {
          arToolkitContext.update(arToolkitSource.domElement);
        }
        const delta = clock.getDelta();
        cube.rotation.x += delta * 1.0;
        cube.rotation.y += delta * 1.5; 
        composer.render();
      });
    </script>
  </body>
</html>
```

基本的には[以前の記事](https://qiita.com/aa_debdeb/items/4edf6a2e053e02305ef5)をベースにしているので、ポストプロセスに関わる部分だけを解説します。

まず、カメラ映像をthree.jsに持ってくる箇所です。`video`要素はAR.jsにより作られます。`ArToolkitSource.init`の第一引数は準備ができたときに実行されるコールバックなので、ここで処理を行います。three.jsには`THREE.VideoTexture`という映像をテクスチャにできる機能があるので、これを画面全体を覆う平面メッシュに適用することでカメラ映像を背景にします。シェーダーで背景を作る方法については[過去の記事](https://qiita.com/aa_debdeb/items/11e6a5e0a0b6807a0ea9)に書いたので参考にしてください。canvasのアスペクト比は4:3ですが、カメラから取得した映像は必ずしも4:3ではないので、シェーダー内の`videoScale`で調整しています。

```js
let cameraPlane;
arToolkitSource.init(() => {
  ...
  const video = document.querySelector('video');
  const videoTexture = new THREE.VideoTexture(video);
  videoTexture.minFilter = THREE.LinearFilter;
  videoTexture.magFilter = THREE.LinearFilter;
  videoTexture.format = THREE.RGBFormat;

  cameraPlane = new THREE.Mesh(
    new THREE.PlaneBufferGeometry(2, 2),
    new THREE.RawShaderMaterial({
      uniforms: {
        videoTexture: {value: videoTexture},
        videoScale: {value: 1.0},
      },
      vertexShader: `
        precision highp float;
        
        attribute vec3 position;
        attribute vec2 uv;
        
        varying vec2 v_uv;

        uniform float videoScale;
        
        void main() {
          v_uv = uv;
          gl_Position = vec4(position.x * videoScale, position.y, position.z, 1.0);
        }
      `,
      fragmentShader: `
        precision highp float;
        
        varying vec2 v_uv;
        
        uniform sampler2D videoTexture;
        
        void main() {
          gl_FragColor = vec4(texture2D(videoTexture, vec2(v_uv.x, v_uv.y)).rgb, 1.0);
        }
      `,
      depthTest: false,
      depthWrite: false,
    })
  );
  cameraPlane.renderOrder = -1;
  cameraPlane.frustumCulled = false;
  scene.add(cameraPlane);
});
```

個人的な検証ではデバイスを回転させたときにカメラ映像のアスペクト比が変化することを確認したので、リサイズに合わせて先述した`videoScale`の値も変更しています。

```js
function onResize() {
  arToolkitSource.onResizeElement();
  arToolkitSource.copyElementSizeTo(renderer.domElement);
  if (arToolkitContext.arController !== null) {
    arToolkitSource.copyElementSizeTo(arToolkitContext.arController.canvas);
  }
  const video = document.querySelector('video');
  if (video !== null) {
    cameraPlane.material.uniforms.videoScale.value = (video.videoWidth / video.videoHeight) / 1.33333;
  }
};
```

ここまでで、three.js側にカメラの映像を持ってこれたので、通常通りのやり方でポストプロセスをかけます。ここでは、グリッチとハーフトーンのポストプロセスをかけています。

```js
const composer = new THREE.EffectComposer(renderer);
const renderpass = new THREE.RenderPass(scene, camera);
composer.addPass(renderpass);
composer.addPass(new THREE.GlitchPass());
composer.addPass(new THREE.HalftonePass());
...
requestAnimationFrame(function animate(){
  ...
  composer.render();
});
```

---

AR.jsとthree.jsの組み合わせでポストプロセスをかける方法について解説しました。video要素をthree.jsに持ってくるなど、少しトリッキーな感じになっています。カメラの映像が4:3でないときにアスペクト比を合わせる処理の部分が若干怪しいので、各自で検証していただければと思います。

---

**追記**

`window.resize`イベント時にビデオのアスペクト比をもとにシェーダーの`videoScale`プロパティを設定していますが、このイベントの段階ではビデオのアスペクト比が更新されていない場合があるようです。以下のように、`video`タグの`resize`イベントで`videoScale`プロパティを設定したほうがよさそうです。

```js
arToolkitSource.init(() => {
  ...
  video.addEventListener('resize', () => {
    videoPlane.material.uniforms.videoScale.value = (video.videoWidth / video.videoHeight) / 1.33333;
  });
});
```

参考: [javascript \- Detecting video resolution changes \- Stack Overflow](https://stackoverflow.com/questions/38438741/detecting-video-resolution-changes)