2018年12月13日

Three.jsでオブジェクトを選択

WebVRでユーザーインターフェースを制作しようとすると、3D空間上のオブジェクトをコントローラーで選択する必要があります。そこで、Three.jsのサンプルを参考に、コントローラーでオブジェクトを選択する方法を調べつつ、draggingを試してみました。

※ChromeがWebXR Device APIに対応し、Three.jsの仕様が変更になったため内容を修正しました。(2019年12月13日)

シャドウマッピング

3DCGにおいて影はクオリティを決める重要な要素です。そこで、オブジェクトの選択方法を試す前にシャドウマッピングを試しました。

Three.jsで影をつけるには、影を落とすオブジェクトに「castShadow = true」を、影を受けるオブジェクトに「receiveShadow = true」を設定する他、レンダラーと光源の設定を有効化します。

● シャドウマッピング

//レンダラーで影を有効化
this.renderer.shadowMap.enabled = true;

//影を受けるオブジェクト
floor.receiveShadow = true;

//影を落とすオブジェクト
const geometry = new THREE.BoxGeometry(0.5,0.5,0.5);
const material = new THREE.MeshStandardMaterial({
		color:0xCCCCCC,
		roughness:0.7,
		metalness:0.0
});
const box = new THREE.Mesh(geometry,material);
box.position.y = 1;
box.castShadow = true;
this.scene.add(box);

//光源の影を有効化
directionalLight.castShadow = true;

//影の描画範囲
directionalLight.shadow.camera.top = 20;
directionalLight.shadow.camera.bottom = -20;
directionalLight.shadow.camera.right = 20;
directionalLight.shadow.camera.left = -20;

//影の描画用解像度
directionalLight.shadow.mapSize.set(4096,4096);

Three.jsでオブジェクトを選択

● コントローラの制御

Three.jsでコントローラーを取得」でもやりましたが、コントローラーを取得して、コントローラーからラインを出します。ラインはサイズを変更するために、頂点データを直接操作できるBufferGeometryを使用しています。

//コントローラを取得
this.controller = this.renderer.vr.getController(0);

//コントローライベントを登録
this.controller.addEventListener('selectstart',onSlectStart);
this.controller.addEventListener('selectend',onSelectEnd);

this.scene.add(this.controller);

//ラインを生成
const geometry = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0,0,0),new THREE.Vector3(0,0,-1)]);
const line = new THREE.Line(geometry);
line.name = 'line';
line.scale.z = 5;

//コントローラーにラインを追加
this.controller.add(line.clone());

//トリガーを押した時のイベント
function onSlectStart(event){
}

//トリガーを離した時のイベント
function onSelectEnd(event){
}

● オブジェクトを選択

3D空間上のオブジェクトを選択するためには、レイキャストを使用して、コントローラーに追加したラインとオブジェクトが交差しているかを調べます。raycaster.intersectObjectsは引数に配列を入れる必要があるため、オブジェクトをGroupに格納します。GroupはObject3Dとほぼ同じ機能を持つオブジェクトを構造的に制するときによく使うものです。

//Groupを生成
this.group = new THREE.Group();
this.scene.add(this.group);

//ボックスを生成
const geometry = new THREE.BoxGeometry(0.5,0.5,0.5);
const material = new THREE.MeshStandardMaterial({
        color:0xCCCCCC,
        roughness:0.7,
        metalness:0.0
});
const box = new THREE.Mesh(geometry,material);
box.position.y = 1;
box.castShadow = true;

//ボックスをグループに格納
this.group.add(box);

オブジェクトが選択されたかどうか、レイキャストとMatrix4を使用して調べます。Matrix4は位置と角度を収納できる4×4の行列です。

トリガーを押した時、選択しているオブジェクトを調べて色を変更し、ユーザーデータにオブジェクトを入れます。トリガーを離した時、ユーザーデータにオブジェクトが入っていたら、色を戻して、ユーザーデータを空にすることで選択を解除します。

this.tempMatrix = new THREE.Matrix4();
this.raycaster = new THREE.Raycaster();

//トリガーを押した時のイベント
function onSlectStart(event){
	const controller = event.target;
	const intersections = this.getIntersections(controller);

	//交差しているオブジェクトがあったら色を変更
	if(intersections.length > 0){
		const intersection = intersections[0];
		const object = intersection.object;
		object.material.emissive.b = 1;
		controller.userData.selected = object;
	}
}

//トリガーを離した時のイベント
function onSelectEnd(event){
	const controller = event.target;

	if(controller.userData.selected !== undefined){
		const object = controller.userData.selected;
		object.material.emissive.b = 0;
		controller.userData.selected = undefined;
	}
}

//交差しているオブジェクトを取得
function getIntersections(controller){
	this.tempMatrix.identity().extractRotation(controller.matrixWorld);
	this.raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
	this.raycaster.ray.direction.set(0,0,-1).applyMatrix4(tempMatrix);
	return this.raycaster.intersectObjects(this.group.children);
}

● script.js

まず、「Three.jsで360°パノラマコンテンツ制作」でやったように、必要なライブラリを読み込みます。

<script src="js/lib/preloadjs.min.js"></script>
<script src="js/lib/TweenMax.min.js"></script>

Three.js関連のライブラリはscript.jsからインポートするので、script.jsはtype="module"をつけて読み込みます。

<script src="js/script.js" type="module"></script>

いろいろな種類のオブジェクトをランダムに配置して、ドラッグできるようにしたscript.jsです。

//===============================================================
// Import Library
//===============================================================
import * as THREE from './lib/three_jsm/three.module.js';
import { OrbitControls } from './lib/three_jsm/OrbitControls.js';
import { VRButton } from './lib/three_jsm/VRButton.js';

//===============================================================
// BasicView
//===============================================================
class BasicView{
    constructor(){
        this.init();
    }

    init(){
        //シーン、カメラ、レンダラーを生成
        this.scene = new THREE.Scene();
        this.camera = new THREE.PerspectiveCamera(90,window.innerWidth/window.innerHeight,0.1,1000);
        this.camera.position.set(0,0,0);
        this.scene.add(this.camera);
        this.renderer = new THREE.WebGLRenderer({antialias:true});
        this.renderer.setPixelRatio(window.devicePixelRatio);
        this.renderer.setSize(window.innerWidth,window.innerHeight);
        this.renderer.shadowMap.enabled = true;

        const container = document.querySelector('#canvas_wrapper');
        container.appendChild(this.renderer.domElement);

        document.body.appendChild(VRButton.createButton(this.renderer));

        const _this = this;
        window.addEventListener('resize',function(){
            _this.camera.aspect = window.innerWidth/window.innerHeight;
            _this.camera.updateProjectionMatrix();
            _this.renderer.setSize(window.innerWidth,window.innerHeight);
        },false);
    }
    startRendering(){
        this.renderer.setAnimationLoop(this.startRendering.bind(this));
        this.render();
        this.onTick();
    }
    render(){
        this.renderer.render(this.scene,this.camera);
    }
    onTick(){
    }
}
//===============================================================
// ThreeWorld extend BasicView
//===============================================================
class ThreeWorld extends BasicView{
    constructor(){
        super();
        this.initThreeWorld();
    }
    initThreeWorld(){
        this.intersected = [];
        this.tempMatrix = new THREE.Matrix4();

        this.checkDevice();
        this.setLoading();
        this.startRendering();
    }

    //開発用にPCで動作確認できるように設定
    checkDevice(){
        const ua = window.navigator.userAgent.toLowerCase();
        let _iOS,_Android,_Tablet,_Pc,_vrDisplay;

        _iOS = /ipad|iphone|ipod/.test(ua);
        _Android = /android/.test(ua);
        _Tablet = /ipad|nexus (7|9)|xoom|sch-i800|playbook|tablet|kindle/i.test(ua);
        _Pc = /windows|mac/.test(ua);

        if(_Pc || _iOS || _Tablet){
            //OrbitControlsを初期化
            const orbitControls = new OrbitControls(this.camera,this.renderer.domElement);
            orbitControls.target.set(
                this.camera.position.x + 0.01,
                this.camera.position.y + 1,
                this.camera.position.z
            );
        }else{
            //VRを許可
            this.renderer.vr.enabled = true;
        }
        document.addEventListener('touchmove', function(e) {e.preventDefault();}, {passive: false});
    }

    //ローディング画面
    setLoading(){
        const _this = this;
        TweenMax.to(".loader",0.1,{opacity:1});

        const manifest = [
            {id:'ground',src:'img/ground.png'}
        ];
        const loadQueue = new createjs.LoadQueue();

        loadQueue.on('progress',function(e){
            const progress = e.progress;
        });

        //ローディング画面を消去
        loadQueue.on('complete',function(){
            const image = loadQueue.getResult('ground');
            _this.texture = new THREE.Texture(image);
            _this.texture.needsUpdate = true;

            TweenMax.to("#loader_wrapper" , 1 , {
                opacity:0,
                onComplete: function(){
                    document.getElementById("loader_wrapper").style.display ="none";
                }
            });

            //ローディング後、実行
            _this.initObject();
            _this.initLight();
            _this.initController();
        });

        loadQueue.loadManifest(manifest);
    }

    initObject(){
        //床を生成
        this.texture.repeat.set(50, 50);
        this.texture.wrapS = this.texture.wrapT = THREE.RepeatWrapping;
        this.texture.magFilter = THREE.NearestFilter;
        let geometry = new THREE.PlaneGeometry(250,250);
        let material = new THREE.MeshStandardMaterial({map:this.texture,roughness:0.0,metalness:0.6});

        const floor = new THREE.Mesh(geometry,material);
        floor.rotation.x = -Math.PI / 2;
        floor.receiveShadow = true;
        this.scene.add(floor);

        //グループを生成
        this.group = new THREE.Group();
        this.scene.add(this.group);

        //いろいろな種類の形状オブジェクトを生成
        const geometries = [
            new THREE.BoxBufferGeometry(0.5,0.5,0.5),
            new THREE.ConeBufferGeometry(0.5,0.5,64),
            new THREE.CylinderBufferGeometry(0.5,0.5,0.5,64),
            new THREE.IcosahedronBufferGeometry(0.5,3),
            new THREE.TorusBufferGeometry(0.5,0.25,64,32)
        ];

        for(let i = 0; i < 70; i++){
            geometry = geometries[Math.floor(Math.random()*geometries.length)];
            material = new THREE.MeshStandardMaterial({
                color:0xCCCCCC,
                roughness:0.7,
                metalness:0.0
            });
            const object = new THREE.Mesh(geometry,material);
            object.position.x = Math.random() * 20 - 10;
            object.position.y = Math.random() * 10 + 1;
            object.position.z = Math.random() * 20 - 10;
            object.rotation.x = Math.random() * 2 * Math.PI;
            object.rotation.y= Math.random() * 2 * Math.PI;
            object.rotation.z = Math.random() * 2 * Math.PI;
            object.scale.setScalar(Math.random() + 0.5);
            object.castShadow = true;
            object.receiveShadow = true;

            this.group.add(object);
        }

    }

    //ライトを生成
    initLight(){
        const ambientLight = new THREE.AmbientLight(0xFFFFFF);
        this.scene.add(ambientLight);

        const directionalLight = new THREE.DirectionalLight(0xFFFFFF, 1, 0);
        directionalLight.position.set(0, 100, 0);
        directionalLight.castShadow = true;
        directionalLight.shadow.camera.top = 20;
        directionalLight.shadow.camera.bottom = -20;
        directionalLight.shadow.camera.right = 20;
        directionalLight.shadow.camera.left = -20;
        directionalLight.shadow.mapSize.set(4096,4096);
        this.scene.add(directionalLight);
    }

    //コントローラの設定
    initController(){
        const _this = this;

        this.controller = this.renderer.vr.getController(0);
        this.controller.addEventListener('selectstart',onSlectStart);
        this.controller.addEventListener('selectend',onSelectEnd);
        this.scene.add(this.controller);

        const geometry = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0,0,0),new THREE.Vector3(0,0,-1)]);
        const line = new THREE.Line(geometry);
        line.name = 'line';
        line.scale.z = 5;

        this.controller.add(line.clone());

        this.raycaster = new THREE.Raycaster();

        //トリガーを押した時のイベント
        function onSlectStart(event){
            const controller = event.target;
            const intersections = _this.getIntersections(controller);

            if(intersections.length > 0){
                const intersection = intersections[0];
                _this.tempMatrix.getInverse(controller.matrixWorld);
                const object = intersection.object;
                object.matrix.premultiply(_this.tempMatrix);
                object.matrix.decompose(object.position,object.quaternion,object.scale);
                object.material.emissive.b = 1;
                controller.add(object);
                controller.userData.selected = object;
            }
        }

        //トリガーを離した時のイベント
        function onSelectEnd(event){
            const controller = event.target;
            if(controller.userData.selected !== undefined){
                const object = controller.userData.selected;
                object.matrix.premultiply(controller.matrixWorld);
                object.matrix.decompose(object.position,object.quaternion,object.scale);
                object.material.emissive.b = 0;
                _this.group.add(object);
                controller.userData.selected = undefined;
            }
        }
    }

    //交差しているオブジェクトを取得
    getIntersections(controller){
        this.tempMatrix.identity().extractRotation(controller.matrixWorld);
        this.raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
        this.raycaster.ray.direction.set(0,0,-1).applyMatrix4(this.tempMatrix);
        return this.raycaster.intersectObjects(this.group.children);
    }

    //オブジェクトとラインの交差の状態を調査
    intersectObjects(controller){
        if(controller.userData.selected !== undefined){
            return;
        }

        const line = controller.getObjectByName('line');
        const intersections = this.getIntersections(controller);

        if(intersections.length > 0){
            const intersection = intersections[0];
            const object = intersection.object;
            object.material.emissive.r = 1;
            this.intersected.push(object);

            line.scale.z = intersection.distance;
        }else{
            line.scale.z = 5;
        }
    }

    clearIntersected(){
        while(this.intersected.length){
            const object = this.intersected.pop();
            object.material.emissive.r = 0;
        }
    }

    //アニメーション
    onTick(){
        if(this.controller){
            this.clearIntersected();
            this.intersectObjects(this.controller);
        }
    }
}
//===============================================================
// Window load
//===============================================================
window.addEventListener("load", function () {
   const threeWorld = new ThreeWorld();
});

完成したデモになります。VRヘッドセットでアクセスして、コントローラーでドラッグできます!

  • このエントリーをはてなブックマークに追加

関連記事

前の記事へ

Three.jsでコントローラーを取得

次の記事へ

Three.jsでキューブ環境マッピング