UntoldEngine Asset Format (.untold)
Overview
.untold is the native runtime asset container for UntoldEngine tile streaming.
It is not an interchange format and it is not intended to preserve full USD
semantics. USD/USDZ remains the authoring/import format. The .untold container is the
runtime package consumed by the engine for:
- tile files
- per-tile LOD files
- HLOD files
- shared bucket files
V1 is intentionally narrow:
- static meshes only
- transforms
- bounds
- vertex/index buffers
- basic PBR materials
- texture references
- no animation
- no skinning
- no blend shapes
The design goals are:
- fast runtime parse with no ModelIO dependency
- direct tile/HLOD/LOD streaming
- byte-range-friendly remote streaming (tiles are downloaded on demand from HTTP/HTTPS CDNs and cached locally before parsing; see
asset_remote_streaming.md) - explicit binary versioning
- stable on-disk layout independent of Swift ABI
Core Rules
- Endianness: little-endian for all integers and floats
- Float format: IEEE-754
Float32 - Chunk alignment: 16-byte aligned file offsets
- String encoding: UTF-8, null-terminated
- Invalid reference sentinel:
UInt32.max - Matrix encoding: 16
Float32s, column-major, matchingsimd_float4x4 - AABB encoding:
min.xyzfollowed bymax.xyz - File offsets: absolute
UInt64offsets from the start of the file - Chunk-local offsets:
UInt64offsets from the start of the uncompressed chunk payload - Versioning: unsupported versions must be rejected by the loader
The Swift types in
Sources/UntoldEngine/AssetFormat/UntoldFormat.swift
are the logical schema. They are not the authoritative on-disk memory layout.
The exporter and loader must write/read fields explicitly.
Physical File Layout
Each .untold file is laid out as:
FileHeaderChunkTable- aligned chunk payloads
Recommended chunk payload order:
STRING_TABLEENTITY_TABLEMESH_TABLEMATERIAL_TABLETEXTURE_TABLEVERTEX_DATAINDEX_DATA
This order allows the runtime to read metadata first and defer heavy geometry reads.
Header Encoding
The header is written field-by-field in this exact order:
magic[8] UInt8 x 8
formatVersion UInt32
fileType UInt32
flags UInt32
headerSize UInt32
chunkCount UInt32
meshCount UInt32
materialCount UInt32
textureRefCount UInt32
entityCount UInt32
vertexLayout UInt32
reserved0 UInt32
worldBounds.min Float32 x 3
worldBounds.max Float32 x 3
rootTransform Float32 x 16
contentHash UInt8 x 32
reserved1 UInt8 x 32
Rules:
magicmust be exactly 8 bytes, recommended value:"UNTOLD\0\0"headerSizeis the serialized byte size of the header, notMemoryLayoutcontentHashis exactly 32 bytesreserved1is exactly 32 bytes
Chunk Table Encoding
Each chunk entry is written in this exact order:
chunkType UInt32
compressionType UInt32
fileOffset UInt64
compressedSize UInt64
uncompressedSize UInt64
elementCount UInt32
reserved0 UInt32
Rules:
fileOffsetmust be 16-byte aligned- if compression is
none,compressedSize == uncompressedSize elementCountis used for record-table chunks
String Table Encoding
The string table payload is a raw byte blob containing:
- concatenated UTF-8 strings
- one
0x00terminator after each string
Rules:
- all string references are
UInt32byte offsets into this chunk UInt32.maxmeans “no string”- the exporter should deduplicate strings
- the loader must verify that each referenced offset is within the chunk and reaches a null terminator before chunk end
Entity Record Encoding
Each entity record is serialized in this exact order:
entityId UInt32
parentEntityId UInt32
nameOffset UInt32
firstMeshRecordIndex UInt32
meshRecordCount UInt32
flags UInt32
localBounds.min Float32 x 3
localBounds.max Float32 x 3
worldBounds.min Float32 x 3
worldBounds.max Float32 x 3
localTransform Float32 x 16
Rules:
parentEntityId == UInt32.maxmeans no parent- mesh records for one entity should be contiguous in the mesh table
firstMeshRecordIndex + meshRecordCountmust stay within mesh table bounds
Mesh Record Encoding
Each mesh record is serialized in this exact order:
entityId UInt32
meshNameOffset UInt32
materialIndex UInt32
indexType UInt32
vertexCount UInt32
indexCount UInt32
vertexStrideBytes UInt32
flags UInt32
vertexDataOffset UInt64
indexDataOffset UInt64
vertexDataSizeBytes UInt64
indexDataSizeBytes UInt64
estimatedGPUBytes UInt64
reserved0 UInt64
localBounds.min Float32 x 3
localBounds.max Float32 x 3
Rules:
vertexDataOffsetis relative to the start ofVERTEX_DATAindexDataOffsetis relative to the start ofINDEX_DATAvertexDataSizeBytes == vertexCount * vertexStrideBytesindexDataSizeBytes == indexCount * indexElementSizematerialIndex == UInt32.maxmeans no material
Material Record Encoding
Each material record is serialized in this exact order:
nameOffset UInt32
flags UInt32
baseColorFactor Float32 x 4
emissiveFactor Float32 x 3
normalScale Float32
metallicFactor Float32
roughnessFactor Float32
occlusionStrength Float32
alphaCutoff Float32
baseColorTextureIndex UInt32
normalTextureIndex UInt32
metallicTextureIndex UInt32
roughnessTextureIndex UInt32
emissiveTextureIndex UInt32
occlusionTextureIndex UInt32
reserved0 UInt32 x 2
Rules:
- texture indices point into
TEXTURE_TABLE - any texture index may be
UInt32.max flagsholds alpha mode, double-sided, transparent, and similar runtime bits
Texture Reference Encoding
Each texture record is serialized in this exact order:
nameOffset UInt32
uriOffset UInt32
textureFormat UInt32
flags UInt32
width UInt32
height UInt32
mipCount UInt32
reserved0 UInt32
Rules:
uriOffsetpoints into the string table- the URI should reference a cooked texture asset or runtime-resolvable texture path
textureFormatdescribes the cooked/runtime texture format
Vertex Layout V1
V1 supports one layout only:
UNT_VERTEX_LAYOUT_PBR_STATIC_V1
Serialized layout:
position.x Float32
position.y Float32
position.z Float32
normalPacked UInt32
tangentPacked UInt32
uv0.u UInt16
uv0.v UInt16
uv1.u UInt16
uv1.v UInt16
color0.r UInt8
color0.g UInt8
color0.b UInt8
color0.a UInt8
Total size: 32 bytes
Rules:
uv0is requireduv1may be zeroed if unusedcolor0defaults to255,255,255,255if unused
Packed Normal and Tangent Encoding
normalPacked and tangentPacked use signed normalized 10:10:10:2 packing.
For normalPacked:
- X: signed normalized 10-bit
- Y: signed normalized 10-bit
- Z: signed normalized 10-bit
- W: unused, stored as 0
For tangentPacked:
- X: signed normalized 10-bit tangent x
- Y: signed normalized 10-bit tangent y
- Z: signed normalized 10-bit tangent z
- W: handedness sign encoded in signed normalized 2-bit space
Recommended tangent handedness mapping:
+1for non-negative handedness-1for negative handedness
Exporter rules:
- normalize input normal/tangent before packing
- clamp components to
[-1, 1] - write
normalPacked.w = 0 - write
tangentPacked.w = +1or-1
Runtime rule:
- reconstruct bitangent as
cross(normal, tangent.xyz) * tangentSign
Index Data Rules
indexType = uint16means 2 bytes per indexindexType = uint32means 4 bytes per index- all indices in one mesh must use one type
- exporter should prefer
uint16whenvertexCount <= 65535
Compression Rules
Supported compression types:
nonelz4zstd
Rules:
- compression is applied per chunk, not whole-file
- offsets stored in metadata reference the uncompressed chunk payload layout
- metadata chunks may remain uncompressed for simpler startup
- geometry chunks may be compressed
Validation Rules
The loader must reject files when:
- magic is invalid
- version is unsupported
- required chunks are missing
- chunk offsets exceed file length
- chunk offsets are not 16-byte aligned
- string offsets fall outside the string table
- mesh/entity/material/texture indices are out of range
- vertex or index ranges exceed their chunk bounds
vertexStrideBytesdoes not match the declared vertex layoutindexDataSizeBytesdoes not matchindexCount * indexElementSize
Implementation Notes
Do not serialize .untold files using MemoryLayout<T> or direct struct dumps.
Both the exporter and the loader must use explicit field-by-field helpers:
writeUInt32LEwriteUInt64LEwriteFloat32LEwriteBytesreadUInt32LEreadUInt64LEreadFloat32LEreadBytes
This keeps the binary stable even if the Swift type layout changes.