Web编程技术营地
研究、演示、创新

glTF文件格式

撰写时间:2023-12-31

修订时间:2024-01-14

在本章中,我们初步学习glTF的几个重要属性。

glTF需以.gltf作为文件的扩展名。我们以最简单的TriangleWithoutIndices.gltf作为例子进行逐步讲解。

Scenes

场景是渲染一个glTF文件最开始的入口。属性scene可用于声明一个在加载glTF文件时需渲染的场景。

{ "scene": 0 }

属性scene的值为0。这个值是索引值,表示是某个数组中的第0个元素。或者说,该属性值指向了某个数组的第0个元素。

可以有0或多个场景。专门用于存储各个场景的属性名为scenes,下面,声明了一个这样的数组:

{ "scene": 0, "scenes": [ { "nodes": [0] } ] }

属性scenes是位于层级最顶端的根部元素,定义了一个由各个scene所组成的数组。在上面的例子中,只有一个scene

结合scenescenes的关系来来看,很明显,scene指向了scenes的第0个元素。

若从代码的角度,则如下所示:

let glTF = loadGLTFFile(); console.log(glTF.scenes.length); // 1 console.log(glTF.scene === glTF.scenes[0]); // true

Nodes

上面的scenes的第0个元素有一个nodes属性,其值为[0],用数组表示可能有多个节点,而数组中仅有一个元素,表示场景中仅一个节点。

同样,该nodes的属性值也是指针,指向某个数组中的第0个元素。因此,我们需要继续在glTF文件的根部声明nodes属性:

{ "scene": 0, "scenes": [ { "nodes": [0] } ], "nodes": [ { "mesh": 0 } ] }

可见,glTF的各个属性,本质上是相互引用的关系,如下图所示:

gltf-top-elements

nodes表示场景中可渲染的元素,如Skin, Mesh, Camera等等。glTF将所有这些可渲染的元素都统一放置于根部的nodes属性中,然后,继续用子元素的相应属性名值来指明节点的类型与索引值。

上面的mesh属性说明,该节点的类型是一个Mesh,通过其值引用了一个meshes数组的第0个元素。

Meshes

因此,继续编写meshes属性,以供上面的nodes的第0个元素引用。

{ "scene": 0, "scenes": [ { "nodes": [0] } ], "nodes": [ { "mesh": 0 } ], "meshes": [ { "primitives": [ { "attributes": { "POSITION": 0 } } ] } ] }

primitives属性指定了该Mesh的基本图元的情况。该属性的值用于GPU渲染命令中。在其attributes属性中,POSITION属性名表示该属性值将用于顶点着色器中存储顶点位置数据的顶点属性,如我们之前所用的aPosition

上面的POSITION属性引用了访问器。

二进制数据的处理

为何需要二进制数据

glTF的主要目的是在网络上高效传输3D数据。而像顶点位置、顶点索引这样的数据,数据量可能较大。例如:

let vertices = [ -0.5, 0.5, 0.0, -0.5, -0.5, 0.0, 0.5, -0.5, 0.0, 0.5, 0.5, 0.0 ]; let indices = [0, 1, 2, 0, 2, 3];

为高效传输,需将这些数据转换为二进制数块binary blob)。对于计算机来讲,二进制数块无须进行转换,即可由计算机直接处理,因此其速度是最快的。当然,有时候为节省空间,事先可能先压缩这些二进制数块数据,此时先进行解压缩即可。

存储二进制数据的媒介

glTF处理二进制共有2种方式:一种是通过Data URIs进行内嵌的基于base64编码的二进制数据,另一种是外部二进制文件。

Base64编码需要额外的操作进行解码,且文件大小也因此增加。

为有效解决文件大小及额外处理的问题,引入了一种称为GLBGL Binaray)的文件格式。

文件类型与媒介类型

文件类型扩展名媒介类型
JSON glTF.gltfmodel/gltf+json
GLB容器中的glTF文件.glbmodel/gltf-binary
代表二进制缓冲区的文件.binapplication/octet-stream
.bin, .glbin, .glbufapplication/gltf-buffer
PNG图像文件.pngimage/png
JPEG图像文件.jpeg, .jpgimage/jpeg

基于Base64的编码与解码

URL

URLUniform Resource Locator统一资源定位系统),是因特网上引用资源的方式。例如,我们常见的网址表现形式:

https://www.sarkuya.com/index.php

就是一种URL,浏览器根据此URL就能在因特网上找到相应的资源并打开该网页。上面的URL有特定的格式,如传输协议为https,服务器名称为www.sarkuya.com,路径为index.php等等。

其他常见的URL还有ftp, mailto, telnet等等。它们都自己特定的格式。

HTML网页中的URL

HTML网页中,URL随处可见。如:

<link rel="shortcut icon" type="image/jpg" href="/imgs/12faces.jpg" /> <script type="module" src="/main.js"></script> <img src="/imgs/triangle.png" alt="triangle" />

上面linkhref属性、scriptimgsrc属性,其值都属于URL,它们的特点是,都用于查找并加载外部的文件资源。

Data URL

但有时,对于一些尺寸较小的数据,也可以使用Data URL的方式,直接内嵌到网页文件中,以加快网络传输速度。

下面的代码,使用Data URL的格式,加载了一张图像。

Larry

Data URL格式为:

data:[mediatype][;base64],<data>

对比上面imgsrc值不难看出,该Data URL的媒介类型为image/gif,使用base64编码,后面全部都是图像的内容了。

其效果如下:

Larry(摘自rtc2397的例子)

图像文件原本是二进制的。我们如果使用文本编辑程序打开,则满眼都是乱码,因为文件的内容都是由二进制的01组成的,无法安全地转换为可打印出来的文本字符。尽管人类看不懂其内容,但计算机的图像编辑软件却可识别,它根据文件扩展名,按特定图像文件的格式进行解析后,就可将其转换为屏幕上的像素数据并最终显示出来。

Base64编码

上面的这段这么难看的字符串是怎么来的?我们可以事先使用base64编码机制,将二进制的图像文件内容转换为使用常见的ASCII字符来表示的六十四进制的字符串。这就是上面这一长串看不懂的字符串的由来。

因为它是常见的ASCII字符串,因此可安全地在网页中存储。又因为它表示的是六十四进制的数据,可方便地直接转换为二进制数据。因此,base64编码机制是二进制数据与可打印的文本之间的一个桥梁。

目前,Firefox允许转换后的字符大小为32MB,Chromium限制为512MB,Safari限制为2048MB。

32 MB = 32 * 1024 = 32,768 KB = 32,768 * 1024 = 33,554,432 bytes = 33,554,432 个字符

上面转换后的字符串(不包括6个断行符)长度为364个字符。

Base64编码机制看起来比较抽象,但实际一操作,就很容易理解。

在Mac OS系统中,打开一个终端,输入:

echo -n hello | base64

屏幕输出:

aGVsbG8=

与文本字符串hello对应的base64编码的字符串为aGVsbG8=

下面,我将一个名为三筒.png的图像文件的内容转换为base64编码的字符串,并存储进一个名为base64-string.txt的文件中:

cat 三筒.png | base64 > base64-string.txt

然后,打开base64-string.txt文件,复制其全部内容,替换下面代码

<img src="data:image/png;base64,<paste_here>" alt="三筒" />

中的<paste_here>部分。下面就显示出了这张横空出世的图像:

Larry一个自制的Data URL图片源

经转换后的base64字符串较长,共有10964个字符,这里就不展示出来了。

Unicode应知细节

Unicode标准对每个字符都指定了一个数值(code point码位)及名称。因此,每个码位成为每个Unicode字符的身份标识。

Unicode标准包含了1,114,112个码位用于字符编码。 前65,536个码位用于全世界主要语言的常用字符,也称为BMPBasic Multilingual Plane, 基本多语言平面)。BMP保留了6,400个码位用以为将来扩充使用。在BMP之外,还保留了131,068个这样的码位作为后备扩充。

Unicode标准不仅支持英语等基于字母的字符,也支持如中文等象形文字的字符。同时支持各种货币符号、标点符号、数学符号、科技符号、几何图形、印刷符号、表情符号等。

Scripts是可用于一或多种语言的书写系统中的字母及其他书写符号的组合,它们都是从历史承袭下来且有共同的书写习惯。而一种语言可能同时用到多个Scripts。如日文,就用到了汉字、平假名、片假名及拉丁字母的书写系统。对于这些通用书写系统,Unicode标准含有149,186个字符以供使用。

汉字书写系统有97,058个象形字符以供全世界各国,包括中国、日本、朝鲜、越南、新加坡等国家使用。

对于ASCII字符,其码位就是ASCII码

let cp = "A".codePointAt(0); console.log(cp); // 65 console.log(getBinStr(cp, 4)); // 0100 0001

对于常用汉字,绝大部分也均位于BMP范围内。

let cp = "天".codePointAt(0); console.log(cp); // 22825 console.log(getBinStr(cp, 4)); // 0101 1001 0010 1001

可以直接使用十六进制的码位来表示特定字符。

let str = "\u2270"; console.log(str); // ≰ let cp = str.codePointAt(0); console.log(cp); // 8816 console.log(getBinStr(cp, 4)); // 0010 0010 0111 0000

上面,十六进制的2270等于十进制的8816,也等于二进制的0010 0010 0111 0000

Unicde字符有3种编码方式:UTF-32, UTF-16, 及UTF-8。JavaScript的字符串使用UTF-16编码。这是一种可变长的编码方式。大多数常用字符落在U+0000至U+FFFF的范围内,只需使用一个16位的双字节就可以存储。这个16位的双字节就称为编码单元code unit)。而落在U+10000至U+10FFFF的范围内(也称为补充平面supplementary planes)的字符,须使用一对被称为surrogate pairs代理对)的16位的编码单元来表示。

代理对高位代理high-surrogate,或称leading surrogate)的编码单元及低位代理low-surrogate,或称trailing surrogate)的编码单元组成。

但代理对加起来共计32位,不符合以16位来存储一个字符的要求,因此,需要有一种算法从代理对中取出相应的部分以构成一个16位的存储空间。

StringcodePointAt方法,如果参数index的值正好在高位代理的编码单元时,可以返回正确的码位;而如果参数index的值在低位代理的编码单元时,仅返回低位代理编码单元的值,但这不是特定字符的码位。 因此,不能简单地使用字符串中字符的索引值来遍历字符串,而应使用for...of语句。该语句能自动遍历每个码位, 不管特定字符有无代理对。

for (let char of str) { console.log(char.codePointAt(0).toString(16)); }

当我们需要对任意的Unicode文本进行Base64编码时,需注意此细节。

File类的UTF-8编码

现代的浏览器中均内在支持base64编码的解码。

结合到WebGL编程的需求来讲,我们需要将:

new Uint8Array([1, 2, 3]);

的数据先用base64编码后,存储到特定的位置,然后再用base64解码。

Blob

最初的思路是通过Blob

let tarr = new Uint8Array([1, 2, 3]); let blob = new Blob(tarr, {type: "application/octet-stream"}); blob.arrayBuffer().then((arrayBuffer) => { let result = new Uint8Array(arrayBuffer); console.log(result); // Uint8Array [49, 50, 51]; });

Blob类虽有一个arrayBuffer方法返回一个ArrayBuffer类的Promise,但当我们将该返回值重新转换回Uint8Array后,发现其值居然都是字符串"1, 2, 3"的ASCII值。

并且,Blob类并没有一个直接转换为Data URL的方法,意味着我们不能通过fetch函数来访问该资源。

FileReader

我们可以尝试使用FileReader类。

let tarr = new Uint8Array([1, 2, 3]); let blob = new Blob(tarr, {type: "application/octet-stream"}); let reader = new FileReader(); reader.readAsDataURL(blob); reader.onloadend = (evt) => { console.assert(evt.target === reader); let dataURL = reader.result; fetch(dataURL) .then(response => response.arrayBuffer()) .then(arrayBuffer => { let result = new Uint8Array(arrayBuffer); console.log(result); // Uint8Array [49, 50, 51]; }); };

先以blob作为参数调用readerreadAsDataURL方法,在readerblob的内容读取并解析为Data URL后,将触发readerloadend事件,且readerresult属性将含有Data URL的数据。

而在loadend事件的处理代码中,调用fetch函数来加载dataURL,最后再将结果转换回Uint8Array

我们发现,上面的方案虽然解决了Data URL的问题,但结果还是ASCII值序列。

原因是JavaScript内部使用了UTF-16编码,而后者使用码位进行转换,而数值1, 2, 3的码位正是它们的ASCII值。

File

File类可以解决这个问题,因为它使用UTF-8的编码,从而保留了数值的原本形式。

let tarr = new Uint8Array([1, 2, 3]); let reader = new FileReader(); reader.readAsDataURL(new File([tarr], "", {type: "application/octet-stream"})); reader.onloadend = () => { let dataURL = reader.result; fetch(dataURL) .then(response => response.arrayBuffer()) .then(arrayBuffer => { let result = new Uint8Array(arrayBuffer); console.log(result); // Uint8Array [1, 2, 3]; }); };

我们得到了想要的结果。

注意File的构造函数。第一个参数类型为iterable object,即可遍历对象,如数组。因此,需先使用[]来表示这么一个可遍历对象。然后,在可遍历对象中,再放置诸如ArrayBuffers, TypedArrays, DataViews, Blobs, 字符串,或上述这些类型的组合。这些内容均将存储为文件的内容。第二个参数为文件名,第三个参数为MIME type。因此,就出现了:

new File([tarr], "", {type: "application/octet-stream"})

这样稍显奇怪的语句。

而如果不小心写成:

new File(tarr, "", {type: "application/octet-stream"})

则程序也能运行,但所得到的结果却还是ASCII值。这是需要特别注意的地方。

Promise

FileReaderreadAsDataURL方法采用的是传统的事件触发机制, 我们可以将其改写为Promise的方式。

init(); async function bytesToDataUrl(bytes, type = "application/octet-stream") { return await new Promise((resolve, reject) => { const reader = Object.assign(new FileReader(), { onload: () => resolve(reader.result), onerror: () => reject(reader.error) }); reader.readAsDataURL(new File([bytes], "", { type })); }); } async function dataUrlToBytes(dataUrl) { const res = await fetch(dataUrl); return new Uint8Array(await res.arrayBuffer()); } async function init() { let tarr = new Uint8Array([1, 2, 3]); let dataURL = await bytesToDataUrl(tarr); let result = await dataUrlToBytes(dataURL); console.log(result); // Uint8Array [1, 2, 3] }

这样改写后,配合asyncawait关键字,在init函数内,代码执行顺序得以从上到下依序执行。

Buffers

现在,回到.gltf文件上面。

{ "scene": 0, "scenes": [ { "nodes": [0] } ], "nodes": [ { "mesh": 0 } ], "meshes": [ { "primitives": [ { "attributes": { "POSITION": 0 } } ] } ], "buffers": [ { "uri": "data:application/octet-stream;base64,AAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAAAA", "byteLength": 36 } ] }

上面的第0个buffer通过uri声明了一个共有36个字节的Data URL

通过上一节的内容我们知道,我们可以加载这个Data URL,并返回ArrayBuffer的内容。

从代码的角度,根据所声明的buffer,现在我们可以得到一个ArrayBuffer的实例:

const res = await fetch(dataUrl); let arrayBuffer = await res.arrayBuffer();

BufferViews

为从ArrayBuffer取出数据,我们需要声明能访问ArrayBuffer的视图。

{ "scene": 0, "scenes": [ { "nodes": [0] } ], "nodes": [ { "mesh": 0 } ], "meshes": [ { "primitives": [ { "attributes": { "POSITION": 0 } } ] } ], "buffers": [ { "uri": "data:application/octet-stream;base64,AAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAAAA", "byteLength": 36 } ], "bufferViews": [ { "buffer": 0, "byteOffset": 0, "byteLength": 36, "target": 34962 } ] }

第0个bufferView指向第0个buffersbyteOffset指定从第0个字节开始,byteLength指定需要取出36个字节。

target使用了WebGL中的术语,该值34962WebGL1.0 Specification所定义的一个常量,借用我们自己编写的工具类,我们可以方便地查看该常量所对应的名称。

console.log(WebGLSpecUtils.GetEnumName(34962)); // ARRAY_BUFFER: 0x8892

说明这个视图的数据需要绑定到WebGLARRAY_BUFFER这个target中。

下表列出了bufferViewtarget的值及其含义。

十进制数值含义
34962ARRAY_BUFFER
34963ELEMENT_ARRAY_BUFFER

可见,每个bufferViews指定了如何从每个buffers划分区域,以及准备如何使用这个视图。

Accessors

accessors属性用以选取bufferViews

{ "scene": 0, "scenes": [ { "nodes": [0] } ], "nodes": [ { "mesh": 0 } ], "meshes": [ { "primitives": [ { "attributes": { "POSITION": 0 } } ] } ], "buffers": [ { "uri": "data:application/octet-stream;base64,AAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAAAA", "byteLength": 36 } ], "bufferViews": [ { "buffer": 0, "byteOffset": 0, "byteLength": 36, "target": 34962 } ], "accessors": [ { "bufferView": 0, "byteOffset": 0, "componentType": 5126, "count": 3, "type": "VEC3", "max": [1.0, 1.0, 0.0], "min": [0.0, 0.0, 0.0] } ] }

对于第0个bufferView,其componentType的值为5126

console.log(WebGLSpecUtils.GetEnumName(5126)); // FLOAT: 0x1406

对应于数据类型FLOAT。下表列出了componentType的值域及对应的数据类型与类型化数组。

十进制数值数据类型类型化数组
5120BYTEInt8Array
5121UNSIGNED_BYTEUint8Array
5122SHORTInt16Array
5123UNSIGNED_SHORTUint16Array
5125UNSIGNED_INTUint32Array
5126FLOATFloat32Array

至此,我们可以确定使用Float32Array来作为arrayBuffer的视图了:

const res = await fetch(dataUrl); let arrayBuffer = await res.arrayBuffer(); let vertices = new Float32Array(arrayBuffer); console.log(vertices); // [0, 0, 0, 1, 0, 0, 0, 1, 0]

对于这个共有9个元素的数组,属性type声明其类型为VEC3,即每个顶点的坐标以3个元素为一组,count说明共有3个这样的顶点。类似于我们之前的代码:

let vertices = new Float32Array([ 0.0, 0.0, 0.0, // V0 1.0, 0.0, 0.0, // V1 0.0, 1.0, 0.0 // V2 ]);

还有一个关键的问题:谁引用了第0个accessor

"meshes": [ { "primitives": [ { "attributes": { "POSITION": 0 // accessor ID } } ] } ],

POSITION的值引用了第0个accessor

对于上面的关系,我们可以理解为:从buffers中取出二进制数据,构造一个Float32Array的数组,以每3个元素作为一个顶点的坐标,投喂至顶点着色器中的POSITION顶点属性,以构建一个基本图元。

构建什么样的基本图元,是LINES还是TRIANGLES,还是其他的基本图元?我们可以用mode来指定:

"meshes": [ { "primitives": [ { "attributes": { "POSITION": 0 // accessor ID }, "mode": 4 } ] } ],

其值与所代表所类型如下表所示:

类型是否默认值
0POINTS
1LINES
2LINE_LOOP
3LINE_STRIP
4TRIANGLES
5TRIANGLE_STRIP
6TRIANGLE_FAN

mesh.primitive.mode的属性是可选的,若未指定,则默认值为4,对应基本图元的类型为TRIANGLES

至此,我们已经获得足够的数据,足以渲染出这个glTF文件的内容了。

POSITIONaccessor必须定义minmax属性。其值列出了各个组件中各个元素的最小值与最大值。

Asset

每个glTF文件必须具备asset属性,且使用version来指定版本。

{ "asset": { "version": "2.0", "generator": "sarkuya@example.com", "copyright": "2024 (c) Sarkuya" } }

完整的glTF文件内容

下面列出了TriangleWitoutIndices.gltf文件的完整内容:

{ "scene": 0, "scenes": [ { "nodes": [0] } ], "nodes": [ { "mesh": 0 } ], "meshes": [ { "primitives": [ { "attributes": { "POSITION": 0 } } ] } ], "buffers": [ { "uri": "data:application/octet-stream;base64,AAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAAAA", "byteLength": 36 } ], "bufferViews": [ { "buffer": 0, "byteOffset": 0, "byteLength": 36, "target": 34962 } ], "accessors": [ { "bufferView": 0, "byteOffset": 0, "componentType": 5126, "count": 3, "type": "VEC3", "max": [1.0, 1.0, 0.0], "min": [0.0, 0.0, 0.0] } ], "asset": { "version": "2.0", "generator": "sarkuya@example.com", "copyright": "2024 (c) Sarkuya" } }

glTFLoader V1.0

根据上面的理解,编写glTFLoader如下。

import {WebGLSpecUtils} from '/tutorials/webgl/textures/examples/js/esm/WebGLSpecUtils.js'; import {GLUHolder} from '/tutorials/webgl/textures/examples/js/esm/WebGLUtils-v12.js'; export class GLTFLoader { gltfObj; returnObj = {}; loadFile(url) { return fetch(url) .then(res => res.json()) .then(json => { return this.parse(json); }); } async parse(gltfObj) { this.gltfObj = gltfObj; let scene = gltfObj.scenes[gltfObj.scene]; for (let nodeIndex of scene.nodes) { let node = gltfObj.nodes[nodeIndex]; for (let nodePropName in node) { if (nodePropName !== 'mesh') { throw new Error(`Unimplemented for property name '${nodePropName}' of Node!`); } await this.processMesh(gltfObj.meshes[node[nodePropName]]); } } return Promise.resolve(this.returnObj); } async processMesh(mesh) { // can ONLY dealing with 'mesh.primitives' now for (let primitive of mesh.primitives) { for (let primitivePropName in primitive) { if (primitivePropName === 'attributes') { let attributes = primitive[primitivePropName]; for (let attrName in attributes) { await this.processAttribute(attrName, attributes[attrName]); } } else { throw new Error(`Unimplemented for property name '${primitivePropName}' of 'mesh.primitive'!`); } } } } async processAttribute(name, value) { if (name !== 'POSITION') { throw new Error(`Unimplemented for attribute '${name}'!`); } let accessorId = value; let accessor = this.getAccessor(accessorId); let bufferViewData = await this.getBufferViewData(accessor); if (WebGLSpecUtils.GetEnumName(accessor.componentType) === 'FLOAT') { let vertices = new Float32Array(bufferViewData.arrayBuffer, accessor.byteOffset); this.returnObj.vertices = vertices; } else { throw new Error(`Unimplemented for '${accessor.componentType}'!`); } } getAccessor(id) { return this.gltfObj.accessors[id]; } async getBufferViewData(accessor) { let bufferViewId = accessor.bufferView; let bufferView = this.gltfObj.bufferViews[bufferViewId]; let arrayBuffer = await this.getBuffer(bufferView.buffer); arrayBuffer = arrayBuffer.slice(bufferView.byteOffset, bufferView.byteLength); bufferView.arrayBuffer = arrayBuffer; return { arrayBuffer: arrayBuffer, target: bufferView.target }; } async getBuffer(bufferId) { let buffer = this.gltfObj.buffers[bufferId]; let bufferLen = buffer.byteLength; // dealing with uri property of buffer return await fetch(buffer.uri) .then(res => res.arrayBuffer()) .then((arrayBuffer) => arrayBuffer.slice(0, bufferLen)); } }

因为GLTFLoader类先后有两次调用异步函数fetch函数,而各方法之间又有相互依赖关系,因此除loadFile方法之外,其他方法均使用await操作符来同步运行结果。

glTF的各个关键元素的处理都分离为相应的方法,且暂未能处理的属性均有意地抛出异常。

当加载完一个glTF后,各个属性数量应不在少数,因此可构建一个树形结构以分类存储在returnObj类属性中。目前只有一个vertices属性。

要从缓冲区中正确地取出数据,glTFbuffer, bufferView, accessor等属性中与字节偏移处byteOffset、字节长度byteLength相关的属性都需特别注意,均已使用数组的slice方法予以处理。

客户端代码如下:

app.doInInitMeshes((scene) => { let gltfLoader = new GLTFLoader(); gltfLoader.loadFile('TriangleWithoutIndices.gltf') .then(wrapper => { showScene(scene, wrapper); }); }); function showScene(scene, wrapper) { let mesh = new SolidMesh(wrapper.vertices); scene.add(mesh); let floorMesh = new GridFloorMesh(); scene.add(floorMesh); ViewportManager.instance.doOnCameraViewChanged(); }

运行应用

我们加载了TriangleWithoutIndices.gltf文件,并如实地渲染了出来。

参考资源

  1. glTF home
  2. glTF 2.0 Specification
  3. glTF Sample Viewer
  4. glTF Sample models
  5. glTF Sample models on Gitee
  6. glTF Sample Viewer Release
  7. The Official Khronos KTX Software Repository
  8. The "data" URL scheme
  9. gltf editor
  10. HTML中Data的数据类型
  11. Unicode Specification
  12. Unicode® Standard Annex #24 - UNICODE SCRIPT PROPERTY
  13. Unicode:Surrogate Pairs UTF-16中用于扩展字符