Blender scenes are displayed through a View Transform (e.g. Standard,
AgX, Filmic), plus optional Look, Exposure, and Gamma settings. The
engine reproduces this at runtime with two independent, composable
mechanisms: a native tonemap operator that runs in the Look pass, and an
optional creative grade LUT layered on top of it.
setPostFX(.tonemapOperator(_)) selects which built-in operator the engine
runs:
setPostFX(.tonemapOperator(.aces)) // default
setPostFX(.tonemapOperator(.agx)) // matches Blender's default View Transform since 4.0AgX is a native Metal port of the widely-used "Minimal AgX" approximation of Blender's AgX OCIO config, not a baked asset — it costs nothing at export time and works on any scene. It has not been empirically validated pixel-for-pixel against a real Blender AgX render; for color-critical work, compare visually against a Blender viewport render set to AgX.
Neither operator is a substitute for a custom OCIO config, a non-stock View Transform, or a Look — for those, use a creative grade LUT below to get closer, or accept the approximation.
A mixed-immersion space on the headset shows the scene next to the passthrough,
whose response the compositor applies to both, so a filmic curve there compresses
an object's whites and lifts its blacks against the room. .none hands the
compositor the scene's linear colour as it is:
setPostFX(.tonemapOperator(.none))RealityKit shows such a scene with the display's P3 primaries: measured on a panel of swatches it was given, its colours are the Display P3 → sRGB matrix of their sRGB values (a red of (0.72, 0.13, 0.11) shows as (192, 0, 8) of 255), its greys unchanged. That is the system's colour pipeline, which works in Display P3 and expects content in it (WWDC24 session 10092, "Render Metal with passthrough in visionOS"); a layer in an sRGB format is converted, so its colour stays as specified. The look pass does the same as RealityKit when asked, after the tone map: every saturated colour widens, and a channel the matrix takes negative clips to black, as it does there. The default keeps the sRGB colour as it is.
In bright sunlight RealityKit adapts further: its content turns part transparent, a curve goes on it and its lit surfaces darken. That is the system's doing, and not something to reproduce; the match above holds in rooms.
setPostFX(.colorPrimaries(.displayP3)) // as RealityKit shows a mixed-immersion scene
setPostFX(.colorPrimaries(.sRGB)) // defaultIf you already have (or want to hand-author, or export from DaVinci
Resolve, Nuke, or any other grading tool) a standard .cube 3D LUT,
--color-grade-lut stages it alongside the export as-is — no Blender
render, no proprietary container, no shaper encoding. It's applied as a
creative grade on top of whichever tonemap ran, so it operates in
ordinary display-referred [0, 1] space (or whatever DOMAIN_MIN/
DOMAIN_MAX the .cube declares):
untoldengine export \
--input GameData/Models/office/office.usdz \
--output GameData/Models/office/office.untold \
--color-grade-lut GameData/LUTs/warm_grade.cubeuntoldengine export-tiles \
--input GameData/Models/city/city.usdz \
--output-dir GameData/Models/city/tile_exports \
--tile-size-x 25 --tile-size-y 10000 --tile-size-z 25 \
--color-grade-lut GameData/LUTs/warm_grade.cubeThe .cube is content-addressed and copied into the export's Textures/
folder, and referenced from a colorGradeLUT key in the .untold file or
tile manifest. The engine parses the .cube text directly and uploads it
to a native Metal 3D texture; there is no conversion step on either side,
so any standard .cube works, not just ones this exporter produces.
A .cube can also be applied without any scene export at all, via the
standalone runtime API:
setColorGradeLUT(filename: "warm_grade", withExtension: "cube")This resolves the LUT the same way LoadingSystem resolves any other
asset — from a LUT/ folder under assetBasePath, or the app bundle. See
Using the Registration System for the full
setColorGradeLUT reference.
A .cube grade referenced from a scene export is scene-authored data, like
scene lights and cameras — it isn't registered by a normal mesh load.
setEntityMesh/setEntityMeshAsync only bring in geometry and materials.
Load it explicitly with loadSceneAuthored:
// From a single .untold asset (file-type: shared)
loadSceneAuthored(filename: "office", withExtension: "untold") { success in
// Scene-authored lights/cameras and the .cube grade (colorGradeLUT)
// are now registered.
}
// From a tile manifest
loadSceneAuthored(url: manifestURL) { success in
// Same, sourced from the manifest's colorGradeLUT key.
}See Using the Registration System
for the full behavior of loadSceneAuthored, including scene-authored lights
and cameras.
- Calling
loadSceneAuthoredclears any previously-loaded.cubegrade first, then re-populates it based on what the asset/manifest actually has. - Scenes saved through the Untold scene serializer remember the selected
tonemap operator and the standalone
.cubegrade (if one was set viasetColorGradeLUT), and restore both on load. - If the source has no
.cubestaged, the engine silently applies no creative grade — this is not an error, and nothing needs to be toggled off manually. - IBL and scene lighting are unrelated to color management — see Use Image-Based Lighting (IBL) if a scene also needs an HDR environment; loading one does not require or imply the other.
setPostFX(.colorGradeLUT(.enabled(_))) toggles the .cube grade off and
on without unloading it, to compare against the native tonemap operator
alone:
setPostFX(.colorGradeLUT(.enabled(false))) // compare with the .cube grade removed
setPostFX(.colorGradeLUT(.enabled(true))) // re-enable the .cube gradeThis is a no-op if no .cube has been loaded (i.e. loadSceneAuthored was
never called with a colorGradeLUT-bearing asset, and setColorGradeLUT
was never called directly). See Using Post-Effects for
the full PostFXProperty reference.
Assets exported before this exporter retired --bake-color-management
carry a scene-wide, baked whole-transform LUT (the colorLUT chunk/
manifest key) instead of, or alongside, a .cube grade. The engine still
loads and applies these for backward compatibility — setPostFX(.colorLUT(.enabled(_)))
still toggles it — but the exporter and Blender add-on no longer produce
new ones. Re-export with the native tonemap operator and/or a .cube
grade instead.