2018年12月31日

Three.jsで360度パノラマギャラリー制作

Flickr VRの写真」を使用させてもらい、パリの360度パノラマギャラリーを制作しました。

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

360度パノラマギャラリー制作

● PreloadJSで複数画像の読み込み

360度パノラマ画像の表示方法やローディング画面、WebVR対応などの方法は、「Three.jsで360度パノラマコンテンツ制作」にまとめていますが、画面を切り替えるときにローディングバーを出したくなかったので、最初にパノラマ画像をまとめて読み込んでおきます。

//読み込む外部ファイル情報の準備
const nameArray = ['pict01','pict02','pict03','pict04','pict05','pict06','pict07','pict08','pict09','pict10'];
let manifestArray = [];

//球体の数を保存
this.sphereNum = nameArray.length;

for(let i = 0; i < this.sphereNum; i++){
	let name = nameArray[i];
	let path = 'img/'+name+'.jpg';
	manifestArray.push({id:name,src:path})
}

// LoadQueueクラス
const loadQueue = new createjs.LoadQueue();

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

let imageArray = [];
this.textureArray = [];

//読み込みが完了したらテクスチャを配列に格納
loadQueue.on('complete',function(){

	for(let i = 0; i < nameArray.length; i++){
		let tempImage = loadQueue.getResult(nameArray[i]);
		let tempTexture = new THREE.Texture(tempImage);
		tempTexture.needsUpdate = true;

		imageArray.push(tempImage);
		this.textureArray.push(tempTexture);
	}
});

● 球体の円形配置

Three.jsでキューブ環境マッピング」でやったように、ガラス玉のような球体を円形配置してインターフェースにします。
球体を円形配置するには、360度を配置したい個数で割り角度を求め、角度からラジアン、ラジアンから三角関数を使用してx座標、z座標を求めます。

・ラジアン = 角度 × Math.PI/180
・x座標 = Math.cos(ラジアン) × 半径
・z座標 = Math.sin(ラジアン) × 半径

球体にガイド用のテキストも設置したかったので、フォントをダウンロードしてテキストを設置しています。また、ガイド用のテキストは常に内側に向けたかったので、球体とテキストをグループ化して、グループ自体の向きを設定しています。また、TweenMaxは数値をアニメーションさせることができるので、オブジェクトのアニメーションに使用しています。

//フォントの読み込み
const _this = this;
const fontLoader = new THREE.FontLoader();
fontLoader.load('./data/gentilis_regular.typeface.json', function(font){
	_this.captFont = font;
});

//円形配置する球体を一つのグループにまとめる
this.group = new THREE.Group();
this.scene.add(this.group);

//キューブ環境マッピング
const pictNameArray = ['posx.jpg','negx.jpg','posy.jpg','negy.jpg','posz.jpg','negz.jpg'];
let textureCubes = [];

for(let i = 0; i < this.sphereNum; i++){

	let urls = [];
	let num;

	for(let j = 0; j < 6; j++){
		if(i < 9){
			num = '0'+(i+1);
		}else{
			num = (i+1);
		}
		let path = 'img/texture/'+num+'/'+pictNameArray[j];
		urls.push(path);
	}

	const loader = new THREE.CubeTextureLoader();
	const textureCube = loader.load(urls);
	textureCube.mapping = THREE.CubeReflectionMapping;
	textureCubes.push(textureCube);
}

const captNameArray = ['Louvre','Park','Versailles','Hotel','ArtMuseum','TheEiffelTower','Birdview','Station','Restaurant','ArcdeTriomphe'];

//球体の円形配置
//角度
const deg = 360/this.sphereNum;
//ラジアン
const rad = (deg*Math.PI/180);
//半径
const r = 4;

for(let i = 0; i < this.sphereNum; i++){

	//球体とテキストをまとめるグループ
	const sphereWrapper = new THREE.Group();

	const geometry = new THREE.SphereBufferGeometry(0.25,64,64);
	const material = new THREE.MeshPhongMaterial({
		envMap:textureCubes[i],
			transparent:true
	});
	//TweenMaxで半透明化
	TweenMax.to(material , 0.5 , {opacity:"0.4",delay:i*0.25});

	//球体
	const sphere = new THREE.Mesh(geometry,material);
	sphere.scale.set(0.01,0.01,0.01);
	TweenMax.to(sphere.scale , 0.5 , {x:"3",y:"3",z:"3",delay:i*0.25});
	//選択する際に取得できるように名前を設定
	sphere.name = 'sphere'+i;
	sphereWrapper.add(sphere);

	//ガイド用のテキスト
	const textGeometry = new THREE.TextBufferGeometry(captNameArray[i], {
		font: this.captFont,
		size: 0.15,
		height:0.01,
		curveSegments: 3
	});

	const textMaterial = new THREE.MeshBasicMaterial( {
		color: 0xFFFFFF,
		transparent:true,
		opacity:0,
		overdraw: 0.5
	} );

	//TweenMaxで半透明化
	TweenMax.to(textMaterial , 0.5 , {opacity:"0.8",delay:i*0.25});

	const textMesh = new THREE.Mesh(textGeometry, textMaterial);
	textMesh.position.x = -(textGeometry.parameters.shapes.length * 0.0475);
	textMesh.position.z = 0.75;

	sphereWrapper.add(textMesh);

	//円形配置
	sphereWrapper.position.x = Math.cos(rad * i) * r;
	sphereWrapper.position.y = 2;
	sphereWrapper.position.z = Math.sin(rad * i) * r;

	//向きを中心に設定
	sphereWrapper.lookAt(0,1.5,0);

	this.group.add(sphereWrapper);
}

● 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です。「Three.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.1,
                this.camera.position.y + 1,
                this.camera.position.z + 0.1
            );
        }else{
        	//VRを許可
            this.renderer.vr.enabled = true;
        }
        document.addEventListener('touchmove', function(e) {e.preventDefault();}, {passive: false});
    }

	//ローディング画面
    setLoading(){
        const _this = this;
        //フォントの読み込み
        const fontLoader = new THREE.FontLoader();
        fontLoader.load('./data/gentilis_regular.typeface.json', function(font){
            _this.captFont = font;
        });

        TweenMax.to(".loader",0.1,{opacity:1});

        //読み込む外部ファイル情報の準備
        const nameArray = ['pict01','pict02','pict03','pict04','pict05','pict06','pict07','pict08','pict09','pict10'];
        let manifestArray = [];

        //球体の数を保存
        this.sphereNum = nameArray.length;

        for(let i = 0; i < this.sphereNum; i++){
            let name = nameArray[i];
            let path = 'img/'+name+'.jpg';
            manifestArray.push({id:name,src:path})
        }

		// LoadQueueクラス
        const loadQueue = new createjs.LoadQueue();
        loadQueue.on('progress',function(e){
            const progress = e.progress;
        });

        let imageArray = [];
        this.textureArray = [];

		//読み込みが完了したらテクスチャを配列に格納
        loadQueue.on('complete',function(){
            for(let i = 0; i < nameArray.length; i++){
                let tempImage = loadQueue.getResult(nameArray[i]);
                let tempTexture = new THREE.Texture(tempImage);
                tempTexture.needsUpdate = true;

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

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

        loadQueue.loadManifest(manifestArray);
    }

    //オブジェクトを生成
    initObject(){
        let geometry = new THREE.SphereBufferGeometry(100,100,100);
        geometry.scale(-1,1,1);
        this.spaceMaterial = new THREE.MeshBasicMaterial({
            map:this.textureArray[Math.floor(Math.random()*10)]
        });
        const spaceSphere = new THREE.Mesh(geometry,this.spaceMaterial);
        this.scene.add(spaceSphere);

		//円形配置する球体を一つのグループにまとめる
        this.group = new THREE.Group();
        this.scene.add(this.group);

        //キューブ環境マッピング
        const pictNameArray = ['posx.jpg','negx.jpg','posy.jpg','negy.jpg','posz.jpg','negz.jpg'];
        let textureCubes = [];

        for(let i = 0; i < this.sphereNum; i++){

            let urls = [];
            let num;
            for(let j = 0; j < 6; j++){
                if(i < 9){
                    num = '0'+(i+1);
                }else{
                    num = (i+1);
                }
                let path = 'img/texture/'+num+'/'+pictNameArray[j];
                urls.push(path);
            }
            const loader = new THREE.CubeTextureLoader();
            const textureCube = loader.load(urls);
            textureCube.mapping = THREE.CubeReflectionMapping;
            textureCubes.push(textureCube);
        }

        const captNameArray = ['Louvre','Park','Versailles','Hotel','ArtMuseum','TheEiffelTower','Birdview','Station','Restaurant','ArcdeTriomphe'];

		//球体の円形配置
		//角度
        const deg = 360/this.sphereNum;
        //ラジアン
        const rad = (deg*Math.PI/180);
        //半径
        const r = 4;

        for(let i = 0; i < this.sphereNum; i++){

        	//球体とテキストをまとめるグループ
            const sphereWrapper = new THREE.Group();

            geometry = new THREE.SphereBufferGeometry(0.25,64,64);
            const material = new THREE.MeshPhongMaterial({
                envMap:textureCubes[i],
                transparent:true
            });

            //TweenMaxで半透明化
            TweenMax.to(material , 0.5 , {opacity:"0.4",delay:i*0.25});

            //球体
            const sphere = new THREE.Mesh(geometry,material);
            sphere.scale.set(0.01,0.01,0.01);
            TweenMax.to(sphere.scale , 0.5 , {x:"3",y:"3",z:"3",delay:i*0.25});
            //選択する際に取得できるように名前を設定
            sphere.name = 'sphere'+i;
            sphereWrapper.add(sphere);

            //ガイド用のテキスト
            const textGeometry = new THREE.TextBufferGeometry(captNameArray[i], {
                font: this.captFont,
                size: 0.15,
                height:0.01,
                curveSegments: 3
            });

            const textMaterial = new THREE.MeshBasicMaterial( {
                color: 0xFFFFFF,
                transparent:true,
                opacity:0,
            } );

            //TweenMaxで半透明化
            TweenMax.to(textMaterial , 0.5 , {opacity:"0.8",delay:i*0.25});

            const textMesh = new THREE.Mesh(textGeometry, textMaterial);
            textMesh.position.x = -(textGeometry.parameters.shapes.length * 0.0475);
            textMesh.position.z = 0.75;

            sphereWrapper.add(textMesh);

            //円形配置
            sphereWrapper.position.x = Math.cos(rad * i) * r;
            sphereWrapper.position.y = 2;
            sphereWrapper.position.z = Math.sin(rad * i) * r;
            //向きを中心に設定
            sphereWrapper.lookAt(0,1.5,0);

            this.group.add(sphereWrapper);
        }

        const textureLoader = new THREE.TextureLoader();
        const texture = textureLoader.load('img/paris.png');

        geometry = new THREE.PlaneBufferGeometry( 2.5, 2.5, 16 );
        const material = new THREE.MeshBasicMaterial( {
            transparent:true,
            map:texture,
            side: THREE.DoubleSide,
            opacity:1
        } );
        const plane = new THREE.Mesh( geometry, material );
        plane.position.set(0,5,-4);
        this.scene.add( plane );
    }

    //ライト生成
    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',onSelectStart);
        this.controller.addEventListener('selectend',onSelectEnd);
        this.scene.add(this.controller);

        this.raycaster = new THREE.Raycaster();

        const geometry = new THREE.BufferGeometry();
        geometry.setAttribute('position',new THREE.Float32BufferAttribute([0,0,0,0,0,-1],3));
        geometry.setAttribute('color',new THREE.Float32BufferAttribute([1.0,1.0,1.0,1.0,1.0,1.0],3));
        geometry.setAttribute('size',new THREE.Float32BufferAttribute([2.0,1.0,1.0],3));

        const material = new THREE.LineBasicMaterial({
            vertexColors:true,
            blending:THREE.AdditiveBlending,
            linewidth:2.0
        });
        this.controller.add(new THREE.Line(geometry,material));
        this.pointerCircle = new THREE.Mesh(
            new THREE.CircleBufferGeometry(0.03,32),
            new THREE.MeshBasicMaterial({
                color:0xFFFFFF,
                transparent:true,
                opacity:0.5,
            })
        );

        this.pointerCircle.position.z = 0;
        this.controller.add(this.pointerCircle);

        function onSelectStart(event){
            const controller = event.target;
            const intersections = _this.getIntersections(controller);

            if(intersections.length > 0){
                const intersection = intersections[0];
                const object = intersection.object;
                const num = parseInt(object.name.slice(6));
                _this.spaceMaterial.map = _this.textureArray[num];
                _this.controller.userData.selected = object;
            }
        }

        function onSelectEnd(event){
            const controller = event.target;

            if(controller.userData.selected !== undefined){
                const object = controller.userData.selected;
                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);

        let sphereArr = [];
        for(let i = 0; i < this.sphereNum; i++){
            let targetSphere = this.group.children[i].children[0];
            sphereArr.push(targetSphere);
        }
        return this.raycaster.intersectObjects(sphereArr);
    }

    intersectObjects(controller){

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

        const intersections = this.getIntersections(controller);

        if(intersections.length > 0){
            const intersection = intersections[0];
            const object = intersection.object;
            object.material.emissive.r = 0.7;
            object.material.emissive.g = 0.7;
            object.material.emissive.b = 0.7;
            TweenMax.to(object.material , 0.5 , {opacity:"1.0"});
            this.intersected.push(object);

            this.pointerCircle.position.z = -intersection.distance+0.1;
            this.pointerCircle.opacity = 0.1;
        }else{
            this.pointerCircle.position.z = -50;
            this.pointerCircle.opacity = 0;
        }
    }

    clearIntersected(){
        while(this.intersected.length){
            const object = this.intersected.pop();
            object.material.emissive.r = 0;
            object.material.emissive.g = 0;
            object.material.emissive.b = 0;
            TweenMax.to(object.material , 0.5 , {opacity:"0.4"});
        }
    }

    //アニメーション
    onTick(){
        if(this.controller){
            this.clearIntersected();
            this.intersectObjects(this.controller);
        }
        if(this.group){
            this.group.rotation.y += 2 * Math.PI / 360 * 0.05;
        }
    }
}
//===============================================================
// Window load
//===============================================================
window.addEventListener("load", function () {
   const threeWorld = new ThreeWorld();
});

完成したデモになります。VRヘッドセットで見ると、パリの360度パノラマギャラリーを見ることができます。

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

関連記事

前の記事へ

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

次の記事へ

Oculus GoでWebVRのデバッグ