XGFLoader reads xeokit's native binary geometry format (.xgf) into a
SceneModel, and XGFExporter writes a SceneModel back out as XGF.
This document describes the binary container, the on-disk schema, and the pipelines a file follows from bytes to a live scene (and back).
XGF is xeokit's compact binary format for geometry, materials, textures, transforms, and objects — the visual half of a model. It's positional, length-prefixed, and designed to be loaded from one binary chunk into a handful of typed-array views.
Two roles:
.xgf..json sidecar (one of the xeokit datamodel / metamodel
formats). Pair them by id and load them together.The XGF loader's DataModel output is intentionally minimal: every
SceneObject id also becomes a BasicEntity DataObject so the
SceneModel ↔ DataModel pair is always queryable. Richer semantics
come from a paired DataModel JSON.
uint16s per vertex, decoded
in the vertex shader.float32 so tiling values outside [0, 1] survive intact.Every XGF file is the same outer container — a version tag, an
(offset, length) table, and a sequence of typed-array payloads laid
out at those offsets:
┌──────────────────────────────────────────────────────────────┐
│ 0..3 uint32 version (1) │
├──────────────────────────────────────────────────────────────┤
│ 4..7 uint32 entry[0].byteOffset │
│ 8..11 uint32 entry[0].byteLength │
│ 12..15 uint32 entry[1].byteOffset │
│ 16..19 uint32 entry[1].byteLength │
│ ... (one (offset, length) pair per entry) │
├──────────────────────────────────────────────────────────────┤
│ entry[0] typed-array payload (padded to its BPE align) │
│ entry[1] typed-array payload │
│ ... │
└──────────────────────────────────────────────────────────────┘
Uint16Array[0] === 1 probe decides whether swapping is
needed).BYTES_PER_ELEMENT. Padding bytes between payloads are unused.eachObjectId,
eachMaterialId) are JSON arrays of strings; they ride in the
container as Uint8Array blobs the reader JSON.parses on
demand.The container header's first uint32 is the schema tag:
1 — XGF v1, a self-contained visual model.2 — XGF v2, v1-compatible visual data plus stable asset IDs,
references-only meshes, and SceneTransform hierarchies.XGF v2 is the streaming contract. A v2 file has exactly one of these chunk roles:
full — self-contained geometry, materials, textures, transforms,
meshes and objects. This is the default exporter output.assetLibrary — reusable geometry, material and texture assets only.
It creates shared assets in the target SceneModel and does not create
meshes or objects.referencesOnly — transforms, meshes and objects only. Meshes bind to
geometry/material IDs that must already exist in the target SceneModel,
normally because an assetLibrary chunk was loaded first.The low-level XGF loader does not fetch dependencies, maintain a manifest, or
manage chunk lifetimes. It only applies the supplied payload to the supplied
SceneModel. Missing asset references are reported through
Scene.events.onError as InvalidInput and the load path returns without
throwing. Streaming orchestration should sit above XGFLoader.
Streaming code should pair each XGF v2 chunk with an
XGFChunkManifest JSON record. The manifest is intentionally separate from
the binary payload so schedulers can inspect bounds and dependencies before
fetching the XGF bytes.
A full/self-contained container carries:
GaussianSplatsPrimitive type.triplanarScale for world-projected texturing.Field order = pack order = read order. See
versions/v1/XGFData_v1.ts and
versions/v2/XGFData_v2.ts for the authoritative
TypeScript shapes. XGF v2 appends stable asset IDs, reference-only mesh fields,
and transform hierarchy fields to the v1-compatible payload.
| Entry | Type | Meaning |
|---|---|---|
positions |
Uint16Array |
Quantised positions (R G B 16-bit per vertex). Decode against the geometry's AABB: p = uint * (max - min) / 65535 + min. |
colors |
Uint8Array |
Per-vertex RGBA (4 bytes/vertex). |
indices |
Uint32Array |
Triangle / line indices. |
edgeIndices |
Uint32Array |
Wireframe edge indices. |
aabbs |
Float32Array |
Per-geometry AABBs (six floats: minX minY minZ maxX maxY maxZ). Multiple geometries may share an AABB by pointing at the same base. |
normals |
Uint16Array |
Octahedral RG16UI normals, two values per vertex. Geometries without normals occupy zero range. |
uvs |
Float32Array |
RG32F UVs, two values per vertex. Floats (not quantised) so tiling values round-trip. |
eachGeometry* is a per-geometry array; each entry is the base
index into the global blob it points to. The geometry's slice runs
from its base to (the next geometry's base) or (the global blob's
length).
| Entry | Type | Meaning |
|---|---|---|
eachGeometryPositionsBase |
Uint32Array |
Base into positions. |
eachGeometryColorsBase |
Uint32Array |
Base into colors. |
eachGeometryIndicesBase |
Uint32Array |
Base into indices. |
eachGeometryEdgeIndicesBase |
Uint32Array |
Base into edgeIndices. |
eachGeometryNormalsBase |
Uint32Array |
Base into normals, or 0xffffffff if this geometry has no normals. |
eachGeometryUVsBase |
Uint32Array |
Base into uvs, or 0xffffffff if no UVs. |
eachGeometryPrimitiveType |
Uint8Array |
0 Triangles, 1 Solid, 2 Surface, 3 Lines, 4 Points. |
eachGeometryAABBBase |
Uint32Array |
Base into aabbs (multiple of 6). |
| Entry | Type | Meaning |
|---|---|---|
matrices |
Float64Array |
Concatenated 4×4 column-major modelling matrices. Each mesh points at one. |
| Entry | Type | Meaning |
|---|---|---|
textureData |
Uint8Array |
Concatenated encoded image bytes (PNG / JPEG / GIF or opaque transcoded buffer). |
eachTextureDataBase |
Uint32Array |
Base into textureData. |
eachTextureMediaType |
Uint8Array |
0 PNG, 1 JPEG, 2 GIF, 255 opaque transcoded buffer (treat as raw bytes; not decoded at load time). |
eachTextureWidth |
Uint16Array |
Per-texture pixel width. |
eachTextureHeight |
Uint16Array |
Per-texture pixel height. |
eachTextureSampler |
Uint8Array |
Five bytes per texture: minFilter, magFilter, wrapS, wrapT, wrapR. Each is a small-integer code (1–9) matching the xeokit/base/constants sampler enums. |
eachTextureId |
string[] |
Per-texture string ID (JSON blob). |
| Entry | Type | Meaning |
|---|---|---|
eachMaterialPBR |
Uint8Array |
Eight bytes per material: R, G, B, opacity, roughness, metallic, alphaMode, alphaCutoff. All values quantised to [0, 255] from [0, 1]. alphaMode is 0 OPAQUE, 1 MASK, 2 BLEND. |
eachMaterialTextures |
Int32Array |
Five entries per material: colorTextureIndex, metallicRoughnessTextureIndex, normalsTextureIndex, occlusionTextureIndex, emissiveTextureIndex. Each is a 0-based index into eachTextureId, or -1 for "no texture". |
eachMaterialId |
string[] |
Per-material string ID (JSON blob). |
| Entry | Type | Meaning |
|---|---|---|
eachMeshGeometriesBase |
Uint32Array |
Index into the geometry list (each mesh references exactly one geometry). |
eachMeshMatricesBase |
Uint32Array |
Index into matrices (multiple of 16). |
eachMeshMaterialAttributes |
Uint8Array |
Inline RGBA fallback (4 bytes/mesh: R, G, B, opacity). Used when eachMeshMaterial is -1. |
eachMeshMaterial |
Int32Array |
Index into the material array, or -1 to fall back to eachMeshMaterialAttributes. |
| Entry | Type | Meaning |
|---|---|---|
eachObjectId |
string[] |
Per-object string ID (JSON blob). |
eachObjectMeshesBase |
Uint32Array |
Base into the per-mesh arrays. The object's meshes run from its base to (the next object's base) or (mesh count). |
A mesh with no material reference falls back to its inline RGBA (the same per-mesh colour the geometry-only path uses).
.xgf bytes (ArrayBuffer)
│
▼
XGFLoader.getVersion ← read uint32 [0]: "1" or "2"
│
▼
versions/v1|v2/parse.ts
│
├─ unpackXGF ← walk the (offset, length) table,
│ byte-swap on big-endian hosts,
│ create typed-array views in place
│ (zero copies)
│
▼
XGFData_v1|v2 payload
│
▼
xgfToModel ← walks objects → meshes → geometries
│
├─ decode textures
│ PNG/JPEG/GIF → createImageBitmap → sceneModel.createTexture
│ opaque → pass raw bytes through with `compressed: true`
│ empty → register a 1×1 white pixel placeholder
│
├─ build materials
│ PBR + alpha + texture references → sceneModel.createMaterial
│
├─ for each object:
│ for each mesh:
│ ensure geometry created once → sceneModel.createGeometryCompressed
│ (per-vertex slices come from `eachGeometry*Base` arrays;
│ normals/UVs slices end at the *next* geometry with
│ non-sentinel base, since geometries without
│ normals/UVs are sparsely indexed)
│ resolve mesh transform from matrices[base..base+16]
│ resolve material or inline RGBA (unset)
│ sceneModel.createMesh
│ sceneModel.createObject({id, meshIds, layerId})
│ dataModel.createObject({id, type: "BasicEntity"})
│ dataModel.createRelationship({type: "BasicAggregation", …})
│
▼
SceneModel + DataModel populated
Every async-loop site calls yieldToHost(signal) at a coarse cadence
(every 4 textures, every 32 objects). Two effects:
options.onProgress fires with {phase, current, total} at the same cadence.yieldToHost checks signal.aborted and throws
DOMException("Aborted", "AbortError") on cancel. Worst-case
abort latency is one yield interval (≈16 ms).PNG/JPEG/GIF blobs are decoded through createImageBitmap so the GPU
atlas can sample them directly. Outside a browser (Node test runs,
worker contexts without createImageBitmap) the loader keeps the
encoded bytes and registers them for later browser-side decode instead
of throwing. For pre-decoded ("opaque transcoded") textures the bytes
pass through unchanged as a SceneTexture buffer; the runtime /
transcoder pipeline handles upload.
SceneModel
│
▼
modelToXGF
│
├─ flatten geometries → concatenated positions / colors /
│ indices / edge indices, build base arrays
├─ flatten matrices ← one per mesh
├─ encode textures
│ imageBitmap → canvas.toBlob("image/png") → bytes
│ buffers → passed through unchanged
├─ build per-material PBR bytes + texture-index references
├─ build per-mesh material index OR inline RGBA fallback
└─ build per-object id + mesh-base arrays
│
▼
XGFData_v1|v2 payload
│
▼
packXGF ← positional pack; byte-swap on big-endian,
│ pad each payload to its BPE alignment,
│ embed JSON arrays as Uint8 blobs
▼
.xgf bytes (ArrayBuffer)
The pack ↔ unpack halves walk the same positional list in the same
order — adding, removing, or reordering an entry is a
schema-version-breaking change that requires bumping the version tag
in XGF_INFO.xgfVersion and a matching reader.
Geometry data round-trips through createGeometryCompressed (load
side) and the packer (write side) without
re-decompressing. Per-vertex positions, normals, UVs and indices are
already quantised in the SceneModel, so the writer mostly concatenates
buffers and emits base arrays.
If options.coordinateSystem is passed to the exporter,
getMeshWorldMatrix bakes the SceneModel-space → target-space
transform into each mesh's modelling matrix before pack. The XGF
container itself doesn't carry a coordinate-system field — the caller
specifies it at load time via the SceneModel.
imageBitmap-backed SceneTextures are re-encoded to PNG bytes via an
OffscreenCanvas (or a <canvas> fallback). buffers-backed
SceneTextures pass through verbatim, with mediaType: 255 flagging
the opaque-transcoded case.
uint16 = round((double - min) * 65535 / (max - min))
double = uint16 * (max - min) / 65535 + min
Applied per axis, per geometry. The AABB ships in aabbs and is
referenced by eachGeometryAABBBase. Multiple geometries can share
an AABB to save space when they cover the same volume.
Practical implication: keep each geometry's AABB tight to its actual extent. At a 1-metre AABB you get ~15 µm precision; at a 100-metre AABB you get ~1.5 mm.
Each normal is folded to the unit-octahedron's 2D parameterisation,
then quantised to two 16-bit unsigned ints. Decoded in the vertex
shader — the renderer's unoctEncodedVec3 does the inverse. Cost:
2 bytes/normal vs 12 bytes for raw float3.
Floats, not quantised. Tiling values outside [0, 1] (e.g. for
repeat-mapping a brick texture across a wall) need to survive intact,
which they wouldn't through a 0..1-clamped quantisation.
import {Scene} from "@xeokit/sdk/model/scene";
import {Data} from "@xeokit/sdk/model/data";
import {XGFLoader} from "@xeokit/sdk/formats/xgf";
const scene = new Scene();
const data = new Data();
const sceneModel = scene.createModel({id: "myModel"}).value!;
const dataModel = data.createModel({id: "myModel"}).value!;
const fileData = await (await fetch("./model.xgf")).arrayBuffer();
await new XGFLoader().load({
fileData,
sceneModel,
dataModel,
});
The loader's fileDataType is "arraybuffer". Pass a DataModel to
also receive the loader's minimal BasicEntity graph keyed by
SceneObject id; omit it if you'll pair the SceneModel with a
hand-authored DataModel from a JSON sidecar.
import {XGFExporter} from "@xeokit/sdk/formats/xgf";
const arrayBuffer = await new XGFExporter().write({
sceneModel,
});
The exporter's fileDataType is "arraybuffer". The dataModel
parameter is accepted by the base ModelExporter API but XGF is a
geometry-only format — semantic data should be written separately
through the datamodel JSON exporter.
For chunked, manifest-backed loading, use
@xeokit/sdk/formats/xgfstream. That format owns stream indexes, chunk
manifests, asset-library references, dependency loading, cache policy, and
view-prioritized streaming. Base xgf remains the single-file reader/writer.
The canonical streamed-model payload is .xgf + .json loaded
together:
import {DataModelImporter} from "@xeokit/sdk/formats/datamodel";
import {XGFLoader} from "@xeokit/sdk/formats/xgf";
await Promise.all([
new DataModelImporter().load({
fileData: await (await fetch("./model.json")).json(),
dataModel,
}),
new XGFLoader().load({
fileData: await (await fetch("./model.xgf")).arrayBuffer(),
sceneModel,
dataModel, // safe to share — the XGF BasicEntity graph merges
// with the JSON-authored DataObjects by id
}),
]);
SceneModel.coordinateSystem; the file
itself is coordinate-system-agnostic.XGFLoader.formats/xgf/
├── README.md (this file)
├── XGFLoader.ts ModelLoader subclass
├── XGFExporter.ts ModelExporter subclass
├── index.ts module re-exports
└── versions/
└── v1/
├── XGF_INFO.ts {xgfVersion: 1}
├── XGFData_v1.ts payload type (geometry, splats, materials, textures, triplanar)
├── parse.ts load pipeline entry — unpack → xgfToModel
├── encode.ts write pipeline entry — modelToXGF → pack
├── unpackXGF.ts ArrayBuffer → XGFData_v1 (positional, BE-aware)
├── packXGF.ts XGFData_v1 → ArrayBuffer
├── xgfToModel.ts XGFData_v1 → SceneModel + DataModel
└── modelToXGF.ts SceneModel → XGFData_v1