みんながthree.jsでやりたい事の1つに、3Dモデルをロードして表示があります。 一般的な3DフォーマットであるOBJファイルを読み込んでみましょう。
ネットで検索しahedovさんのCC-BY-NC 3.0 風車3Dモデルを見つけました。
blendファイルをダウンロードしBlenderで読み込んでOBJファイルを書き出してみました。
注意:Blenderを使った事がない人は、Blenderは今まで使ってきた他のプログラムとは異なり驚くかもしれません。また、Blenderの基本的なUI操作を理解する時間が必要かもしれません。
一般的な3Dプログラムは、1000以上の機能を持つ巨大なモンスターである事も付け加えておきましょう。Blenderもその中の最も複雑なソフトウェアの1つです。 私が1996年に3D Studio Maxを初めて知った時、600ページのマニュアルの70%を3週間ほど1日数時間かけて読み通しました。 数年後にMayaを学んだ時には、3d Studio Maxで学んだ経験がMayaでも生かせました。 もし本当に3Dソフトウェアを使って3Dアセットを構築したり、既存のものを修正したりできるようになりたいなら、自分のスケジュールと時間を確保していくつかのレッスンを受ける事をお勧めします。
いずれにしても、私は以下のExportオプションを使用しました。
それでは表示してみましょう!
ライティングの記事 にあるディレクショナルライティングの例から始めて、半球ライティングの例と組み合わせて HemisphereLight
と DirectionalLight
を1つ作る事にしました。その結果として HemisphereLight
は1つ、DirectionalLight
は1つになりました。
また、ライトの調整に関連する全てのGUIを削除しました。シーンに追加していたキューブとスフィアも削除しました。
まず最初に OBJLoader
のローダーをコードに含める必要があります。
import {OBJLoader} from '/examples/jsm/loaders/OBJLoader.js';
次にOBJファイルをロードするために OBJLoader
のインスタンスを作成し、OBJファイルのURLを渡し、ロードされたモデルをシーンに追加するコールバックを渡します。
{ const objLoader = new OBJLoader(); objLoader.load('resources/models/windmill/windmill.obj', (root) => { scene.add(root); }); }
それを実行したらどうなりますか?
これはやりたい事に近いですが、シーンにマテリアルとOBJファイルにマテリアルのパラメーターがなく、マテリアルのエラーが発生しています。
OBJローダーには名前とマテリアルのペアのオブジェクトを渡す事ができます。 OBJファイルをロードした時に見つけたマテリアル名で、ローダーに設定されたマテリアルのマップ内で対応するマテリアルを探します。 マテリアル名で一致するものが見つかった場合はそのマテリアルを使用します。 見つからない場合はローダーのデフォルトマテリアルを使用します。
OBJファイルにはマテリアルを定義するMTLファイルが付属している事があります。 今回はエクスポーターでMTLファイルも作成しました。 MTL形式はプレーンなASCIIコードなので見やすいです。MTLファイルの中身を見てみると
# Blender MTL File: 'windmill_001.blend' # Material Count: 2 newmtl Material Ns 0.000000 Ka 1.000000 1.000000 1.000000 Kd 0.800000 0.800000 0.800000 Ks 0.000000 0.000000 0.000000 Ke 0.000000 0.000000 0.000000 Ni 1.000000 d 1.000000 illum 1 map_Kd windmill_001_lopatky_COL.jpg map_Bump windmill_001_lopatky_NOR.jpg newmtl windmill Ns 0.000000 Ka 1.000000 1.000000 1.000000 Kd 0.800000 0.800000 0.800000 Ks 0.000000 0.000000 0.000000 Ke 0.000000 0.000000 0.000000 Ni 1.000000 d 1.000000 illum 1 map_Kd windmill_001_base_COL.jpg map_Bump windmill_001_base_NOR.jpg map_Ns windmill_001_base_SPEC.jpg
5つのjpgテクスチャを参照しているマテリアルが2つありますが、テクスチャのファイルはどこにあるのでしょうか?
存在するのはOBJファイルとMTLファイルだけです。
このモデルではテクスチャはダウンロードしたblendファイルに埋め込まれている事が判明しました。 blenderで File->External Data->Unpack All Into Files を選択し、これらのファイルをエクスポートする事ができます。
そして Write Files to Current Directory を選択します。
これでテクスチャのファイルはblendファイルと同じフォルダ内の textures というサブフォルダに出力されます。
これらのテクスチャをOBJファイルと同じフォルダにコピーしました。
テクスチャを利用できるようになったのでMTLファイルをロードします。
MTLLoader
をimportする必要があります。
import * as THREE from '/build/three.module.js'; import {OrbitControls} from '/examples/jsm/controls/OrbitControls.js'; import {OBJLoader} from '/examples/jsm/loaders/OBJLoader.js'; +import {MTLLoader} from '/examples/jsm/loaders/MTLLoader.js';
まず、MTLファイルをロードします。
読込後にロードしたマテリアルを OBJLoader
に設定して、OBJLoader
でOBJファイルをロードします。
{ + const mtlLoader = new MTLLoader(); + mtlLoader.load('resources/models/windmill/windmill.mtl', (mtl) => { + mtl.preload(); + objLoader.setMaterials(mtl); objLoader.load('resources/models/windmill/windmill.obj', (root) => { scene.add(root); }); + }); }
それを試してみると...
モデルを回転させると風車の布が消える事に注意して下さい。
風車の羽根のマテリアルは両面に適用する必要があり、これはマテリアルの記事で説明しました。 MTLファイルを簡単に修正する方法はありません。 私の思いつきではこの問題を修正する3つの方法があります。
マテリアルの読込後、全てのマテリアルをループさせて両面を適用する
const mtlLoader = new MTLLoader(); mtlLoader.load('resources/models/windmill/windmill.mtl', (mtl) => { mtl.preload() for (const material of Object.values(mtl.materials)) { material.side = THREE.DoubleSide; } ...
この解決策は動作しますが、理想的には両面描画は片面描画よりも遅く、両面描画が必要なマテリアルだけを両面にしたいです。
特定のマテリアルを手動で設定する
MTLファイルを見ると2つのマテリアルがあります。
1つは "winddmill"
と呼び、もう1つは "Material"
と呼びます。
試行錯誤の結果、風車の羽根は "Material"
というマテリアル名を使う事が分かりました。
const mtlLoader = new MTLLoader(); mtlLoader.load('resources/models/windmill/windmill.mtl', (mtl) => { mtl.perload(); mtl.materials.Material.side = THREE.DoubleSide; ...
MTLファイルには制限がある事に気付き、MTLファイルを使わず自前でマテリアルを作成する
objLoader.load('resources/models/windmill/windmill.obj', (root) => { const materials = { Material: new THREE.MeshPhongMaterial({...}), windmill: new THREE.MeshPhongMaterial({...}), }; root.traverse(node => { const material = materials[node.material?.name]; if (material) { node.material = material; } }) scene.add(root); });
どれを選ぶかはあなた次第です。 1が1番簡単です。3が最も柔軟です。2はその中間で今回は2を選びます。
この変更で背面から見た時にはまだ風車の羽根に布が見えるはずですが、もう1つ問題があります。 近くで拡大すると濃淡のむらがある事がわかります。
これはどうしたんでしょう?
テクスチャを見てみると、ノーマルマップにはNORと書かれた2つのテクスチャがあります。 これはノーマルマップのように見えます。 ノーマルマップは一般的に紫色ですが、バンプマップは黒と白になっています。 ノーマルマップはサーフェスの方向を表し、バンプマップはサーフェスの高さを表します。
MTLLoaderのソースを見るとノーマルマップのキーワード norm
を期待しているのでMTLファイルを編集してみましょう。
# Blender MTL File: 'windmill_001.blend' # Material Count: 2 newmtl Material Ns 0.000000 Ka 1.000000 1.000000 1.000000 Kd 0.800000 0.800000 0.800000 Ks 0.000000 0.000000 0.000000 Ke 0.000000 0.000000 0.000000 Ni 1.000000 d 1.000000 illum 1 map_Kd windmill_001_lopatky_COL.jpg -map_Bump windmill_001_lopatky_NOR.jpg +norm windmill_001_lopatky_NOR.jpg newmtl windmill Ns 0.000000 Ka 1.000000 1.000000 1.000000 Kd 0.800000 0.800000 0.800000 Ks 0.000000 0.000000 0.000000 Ke 0.000000 0.000000 0.000000 Ni 1.000000 d 1.000000 illum 1 map_Kd windmill_001_base_COL.jpg -map_Bump windmill_001_base_NOR.jpg +norm windmill_001_base_NOR.jpg map_Ns windmill_001_base_SPEC.jpg
これでロードするとノーマルマップとして扱うようになり、風車の羽根の裏が描画されるようになりました。
別のファイルを読み込んでみましょう。
ネットで検索するとRoger Gerzner / GERIZ.3D Artで作られたCC-BY-NCの風車の3Dモデルを見つけました。
これにはOBJファイルが既にありました。 それをロードしてみましょう(ここでMTLローダーを削除した事に注意して下さい)
- objLoader.load('resources/models/windmill/windmill.obj', ... + objLoader.load('resources/models/windmill-2/windmill.obj', ...
うーん、何も出てこない...。何が問題でしょうか? モデルのサイズはどれくらいなんだろう?
Three.jsにモデルのサイズを確認してカメラを自動設定してみます。
まず最初にThree.jsに先ほど読み込んだシーンを含むボックスを計算し、そのサイズと中心座標を確認してみましょう。
objLoader.load('resources/models/windmill_2/windmill.obj', (root) => { scene.add(root); + const box = new THREE.Box3().setFromObject(root); + const boxSize = box.getSize(new THREE.Vector3()).length(); + const boxCenter = box.getCenter(new THREE.Vector3()); + console.log(boxSize); + console.log(boxCenter);
JavaScriptコンソールを見ると
size 2123.6499788469982 center p {x: -0.00006103515625, y: 770.0909731090069, z: -3.313507080078125}
現在カメラは near
が0.1、far
が100で約100ユニットしか表示されていません。
地上は40ユニットしかなく、この風車のモデルは2000ユニットと非常に大きく、カメラとその全ての部分が錐台の外にあります。
手動で修正することもできますが、シーンを自動でフレーム化する事もできます。
それを試してみましょう。 先ほど計算したボックスを使いシーン全体を表示するためにカメラの設定を調整する事ができます。カメラをどこに置くかは 正解はない 事に注意して下さい。 どの方向から見てもどの高さでも向き合う事ができるので何かを選ぶしかないですね。
カメラの記事で説明したようにカメラは錐台を定義します。
視野 (fov
) と near
と far
の設定によって錐台が定義されます。
カメラが現在持っている視野がどのようなものであっても、シーンが入っているボックスが画面外が永遠に伸びていると仮定して画面外の中に収まるように、カメラはどのくらい離れている必要があるのかを知りたいです。
つまり near
は0.00000001、far
は無限大であるとすると near
は0.00000001、far
は無限大です。
ボックスの大きさと視野が分かっているので次のような三角形ができます。
左側にカメラがあり、青い錐台が突き出しているのが分かります。 風車が入っているボックスを計算してみました。 ボックスが錐台の中に現れるように、カメラがボックスからどのくらい離れているかを計算する必要があります。
基本的な 直角三角形 の三角法とSOHCAHTOAを使用します。 視野とボックスの大きさが分かっていれば 距離 を計算できます。
この図に基づいて距離を計算する式は次のようになります。
distance = halfSizeToFitOnScreen / tangent(halfFovY)
コードに変換してみましょう。
まずは 距離
を計算する関数を作り、ボックスの中心から 距離
単位でカメラを移動させてみましょう。
次にカメラをボックスの 中心
に向けます。
function frameArea(sizeToFitOnScreen, boxSize, boxCenter, camera) { const halfSizeToFitOnScreen = sizeToFitOnScreen * 0.5; const halfFovY = THREE.MathUtils.degToRad(camera.fov * .5); const distance = halfSizeToFitOnScreen / Math.tan(halfFovY); // compute a unit vector that points in the direction the camera is now // from the center of the box const direction = (new THREE.Vector3()).subVectors(camera.position, boxCenter).normalize(); // move the camera to a position distance units way from the center // in whatever direction the camera was from the center already camera.position.copy(direction.multiplyScalar(distance).add(boxCenter)); // pick some near and far values for the frustum that // will contain the box. camera.near = boxSize / 100; camera.far = boxSize * 100; camera.updateProjectionMatrix(); // point the camera to look at the center of the box camera.lookAt(boxCenter.x, boxCenter.y, boxCenter.z); }
2つのサイズを引数に渡しています。
boxSize
と sizeToFitOnScreen
の事です。
boxSize
を渡し sizeToFitOnScreen
として使用すれば、計算でボックスが錐台の中に完全に収まるようになります。
上下に少し余分なスペースが欲しいので少し大きめのサイズにします。
{ const objLoader = new OBJLoader(); objLoader.load('resources/models/windmill_2/windmill.obj', (root) => { scene.add(root); + // compute the box that contains all the stuff + // from root and below + const box = new THREE.Box3().setFromObject(root); + + const boxSize = box.getSize(new THREE.Vector3()).length(); + const boxCenter = box.getCenter(new THREE.Vector3()); + + // set the camera to frame the box + frameArea(boxSize * 1.2, boxSize, boxCenter, camera); + + // update the Trackball controls to handle the new size + controls.maxDistance = boxSize * 10; + controls.target.copy(boxCenter); + controls.update(); }); }
上記の図のように boxSize * 1.2
を渡しボックスを錐台内に収める際にボックスの上下に20%のスペースを確保する事ができます。
また OrbitControls
を更新し、カメラがシーンの中心を周回するようにしました。
それを試してみると...
これはほぼ動作してますね。
カメラを回転させるためにマウスを使用し、風車が表示されるはずです。
問題は風車が大きく、箱の中心が(0,770,0)くらいにあります。
つまり、カメラをスタート地点(0, 10, 20)から中心から 距離
単位で移動させると、カメラは中心に対して相対的に風車の下をほぼ真下に移動しています。
ボックスの中心からカメラのある方向に横に移動するように変更してみましょう。
そのために必要なのはボックスからカメラまでのベクトルの y
をゼロにする事です。
ベクトルを正規化するとXZ平面に平行なベクトルになります。
言い換えれば地面と平行になります。
-// compute a unit vector that points in the direction the camera is now -// from the center of the box -const direction = (new THREE.Vector3()).subVectors(camera.position, boxCenter).normalize(); +// compute a unit vector that points in the direction the camera is now +// in the xz plane from the center of the box +const direction = (new THREE.Vector3()) + .subVectors(camera.position, boxCenter) + .multiply(new THREE.Vector3(1, 0, 1)) + .normalize();
風車の底を見ると小さな四角いものが見えます。それが地上面です。
40 × 40ユニットしかないので風車に比べて小さすぎます。 風車の大きさが2000ユニットを超えているので、地上面の大きさをもっとピッタリしたものに変えてみましょう。 また、テクスチャのrepeatを調整する必要があります。 チェッカーボードはズームインしない限り、見る事さえできないような細かいものになります。
-const planeSize = 40; +const planeSize = 4000; const loader = new THREE.TextureLoader(); const texture = loader.load('resources/images/checker.png'); texture.wrapS = THREE.RepeatWrapping; texture.wrapT = THREE.RepeatWrapping; texture.magFilter = THREE.NearestFilter; -const repeats = planeSize / 2; +const repeats = planeSize / 200; texture.repeat.set(repeats, repeats);
これで風車を見る事ができます。
マテリアルを元に戻してみましょう。 先ほどと同じようにテクスチャを参照しているMTLファイルがありますが、ファイルを見てみるとすぐに問題点が浮き上がります。
$ ls -l windmill -rw-r--r--@ 1 gregg staff 299 May 20 2009 windmill.mtl -rw-r--r--@ 1 gregg staff 142989 May 20 2009 windmill.obj -rw-r--r--@ 1 gregg staff 12582956 Apr 19 2009 windmill_diffuse.tga -rw-r--r--@ 1 gregg staff 12582956 Apr 20 2009 windmill_normal.tga -rw-r--r--@ 1 gregg staff 12582956 Apr 19 2009 windmill_spec.tga
TARGA(.tga)ファイルが巨大です!
Three.jsにはTGAローダーがありますが、ほとんどのユースケースでそれを使うのは間違いです。 ネット上で見つけたランダムな3Dファイルを閲覧できるようなビューアを作っているのであれば、TGAファイルを読み込んだ方がいいかもしれません。(*)
TGAファイルの問題点は、全てを上手く圧縮できない事です。 TGAは非常に単純な圧縮しかサポートしておらず、上記を見てみると全てのファイルが同じサイズになる確率が非常に低いため、圧縮されていない事がわかります。
さらにそれぞれが12MB!
もしTGAのファイルを使った場合、風車を見るために36MBのファイルをダウンロードしなければならないでしょう。
TGAのもう1つの問題はブラウザ自体がTGAをサポートしていない事です。 TGAの読み込みはJPGやPNGのようなサポートされているフォーマットの読込よりも遅くなる可能性が高いです。
私はthree.jsで3Dモデルを表示するためには、TGAをJPGに変換する事が最善の選択肢であると確信しています。 中身を見るとそれぞれ3チャンネル、RGBでアルファチャンネルはありません。 JPGはダウンロードするためにファイルをロス有り圧縮を行い、少ないサイズのファイルダウンロードを提供します。
ファイルを読み込むと2048 x 2048サイズになっていました。 私には無駄に大きいサイズに思えましたが、もちろん使用ケースによります。 1024 x 1024にしPhotoshopで50%の画質設定で保存してみました。 ファイルリストを取得すると
$ ls -l ../threejs.org/manual/examples/resources/models/windmill -rw-r--r--@ 1 gregg staff 299 May 20 2009 windmill.mtl -rw-r--r--@ 1 gregg staff 142989 May 20 2009 windmill.obj -rw-r--r--@ 1 gregg staff 259927 Nov 7 18:37 windmill_diffuse.jpg -rw-r--r--@ 1 gregg staff 98013 Nov 7 18:38 windmill_normal.jpg -rw-r--r--@ 1 gregg staff 191864 Nov 7 18:39 windmill_spec.jpg
36MEGから0.55MEGまで圧縮できました! 3DCGデザイナーなどのアーティストはこの圧縮に満足していないかもしれませんので、トレードオフについて相談するようにして下さい。
さてMTLファイルを使用するには、JPGファイルを参照するように編集する必要があります。 TGAファイルの代わりにJPGファイルを使用します。 幸いな事にこれは単純なテキストファイル編集のみで簡単です。
newmtl blinn1SG Ka 0.10 0.10 0.10 Kd 0.00 0.00 0.00 Ks 0.00 0.00 0.00 Ke 0.00 0.00 0.00 Ns 0.060000 Ni 1.500000 d 1.000000 Tr 0.000000 Tf 1.000000 1.000000 1.000000 illum 2 -map_Kd windmill_diffuse.tga +map_Kd windmill_diffuse.jpg -map_Ks windmill_spec.tga +map_Ks windmill_spec.jpg -map_bump windmill_normal.tga -bump windmill_normal.tga +map_bump windmill_normal.jpg +bump windmill_normal.jpg
MTLファイルが適度なサイズのテクスチャを指しているので、それをロードする必要があります。
上記で行ったようにまずマテリアルをロードしてから OBJLoader
に設定します。
{ + const mtlLoader = new MTLLoader(); + mtlLoader.load('resources/models/windmill_2/windmill-fixed.mtl', (mtl) => { + const objLoader = new OBJLoader(); + mtl.preload(); + objLoader.setMaterials(mtl); objLoader.load('resources/models/windmill/windmill.obj', (root) => { root.updateMatrixWorld(); scene.add(root); // compute the box that contains all the stuff // from root and below const box = new THREE.Box3().setFromObject(root); const boxSize = box.getSize(new THREE.Vector3()).length(); const boxCenter = box.getCenter(new THREE.Vector3()); // set the camera to frame the box frameArea(boxSize * 1.2, boxSize, boxCenter, camera); // update the Trackball controls to handle the new size controls.maxDistance = boxSize * 10; controls.target.copy(boxCenter); controls.update(); }); + }); }
実際にやってみる前にいくつかの問題にぶつかりました。
問題1: 3つの MTLLoader
は、マテリアルのディフューズカラーにディフューズテクスチャマップを乗算したマテリアルを作成します。
これは便利な機能ですがMTLファイルの上記の行を見ると
Kd 0.00 0.00 0.00
ディフューズ色を0に設定します。 テクスチャマップ * 0 = 黒です! 風車を作るのに使われたモデリングツールがディフューズテクスチャマップにディフューズカラーを掛けていなかった可能性があります。 だからこそ、この風車を作った3DCGデザイナーなどのアーティストの3DCG Tool上では動作してました。
これを修正するには、次の行を修正する必要があります。
Kd 1.00 1.00 1.00
テクスチャ マップ * 1 = テクスチャマップです。
問題点その2:スペキュラカラーも黒である
Ks
で始まる行はスペキュラカラーを指定します。
風車を作るのに使われたモデリングソフトはスペキュラマップの色をスペキュラハイライトに使うという点でディフューズマップと似たような事をしていたのでしょう。
Three.jsではスペキュラカラーをどの程度反射させるかの入力値は、スペキュラマップの赤チャンネルのみを使用しています。
つまり、スペキュラカラーセットが必要です。
上記のようにMTLファイルを以下のように修正する事ができます。
-Ks 0.00 0.00 0.00 +Ks 1.00 1.00 1.00
問題 #3: windmill_normal.jpg
はバンプマップではなくノーマルマップです
上記のようにMTLファイルを編集する必要があります。
-map_bump windmill_normal.jpg -bump windmill_normal.jpg +norm windmill_normal.jpg
それを考慮し試してみるとマテリアルが一杯になるはずです。
モデルをロードするとこのような問題が発生することがよくあります。 よくある問題には以下のようなものがあります。
サイズを把握する必要がある
上記のようにカメラにシーンをフレーミングしようとさせましたが、それは必ずしも適切な事ではありません。 一般的に最も適切なのは自分でモデルを作るか、モデルをダウンロードし、3Dソフトでロードしてそのスケールを見て必要に応じて調整する事です。
方向性の違い
three.jsは一般的にはy = upです。モデリングツールによってはZ = upにデフォルトで設定されているものもあれば、Y = upに設定されているものもあります。 設定可能なものもあります。 モデルをロードして横になっているようなケースに遭遇した場合、モデルを回転させるコードをハックすることもできます(推奨されません)。 また、お気に入りのモデリングツールにモデルを読み込むか、コマンドラインツールを使い、ウェブサイト用の画像をダウンロードしてコードを適用するのではなく、ウェブサイト用の画像を編集するように必要な方向にオブジェクトを回転させる事もできます。
MTLファイルや間違ったマテリアル、互換性のないパラメータがない場合
上記ではMTLファイルを使用していましたが、マテリアルの読込には問題がありました。 OBJファイルの中身を見てどんなマテリアルがあるのかを確認したり、three.jsでOBJファイルを読み込んでシーンを歩いて全てのマテリアルをプリントアウトしたりするのも一般的です。
テクスチャが大きすぎる
3Dモデルの多くは建築用、映画やCM用、ゲーム用のどちらかに作られています。 建築や映画の場合は誰もがテクスチャのサイズを気にしていません。 ゲームはメモリが限られていますが、ほとんどのゲームはローカルで実行されているので、ゲーム開発者は気にしています。 Webページはできるだけ速く読みたいので、テクスチャをできるだけ小さく、かつ見栄えの良いものにする必要があります。 実際に最初の風車では、間違いなくテクスチャについて何かをするべきでした。現在のサイズ合計は10MBです!
テクスチャの記事で述べたようにテクスチャはメモリを取ります。 4096 x 4096に展開された50kのJPGは高速にダウンロードされますが、大量のメモリを消費する事を覚えておいて下さい。
最後に見せたかったのは風車を回している所です。残念ながらOBJファイルには階層がありません。 つまり、各風車のパーツは基本的に1つのメッシュとして考えられています。 ミルの羽根は分離されていないので回転させる事はできません。
これがOBJファイルは良いフォーマットではない主な理由の1つです。 推測するならば、他のフォーマットよりも一般的な理由はシンプルで多くの機能をサポートしていないため、より多くの場合に動作します。 特に建築物のイメージのような静止したものを作っていて、何かをアニメーション化する必要がない場合はシーンに静的な小道具を入れるのには悪くない方法です。
次はgLTFシーンをロードしてみます。gLTFフォーマットは他にも多くの機能をサポートしています。