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

glTF文件格式

撰写时间:2023-12-31

修订时间:2026-06-20

在本章中,我们初步学习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,而数组中仅有一个元素,表示这里仅一个node

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

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

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

gltf-top-elements

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

上面的mesh属性说明,该node下面有一个mesh,指向了一个meshes数组的第0个元素。

Meshes

因此,继续编写meshes属性,以供上面的mesh引用。

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

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

上面的POSITION属性值引用了下面要谈到的数组属性accessors(访问器)中的索引值。

二进制数据的处理

为何需要二进制数据

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允许转换后的字符大小为32MBChromium限制为512MBSafari限制为2048MB

32 MB = 32 * 1024 = 32,768 KB = 32,768 * 1024 = 33,554,432 bytes = 33,554,432 chars

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

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

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

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

Unicode字符有3种编码方式:UTF-32, UTF-16, 及UTF-8JavaScript的字符串使用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([11, 12, 13]); let blob = new Blob([tarr], {type: "application/octet-stream"}); blob.arrayBuffer().then((arrayBuffer) => { let result = new Uint8Array(arrayBuffer); pc.log(result); });

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

FileReader

我们可以尝试使用FileReader类。

let tarr = new Uint8Array([11, 12, 13]); let blob = new Blob([tarr], {type: "application/octet-stream"}); let reader = new FileReader(); reader.readAsDataURL(blob); reader.onloadend = (evt) => { let dataURL = reader.result; fetch(dataURL) .then(response => response.arrayBuffer()) .then(arrayBuffer => { let result = new Uint8Array(arrayBuffer); pc.log(result); }); };

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

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

File

可以使用File类来替换Blob。这是因为FileprototypeBlob,且其name属性即文件名。

let tarr = new Uint8Array([11, 12, 13]); let file = new File([tarr], "some-data.bin", {type: "application/octet-stream"}); pc.log(file); pc.log(file.name); let reader = new FileReader(); reader.readAsDataURL(file); reader.onloadend = () => { let dataURL = reader.result; pc.log(dataURL); fetch(dataURL) .then(response => response.arrayBuffer()) .then(arrayBuffer => { let result = new Uint8Array(arrayBuffer); pc.log(result); }); };

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

let file = new File([tarr], "some-data.bin", {type: "application/octet-stream"});

这样稍显奇怪的语句。

而如果不小心写成:

let file = new File(tarr, "some-data.bin", {type: "application/octet-stream"});

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

注意到变量dataURL是一个base64编码,上面打印了其编码后的值CwwN,而response.arrayBuffer方法进行了正确的解码。

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); pc.log(result); }

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

Accessors

回到glTF文件内容上面。上面谈到,POSITION属性引用了accessors属性值。

现在,我们准备与二进制数据打交道了。这里存在一个层级为4级的引用关系。

因这里存在多层级、多对多的引用关系,因此,本节中我们将先根据实际需求,绘制出完整的引用关系,然后再逐步给出相应的JSON结构。这样可帮我们更好地理解glTF文件内容。4级引用关系图示如下:

digraph { node [] edge [colorscheme=set312] Buffer [shape=plaintext, colorscheme=brbg9, label=<
buffer0byteLength10
uri0000000000
0123456789
>] BufferView0 [shape=plaintext, colorscheme=brbg9, label=<
bufferView0buffer0
byteOffset0
byteLength8
target34962
[sub buffer data]00000000
01234567
>] BufferView1 [shape=plaintext, colorscheme=brbg9, label=<
bufferView1buffer0
byteOffset8
byteLength2
target34963
>] Accessor0 [shape=plaintext, colorscheme=brbg9, label=<
accessor0bufferView0
byteOffset0
componentType5120
count2
typeVEC2
>] Accessor1 [shape=plaintext, colorscheme=brbg9, label=<
accessor1bufferView0
byteOffset4
componentType5120
count2
typeVEC2
>] POSITION [shape=box] Buffer:f1 -> BufferView0:f2 [color=1 dir=back]; Buffer:f2 -> BufferView0:f3 [color=3 dir=back]; Buffer:f1 -> BufferView1:f2 [color=1 dir=back]; Buffer:f3 -> BufferView1:f3 [color=4 dir=back]; BufferView0:f1 -> Accessor0:f2 [color=2 dir=back]; BufferView0:f4 -> Accessor0:f3 [color=5 dir=back]; BufferView0:f1 -> Accessor1:f2 [color=2 dir=back]; BufferView0:f5 -> Accessor1:f3 [color=6 dir=back]; Accessor0:f1 -> POSITION [color=2 dir=back]; }

Buffers

首先,buffer0为一个每个元素字长为1字节的数组,以字节的方式用以存储数据类型包括整数值、浮点数值、或图像数据等的任意数据。具体到WebGL应用来讲,可用buffer0来存储诸如顶点位置数据、颜色数据、纹理坐标值,甚至是图像数据。当然,还允许存在buffer1, buffer2等其他多个缓冲区。当然最好是分类存储,例如顶点位置数据可存储于buffer0中,纹理坐标值存储于buffer1中等等。这由各个开发人员自行决定。但我们这里暂不关心其存储的是什么数据,而是关心其内部数据结构。

图中第一行,buffer0共有10个字节的数据。对应于JSON内容:

BufferViews

图中第二行,buffer0bufferView0bufferView1这两个视图进行了分割。它们的buffer属性值指向同一个buffer0

其中,bufferView0所分割的视图从buffer0的第0个字节开始,共分割了8个字节,target值为34962bufferView1所分割的视图从buffer0的第8个字节开始,共分割了2个字节,target值为34963

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

十进制数值含义
34962ARRAY_BUFFER
34963ELEMENT_ARRAY_BUFFER

可使用WebGLSpecUtils来方便地查看各个枚举数值的名称。

说明bufferView0的数据将用于绑定ARRAY_BUFFER目标,bufferView1的数据将用于绑定ELEMENT_ARRAY_BUFFER目标。

图中的[sub buffer data]并非bufferView0真实存在的属性,图中添加此行的目的,在于清晰地绘制第3行的箭头指向。

因此,图中第二行的内容对应于以下的JSON内容:

Accessors

图中第三行,bufferView0的数据被accessor0accessor1再次进行分割。

其中,accessor0bufferView0的第0个字节开始,componentType属性值为5120accessor1bufferView0的第0个字节开始,componentType属性值为5120

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

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

下表列出了type的值域:

字符串值含义
SCALAR标量
VEC22维向量
VEC33维向量
VEC44维向量
MAT22阶矩阵
MAT33阶矩阵
MAT44阶矩阵

注意,accessor没有byteLength属性,因为其字节长度需通过以下公式来计算:

因此,accessor0的字节长度为:

accessor1的字节长度也依此计算。

因此,图中第三行的内容对应于以下的JSON内容:

最后,POSITION指向了accessor0

构建什么样的基本图元,是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 Assets
  5. glTF Sample models on Gitee
  6. glTF Sample Viewer Release
  7. The Official Khronos KTX Software Repository
  8. glTF-Tutorials
  9. The "data" URL scheme
  10. gltf editor
  11. HTML中Data的数据类型
  12. Unicode Specification
  13. Unicode® Standard Annex #24 - UNICODE SCRIPT PROPERTY
  14. Unicode:Surrogate Pairs UTF-16中用于扩展字符