みかづきブログ・カスタム

基本的にはちょちょいのほいです。

Three.js + Next.js でスマートフォン向けのウェブARを作った際にcanvas、videoがbodyよりも大きくなるのを防ぐ 📱

先日、Three.js + Next.jsでスマートフォン向けのウェブARをつくりました。

制作している時、最後の最後に気づきました。
微妙にマーカーとオブジェクトの位置がずれていることに。

インスペクタで確認してみると。

f:id:kimizuka:20211217173103p:plain

bodyよりも中の要素の横幅の方が大きくなっています。

なぜ、こうなってしまったのかソースを見返してみると。

  1. スマートフォンなのでbodyにoverflow: hiddenが効かない
  2. AR.jsがbody直下にvideoタグをappendする

というのが原因のようです。

つまり、body直下に巨大なvideoタグがappendされているため、bodyの横幅を超えてしまい、結果、ビデオとオブジェクトの位置に微妙なずれが発生しているようです。
対策を考えたのですが、今回はNext.jsを併用しているため、body直下に#__nextがあります。

なので、

#__next {
  overflow: hidden;
}

とした上で、AR.jsがvideoをappendする先を#__nextにすれば解決しそうです。

append先を変更できるAPIはないものかと、AR.jsのドキュメントを読んだり、ソースを読んだりしてみたのですが、

function onSourceReady() {
    document.body.appendChild(_this.domElement);
    window.dispatchEvent(new CustomEvent('arjs-video-loaded', {
        detail: {
            component: document.querySelector('#arjs-video'),
        },
    }));

    _this.ready = true

    onReady && onReady()
}

github.com

残念ながら、bodyにappendされる仕様のようでした。。

なので、

arToolkitSource.init(() => {
  document.getElementById('__next').appendChild(arToolkitSource.domElement);
});

という形で、appendされたvideoを取得して#__nuxtにappendし直すようにしてみたところ、

f:id:kimizuka:20211217180303p:plain

無事にbody内に収まるようになりました。一件落着です。

ソースコード(抜粋)

import useResize from '../hooks/useResize';
import { useCallback, useEffect, useRef, useState } from 'react';
import styled from 'styled-components';

declare global {
  interface Window {
    THREE: any;
    THREEx: any;
  }
}

const Wrapper = styled.div`
  .overlay {
    position: fixed;
    top: 50%; left: 50%;
    border: solid 8000px rgba(0, 0, 0, .6);
    width: 200px; height: 200px;
    transform: translate(-50%, -50%);

    &:after {
      position: fixed;
      top: calc(50% + 120px);
      left: 0; right: 0;
      content: 'マーカーを探してください';
      text-align: center;
    }
  }
`;

export default function Index() {
  const [ isBrowser, setIsBrowser ] = useState(false);
  const [ renderer, setRenderer ] = useState(null);
  const [ isFound, setIsFound ] = useState(false);
  const [ arToolkitSource, setArToolkitSource ] = useState(null);
  const [ arToolkitContext, setArToolkitContext ] = useState(null);
  const canvasRef = useRef(null);
  const { windowWidth, windowHeight } = useResize();
  const size = 1;

  useEffect(() => setIsBrowser(process.browser), []);

  useEffect(() => {
    const THREE = window.THREE;
    const THREEx = window.THREEx;
    const scene = new THREE.Scene();

    const renderer = new THREE.WebGLRenderer({
      canvas: canvasRef.current,
      antialias : true,
      alpha: true
    });

    renderer.outputEncoding = THREE.GammaEncoding;
    renderer.setPixelRatio(window.devicePixelRatio);
    renderer.setSize(window.innerWidth, window.innerHeight);

    setRenderer(renderer);

    const camera = new THREE.Camera();

    scene.add(camera);

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

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

    setArToolkitContext(arToolkitContext);

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

    setArToolkitSource(arToolkitSource);

    arToolkitSource.init(() => {
      document.getElementById('__next').appendChild(arToolkitSource.domElement); // DOMを移動する
      setTimeout(() => {
        arToolkitSource.onResize();
        arToolkitSource.copySizeTo(renderer.domElement);
        document.body.style.opacity = String(1);
      }, 800);
    });

    const markerRoot = new THREE.Group();

    scene.add(markerRoot);

    const arMarkerControl = new THREEx.ArMarkerControls(arToolkitContext, markerRoot, {
      type: 'pattern',
      patternUrl: 'pattern.patt'
    });

    arMarkerControl.addEventListener('markerFound', () => {
      setIsFound(true);
    });

    const geometry = new THREE.BoxGeometry(size, size, size);
    const material = new THREE.MeshBasicMaterial({
      color: 0xCCCCCC,
      opacity: .8
    });
    const model = new THREE.Mesh(geometry, material);

    model.position.set(0, 1, 0);
    model.rotation.y = Math.PI / 180 * -90;

    markerRoot.add(model);

    renderer.setAnimationLoop(() => {
      if (arToolkitSource.ready) {
        arToolkitContext.update(arToolkitSource.domElement);
      }

      renderer.render(scene, camera);
    });

    const light = new THREE.AmbientLight();

    scene.add(light);

    return () => renderer.setAnimationLoop(null);
  }, [canvasRef]);

  const handleResize = useCallback(() => {
    if (arToolkitSource && renderer) {
      arToolkitSource.onResize();
      arToolkitSource.copySizeTo(renderer.domElement);

      if (arToolkitContext && arToolkitContext.arController) {
        arToolkitSource.copySizeTo(arToolkitContext.arController.canvas);
      }
    }
  }, [renderer, arToolkitSource, arToolkitContext]);

  useEffect(handleResize, [handleResize, windowWidth, windowHeight]);

  return (
    <Wrapper>
      <canvas ref={ canvasRef } />
      {(() => {
        if (isBrowser) {
          return (() => !isFound && <div className="overlay" /> )();
        }
      })()}
    </Wrapper>
  );
}

DEMO

bodyから飛び出すバージョン

https://develop.kimizuka.org/next-three-ar/error.html

bodyに収めるバージョン

https://develop.kimizuka.org/next-three-ar/

マーカー

f:id:kimizuka:20211222103625p:plain