はじめに
gltfというファイル形式があります。メッシュデータのほか、アニメーションデータやテクスチャ画像なども包含することができる「3DのJPEG」と呼ばれているファイル形式です。今回はこれをJSON形式で読み込んで、オブジェクトを表示させたいと思います。
blender3.1のgltf出力形式には、完全バイナリのglb, 数値データのみバイナリファイルとして別ファイルになっているgltf,最後にそれら2つがJSON形式でひとつにまとまっている埋め込み型gltfの3種類があります。今回用いるのはこれです。この形式は、数値データがbase64という比較的取り扱いやすい形式でJSON内部に埋め込まれており、全体がJSONなのでp5.jsの関数であるloadJSONで読み込むことができ非常に便利です。今回はこれを読み込んで、オブジェクトを表示するところまでやりたいと思います。
オブジェクトを用意する
次の動画のようにして、オブジェクトのgltfファイルを用意します。
埋め込み式は3つあるうちの一番下です(最近のバージョンでは分かりませんが...)。すると、JSON形式のこのようなファイルが生成されます。下の方のbuffersにとんでもない長さの文字列が入っています。これがbase64形式と呼ばれるもので、この場合は頂点や法線のデータが入っています。ここからデータを抽出して、p5.Geometryに落とし、model()を使えばオブジェクトが表示されます。
コード全文
mesh from gltf
おお2468888ですね...8が並んだようです(縁起がいいっ)。
ここの数字減らして任意のスケッチへとぶのも楽しいです(ブラクラに当たってフリーズする可能性...自己責任でお願いします)。
/*
gltfからめっしゅ
つくろう
*/
let myGLTF, geom;
function preload(){
myGLTF = loadJSON("testMesh_4.gltf");
}
function setup() {
createCanvas(600, 600, WEBGL);
const nodes = myGLTF.nodes;
const arrayBuffers = getArrayBuffers(myGLTF);
const objData = getObjData(myGLTF, arrayBuffers);
const mesh = objData[0].primitives[0];
geom = new p5.Geometry();
for(let i=0; i<mesh.POSITION.data.length; i+=3){
const v = mesh.POSITION.data;
geom.vertices.push(createVector(v[i],v[i+1],v[i+2]));
}
for(let i=0; i<mesh.NORMAL.data.length; i+=3){
const n = mesh.NORMAL.data;
geom.vertexNormals.push(createVector(n[i],n[i+1],n[i+2]));
}
for(let i=0; i<mesh.indices.data.length; i+=3){
const f = mesh.indices.data;
geom.faces.push([f[i],f[i+1],f[i+2]]);
}
camera(0,16,8, 0,0,0, 0,1,0);
const eyeDist = dist(0,16,8,0,0,0);
// そのうえでfrustumモードを使い、上下を逆転させ、射影行列の-1を殺す。
const nearPlaneHeightHalf = eyeDist*tan(PI/6)*0.1;
const nearPlaneWidthHalf = nearPlaneHeightHalf*width/height;
// ここの第3,4引数。
frustum(-nearPlaneWidthHalf, nearPlaneWidthHalf, nearPlaneHeightHalf, -nearPlaneHeightHalf, 0.1*eyeDist, 100*eyeDist);
}
function draw(){
background(0);
orbitControl(1,-1,1);
directionalLight(128,128,64,-1,-1,-1);
directionalLight(128,64,128,1,1,-1);
directionalLight(64,128,64,1,-1,-1);
directionalLight(64,64,128,-1,1,-1);
ambientLight(128);
fill(255,128,0);
model(geom);
}
function getArrayBuffers(data){
// arrayBufferの取得
const arrayBuffers = [];
for(let i=0; i<data.buffers.length; i++){
const bin = data.buffers[i].uri.split(',')[1];
arrayBuffers.push(getArrayBuffer(bin));
}
return arrayBuffers;
}
function getObjData(data, buf){
const {meshes, accessors, bufferViews} = data;
// dataは上記のdataのdata.meshesの他に
// data.accessorsとdata.bufferViewsが入っています
// 加えて解読済みのarrayBufferが入っています(data.buf)
const result = [];
for (const mesh of meshes) {
const resultMesh = {};
resultMesh.name = mesh.name;
resultMesh.primitives = [];
for (const prim of mesh.primitives) {
const primitive = {};
const attrs = prim.attributes;
for (const attrName of Object.keys(attrs)) {
// POSITION:0, NORMAL:1, TEXCOORD_0:2
const attrAccessor = accessors[attrs[attrName]];
const attrArrayData = getArrayData(
buf, attrAccessor, bufferViews[attrAccessor.bufferView]
);
primitive[attrName] = {
data:attrArrayData, info:getInfo(attrAccessor)
}
}
const indexAccessor = accessors[prim.indices];
const indexArrayData = getArrayData(
buf, indexAccessor, bufferViews[indexAccessor.bufferView]
);
primitive.indices = {
data:indexArrayData, info:getInfo(indexAccessor)
}
// shapeKeyAnimation用のtarget関連の記述はカット
resultMesh.primitives.push(primitive);
// とりあえずこんなもんで
}
result.push(resultMesh);
}
return result;
}
function getArrayBuffer(bin){
const byteString = atob(bin);
const byteStringLength = byteString.length;
const arrayBuffer = new ArrayBuffer(byteStringLength);
// このプロセスを挟むとうまくいくようです
const intArray = new Uint8Array(arrayBuffer);
for (let i = 0; i < byteStringLength; i++) {
intArray[i] = byteString.charCodeAt(i);
}
return arrayBuffer;
}
// arrayOutputがfalseの場合はそのまま出力されるけども。
// まあjsのarrayのが便利やろ
function getArrayData(buf, accessor, bufferView, arrayOutput = true){
// それぞれのデータを取得する感じのあれ
const componentType = accessor.componentType;
//const _count = accessor.count; // 24とか30
const _type = accessor.type; // "VEC3"とか"SCALAR"
let _size;
switch(componentType) {
case 5126: _size = 4; break;
case 5123: _size = 2; break;
case 5121: _size = 1; break; // 追加(boneのJOINTが5121)
}
const byteOffset = bufferView.byteOffset;
const byteLength = bufferView.byteLength;
const arrayLength = byteLength / _size;
const resultArray = createArrayData(buf[bufferView.buffer], componentType, byteOffset, arrayLength);
if(arrayOutput){
const outputArray = new Array(resultArray.length);
for(let i=0; i<outputArray.length; i++){
outputArray[i] = resultArray[i];
}
return outputArray;
}
return resultArray;
}
function createArrayData(buf, componentType, byteOffset, arrayLength) {
switch(componentType) {
case 5126:
const f32Array = new Float32Array(buf, byteOffset, arrayLength);
return f32Array;
case 5123:
const i16Array = new Uint16Array(buf, byteOffset, arrayLength);
return i16Array;
case 5121: // 追加
const u8Array = new Uint8Array(buf, byteOffset, arrayLength);
return u8Array;
}
return [];
}
function getComponentType(code){
switch(code){
case 5126: return "float32";
case 5123: return "uint16";
case 5121: return "uint8"; // 追加
}
return "";
}
// accessorそのままでもいいと思う
// 適宜内容変更して
function getInfo(accessor){
const result = {
type:accessor.type,
count:accessor.count,
componentType:getComponentType(accessor.componentType)
}
if (accessor.max !== undefined) { result.max = accessor.max; }
if (accessor.min !== undefined) { result.min = accessor.min; }
return result;
}
ちゃんとgltfファイルをloadJSONで読み込んで加工しているのが分かると思います。
gltfファイル内部
{
"asset" : {
"generator" : "Khronos glTF Blender I/O v1.8.19",
"version" : "2.0"
},
"scene" : 0,
"scenes" : [
{
"name" : "Scene",
"nodes" : [
0
]
}
],
"nodes" : [
{
"mesh" : 0,
"name" : "Cube"
}
],
"materials" : [
{
"doubleSided" : true,
"name" : "Material",
"pbrMetallicRoughness" : {
"baseColorFactor" : [
0.800000011920929,
0.800000011920929,
0.800000011920929,
1
],
"metallicFactor" : 0,
"roughnessFactor" : 0.4000000059604645
}
}
],
"meshes" : [
{
"name" : "Cube",
"primitives" : [
{
"attributes" : {
"POSITION" : 0,
"NORMAL" : 1,
"TEXCOORD_0" : 2
},
"indices" : 3,
"material" : 0
}
]
}
],
"accessors" : [
{
"bufferView" : 0,
"componentType" : 5126,
"count" : 464,
"max" : [
4.889997959136963,
4.949945449829102,
2.2867751121520996
],
"min" : [
-3.1606874465942383,
-1,
-3.2189972400665283
],
"type" : "VEC3"
},
{
"bufferView" : 1,
"componentType" : 5126,
"count" : 464,
"type" : "VEC3"
},
{
"bufferView" : 2,
"componentType" : 5126,
"count" : 464,
"type" : "VEC2"
},
{
"bufferView" : 3,
"componentType" : 5123,
"count" : 876,
"type" : "SCALAR"
}
],
"bufferViews" : [
{
"buffer" : 0,
"byteLength" : 5568,
"byteOffset" : 0
},
{
"buffer" : 0,
"byteLength" : 5568,
"byteOffset" : 5568
},
{
"buffer" : 0,
"byteLength" : 3712,
"byteOffset" : 11136
},
{
"buffer" : 0,
"byteLength" : 1752,
"byteOffset" : 14848
}
],
"buffers" : [
{
"byteLength" : 16600,
"url": "data:application/octet-stream;base64,/swcPw...XgHKAWIBygHOAQ=="
}
]
}
"asset"にはメタデータが入っています。GLTFのバージョンは2.0です。メッシュの情報はmeshesに入ってます。今は単独なので1つだけです。Cubeを変形して作ったので名前がデフォルトのCubeになっています。primitivesはよく分かんないのですが単独の場合は1つしかないです。attributesのPOSITION,NORMAL,TEXCOORD_0がそれぞれp5.Geometryでいうところのvertices, vertexNormals, uvに対応します。その外、indicesがfaces(面)に対応します。
では0,1,2,3とは何かというと、これらはその下、accessorsの配列のインデックスです。ここにデータへアクセスするための情報が格納されています。たとえばPOSITIONは0なので0番の
{
"bufferView" : 0,
"componentType" : 5126,
"count" : 464,
"max" : [
4.889997959136963,
4.949945449829102,
2.2867751121520996
],
"min" : [
-3.1606874465942383,
-1,
-3.2189972400665283
],
"type" : "VEC3"
}
がPOSITIONのデータというわけ。5126とはFloat32という意味です(glslで使われている32bit浮動小数点数)。464は頂点の数です。464個というわけです。typeはVEC3なので、3つずつ取ってx,y,zに入れるわけです。
以降、NORMAL, TEXCOORD_0, indicesが対応します。5123はUnit16で、0以上の整数です。
ではbufferViewsは何かというと、こっちは以下のbufferViewsにおけるデータの格納場所の番号です。byteLengthがバイト長、byteOffsetが読み取りの開始位置のような感じです。これに基づいて、あのbase64形式の暗号のような文字列(一番最後のuriに格納されているもの)から該当箇所を切り出して、データとするわけです。
データの抽出
手始めに、buffersの配列内のそれぞれのuriにあるbase64文字列を「ArrayBuffer」という形式にしておく必要があります。
function getArrayBuffers(data){
// arrayBufferの取得
const arrayBuffers = [];
for(let i=0; i<data.buffers.length; i++){
const bin = data.buffers[i].uri.split(',')[1];
arrayBuffers.push(getArrayBuffer(bin));
}
return arrayBuffers;
}
function getArrayBuffer(bin){
const byteString = atob(bin);
const byteStringLength = byteString.length;
const arrayBuffer = new ArrayBuffer(byteStringLength);
// このプロセスを挟むとうまくいくようです
const intArray = new Uint8Array(arrayBuffer);
for (let i = 0; i < byteStringLength; i++) {
intArray[i] = byteString.charCodeAt(i);
}
return arrayBuffer;
}
まずatobでbase64をバイトデータに変換します。次に、これを元にArrayBufferを作ります。そこからUint8Arrayという配列を作って数値データに変換するようです。これでarrayBufferの方がなんかいい感じになるということらしいです。
正直よくわかりません。
去年の自分が四苦八苦してこの答えにたどり着いたようです。過去のログを振り返ってみたのですがいまいち分かりませんでした。この記事の最後で参考にしたサイト集を載せておくので、興味のある人は調べてみてください。
このbufferから、accessorにより取得できるデータ型をもとにしつつ、配列を構成します。
// arrayOutputがfalseの場合はそのまま出力されるけども。
// まあjsのarrayのが便利やろ
function getArrayData(buf, accessor, bufferView, arrayOutput = true){
// それぞれのデータを取得する感じのあれ
const componentType = accessor.componentType;
//const _count = accessor.count; // 24とか30
const _type = accessor.type; // "VEC3"とか"SCALAR"
let _size;
switch(componentType) {
case 5126: _size = 4; break;
case 5123: _size = 2; break;
case 5121: _size = 1; break; // 追加(boneのJOINTが5121)
}
const byteOffset = bufferView.byteOffset;
const byteLength = bufferView.byteLength;
const arrayLength = byteLength / _size;
const resultArray = createArrayData(buf[bufferView.buffer], componentType, byteOffset, arrayLength);
if(arrayOutput){
const outputArray = new Array(resultArray.length);
for(let i=0; i<outputArray.length; i++){
outputArray[i] = resultArray[i];
}
return outputArray;
}
return resultArray;
}
function createArrayData(buf, componentType, byteOffset, arrayLength) {
switch(componentType) {
case 5126:
const f32Array = new Float32Array(buf, byteOffset, arrayLength);
return f32Array;
case 5123:
const i16Array = new Uint16Array(buf, byteOffset, arrayLength);
return i16Array;
case 5121: // 追加
const u8Array = new Uint8Array(buf, byteOffset, arrayLength);
return u8Array;
}
return [];
}
構成したArrayBuffer, 及びbufferViewsにあるデータをもとにして、Float32ArrayやUint16Arrayをデータ型をもとにして構成します。この時点でデータが確定します。arrayOutputはこれをjavascriptのデータ型に変換するため作りました。色情報とか扱うのに便利なのと、jsの配列でないと適用できないメソッドなどがあるからです。利便性のために導入しました。
p5.Geometryを作る
オブジェクトのデータが揃ったらメッシュを生成します。
const objData = getObjData(myGLTF, arrayBuffers);
const mesh = objData[0].primitives[0];
geom = new p5.Geometry();
for(let i=0; i<mesh.POSITION.data.length; i+=3){
const v = mesh.POSITION.data;
geom.vertices.push(createVector(v[i],v[i+1],v[i+2]));
}
for(let i=0; i<mesh.NORMAL.data.length; i+=3){
const n = mesh.NORMAL.data;
geom.vertexNormals.push(createVector(n[i],n[i+1],n[i+2]));
}
for(let i=0; i<mesh.indices.data.length; i+=3){
const f = mesh.indices.data;
geom.faces.push([f[i],f[i+1],f[i+2]]);
}
今回は単独メッシュなのでどっちも0でアクセスします。POSITIONは3つずつx,y,zのデータが入ってるのでそれをcreateVector()でベクトルに変換します。法線も同様です。面は普通に0ベースなのでそのまま3つずつ切り出します。
カメラ設定
今回デフォルトでyUp出力しているので、そのまま出すと上下が逆になってしまいます(p5はy軸下)。そこでカメラを工夫して見た目を揃えます。
camera(0,16,8, 0,0,0, 0,1,0);
const eyeDist = dist(0,16,8,0,0,0);
// そのうえでfrustumモードを使い、上下を逆転させ、射影行列の-1を殺す。
const nearPlaneHeightHalf = eyeDist*tan(PI/6)*0.1;
const nearPlaneWidthHalf = nearPlaneHeightHalf*width/height;
// ここの第3,4引数。
frustum(-nearPlaneWidthHalf, nearPlaneWidthHalf, nearPlaneHeightHalf, -nearPlaneHeightHalf, 0.1*eyeDist, 100*eyeDist);
frustumを使うと、射影行列の-1を殺すことができます。これがBlenderやUnityやThreeとの違いを生み出しているので。ただこの場合orbitControl()の上下の操作が逆になってしまうので、引数で調整します。
orbitControl(1,-1,1);
directionalLightをやたら呼び出してるのは、面ごとに違う色にしたくて雑なことをしてるだけです。
おわりに
頂点色やテクスチャ画像などはまたの機会に...ここまでお読みいただいてありがとうございました。
参考にしたサイト
gltf形式の基本
mebiusboxさんの覚書
gltfをライブラリを使わずに読み込んでみよう
atobとbtoaについての分かりやすい記事
補足:分かんなかったところの理解が進みました
ほんのちょっとだけ...
arrayBufferというのはバイト単位の長さのバッファなんですね。配列とは似て非なる、単なる領域ということです。あそこの処理はそこにバイト単位で数を書き込んでいる。
atobはbase64でエンコードされた文字列をバイト文字列に変換しています。それは0~255のASCIIコードの文字の列ということです。こんなの↓
こんなのです。ほとんど制御文字です。
function setup() {
createCanvas(400, 400);
let strr = "";
for(let k=0; k<256; k++){
strr += String.fromCharCode(k);
}
strr+="ここまで"
console.log(strr);
}
これらを0~255に変換するわけですね。arrayBuffer自体はただの入れ物であるため、中身を操作するメソッドが無いので、型付配列やDataViewと呼ばれるクラスを用いる必要があるようです。
const intArray = new Uint8Array(arrayBuffer);
for (let i = 0; i < byteStringLength; i++) {
intArray[i] = byteString.charCodeAt(i);
}
まずarrayBufferを使う方法がこれです。arrayBufferからUint8Arrayを生成すると、バイトごとの読み書きが可能になります。ここにさっきの文字列を1つずつフェッチして0~255の値を書き込んでいます。
もうひとつ、DataViewを使うやり方もあります。
const dataView = new DataView(arrayBuffer);
for(let i=0; i<byteStringLength; i++){
dataView.setUint8(i, byteString.charCodeAt(i));
}
こちらはまずarrayBufferをベースとしてDataViewというクラスを作ってしまいます。あとは各種メソッドにより自由な読み書きが可能となります(エンディアン指定の書き込みもできるそうです)。
参考にしたサイト(arrayBuffer関連)
[JavaScript] ArrayBufferについて調べてみた
ArrayBuffer, binary arrays