前回、Three.jsでPOLAR STAR(ポーラースター)のようなゲームを作るで久々にThree.jsを使ったのですが、完全に忘れてしまっていたので自分のために備忘録をつくります。
基本形
これでY軸を中心に回転する立方体が描画されます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8" /> <meta name = "viewport" content = "width=device-width, initial-scale = 1.0"> <title>テスト</title> <link rel="stylesheet" href="./style.css"> </head> <body> <div> <canvas id="canvas"></canvas> </div> <script type="module" src="./index.js"></script> </body> </html> |
style.css
1 2 3 4 5 6 7 8 |
#canvas { display: block; margin-bottom: 10px; } #increase, #decrease { width: 100px; height: 50px; } |
index.js
VS Code を使ってコーディングする場合、https://cdn.jsdelivr.net/npm/three@0.167.0/build/three.module.js をダウンロードして同じディレクトリのなかに入れ、最初に2行に //@ts-check と //<reference path=”./three.module.js”/> をいれておくと型が機能することによるVSCodeでの厳密なコード補完の恩恵を受けることができるので便利かもしれません。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
//@ts-check ///<reference path="./three.module.js"/> import * as THREE from "./three.module.js"; // サイズを指定 const width = 360; const height = 360; // レンダラー,シーン,カメラを作成 const renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('canvas'), }); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(45, width / height); // 箱 /** @type {THREE.Mesh} */ let box; window.onload = () => { renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(width, height); camera.position.set(0, 0, 150); // カメラの位置 camera.lookAt(0, 0, 0); // カメラはどこを見つめるか? (0, 0, 0) なら書かなくてもよい addObjects(); addEventListeners(); // 60fps const INTERVAL = 1000 / 60; let nextUpdateTime = new Date().getTime() + INTERVAL; frameProc(); function frameProc(){ const curTime = new Date().getTime(); if(nextUpdateTime < curTime){ nextUpdateTime += INTERVAL; update(); } requestAnimationFrame(() => frameProc()); } } function addObjects(){ const geometry = new THREE.BoxGeometry(32, 32, 32); const material = new THREE.MeshNormalMaterial(); box = new THREE.Mesh(geometry, material); scene.add(box); } function addEventListeners(){ } // 更新処理 function update() { box.rotation.y += 0.02; renderer.render(scene, camera); // レンダリング } |
直線の描画
これで直線を生成して追加することができます。これまではimport文でThree.jsを読み込む方法とは別の方法を使ってきましたが、importする方法だと直線を生成する方法が変わってきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
function addObjects(){ const geometry = new THREE.BoxGeometry(32, 32, 32); const material = new THREE.MeshNormalMaterial(); box = new THREE.Mesh(geometry, material); scene.add(box); const lineX = createLine(1000, 0, 0, -1000, 0, 0, 0xff0000) scene.add(lineX); const lineY = createLine(0, 1000, 0, 0, -1000, 0, 0x00ff00) scene.add(lineY); } // 色がcolorの(x1, y1, z1)と(x2, y2, z2)を結ぶ直線を生成する function createLine(x1, y1, z1, x2, y2, z2, color){ const lineMaterial = new THREE.LineBasicMaterial({ color: color }); const lineGeometry = new THREE.BufferGeometry(); const vertices = new Float32Array( [ x1, y1, z1, x2, y2, z2, ] ); lineGeometry.setAttribute( 'position', new THREE.BufferAttribute( vertices, 3 ) ); return new THREE.Line(lineGeometry, lineMaterial); // import文でThree.jsを読み込む方法だと以下はうまくいかない // const material = new THREE.LineBasicMaterial({ color: color }); // const geometry = new THREE.Geometry(); // geometry.vertices.push(new THREE.Vector3(x1, y1, z1), new THREE.Vector3(x2, y2, z2)); // const line = new THREE.Line(geometry, material); // scene.add(line); } |
回転と移動
Object3D.position.xを変更すれば移動、Object3D.rotation.xを変更すれば回転させることができます。ただし回転とともに回転の軸も回転してしまいます(実際に X軸で少し回転させたあとY軸で回転させてみるとわかる)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8" /> <meta name = "viewport" content = "width=device-width, initial-scale = 1.0"> <title>テスト</title> <link rel="stylesheet" href="./style.css"> </head> <body> <div> <canvas id="canvas"></canvas> </div> <div> <label for="position-x">position-x</label><input type="checkbox" id = "position-x"> <label for="position-y">position-y</label><input type="checkbox" id = "position-y"> <label for="position-z">position-z</label><input type="checkbox" id = "position-z"> </div> <div> <label for="rotate-x">rotate-x</label><input type="checkbox" id = "rotate-x"> <label for="rotate-y">rotate-y</label><input type="checkbox" id = "rotate-y"> <label for="rotate-z">rotate-z</label><input type="checkbox" id = "rotate-z"> </div> <button id = "increase">増加</button> <button id = "decrease">減少</button> <script type="module" src="./index.js"></script> </body> </html> |
index.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 |
function addObjects(){ // 細長い直方体を追加する const geometry = new THREE.BoxGeometry(24, 24, 48); const material = new THREE.MeshNormalMaterial(); box = new THREE.Mesh(geometry, material); scene.add(box); const lineX = createLine(1000, 0, 0, -1000, 0, 0, 0xff0000) scene.add(lineX); const lineY = createLine(0, 1000, 0, 0, -1000, 0, 0x00ff00) scene.add(lineY); } // 増加・減少のボタンが押下されているかどうか? let pressIncrease = false; let pressDecrease = false; function addEventListeners(){ const $increase = document.getElementById('increase'); const $decrease = document.getElementById('decrease'); const arr1 = ['mousedown', 'touchstart']; const arr2 = ['mouseup', 'touchend']; for(let i=0; i<arr1.length; i++){ $increase?.addEventListener(arr1[i], () => pressIncrease = true); $decrease?.addEventListener(arr1[i], () => pressDecrease = true); } for(let i=0; i<arr2.length; i++){ $increase?.addEventListener(arr2[i], () => pressIncrease = false); $decrease?.addEventListener(arr2[i], () => pressDecrease = false); } // スマホでボタンを長時間タップしたときのデフォルトの動作を抑止する const arr3 = ['touchstart', 'touchend']; for(let i=0; i<arr3.length; i++){ $increase?.addEventListener(arr3[i], (ev) => ev.preventDefault()); $decrease?.addEventListener(arr3[i], (ev) => ev.preventDefault()); } } const $positionX = document.getElementById('position-x'); const $positionY = document.getElementById('position-y'); const $positionZ = document.getElementById('position-z'); const $rotateX = document.getElementById('rotate-x'); const $rotateY = document.getElementById('rotate-y'); const $rotateZ = document.getElementById('rotate-z'); // 更新処理 function update() { if(pressIncrease){ if($positionX.checked) box.position.x += 0.4; if($positionY.checked) box.position.y += 0.4; if($positionZ.checked) box.position.z += 0.4; if($rotateX.checked) box.rotation.x += 0.02; if($rotateY.checked) box.rotation.y += 0.02; if($rotateZ.checked) box.rotation.z += 0.02; } if(pressDecrease){ if($positionX.checked) box.position.x -= 0.4; if($positionY.checked) box.position.y -= 0.4; if($positionZ.checked) box.position.z -= 0.4; if($rotateX.checked) box.rotation.x -= 0.02; if($rotateY.checked) box.rotation.y -= 0.02; if($rotateZ.checked) box.rotation.z -= 0.02; } renderer.render(scene, camera); } |
オイラー角を指定した回転
回転しても軸がずれないような回転をさせるにはsetRotationFromEuler関数でオイラー角を指定します。
THREE.Eulerのorderを’YXZ’と指定することによって、eulerYを機首方位角、eulerXをピッチ角、eulerZをバンク角とする回転が可能になります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
let eulerX = 0; let eulerY = 0; let eulerZ = 0; // 更新処理 function update() { if(pressIncrease){ if($positionX.checked) box.position.x += 0.4; if($positionY.checked) box.position.y += 0.4; if($positionZ.checked) box.position.z += 0.4; if($rotateX.checked) eulerX += 0.02; if($rotateY.checked) eulerY += 0.02; if($rotateZ.checked) eulerZ += 0.02; } if(pressDecrease){ if($positionX.checked) box.position.x -= 0.4; if($positionY.checked) box.position.y -= 0.4; if($positionZ.checked) box.position.z -= 0.4; if($rotateX.checked) eulerX -= 0.02; if($rotateY.checked) eulerY -= 0.02; if($rotateZ.checked) eulerZ -= 0.02; } const euler = new THREE.Euler(eulerX, eulerY, eulerZ, 'YXZ'); box.setRotationFromEuler(euler) renderer.render(scene, camera); } |
スプライト
スプライトとは常に正面を向く3Dオブジェクトです。
読み込んだ画像ファイルからスプライトを生成しています。スプライトを100個生成してカメラに向かって移動させ、Z座標がカメラの位置に達したらZ座標を-1000にする処理を繰り返しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
/** @type {THREE.Sprite[]} */ let sprites = []; function addObjects(){ for(let i=0; i<100; i++){ /** @type {THREE.Sprite} */ const sprite = createSprite(); sprite.position.x = Math.random() * 200 - 100; // -100 ~ +100 sprite.position.y = Math.random() * 200 - 100; // -100 ~ +100 sprite.position.z = Math.random() * 1000 - 900; // 100 ~ -900 sprites.push(sprite); scene.add(sprite); } } function createSprite(){ const texture = new THREE.TextureLoader().load('./images/star.png'); texture.colorSpace = THREE.SRGBColorSpace; const material = new THREE.SpriteMaterial({ map: texture, }); const sprite = new THREE.Sprite(material); sprite.scale.set(20, 20, 20); // サイズ調整 return sprite; } // 更新処理 function update() { for(let i=0; i<sprites.length; i++){ const sprite = sprites[i]; sprite.position.z += 4; if(sprite.position.z > 150) sprite.position.z = -1000; } renderer.render(scene, camera); } |
平面へのテクスチャの貼り付け
平面に画像を貼り付けたいのであれば以下の方法でできます。注意点はテクスチャからマテリアルを生成するとき side: THREE.DoubleSide を指定しないと裏側には描画されないことです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
/** @type {THREE.Mesh} */ let plane; function addObjects(){ plane = createPlane(); scene.add(plane); // MeshStandardMaterialを使用するのでライトが必要 const light = new THREE.AmbientLight(0xFFFFFF, 2.0); scene.add(light); } function createPlane(){ const texture = new THREE.TextureLoader().load('./images/star.png'); texture.colorSpace = THREE.SRGBColorSpace; const material = new THREE.MeshStandardMaterial({ map: texture, transparent: true, side: THREE.DoubleSide // これを忘れると裏側には描画されない }); const geometry = new THREE.PlaneGeometry(100, 100); return new THREE.Mesh(geometry, material); } // 更新処理 function update() { plane.rotation.x += 0.04; renderer.render(scene, camera); } |
グループ化
Three.jsの3Dオブジェクトはまとめて管理できる機能があります。これによってまとめて複数の3Dオブジェクトを移動させたり回転させることができるようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
// 箱 /** @type {THREE.Mesh} */ let box1, box2; /** @type {THREE.Group} */ let group; function addObjects(){ // グループを作り3D空間に追加する group = new THREE.Group(); scene.add(group); // 立方体を作りグループに追加する const geometry = new THREE.BoxGeometry(16, 16, 16); const material = new THREE.MeshNormalMaterial(); box1 = new THREE.Mesh(geometry, material); box1.position.x = 16; group.add(box1); box2 = new THREE.Mesh(geometry, material); box2.position.x = -16; group.add(box2); } function update() { if(pressIncrease){ if($positionX.checked) group.position.x += 0.4; if($positionY.checked) group.position.y += 0.4; if($positionZ.checked) group.position.z += 0.4; if($rotateX.checked) group.rotation.x += 0.04; if($rotateY.checked) group.rotation.y += 0.04; if($rotateZ.checked) group.rotation.z += 0.04; } if(pressDecrease){ if($positionX.checked) group.position.x -= 0.4; if($positionY.checked) group.position.y -= 0.4; if($positionZ.checked) group.position.z -= 0.4; if($rotateX.checked) group.rotation.x -= 0.04; if($rotateY.checked) group.rotation.y -= 0.04; if($rotateZ.checked) group.rotation.z -= 0.04; } renderer.render(scene, camera); // レンダリング } |
グループ化の解除
グループから削除したい場合は、remove関数を使います。グループから削除するとsceneから消えてしまうので再追加の処理が必要です。そのときグループ化されていたときの座標をバックアップしておき再設定しないとグループ化されていたときの座標が反映されません。
以下はボタンがクリックされたらグループ化をして回転処理をおこない、完了したらグループ化を解除するコードです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8" /> <meta name = "viewport" content = "width=device-width, initial-scale = 1.0"> <title>テスト</title> <link rel="stylesheet" href="./style.css"> </head> <body> <canvas id="canvas"></canvas> <button id = "rotate-right">右側を回転</button> <button id = "rotate-top">上側を回転</button> <script type="module" src="./index.js"></script> </body> </html> |
index.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 |
// 箱 /** @type {THREE.Mesh[]} */ let boxes = []; function addObjects(){ // MeshPhongMaterialを使用するのでライトが必要 // 環境光 const ambientLight = new THREE.AmbientLight(0xFFFFFF, 1.0); scene.add(ambientLight); // 平行光源 const directionalLight = new THREE.DirectionalLight(0xFFFFFF, 3); directionalLight.position.set(0, 0, 150); scene.add(directionalLight); const geometry = new THREE.BoxGeometry(16, 16, 16); const arr = [ {x:24, y:24, z:24}, {x:-24, y:24, z:24}, {x:24, y:-24, z:24}, {x:-24, y:-24, z:24}, {x:24, y:24, z:-24}, {x:-24, y:24, z:-24}, {x:24, y:-24, z:-24}, {x:-24, y:-24, z:-24}, ]; const materials = [ new THREE.MeshPhongMaterial({color: 0xff0000}), new THREE.MeshPhongMaterial({color: 0x00ff00}), new THREE.MeshPhongMaterial({color: 0x0000ff}), new THREE.MeshPhongMaterial({color: 0xffff00}), new THREE.MeshPhongMaterial({color: 0xff00ff}), new THREE.MeshPhongMaterial({color: 0x00ffff}), new THREE.MeshPhongMaterial({color: 0x800080}), new THREE.MeshPhongMaterial({color: 0x008080}), ]; // 一辺の長さが48の立方体の頂点にあたる座標に8個の立方体をつくる for(let i = 0; i < arr.length; i++){ const box = new THREE.Mesh(geometry, materials[i]); box.position.set(arr[i].x, arr[i].y, arr[i].z); scene.add(box); boxes.push(box); } } function addEventListeners(){ // 回転処理が重なると位置関係がおかしくなるので複数の回転が同時におきないようにする let noMeve = false; document.getElementById('rotate-right')?.addEventListener('click', async() => { if(noMeve) return; noMeve = true; // グループを作り、3D空間に追加する /** @type {THREE.Group} */ let group = new THREE.Group(); scene.add(group); const rights = boxes.filter(box => box.position.x > 10); // グループに該当するボックスを追加 for(let i=0; i<rights.length; i++) group.add(rights[i]); // 64回の更新で90度回転させる const angle = Math.PI / 2 / 64; for(let i= 0; i < 64; i++){ await new Promise(resolve => setTimeout(resolve, 1000 / 60)); group.rotation.x += angle; } // 回転処理が終わったのでグループを削除するが、 // 3Dオブジェクトをsceneに追加しなおす // そのさい3Dオブジェクトの位置と回転状態を取得して設定しなおす for(let i=0; i<rights.length; i++){ const worldPosition = rights[i].getWorldPosition(new THREE.Vector3()); const worldQuaternion = rights[i].getWorldQuaternion(new THREE.Quaternion()); group.remove(rights[i]); rights[i].position.set(worldPosition.x, worldPosition.y, worldPosition.z); rights[i].setRotationFromQuaternion(worldQuaternion); scene.add(rights[i]); } scene.remove(group); noMeve = false; }); document.getElementById('rotate-top')?.addEventListener('click', async() => { if(noMeve) return; noMeve = true; let group = new THREE.Group(); scene.add(group); const tops = boxes.filter(box => box.position.y > 10); for(let i=0; i<tops.length; i++) group.add(tops[i]); const angle = Math.PI / 2 / 64; for(let i=0; i<64; i++){ await new Promise(resolve => setTimeout(resolve, 1000 / 60)); group.rotation.y += angle; } for(let i=0; i<tops.length; i++){ const worldPosition = tops[i].getWorldPosition(new THREE.Vector3()); const worldQuaternion = tops[i].getWorldQuaternion(new THREE.Quaternion()); group.remove(tops[i]); tops[i].position.set(worldPosition.x, worldPosition.y, worldPosition.z); tops[i].setRotationFromQuaternion(worldQuaternion); scene.add(tops[i]); } scene.remove(group); noMeve = false; }); } function update() { renderer.render(scene, camera); } |