2018年12月31日

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

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

※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 = [];

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

for(let i = 0; i < 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 = [];
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);
		textureArray.push(tempTexture);
	}
});

● 球体の円形配置

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

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

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

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

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

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

for(let i = 0; i < 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/sphereNum;
//ラジアン
const rad = (deg*Math.PI/180);
//半径
const r = 4;

for(let i = 0; i < 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: 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);

	group.add(sphereWrapper);
}

● script.js

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

<script src="js/preloadjs.min.js"></script>
<script src="js/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';

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

let scene,camera,renderer;
let orbitControls;
let captFont;
let sphereNum = 0;
let textureArray;
let spaceMaterial;
let group;
let controller,raycaster,pointerCircle;
let tempMatrix = new THREE.Matrix4();
let intersected = [];

function init(){
    scene = new THREE.Scene();
    camera = new THREE.PerspectiveCamera(90,window.innerWidth/window.innerHeight,0.1,1000);
    camera.position.set(0,1.6,0);
    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(){
    const fontLoader = new THREE.FontLoader();
    fontLoader.load('./data/gentilis_regular.typeface.json', function(font){
        captFont = font;
    });

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

    const nameArray = ['pict01','pict02','pict03','pict04','pict05','pict06','pict07','pict08','pict09','pict10'];
    let manifestArray = [];
    sphereNum = nameArray.length;

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

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

    let imageArray = [];
    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);
            textureArray.push(tempTexture);
        }
        TweenMax.to('#loader_wrapper',1,{
            opacity:0,
            onComplete: function(){
                document.getElementById('loader_wrapper').style.display = 'none';
            }
        });

        threeWorld();
        setLight();
        rendering();
    });

    loadQueue.loadManifest(manifestArray);
}

function threeWorld(){
    let geometry = new THREE.SphereBufferGeometry(100,100,100);
    geometry.scale(-1,1,1);
    spaceMaterial = new THREE.MeshBasicMaterial({
        map:textureArray[Math.floor(Math.random()*10)]
    });
    const spaceSphere = new THREE.Mesh(geometry,spaceMaterial);
    scene.add(spaceSphere);

    group = new THREE.Group();
    scene.add(group);

    const pictNameArray = ['posx.jpg','negx.jpg','posy.jpg','negy.jpg','posz.jpg','negz.jpg'];
    let textureCubes = [];

    for(let i = 0; i < 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/sphereNum;
    const rad = (deg*Math.PI/180);
    const r = 4;

    for(let i = 0; i < 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.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:captFont,
            size:0.15,
            height:0.01,
            curveSegments: 3
        });

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

        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);

        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,-5);
    scene.add(plane);
}

function setLight(){
    const ambientLight = new THREE.AmbientLight(0xFFFFFF);
    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);
    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',onSelectStart);
    controller.addEventListener('selectend',onSelectEnd);
    scene.add(controller);

    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
    });
    controller.add(new THREE.Line(geometry,material));
    pointerCircle = new THREE.Mesh(
        new THREE.CircleBufferGeometry(0.03,32),
        new THREE.MeshBasicMaterial({
            color:0xFFFFFF,
            transparent:true,
            opacity:0.5,
        })
    );

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

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

        if(intersections.length > 0){
            const intersection = intersections[0];
            const object = intersection.object;
            const num = parseInt(object.name.slice(6));
            spaceMaterial.map = textureArray[num];
            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;
        }
    }
}

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

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

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

    const intersections = 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'});
        intersected.push(object);

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

function clearIntersected(){
    while(intersected.length){
        const object = 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'});
    }
}

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

function animate(){
    if(orbitControls){
        orbitControls.update();
    }
    if(controller){
        clearIntersected();
        intersectObjects(controller);
    }
    if(group){
        group.rotation.y += 2 * Math.PI / 360 * 0.05;
    }
    renderer.render(scene,camera);
}

完成したデモになります。ヘッドマウントディスプレイで確認すると、パリの360度パノラマギャラリーを見ることができます。

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

関連記事

前の記事へ

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

次の記事へ

Oculus GoでWebVRのデバッグ