ガチャガチャ3D

ガチャガチャの3DCGのスクショ CG

公開先

以下のページでシーンをみることができます。

ガチャガチャ3D

シーンの概要

Three.jsとcannon-esを用いて、ガチャガチャを制作しました。

Enterキーを押してガチャを回せます。ハンドルが一回転し、一つずつカプセルが出てきます。

オブジェクトはblenderを用いて自作し、objファイルとmtlファイルにエクスポートしたものを読み込んで、シーン内で使用しました。

使用ライブラリ

Three.js

概要:3DCG描画ライブラリ

URL:https://threejs.org/

cannon-es

概要:物理演算ライブラリ

URL:https://github.com/pmndrs/cannon-es

制作過程

モデルの作成

blenderを用いてガチャガチャの3Dモデルを自作しました。

この3DモデルをobjファイルとしてエクスポートしてTypeScriptで読み込んで使いました。

また、読み込んでみたあと、TypeScriptでの見た目に合わせて、マテリアルファイルを書き換えて見た目を調整しました。

コードの作成

まず、最終的なコードは以下の通り。

完成したコード
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import * as CANNON from 'cannon-es';
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader.js';
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';

class ThreeJSContainer {
    private scene: THREE.Scene;
    private light: THREE.Light;
    private world: CANNON.World;

    constructor() {

    }

    // 画面部分の作成(表示する枠ごとに)*
    public createRendererDOM = (width: number, height: number, cameraPos: THREE.Vector3) => {
        const renderer = new THREE.WebGLRenderer();
        renderer.setSize(width, height);
        renderer.setClearColor(new THREE.Color(0x000000));
        renderer.shadowMap.enabled = true; //シャドウマップを有効にする

        //カメラの設定
        const camera = new THREE.PerspectiveCamera(75, width / height, 0.01, 1000);
        camera.position.copy(cameraPos);
        camera.lookAt(new THREE.Vector3(0, 15, 0));
        const orbitControls = new OrbitControls(camera, renderer.domElement);
        orbitControls.target.set(0,8,0);

        this.createScene();
        // 毎フレームのupdateを呼んで,render
        // reqestAnimationFrame により次フレームを呼ぶ
        const render: FrameRequestCallback = (time) => {
            orbitControls.update();

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

        renderer.domElement.style.cssFloat = "left";
        renderer.domElement.style.margin = "10px";
        return renderer.domElement;
    }

    // シーンの作成(全体で1回)
    private createScene = () => {
        this.scene = new THREE.Scene();
        //const cannonDebugger = CannonDebugger(this.scene, this.world);

        // 物理演算空間
        this.world = new CANNON.World({ gravity: new CANNON.Vec3(0, -9.82, 0)});
        this.world.defaultContactMaterial.restitution = 0.8;
        this.world.defaultContactMaterial.friction = 0.03;

         // objファイルの読み込み
         let loadOBJ = (objFilePath: string, mtlFilePath: string) => {
            let object = new THREE.Object3D;
            const mtlLoader = new MTLLoader();
            mtlLoader.load(mtlFilePath, (material) => {
                material.preload();
                const objLoader = new OBJLoader();
                objLoader.setMaterials(material);
                objLoader.load(objFilePath, (obj) => {
                    object.add(obj);
        
                })
            })
            return object;
        }

        //ガチャガチャ本体
        const bodyMesh = loadOBJ("./gashapon_body.obj", "./gashapon_body.mtl");
        this.scene.add(bodyMesh);

        let insideShape : CANNON.Box[] = [];
        let insideBody : CANNON.Body[] = [];
        const sizes : CANNON.Vec3[] = [
            new CANNON.Vec3(4, 1, 4),
            new CANNON.Vec3(4, 4, 1),
            new CANNON.Vec3(4, 4, 1),
            new CANNON.Vec3(1, 4, 4),
            new CANNON.Vec3(1, 4, 4),
            new CANNON.Vec3(4, 1, 4),
            new CANNON.Vec3(1, 1, 5)
        ];
        const poss : THREE.Vector3[] = [
            new THREE.Vector3(0, 7.3, 0),
            new THREE.Vector3(0, 11, -3),
            new THREE.Vector3(0, 11, 5),
            new THREE.Vector3(-4, 11, 0),
            new THREE.Vector3(4, 11, 0),
            new THREE.Vector3(0, 15, 0),
            new THREE.Vector3(-1.25, 0, 0)
        ];
        for(let i = 0; i < sizes.length; i++){
            insideShape[i] = new CANNON.Box(sizes[i]);
            insideBody[i] = new CANNON.Body({ mass: 0 });
            insideBody[i].addShape(insideShape[i]);
            insideBody[i].position.set(poss[i].x, poss[i].y, poss[i].z);
            if(i == 6){
                let quo = new CANNON.Quaternion();
                quo.setFromAxisAngle(new CANNON.Vec3(1,0,0), Math.PI/30);
                insideBody[6].quaternion.set(quo.x, quo.y ,quo.z ,quo.w);
            }
            //insideBody1.quaternion.set();
            this.world.addBody(insideBody[i]);
        }

        // テスト用geometry
        // const i : number = 6;
        // const geometry = new THREE.BoxGeometry(sizes[i].x * 2, sizes[i].y * 2, sizes[i].z * 2);
        // const material = new THREE.MeshLambertMaterial({ color: 0x00ff00 });
        // const cube = new THREE.Mesh(geometry, material);
        // cube.position.set(insideBody[i].position.x, insideBody[i].position.y, insideBody[i].position.z);
        // cube.quaternion.set(insideBody[i].quaternion.x, insideBody[i].quaternion.y, insideBody[i].quaternion.z, insideBody[i].quaternion.w);
        // this.scene.add(cube);

        //ガチャガチャのハンドル
        const handleMesh = loadOBJ("./gashapon_handle.obj", "./gashapon_handle.mtl");
        handleMesh.position.set(0, 5.3, 3);
        this.scene.add(handleMesh);

        //カプセルの生成
        const capnum : number = 20; // カプセルの数
        let capMeshs : THREE.Object3D[] = [];
        let capBodys : CANNON.Body[] = [];
        for(let i : number = 0; i < capnum; i++){
            switch(i % 4){
                case 0: //青
                    capMeshs[i] = loadOBJ("./gashapon_blue.obj", "./gashapon_blue.mtl");
                    break;
                case 1: //赤
                    capMeshs[i] = loadOBJ("./gashapon_red.obj", "./gashapon_red.mtl");
                    break;
                case 2: //緑
                    capMeshs[i] = loadOBJ("./gashapon_green.obj", "./gashapon_green.mtl");
                    break;
                case 3: //黃
                    capMeshs[i] = loadOBJ("./gashapon_yellow.obj", "./gashapon_yellow.mtl");
                    break;
            }
            capMeshs[i].position.setX(Math.random() * 3 - 1.5);
            capMeshs[i].position.setY(9 + Math.random() * 2);
            capMeshs[i].position.setZ(Math.random() * 3 - 1.5);
            this.scene.add(capMeshs[i]);
            const capShape = new CANNON.Sphere(1);
            capBodys[i] = new CANNON.Body({ mass: 1, shape: capShape });
            capBodys[i].position.set(capMeshs[i].position.x , capMeshs[i].position.y , capMeshs[i].position.z);
            this.world.addBody(capBodys[i]);
        }
        

        //ライトの設定
        this.light = new THREE.DirectionalLight(0xffffff);
        const lvec = new THREE.Vector3(1, 1, 1).normalize();
        this.light.position.set(lvec.x, lvec.y, lvec.z);
        this.scene.add(this.light);

        let handleRotate = false;
        let outCap = 0;
        document.addEventListener('keyup', (event) => {
            switch (event.key) {
                case 'Enter':
                    if(!handleRotate){
                        handleRotate = true;
                    }
                    break;
            }
        });

        //アップデート
        let update: FrameRequestCallback = (time) => {
            this.world.fixedStep();
            for(let i : number = 0; i < capnum; i++){
                capMeshs[i].position.set(capBodys[i].position.x, capBodys[i].position.y, capBodys[i].position.z);
                capMeshs[i].quaternion.set(capBodys[i].quaternion.x, capBodys[i].quaternion.y, capBodys[i].quaternion.z, capBodys[i].quaternion.w);
            }
            // ハンドルを回すアニメ
            if(handleRotate){
                handleMesh.rotation.set(0, 0, handleMesh.rotation.z + Math.PI / 30);
                if(handleMesh.rotation.z > Math.PI * 2){
                    handleRotate = false;
                    if(outCap < capnum){
                        capBodys[outCap].position.set(-1.25, 2, -1);
                        outCap++;
                    }
                    handleMesh.rotation.set(0, 0, 0);
                }
            }
            requestAnimationFrame(update);
        }
        requestAnimationFrame(update);
    }
}

window.addEventListener("DOMContentLoaded", init);

function init() {
    let container = new ThreeJSContainer();

    let viewport = container.createRendererDOM(640, 480, new THREE.Vector3(0, 8, 16));
    document.body.appendChild(viewport);
}

デモページでは、このコードをビルドし、WordPress用にパスを書き換えている。

オブジェクトの配置

関数「loadOBJ()」を用いて、オブジェクトファイルとマテリアルファイルを読み込み、Three.jsのオブジェクトに変換しています。

  • ガチャガチャ本体
    コード
    //ガチャガチャ本体
    const bodyMesh = loadOBJ("./gashapon_body.obj", "./gashapon_body.mtl");
    this.scene.add(bodyMesh);
    
    let insideShape : CANNON.Box[] = [];
    let insideBody : CANNON.Body[] = [];
    const sizes : CANNON.Vec3[] = [
        new CANNON.Vec3(4, 1, 4),
        new CANNON.Vec3(4, 4, 1),
        new CANNON.Vec3(4, 4, 1),
        new CANNON.Vec3(1, 4, 4),
        new CANNON.Vec3(1, 4, 4),
        new CANNON.Vec3(4, 1, 4),
        new CANNON.Vec3(1, 1, 5)
    ];
    const poss : THREE.Vector3[] = [
        new THREE.Vector3(0, 7.3, 0),
        new THREE.Vector3(0, 11, -3),
        new THREE.Vector3(0, 11, 5),
        new THREE.Vector3(-4, 11, 0),
        new THREE.Vector3(4, 11, 0),
        new THREE.Vector3(0, 15, 0),
        new THREE.Vector3(-1.25, 0, 0)
    ];
    for(let i = 0; i < sizes.length; i++){
        insideShape[i] = new CANNON.Box(sizes[i]);
        insideBody[i] = new CANNON.Body({ mass: 0 });
        insideBody[i].addShape(insideShape[i]);
        insideBody[i].position.set(poss[i].x, poss[i].y, poss[i].z);
        if(i == 6){
            let quo = new CANNON.Quaternion();
            quo.setFromAxisAngle(new CANNON.Vec3(1,0,0), Math.PI/30);
            insideBody[6].quaternion.set(quo.x, quo.y ,quo.z ,quo.w);
        }
        //insideBody1.quaternion.set();
        this.world.addBody(insideBody[i]);
    }

    ガチャガチャ本体の見た目は読みこんだファイルをそのまま使っていますが、カプセルとのあたり判定は別で見えないボックスを設置することで実現しています。ボックスの位置と座標は配列で管理しており、コードが短く見やすくなるようにしました。

    ボックスは上部のカゴの6面と排出口に1つ設置しています。テスト用のコードで見えるようにしながら位置を調整しています。

  • ガチャガチャのハンドル
    コード
    //ガチャガチャのハンドル
    const handleMesh = loadOBJ("./gashapon_handle.obj", "./gashapon_handle.mtl");
    handleMesh.position.set(0, 5.3, 3);
    this.scene.add(handleMesh);

    ハンドルはファイルを読みこんだ後、本体の位置に合わせて移動しています。ハンドルを単独で回転させるために本体とは別のオブジェクトとしました。

  • カプセル
    コード
    //カプセルの生成
    const capnum : number = 20; // カプセルの数
    let capMeshs : THREE.Object3D[] = [];
    let capBodys : CANNON.Body[] = [];
    for(let i : number = 0; i < capnum; i++){
        switch(i % 4){
            case 0: //青
                capMeshs[i] = loadOBJ("./gashapon_blue.obj", "./gashapon_blue.mtl");
                break;
            case 1: //赤
                capMeshs[i] = loadOBJ("./gashapon_red.obj", "./gashapon_red.mtl");
                break;
            case 2: //緑
                capMeshs[i] = loadOBJ("./gashapon_green.obj", "./gashapon_green.mtl");
                break;
            case 3: //黃
                capMeshs[i] = loadOBJ("./gashapon_yellow.obj", "./gashapon_yellow.mtl");
                break;
        }
        capMeshs[i].position.setX(Math.random() * 3 - 1.5);
        capMeshs[i].position.setY(9 + Math.random() * 2);
        capMeshs[i].position.setZ(Math.random() * 3 - 1.5);
        this.scene.add(capMeshs[i]);
        const capShape = new CANNON.Sphere(1);
        capBodys[i] = new CANNON.Body({ mass: 1, shape: capShape });
        capBodys[i].position.set(capMeshs[i].position.x , capMeshs[i].position.y , capMeshs[i].position.z);
        this.world.addBody(capBodys[i]);
    }

    数を設定するとその数だけカプセルが生成されるようにしました。各色がバランス良く出現するように、あまりの演算とswitch文を用いて順に生成されるようになっています。

    また、本体内部のランダムな位置に生成されるようになっており毎回違う配置で生成されます。

ガチャを回す

まず、イベントリスナーを用いて、Enterキーの入力を受け付けます。

let handleRotate = false;
let outCap = 0;
document.addEventListener('keyup', (event) => {
    switch (event.key) {
        case 'Enter':
            if(!handleRotate){
                handleRotate = true;
            }
            break;
    }
});

Enterキーが押されると、handleRotateがtrueになり、ハンドルが回転するアニメーションが開始されます。

// ハンドルを回すアニメ
if(handleRotate){
    handleMesh.rotation.set(0, 0, handleMesh.rotation.z + Math.PI / 30);
    if(handleMesh.rotation.z > Math.PI * 2){
        handleRotate = false;
        if(outCap < capnum){
            capBodys[outCap].position.set(-1.25, 2, -1);
            outCap++;
        }
        handleMesh.rotation.set(0, 0, 0);
    }
}

毎フレーム、ハンドルの角度を変更し、回転させたのち、カプセルを1つ、排出口の真上の座標に移動させて排出させます。排出口のあたり判定のブロックは斜めになっており、物理演算で転がって、あとは自動でカプセルが排出されます。

何度も排出すると、ガチャガチャ本体から一つずつ減っていく様子が確認できます。