Three.js

【Three.js】 Popping Sphere Animation

最近THREE.jsを勉強しており、アウトプットがてらブログにしていこうと思います。跳ねながら球体が成長していくようなアニメーションを作成していこうと思います。ただ、あくまでThree.js初学者ですので、コードは参考程度にしていただければと思います。きっとより効率のよい実装方法があるかと思います。Code Pen というサービスでソースコードと完成品を公開しておりますので、是非のぞいてみてください。Youtubeにも完成版をアップロードしております。以下のリンクから完成形を確認できます。

準備

  • index.html, style.css, main.jsファイルを作成し、index.htmlとstyle.cssを以下の様なコードになります。

[ index.html ]

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="style.css">
    <title>Document</title>
</head>
<body>
    <script
    src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"
    integrity="sha512-dLxUelApnYxpLt6K2iomGngnHO83iUvZytA3YjDUCjT0HDOHKXnVYdf3hU4JjM8uEhxf9nD1/ey98U3t2vZ0qQ=="
    crossorigin="anonymous"
    referrerpolicy="no-referrer"
    ></script>
    <script src="main.js"></script>
</body>
</html>

[ style.css ]

* {
    margin: 0;
    padding:0;
  }
  • Three.jsの実行方法は色々ありますが、今回はこちらのリンクからThreej.sのCDNをコピーして、scriptタグで張り付けていきます。その後、main.jsのscriptを読み込んでおります。

ベースとなる球の生成

window.addEventListener("load", init);

function init() {
  let width = window.innerWidth;
  let height = window.innerHeight;

  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0x050505);

  const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
  cameraSetBackDist = 7;
  camera.position.z = cameraSetBackDist;

  const light = new THREE.PointLight(0xffffff, 2);
  light.position.set(10, 10, 10);
  scene.add(light);

  const renderer = new THREE.WebGLRenderer();
  renderer.setSize(width, height);
  document.body.appendChild(renderer.domElement);

  const geometry = new THREE.IcosahedronGeometry(1, 10);

  //↓この範囲は後で消す
  const material = new THREE.MeshBasicMaterial({
      wireframe: true,
      color: 0xc100eb
  });
  const sphere = new THREE.Mesh(geometry, material);
  scene.add(sphere)
  scene.add(camera)
  renderer.render(scene, camera);
  //↑この範囲は後で消す
}
  • ページが読み込まれたら、init関数を実行していきます。
  • まず初めに、scene / camera / light /rendererを生成しています。
  • その後、icoSphereのジオメトリを生成します。可視化するために、マテリアルを設定しメッシュ化します。上画像の様なメッシュが生成されるかと思います。
  • 上コードのこの範囲は消すで囲まれたところは、一時的に可視化するためのものなので消します。

球を生成する三角形メッシュの法線ベクトルの取得と移動させる球の生成

window.addEventListener("load", init);

function init() {
  let width = window.innerWidth;
  let height = window.innerHeight;

  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0x050505);

  const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
  cameraSetBackDist = 7;
  camera.position.z = cameraSetBackDist;

  const light = new THREE.PointLight(0xffffff, 2);
  light.position.set(10, 10, 10);
  scene.add(light);

  const renderer = new THREE.WebGLRenderer();
  renderer.setSize(width, height);
  document.body.appendChild(renderer.domElement);

  const geometry = new THREE.IcosahedronGeometry(1, 10);
  const geometryPos = geometry.getAttribute("position").array;

  const mesh = [];
  const normalDirection = [];

  for (let i = 0; i < geometryPos.length; i += 9) {
    const geometry2 = new THREE.BufferGeometry();

    const vertices = new Float32Array([
      geometryPos[i],
      geometryPos[i + 1],
      geometryPos[i + 2],
      geometryPos[i + 3],
      geometryPos[i + 4],
      geometryPos[i + 5],
      geometryPos[i + 6],
      geometryPos[i + 7],
      geometryPos[i + 8]
    ]);

    geometry2.setAttribute("position", new THREE.BufferAttribute(vertices, 3));
    geometry2.setAttribute("normal", new THREE.BufferAttribute(vertices, 3));

    const normal = new THREE.Vector3(
      (geometryPos[i] + geometryPos[i + 3] + geometryPos[i + 6]) / 3,
      (geometryPos[i + 1] + geometryPos[i + 4] + geometryPos[i + 7]) / 3,
      (geometryPos[i + 2] + geometryPos[i + 5] + geometryPos[i + 8]) / 3
    );

    normal.normalize();
    const icoSphereGeometry = new THREE.IcosahedronGeometry(0.1, 1);
    const material = new THREE.MeshBasicMaterial({
      wireframe: false,
      color: 0xc100eb
    });

    const sphere = new THREE.Mesh(icoSphereGeometry, material);
    mesh.push(sphere);

    normalDirection.push(normal);

  }
}
  • 23行目で、先ほど生成したicoSphereのジオメトリの頂点の座標を取得します。
  • 25/ 26行目でmesh / normalDirectionという変数でからの配列を生成しておきます。
  • 28~44行目では取得した頂点の座標から、icoSphereを生成する、三角形メッシュ単体のジオメトリを生成し、position / normalのattributeをセットしています。
  • 46~52行目までで、各三角形の法線ベクトルを取得し、単位ベクトルに直しています。
  • icoSphereのジオメトリとそれに割り当てるマテリアルを生成し、メッシュ化したものと、三角形の法線ベクトルを先ほど生成した空の配列に格納していきます。これをfor文でまわしているので、最初に生成したicoSphereの三角形メッシュの回数だけicoSphereが生成されることになります。

window.addEventListener("load", init);

function init() {
  let width = window.innerWidth;
  let height = window.innerHeight;

//////さきほどの続き、省略/////

  let loopSpeed = 0;
  let rot = 0;
  const clock = new THREE.Clock();

  const tick = () => {
    rot += 0.3;
    const cameraAngle = (rot * Math.PI) / 180;
    let z = cameraSetBackDist * Math.cos(cameraAngle);
    let x = cameraSetBackDist * Math.sin(cameraAngle);
    camera.position.set(x, 0, z);
    camera.lookAt(0, 0, 0);

    const elapsedTime = clock.getElapsedTime();

    mesh.map((spheremesh, index) => {
      const coordinateAverageValue =
        (normalDirection[index].x +
         normalDirection[index].y +
         normalDirection[index].z) / 3;
      const addAngle = coordinateAverageValue * elapsedTime ;
      const distance = 1;
   loopSpeed += 0.002; 
      
   const radians = (loopSpeed * Math.PI) / 180;
      const angle = radians + addAngle;
      const loop = (Math.sin(angle) + 1) * distance;
      const scale = (Math.sin(angle) + 1.1) * 0.3;
   
      spheremesh.position.set(
        normalDirection[index].x * loop,
        normalDirection[index].y * loop,
        normalDirection[index].z * loop
      );
      spheremesh.scale.set(scale, scale, scale);

      const h = Math.abs(Math.sin(angle)) * 360;
      const s = 100;
      const l = 70;
      const color = new THREE.Color(`hsl(${h},${s}%,${l}%)`);
      spheremesh.material.color = color;

      scene.add(spheremesh);
    });
    requestAnimationFrame(tick);
  };
  tick();

}
  • tick()関数を作成して、アニメーションを生成していきます。
  • 9行目~19行目までで、まずはCameraを動かしていきます。カメラは、XZ平面上の円を周回しながら、最初に可視化したIcoSphereの中心を常に見るように移動させます。
  • 実際に球を移動させていきます。23行目~27行目までで、まずは各三角形の法線ベクトルのxyz成分の平均を値を取得します。xyzがすべて正で中心から離れれば離れるほど値は大きくなり、全て負で中心から離れれば離れるほど値は小さくなります。
  • 28行目で、この平均した値に、clockインスタンスを生成してからの時間(elapsedTime)を掛けます。これをaddAngleとします。
  • 32行目で、アニメーションのスピードを調整するための値、loopSpeedをラジアン化し、そのラジアン化した角度に先ほどのaddAngleを足してsin関数でsin波を取得します。
  • 34行目。取得したsin波は-1~1の値なので、1を足すことで0~2の正の値にします。これに、球体の移動させる距離を調整するための値distanceをかけ合わせます。これで、0~2の値をループする値が取得できました。
  • 35行目。移動距離に応じで球体をscaleさせたいので、scaleするための値も取得しておきます。
  • 37~41行目。mesh配列に格納したicoSphereのメッシュをセットしていきます。法線ベクトルのxyz座標に、先ほど生成した0~2までをループする値を掛けることで、0~2までの範囲を行ったりきたりし、addAngleの値がそれぞれのicoSphereごとに違う値をとるので、sine波の位相がずれ、アニメーションの様な動きをします。
  • 42行目。scaleもセットすることで、移動距離に応じて球が大きなったり小さくなったりします。
  • 44~48行目。マテリアルも虹色に変化するように設定します。-1~1までの絶対値、すなわち0~1までの値に360度を掛け、hslカラーのhに割り当てることで、虹色に変化するようになります。
  • 50~52行目。sceneに移動させたicoSphereをaddし、その後再度tick関数自信を関数内で呼び出します。
  • 54行目。tick関数を忘れずに実行しましょう。

window.addEventListener("load", init);

function init() {
  let width = window.innerWidth;
  let height = window.innerHeight;

 //////さきほどの続き、省略/////

  window.addEventListener("resize", () => {
    width = window.innerWidth;
    height = window.innerHeight;
    camera.aspect = width / height;
    camera.updateProjectionMatrix();
    renderer.setSize(width, height);
  });

  function animate() {
    requestAnimationFrame(animate);
    renderer.render(scene, camera);
  }

  animate();
}
  • 9~15行目で画面サイズが変わっても、レンダリングされたアニメーションもサイズに応じて変化するように設定しています。
  • 17~22行目でレンダリングしています。

ソースコード全体

[ index.html ]

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="style.css">
    <title>Document</title>
</head>
<body>
    <script
    src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"
    integrity="sha512-dLxUelApnYxpLt6K2iomGngnHO83iUvZytA3YjDUCjT0HDOHKXnVYdf3hU4JjM8uEhxf9nD1/ey98U3t2vZ0qQ=="
    crossorigin="anonymous"
    referrerpolicy="no-referrer"
    ></script>
    <script src="main.js"></script>
</body>
</html>

[ css ]

* {
    margin: 0;
    padding:0;
  }

[ javascript ]

window.addEventListener("load", init);

function init() {
  let width = window.innerWidth;
  let height = window.innerHeight;

  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0x050505);

  const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
  cameraSetBackDist = 7;
  camera.position.z = cameraSetBackDist;

  const light = new THREE.PointLight(0xffffff, 2);
  light.position.set(10, 10, 10);
  scene.add(light);

  const renderer = new THREE.WebGLRenderer();
  renderer.setSize(width, height);
  document.body.appendChild(renderer.domElement);

  const geometry = new THREE.IcosahedronGeometry(1, 10);
  const geometryPos = geometry.getAttribute("position").array;

  const mesh = [];
  const normalDirection = [];

  for (let i = 0; i < geometryPos.length; i += 9) {
    const geometry2 = new THREE.BufferGeometry();

    const vertices = new Float32Array([
      geometryPos[i],
      geometryPos[i + 1],
      geometryPos[i + 2],
      geometryPos[i + 3],
      geometryPos[i + 4],
      geometryPos[i + 5],
      geometryPos[i + 6],
      geometryPos[i + 7],
      geometryPos[i + 8]
    ]);

    geometry2.setAttribute("position", new THREE.BufferAttribute(vertices, 3));
    geometry2.setAttribute("normal", new THREE.BufferAttribute(vertices, 3));

    const normal = new THREE.Vector3(
      (geometryPos[i] + geometryPos[i + 3] + geometryPos[i + 6]) / 3,
      (geometryPos[i + 1] + geometryPos[i + 4] + geometryPos[i + 7]) / 3,
      (geometryPos[i + 2] + geometryPos[i + 5] + geometryPos[i + 8]) / 3
    );

    normal.normalize();
    const icoSphereGeometry = new THREE.IcosahedronGeometry(0.1, 1);
    const material = new THREE.MeshBasicMaterial({
      wireframe: false,
      color: 0xc100eb
    });

    const sphere = new THREE.Mesh(icoSphereGeometry, material);
    mesh.push(sphere);

    normalDirection.push(normal);

  }

  let loopSpeed = 0;
  let rot = 0;
  const clock = new THREE.Clock();

  const tick = () => {
    rot += 0.3;
    const cameraAngle = (rot * Math.PI) / 180;
    let z = cameraSetBackDist * Math.cos(cameraAngle);
    let x = cameraSetBackDist * Math.sin(cameraAngle);
    camera.position.set(x, 0, z);
    camera.lookAt(0, 0, 0);

    const elapsedTime = clock.getElapsedTime();

    mesh.map((spheremesh, index) => {
      const coordinateAverageValue =
        (normalDirection[index].x +
         normalDirection[index].y +
         normalDirection[index].z) / 3;
      const addAngle = coordinateAverageValue * elapsedTime * 1;
      const distance = 1;
      loopSpeed += 0.002;
      const radians = (loopSpeed * Math.PI) / 180;
      const angle = radians + addAngle;
      const loop = (Math.sin(angle) + 1) * distance;
      const scale = (Math.sin(angle) + 1.1) * 0.3;

      spheremesh.position.set(
        normalDirection[index].x * loop,
        normalDirection[index].y * loop,
        normalDirection[index].z * loop
      );
      spheremesh.scale.set(scale, scale, scale);

      const h = Math.abs(Math.sin(angle)) * 360;

      const s = 100;
      const l = 70;
      const color = new THREE.Color(`hsl(${h},${s}%,${l}%)`);
      spheremesh.material.color = color;

      scene.add(spheremesh);

    });
    requestAnimationFrame(tick);
  };
  tick();

  window.addEventListener("resize", () => {
    width = window.innerWidth;
    height = window.innerHeight;
    camera.aspect = width / height;
    camera.updateProjectionMatrix();
    renderer.setSize(width, height);
});


  function animate() {
    requestAnimationFrame(animate);
    renderer.render(scene, camera);
  }

  animate();
}

以上になります。ソースコードはCodePenでも公開してますので、是非参考にしてみてください。

夢は大きく. 対象コースが¥1,600から。

-Three.js
-,