Using The Exporter
UntoldEngine ships global CLI commands for both individual-asset and tiled scene exports, plus a repository script wrapper for tiled exports:
untoldengine export— single assetuntoldengine export-tiles— tiled scene (CLI equivalent of the script below)export-untold-tiles— repository script wrapper for tiled scene exports
All of these launch Blender in background mode and run the Python exporters
for you. Users do not need to invoke Blender or the Python scripts directly.
See Using the UntoldEngine CLI for the full CLI
subcommand reference, including untoldengine export-tiles --help.
Install The Export Command
From the UntoldEngine repository root, install the CLI:
The installer places untoldengine on the system PATH and installs its
exporter support files. After installation, untoldengine export works from a
game project directory or any other directory; it does not depend on the
current working directory or require navigating back to the engine repository.
Confirm that the command is available:
Prerequisites
Blender must be installed.
The wrappers resolve Blender in this order:
--blender /path/to/BlenderBLENDER_BIN=/path/to/Blender/Applications/Blender.app/Contents/MacOS/BlenderblenderonPATH
If Blender cannot be found, the wrapper prints an install message and exits.
Export A Single Asset
Use untoldengine export from any directory to convert one USD/USDZ or
.blend asset into one .untold runtime file.
Basic usage:
Absolute paths work as shown above. Relative paths are resolved from the directory in which the command is run, which is convenient when working from a generated game project:
cd /path/to/MyGame
untoldengine export \
--input Sources/MyGame/GameData/Models/robot/robot.usdz \
--output Sources/MyGame/GameData/Models/robot/robot.untold \
--convert-orientation
Common options:
--input <path>: required source.usd,.usda,.usdc,.usdz, or.blend--output <path>: required destination.untold(or.untoldanimwith--animation). A scene with several models is written as a.untoldpackof the same name; that name may be given as well--file-type <tile|lod|hlod|shared|animation>: optional, defaults totile--mesh-name <name>: optional, export only one mesh from a multi-mesh asset--convert-orientation: optional, convert the export into engine space--source-orientation <blender-native|engine-oriented>: optional, defaults toblender-native--assets-dir <path>: optional, folder for what the export writes besides the result; defaults to the--outputfolder. The result refers to the textures, the color grade LUT and the per-model folders of a.untoldpackin it by relative paths, so keep both folders together. TheHDR/copies (see below) go there as well. A result an earlier export left inside that folder is removed.--include-hidden: optional, also export objects hidden in the viewport or disabled in renders (see What a.blendscene exports)--validate: optional, also writes<name>.validation.json--compress-geometry: optional, LZ4-compress vertex and index chunks (requirespip install lz4)--optimize: optional, compress geometry and bake/patch textures after export (implies--compress-geometry)--color-grade-lut <path>: optional, stage an externally-authored standard.cube3D LUT and apply it as a post-tonemap creative grade — composes with (does not replace) the default tonemap. No Blender render, no conversion. See Using Color Management--animation: optional, export animation clips only — no mesh geometry is written; requires a.untoldanim--outputpath--blender <path>: optional Blender executable override
Example using absolute paths and geometry compression:
untoldengine export \
--input /Users/haroldserrano/Downloads/FloorPlanA/floorplanA.usdz \
--output /Users/haroldserrano/Downloads/FloorPlanA/floorplanA.untold \
--convert-orientation \
--compress-geometry
Expected output:
floorplanA.untoldTextures/...beside the.untoldfile if the asset uses texturesHDR/...beside the.untoldfile if the Blender scene has an environment image: the World's, or the studio light of a viewport in Material PreviewfloorplanA.validation.jsononly when--validateis passed
The files in HDR/ are copies for you to use. Nothing in the export refers to
them, and the engine does not look for them there: it loads an environment by
name from your project's GameData/HDR/ folder, so copy the ones you want into it.
A texture that cannot be exported does not stop the export. Its material is written without that texture, and the end of the export log lists every texture that was left out, with the material and the object that use it.
The older ./scripts/export-untold repository wrapper remains available for
engine development and compatibility. Game developers should prefer
untoldengine export because it can be called directly from their project.
What A .blend Scene Exports
A whole-scene export (no --mesh-name) follows what Blender itself shows:
- Objects in collections excluded from the view layer (the checkbox in the Outliner) are never exported. Blender does not evaluate them, so their placement would be stale.
- Objects hidden in the viewport (the eye or monitor icon, on the object or on
a collection holding it) or disabled in renders (the camera icon) are skipped
by default; the export log lists them. Pass
--include-hiddento export them, for example when an artist hides parts of the model while working that the game still needs. - Curve, surface and text objects are exported as meshes when their geometry has faces (a bevelled or extruded curve). Curves without faces, such as paths used by a Curve modifier, export nothing.
- Modifiers, Geometry Nodes and shape keys are applied, including on objects split into one mesh per material. Objects deformed by an Armature modifier keep their rest pose and skinning.
- An object whose parent is not exported (skipped, or split into one mesh per material) keeps its place in the scene.
Some material nodes are carried over instead of dropped:
- A Mapping node between UV coordinates and the image textures (scale and location, no rotation) is applied to the mesh's first UV map, so tiled textures keep their tiling. When textures use different Mapping nodes, the one most of them use is applied and the material fidelity report says so.
- Colour nodes between an image texture and its socket are written into the
staged texture, with the same math Cycles uses: Invert, Gamma,
Bright/Contrast, Hue/Saturation/Value, RGB Curves and ColorRamp (its Color
output). They are applied to linear values, so an sRGB texture is decoded and
encoded again. The texture is saved as
<name>_inverted.pngfor a lone Invert, or as<name>_adj<fingerprint>.png. A node whose settings are themselves linked to other nodes is still dropped, and the material fidelity report says so. - A material whose surface is an Emission shader exports as an emissive material with a black base color.
- A Base Color, Roughness, Metallic or Emission input driven by node math with no texture behind it (Mix, Math, RGB Curves, ColorRamp, node groups, ...) exports the value the chain gives for a surface seen straight on. View-dependent nodes such as Layer Weight and Fresnel take their straight-on value; the engine's own Fresnel then brightens the edges. A chain with an image or procedural texture in the way keeps the input's slider value, as before.
- Each mesh exports the material of the slot its faces use, which need not be the first slot.
- EXR textures used by a material (a normal or metallic map, for example) are converted to PNG. Values above 1 are clipped.
Transparency becomes the engine's blended alpha mode:
- A constant Alpha below 1 blends the material at that opacity.
- An Alpha fed by the base color image's own Alpha output uses that alpha.
- An Alpha fed by another texture, or through colour nodes, is written into the alpha channel of the base color texture (a white one when the base color is a constant), since the engine reads alpha from the base color texture.
- Glass is approximated, since the engine has no transmission: a Principled BSDF with Transmission becomes a blended surface. Clear glass (a white base color) keeps 10 % opacity at full transmission. The base color tints the light that crosses glass, so tinted glass is more opaque by the light its color takes (counted by its brightness), and black glass is exported opaque: the black mirror it is in Blender. The metallic share of a surface lets no light through, so a metal with Transmission left on is opaque as well. A rough surface scatters what crosses it, so frosted glass is more opaque the rougher it is. A base color, a metallic value or a roughness that comes from a texture counts as clear, as no metal and as polished. Transparent BSDFs mixed in by a Mix Shader lower the opacity by their share. A mix driven by Geometry > Backfacing takes its front-face side. The material fidelity report lists these approximations.
A height texture drives the engine's parallax occlusion mapping:
- The image on a Displacement node's Height input exports as the height texture, with the node's Scale as its depth, and failing that the image on the Height input of a Bump node that feeds the Normal input, with its Distance.
- The engine's depth is a share of the texture's width, not a distance, so a small Scale (0.02 to 0.1) carries over well and may need tuning after import.
- A Scale or Distance above 0.2 is not a depth parallax can show. Blender leaves both at 1, a metre, and with its default "Bump Only" displacement draws the shading of a bump from them. Such a height is left out of the export, and the material fidelity report says so; the surface keeps its normal map.
Lights and cameras follow the same rules as objects: never from collections
excluded from the view layer, and hidden ones only with --include-hidden.
Bake Textures To .utex
The CLI also exposes the ASTC texture baker, so it can be used without locating
scripts/texbake.py in the engine repository.
Bake every supported image in a texture directory:
Bake one texture with an explicit material slot:
Patch an exported asset to reference the generated .utex files:
Texture baking requires Python 3 with Pillow and the astcenc executable.
Download the appropriate macOS release from the
astc-encoder releases page,
extract it, and make sure the encoder binary is executable:
Set ASTCENC_BIN to the absolute path of that executable before running the
texture baker:
For example, an encoder stored in UntoldEngineStudio's shared Tools directory
can be used with:
ASTCENC_BIN="/path/to/UntoldEngineStudio/Tools/astcenc/astcenc" \
untoldengine texbake --dir /path/to/Textures
Add the export ASTCENC_BIN=... line to ~/.zshrc when that custom location
should be used for every terminal session. Alternatively, place a binary named
astcenc, astcenc-native, astcenc-avx2, or astcenc-sse4.2 on PATH. The
CLI uses python3 from PATH; set PYTHON3_BIN only when a different Python
installation is required.
Available options:
--input <path>: bake one PNG, JPEG, TGA, or BMP image--output <path>: destination.utexpath for a single image--slot <slot>: override automatic texture-slot detection--dir <path>: bake all supported images in a directory--quality <level>:fastest,fast,medium,thorough, orexhaustive--keep-temp: retain intermediate mip and ASTC files--patch-refs <path>: patch one.untoldfile or every.untoldfile in a directory
Export A Scene Into Tiles
Use export-untold-tiles (found in scripts/), or the equivalent untoldengine export-tiles CLI command, to partition a USD/USDZ or .blend scene into tile payloads and generate a manifest JSON file.
Basic usage:
./scripts/export-untold-tiles \
--input /path/scene.usdz \
--output-dir /path/tile_exports \
--tile-size-x 25 \
--tile-size-y 10000 \
--tile-size-z 25
Common options:
--input <path>: required source.usd,.usda,.usdc,.usdz, or.blend--output-dir <path>: required destination directory for tile payloads--tile-size-x <number>: optional tile width in world units (ignored in--quadtree/--kdtreemode)--tile-size-y <number>: optional tile height in world units, defaults to10000--tile-size-z <number>: optional tile depth in world units (ignored in--quadtree/--kdtreemode)--auto-tile-size: optional automatic tile sizing--generate-hlod: optional HLOD generation--generate-lod: optional per-tile LOD generation--lod-level <distance:ratio>: optional override for a per-tile LOD level. May be repeated.--hlod-level <suffix:distance:ratio>: optional override for an HLOD level. May be repeated.--dry-run: optional planning pass without writing payload files--write-manifest-in-dry-run: optional manifest write during dry run--visible-only: optional export only visible meshes--all-meshes: optional include hidden meshes--debug-aabb-only: optional emit debug AABB payloads instead of geometry--quadtree: optional partition tiles using a quadtree instead of a uniform grid--kdtree: optional partition tiles using a KD-tree instead of a quadtree (inline annotation only). Splits each floor's XY plane on the longer axis at the median object center, producing better-balanced tiles in scenes where geometry is unevenly distributed. Producespartitioning_mode: "kdtree_floor"in the manifest. Ignored if the input is pre-annotated (quadtree metadata takes precedence)--scene-profile <auto|indoor|outdoor>: optional streaming radius profile, defaults toauto. Radii are always proportional to scene size — no fixed distances to hand-tune. Useoutdoorfor cities, terrain, and large exterior scenes if auto-detection misses.--tier-radius <Tier=stream,unload[,priority]>: optional quadtree semantic-tier radius override in world units. May be repeated.--min-objects-per-tile-tier <count>: optional, collapse underfilled tile-tiers upward until reaching this many objects, defaults to4--untagged-semantic-tier <Auto|ExteriorShell|StructuralInterior|RoomContents|FineProps>: optional semantic tier for meshes without an explicit override, defaults toAuto--floor-count <number>: optional number of vertical floors to split each tile into (for quadtree/KD-tree mode)--floor-band-height <number>: optional per-floor height in world units (overrides auto-detection from scene Z extent)--sample: optional, export only a small tile patch near the world origin for fast iteration--sample-fraction <fraction>: optional fraction of total tiles to keep in sample mode, defaults to0.10--perimeter: optional, export only the outer shell of tiles, skipping interior tiles--perimeter-depth <count>: optional number of tiles inward from the boundary to keep, defaults to1--parallel-workers <number>: optional number of parallel Blender worker processes (0= auto-detect CPU count,1= sequential)--compress-geometry: optional LZ4-compress vertex and index chunks in every exported tile payload (requirespip install lz4)--optimize: optional, compress geometry and bake/patch textures after export (implies--compress-geometry)--color-grade-lut <path>: optional, stage an externally-authored standard.cube3D LUT once for the whole scene, referenced from the manifest'scolorGradeLUTkey and applied as a post-tonemap creative grade. See Using Color Management — it is only applied via an explicitloadSceneAuthored(url:)call, not by normal tile loading--blender <path>: optional wrapper-level Blender override
Example:
./scripts/export-untold-tiles \
--input GameData/Models/dungeon/dungeon.usdz \
--output-dir GameData/Models/dungeon/tile_exports \
--tile-size-x 25 \
--tile-size-y 10000 \
--tile-size-z 25 \
--generate-hlod \
--generate-lod
Dry-run example:
./scripts/export-untold-tiles \
--input GameData/Models/dungeon/dungeon.usdz \
--output-dir GameData/Models/dungeon/tile_exports \
--tile-size-x 25 \
--tile-size-y 10000 \
--tile-size-z 25 \
--dry-run \
--write-manifest-in-dry-run
Quadtree Tier Radius Overrides
Quadtree exports assign each tile group to a semantic tier:
ExteriorShellStructuralInteriorRoomContentsFineProps
--scene-profile chooses default stream/unload bands for these tiers. Use
--tier-radius when a scene needs tighter or wider bands than the selected
profile.
Syntax:
Example:
./scripts/export-untold-tiles \
--input GameData/Models/building/building.usdz \
--output-dir GameData/Models/building/tile_exports \
--quadtree \
--scene-profile indoor \
--tier-radius ExteriorShell=55,80,15 \
--tier-radius StructuralInterior=10,18,12 \
--tier-radius RoomContents=4,7,8 \
--tier-radius FineProps=1.5,3,5
ExteriorShell=55,80,15 means:
55:streaming_radiusin world units. The tile becomes eligible to load when the camera enters this distance band.80:unload_radiusin world units. Once loaded, the tile stays resident until the camera moves beyond this distance.15: optional load priority. Higher values are considered more important when multiple tile candidates compete for load slots.
unload_radius must be greater than streaming_radius. The gap is the
hysteresis band that prevents rapid load/unload oscillation near the boundary.
Expected output layout:
dungeon.jsonbeside the tile payload directorytile_exports/tile_*.untold- optional HLOD and LOD
.untoldfiles intile_exports/ tile_exports/Textures/...for staged textures
The manifest stores relative runtime paths so it remains portable across machines, repos, and app bundles.
KD-tree Partitioning
Use --kdtree instead of --quadtree when geometry is unevenly distributed across the scene floor — for example, when most objects cluster in corridors or specific rooms while other areas are sparse. The KD-tree splits each floor on the longer axis at the median object center, producing tiles that reflect actual geometry density rather than equal-area subdivisions.
./scripts/export-untold-tiles \
--input GameData/Models/building/building.usdz \
--output-dir GameData/Models/building/tile_exports \
--kdtree \
--scene-profile indoor \
--floor-count 10
The --tier-radius and --scene-profile flags work identically for --kdtree and --quadtree. The manifest will contain "partitioning_mode": "kdtree_floor" and tile node IDs use the F{nn}_K_... naming convention (e.g. "F02_K_0_1_0").
When to choose KD-tree vs. quadtree:
| Quadtree | KD-tree | |
|---|---|---|
| Geometry distribution | Uniform across floor | Clustered in sub-regions |
| Tile balance | Equal-area (can produce empty tiles) | Object-count balanced |
| Hierarchy culling | Yes | Yes |
| Pre-annotated input (phase12) | Yes | No (inline annotation only) |
Selective Merging With The NM_ Prefix
When MERGE_BY_MATERIAL is enabled (the default), objects that share the same material within a tile are joined into a single mesh entity before export. This reduces draw calls significantly, but means multiple original objects collapse into one exported entity — losing their individual names.
If you need certain objects to remain as separate identifiable entities (for example, to support tap-to-select workflows or per-object JSON lookups at runtime), prefix their name in Blender with NM_.
Objects whose name starts with NM_ are excluded from the merge step and exported individually, preserving their original name in the .untold file. All other objects are still merged normally.
Example naming in Blender:
NM_Pipe_001— exported as its own entity, name survives into.untoldNM_LightFixture_A— exported as its own entityWall_North— merged with other same-material walls, one entity for the groupDoor_Main— merged with same-material doors
This lets you keep background geometry (walls, floors, ceilings) optimized while still being able to identify and interact with specific objects at runtime:
To change the prefix or disable selective merging, edit NO_MERGE_PREFIX at the top of scripts/tilestreamingpartition.py. Set it to "" to merge all objects regardless of name.
At runtime, NM_ objects default to .selectableGeometry and .preserveIdentity scene channels. Regular render/streaming geometry defaults to .contextGeometry. This lets an app hide context geometry with setSceneChannel(.contextGeometry, .renderMode(.hidden)) while keeping NM_ objects visible and selectable. See Scene Channels.
Optimization Workflows
After exporting assets, use Optimizations for optional workflows such as ASTC texture compression and LZ4 geometry compression.
The export scripts write a .untoldpack without LOD chains. untoldengine
export adds them as its last step; for a pack written by scripts/export-untold
or by the Blender add-on, run untoldengine bake-lods --input <name>.untoldpack
afterwards (see LOD chains for packs).
Loading The Result In The Engine
Single asset:
Tiled scene:
let sceneRoot = createEntity()
setEntityName(entityId: sceneRoot, name: "dungeon")
setEntityStreamScene(entityId: sceneRoot, manifest: "dungeon", withExtension: "json")
The manifest should live next to the tile payload directory. Tile, HLOD, LOD, and shared-bucket payloads are resolved relative to the manifest file.
Notes
.untoldtile payloads participate in the current tiled streaming architecture, including tile-level load/unload, remote download + cache, per-tile LOD/HLOD, and large-tile OCC sub-mesh streaming when the runtime classifies a tile into the OOC path.- The Python files in
scripts/are implementation details. The recommended user entry points are the shell wrappers in the same folder.