2018年12月13日

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

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

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

シャドウマッピング

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

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

● シャドウマッピング

//レンダラーで影を有効化
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;
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を使用します。

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

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

scene.add(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;

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

//ボタンを押した時のイベント
function onSlectStart(event){
}

//ボタンを離した時のイベント
function onSelectEnd(event){
}

● オブジェクトを選択

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

//Groupを生成
group = new THREE.Group();
scene.add(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;

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

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

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

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

//ボタンを押した時のイベント
function onSlectStart(event){
	const controller = event.target;
	const intersections = 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){
	tempMatrix.identity().extractRotation(controller.matrixWorld);
	raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
	raycaster.ray.direction.set(0,0,-1).applyMatrix4(tempMatrix);
	return raycaster.intersectObjects(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>

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

//===============================================================
// 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';

//===============================================================
// Main
//===============================================================
window.addEventListener('load',function(){
   init();
});

let scene,camera,renderer;
let orbitControls;
let group,controller,raycaster;
let texture;
let intersected = [];
let tempMatrix = new THREE.Matrix4();

function init(){
    scene = new THREE.Scene();
    camera = new THREE.PerspectiveCamera(50,window.innerWidth/window.innerHeight,0.1,1000);
    camera.position.set(0,1.6,3);
    scene.add(camera);
    renderer = new THREE.WebGLRenderer({antialias:true});
    renderer.setPixelRatio(window.devicePixelRatio);
    renderer.setSize(window.innerWidth,window.innerHeight);
    renderer.shadowMap.enabled = true;

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

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

    window.addEventListener('resize',function(){
        camera.aspect = window.innerWidth/window.innerHeight;
        camera.updateProjectionMatrix();
        renderer.setSize(window.innerWidth,window.innerHeight);
    },false);

    checkDevice();
    setLoading();
}

function checkDevice(){
    if ('xr' in navigator) {
        navigator.xr.isSessionSupported('immersive-vr').then(function(supported){
            if(supported){
                renderer.xr.enabled = true;
                setVrController();
            }else{
                setController();
            }
        });
    } else {
        setController();
    }
}

function setLoading(){
    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');
        texture = new THREE.Texture(image);
        texture.needsUpdate = true;

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

    loadQueue.loadManifest(manifest);
}

function threeWorld(){
    texture.repeat.set(50, 50);
    texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
    texture.magFilter = THREE.NearestFilter;
    let geometry = new THREE.PlaneGeometry(250,250);
    let material = new THREE.MeshStandardMaterial({map:texture,roughness:0.0,metalness:0.5});

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

    group = new THREE.Group();
    scene.add(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;

        group.add(object);
    }
}

function setLight(){
    const ambientLight = new THREE.AmbientLight(0xFFFFFF);
    scene.add(ambientLight);

    const directionalLight = new THREE.DirectionalLight(0xFFFFFF,1,0);
    directionalLight.position.set(0,80,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);
    scene.add(directionalLight);
}

function setController(){
    document.addEventListener('touchmove',function(e){e.preventDefault();},{passive:false});
    orbitControls = new OrbitControls(camera,renderer.domElement);
    orbitControls.target.set(0,1.6,0);
    orbitControls.enableDamping = true;
    orbitControls.dampingFactor = 0.5;
    orbitControls.enableZoom = false;
}

function setVrController(){
    controller = renderer.xr.getController(0);
    controller.addEventListener('selectstart',onSlectStart);
    controller.addEventListener('selectend',onSelectEnd);
    scene.add(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;

    controller.add(line.clone());

    raycaster = new THREE.Raycaster();

    function onSlectStart(event){
        const controller = event.target;
        let intersections = getIntersections(controller);

        if(intersections.length > 0){
            const intersection = intersections[0];
            tempMatrix.getInverse(controller.matrixWorld);
            const object = intersection.object;
            object.matrix.premultiply(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;
            group.add(object);
            controller.userData.selected = undefined;
        }
    }
}

function getIntersections(controller){
    tempMatrix.identity().extractRotation(controller.matrixWorld);
    raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
    raycaster.ray.direction.set(0,0,-1).applyMatrix4(tempMatrix);
    return raycaster.intersectObjects(group.children);
}

function intersectObjects(controller){
    if(controller.userData.selected !== undefined){
        return;
    }

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

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

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

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

function rendering(){
    renderer.setAnimationLoop(animate);
}

function animate(){
    if(orbitControls){
        orbitControls.update();
    }

    if(controller){
        clearIntersected();
        intersectObjects(controller);
    }
    renderer.render(scene,camera);
}

完成したデモになります。ヘッドマウントディスプレイで確認すると、コントローラーでドラッグできます!

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

関連記事

前の記事へ

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

次の記事へ

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