Three.jsでロコモーション

WebVRコンテンツの空間を自由に移動したいと思い、Three.jsでロコモーションする方法を調べてみました。
Three.jsでロコモーション
● コントローラーと光線を表示
「Three.jsでコントローラーを制御」で、Three.jsのWebXRManagerとXRControllerModelFactory.jsを使用して、コントローラーと光線を表示しました。
● ロコモーションについて
「Oculusドキュメント」によると、VRにおけるロコモーションとは、現実世界では静止した状態のまま仮想空間を移動することです。視覚的には空間を移動しているのに、身体は反対の感覚であるため、加速するとVR酔いなどの不快感を感じます。
そこで、「Using VR controllers and locomotion in THREE.js」を参考に、VR酔いしにくいテレボーテーションするタイプのロコモーションを実装します。
● ナビゲーションライン
どの位置まで移動するかを指定するナビゲーションライン(光線)は、直線ではなく放物線のほうがスムーズに移動できると思ったので、VRアプリでもよく使われる放物線で実装します。
//コントローラー制御
let guidingController = null;
const controller1 = renderer.xr.getController(0);
controller1.addEventListener('selectstart',onSelectStart);
controller1.addEventListener('selectend',onSelectEnd);
scene.add(controller1);
function onSelectStart(event){
guidingController = this;
this.add(guideline);
}
function onSelectEnd(){
if(guidingController = this){
this.remove(guideline);
guidingController = null;
}
}
//ナビゲーションライン
const g = new THREE.Vector3(0,-9.8,0);
const tempVec = new THREE.Vector3();
const tempVec1 = new THREE.Vector3();
const tempVecP = new THREE.Vector3();
const tempVecV = new THREE.Vector3();
let lineGeometryVertices;
let guideline;
const lineSegments = 10;
//頂点座標を管理する配列
lineGeometryVertices = new Float32Array((lineSegments + 1) * 3);
lineGeometryVertices.fill(0);
//形状
const lineGeometry = new THREE.BufferGeometry();
lineGeometry.setAttribute('position',new THREE.BufferAttribute(lineGeometryVertices,3));
//マテリアル
const lineMaterial = new THREE.LineBasicMaterial({color:0xFFFFFF,blending:THREE.AdditiveBlending});
//ライン生成
guideline = new THREE.Line(lineGeometry,lineMaterial);
//座標の計算
function positionAtT(inVec,t,p,v,g){
inVec.copy(p);
inVec.addScaledVector(v,t);
inVec.addScaledVector(g,0.5*t**2);
return inVec;
}
//アニメーション
function animate(){
if(guidingController){
//放物線のシミュレーション
const p = guidingController.getWorldPosition(tempVecP);
const v = guidingController.getWorldDirection(tempVecV);
v.multiplyScalar(6);
const t = (-v.y + Math.sqrt(v.y**2-2*p.y*g.y))/g.y;
const vertex = tempVec.set(0,0,0);
for(let i=1; i<=lineSegments; i++){
positionAtT(vertex,i*t/lineSegments,p,v,g);
guidingController.worldToLocal(vertex);
vertex.toArray(lineGeometryVertices,i*3);
}
guideline.geometry.attributes.position.needsUpdate = true;
}
}
● ターゲットへ移動
ナビゲーションラインが地面に接する場所にターゲットを表示して、実際にターゲットまで移動します。
//カメラとコントローラーをcameraGroupに格納
let cameraGroup;
cameraGroup = new THREE.Group();
cameraGroup.add(camera);
cameraGroup.add(controller1);
//ターゲット生成
const guidespriteTexture = new THREE.TextureLoader().load('./img/target.png');
guidesprite = new THREE.Mesh(
new THREE.PlaneGeometry(0.3,0.3,1,1),
new THREE.MeshBasicMaterial({
map:guidespriteTexture,
blending:THREE.AdditiveBlending,
color:0x555555,
transparent:true
})
);
guidesprite.rotation.x = -Math.PI/2;
function onSelectStart(event){
guidingController = this;
scene.add(guidesprite);
}
function onSelectEnd(){
if(guidingController = this){
//ターゲットまで移動
const feetPos = renderer.xr.getCamera(camera).getWorldPosition(tempVec);
feetPos.y = 0;
const p = guidingController.getWorldPosition(tempVecP);
const v = guidingController.getWorldDirection(tempVecV);
v.multiplyScalar(6);
const t = (-v.y + Math.sqrt(v.y**2-2*p.y*g.y))/g.y;
const cursorPos = positionAtT(tempVec1,t,p,v,g);
const offset = cursorPos.addScaledVector(feetPos,-1);
locomotion(offset);
scene.remove(guidesprite);
guidingController = null;
}
function locomotion(offset){
cameraGroup.position.add(offset);
}
}
function animate(){
if(guidingController){
//ターゲットの配置座標の計算
const p = guidingController.getWorldPosition(tempVecP);
const v = guidingController.getWorldDirection(tempVecV);
v.multiplyScalar(6);
const t = (-v.y + Math.sqrt(v.y**2-2*p.y*g.y))/g.y;
positionAtT(guidesprite.position,t*0.98,p,v,g);
guidesprite.position.y = 0;
}
}
● script.js
必要なライブラリを読み込みます。
<script src="js/preloadjs.min.js"></script> <script src="js/TweenMax.min.js"></script> <script src="js/script.js" type="module"></script>
完成したscript.jsになります。拡張性を考慮して、基本的な設定を管理するbasescene.jsとWebVR用のコントローラーを管理するvrcontroller.jsに分けました。また、ナビゲーションラインとターゲットのデザインをブラッシュアップしました。
//===============================================================
// Import Library
//===============================================================
import * as THREE from './lib/three_jsm/three.module.js';
import { OrbitControls } from './lib/three_jsm/OrbitControls.js';
import { GLTFLoader } from './lib/three_jsm/GLTFLoader.js';
import { scene, camera, cameraGroup, renderer } from './lib/basescene.js';
import * as VRCONTROLLER from './lib/vrcontroller.js';
//===============================================================
// Init
//===============================================================
window.addEventListener('load',function(){
init();
});
let orbitControls;
function init(){
checkDevice();
setLoading();
}
function checkDevice(){
if ('xr' in navigator) {
navigator.xr.isSessionSupported('immersive-vr').then(function(supported){
if(supported){
renderer.xr.enabled = true;
VRCONTROLLER.init();
}else{
setControll();
}
});
}else{
setControll();
}
}
function setLoading(){
TweenMax.to('.loader',0.1,{opacity:1});
const gltfLoader = new GLTFLoader();
gltfLoader.load('./data/space_corridor_webvr.glb',
function(gltf){
const obj = gltf.scene;
obj.traverse(function(child) {
if(child.isMesh){}
});
obj.position.set(0,1,0);
scene.add(obj);
TweenMax.to('#loader_wrapper',1,{
opacity:0,
delay:1,
onComplete: function(){
document.getElementById('loader_wrapper').style.display = 'none';
TweenMax.to('.loader',0,{opacity:0});
}
});
threeWorld();
setLight();
rendering();
});
}
//===============================================================
// Create World
//===============================================================
function threeWorld(){
renderer.outputEncoding = THREE.sRGBEncoding;
}
function setLight(){
const ambientlight = new THREE.AmbientLight(0x333333);
scene.add(ambientlight);
const pointLight = new THREE.PointLight(0xFFFFFF,10,30);
pointLight.position.set(0,0,5);
scene.add(pointLight);
}
function setControll(){
document.addEventListener('touchmove',function(e){e.preventDefault();},{passive:false});
orbitControls = new OrbitControls(camera,renderer.domElement);
orbitControls.target.set(0,1.6,1);
orbitControls.enableDamping = true;
orbitControls.dampingFactor = 0.5;
orbitControls.enableZoom = false;
}
function rendering(){
renderer.setAnimationLoop(animate);
}
function animate(){
VRCONTROLLER.animate();
if(orbitControls){
orbitControls.update();
}
renderer.render(scene,camera);
}
● basescene.js
sceneやcameraなど基本的な設定を管理するbasescene.jsです。
//===============================================================
// Import Library
//===============================================================
import * as THREE from './three_jsm/three.module.js';
import { VRButton } from './three_jsm/VRButton.js';
//===============================================================
// Base scene
//===============================================================
let scene,camera,cameraGroup,renderer;
init();
function init(){
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(80,window.innerWidth/window.innerHeight,0.1,100);
camera.position.set(0,1.6,1);
cameraGroup = new THREE.Group();
cameraGroup.add(camera);
scene.add(cameraGroup);
renderer = new THREE.WebGLRenderer({antialias:true});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth,window.innerHeight);
renderer.setClearColor(new THREE.Color(0x000000));
renderer.physicallyCorrectLights = true;
renderer.outputEncoding = THREE.sRGBEncoding;
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);
}
export { scene, camera, cameraGroup, renderer }
● vrcontroller.js
ナビゲーションラインはTHREE.MeshLineを使用して線の太さを調整しました。
//===============================================================
// Import Library
//===============================================================
import * as THREE from './three_jsm/three.module.js';
import { XRControllerModelFactory } from './three_jsm/XRControllerModelFactory.js';
import { scene, camera, cameraGroup, renderer } from './basescene.js';
import MeshLine from './three_jsm/meshline/MeshLine.js';
import MeshLineMaterial from './three_jsm/meshline/MeshLineMaterial.js';
//===============================================================
// Variable
//===============================================================
let guidingController = null;
const g = new THREE.Vector3(0,-9.8,0);
const tempVec = new THREE.Vector3();
const tempVec1 = new THREE.Vector3();
const tempVecP = new THREE.Vector3();
const tempVecV = new THREE.Vector3();
const lineSegments = 30;
let angle = 0;
let lineGeometryVertices;
let meshLine,guideline,guidesprite;
//===============================================================
// VR Controller
//===============================================================
function init(){
setGuidesline();
const controller1 = renderer.xr.getController(0);
controller1.addEventListener('selectstart',onSelectStart);
controller1.addEventListener('selectend',onSelectEnd);
scene.add(controller1);
cameraGroup.add(controller1);
const controller2 = renderer.xr.getController(1);
controller2.addEventListener('selectstart',onSelectStart);
controller2.addEventListener('selectend',onSelectEnd);
scene.add(controller2);
cameraGroup.add(controller2);
function onSelectStart(event){
guidingController = this;
this.add(guideline);
scene.add(guidesprite);
}
function onSelectEnd(){
if(guidingController = this){
const feetPos = renderer.xr.getCamera(camera).getWorldPosition(tempVec);
feetPos.y = 0;
const p = guidingController.getWorldPosition(tempVecP);
const v = guidingController.getWorldDirection(tempVecV);
v.multiplyScalar(6);
const t = (-v.y + Math.sqrt(v.y**2-2*p.y*g.y))/g.y;
const cursorPos = positionAtT(tempVec1,t,p,v,g);
const offset = cursorPos.addScaledVector(feetPos,-1);
locomotion(offset);
this.remove(guideline);
scene.remove(guidesprite);
guidingController = null;
}
function locomotion(offset){
cameraGroup.position.add(offset);
}
}
const controllerModelFactory = new XRControllerModelFactory();
const controllerGrip1 = renderer.xr.getControllerGrip(0);
const model1 = controllerModelFactory.createControllerModel(controllerGrip1);
controllerGrip1.add(model1);
scene.add(controllerGrip1);
cameraGroup.add(controllerGrip1);
const controllerGrip2 = renderer.xr.getControllerGrip(1);
const model2 = controllerModelFactory.createControllerModel(controllerGrip2);
controllerGrip2.add(model2);
scene.add(controllerGrip2);
cameraGroup.add(controllerGrip2);
}
function setGuidesline(){
lineGeometryVertices = new Float32Array((lineSegments+1)*3);
lineGeometryVertices.fill(0);
const lineGeometry = new THREE.BufferGeometry();
lineGeometry.setAttribute('position',new THREE.BufferAttribute(lineGeometryVertices,3).setUsage(THREE.DynamicDrawUsage));
meshLine = new MeshLine();
meshLine.setGeometry(lineGeometry);
const lineMaterial = new MeshLineMaterial({
lineWidth:0.005,
});
guideline = new THREE.Mesh(meshLine.geometry,lineMaterial);
scene.add(guideline);
guidesprite = new THREE.Group();
let texture = new THREE.TextureLoader().load('./img/vrcontroller/pillar.png');
let geometry = new THREE.CylinderGeometry(0.09,0.09,0.1,20,20,true);
let material = new THREE.MeshBasicMaterial({
map:texture,
color:0xFFFFFF,
transparent:true,
opacity:0.45,
blending: THREE.AdditiveBlending,
side:THREE.DoubleSide,
depthWrite:false
});
const pillar = new THREE.Mesh(geometry,material);
pillar.position.y = 0.075;
guidesprite.add(pillar);
texture = new THREE.TextureLoader().load('./img/vrcontroller/ground.png');
geometry = new THREE.PlaneGeometry(0.3,0.3,16,16);
material = new THREE.MeshBasicMaterial({
map:texture,
color:0xFFFFFF,
transparent:true,
opacity:0.8,
blending: THREE.AdditiveBlending,
side:THREE.DoubleSide
});
const ground = new THREE.Mesh(geometry,material);
ground.scale.multiplyScalar(1.1);
ground.rotation.x = Math.PI/2;
ground.position.y = 0.01;
guidesprite.add(ground);
}
function positionAtT(inVec,t,p,v,g){
inVec.copy(p);
inVec.addScaledVector(v,t);
inVec.addScaledVector(g,0.5*t**2);
return inVec;
}
function animate(){
if(guidingController){
const p = guidingController.getWorldPosition(tempVecP);
const v = guidingController.getWorldDirection(tempVecV);
v.multiplyScalar(6);
const t = (-v.y + Math.sqrt(v.y**2-2*p.y*g.y))/g.y;
const vertex = tempVec.set(0,0,0);
let lineGeometryVertices = guideline.geometry.attributes.position.array;
for(let i=1; i<=lineSegments; i++){
positionAtT(vertex,i*t/lineSegments,p,v,g);
guidingController.worldToLocal(vertex);
vertex.toArray(lineGeometryVertices,i*3);
}
lineGeometryVertices = lineGeometryVertices.slice(0,(lineSegments+1)*3);
guideline.geometry.setAttribute('position',new THREE.BufferAttribute(lineGeometryVertices,3).setUsage(THREE.DynamicDrawUsage));
meshLine.setGeometry(guideline.geometry);
guideline.geometry.attributes.position.needsUpdate = true;
positionAtT(guidesprite.position,t*0.98,p,v,g);
guidesprite.position.y = 0;
}
}
export { init , animate }
完成したデモになります。WebXR API Emulatorかヘッドマウントディスプレイで確認すると、WebVRコンテンツの空間を自由に移動することができます。

