Skip to main content

API Summary

This page summarizes the public API exposed by the Genies NAFKit iOS SDK.

Integration Paths

There are two integration paths based on your current project specifications.

PathYou writeSDK handles
Integrated — drop in NAFViewUIView placement, optional weights/pickingLoading, silhouette, LODs, animation, behavior, gaze, blendshapes, rendering, camera
Custom — own the Metal pipelineRenderer code (mesh upload + draw calls)Loading, silhouette, LODs, animation, behavior, gaze, blendshapes
info

The Custom path is rendering only — you don't touch loaders, behavior state, LOD orchestration, or animation pumping. The SDK does that. You just paint the meshes it hands you.


Integrated Path

This path creates a NAFView that handles the rendering:

import NAFKitUI

let view = try NAFView()
addSubview(view)

let avatar = try NAFAvatar(fromJSON: avatarJSON)
try await view.loadAvatar(avatar, mode: .behavior(silhouette: true), lods: [2, 0])
tip

NAFView() defaults resolverConfig: to NAFKitConfig.resolverConfig. loadAvatar defaults mode: to .behavior() — the full smart-avatar pipeline (silhouette → progressive LODs → default idle / talking / breathing from NAFDefaultAssets → behavior graph keyed on avatar.slug). Pass mode: .staticPose for a thumbnail-style load or mode: .animated() for "idle only, no behavior."

Common follow-ups:

// Toon shader — bundled "default" preset.
view.applyDefaultToonPreset()

// Camera framing
view.frameAvatar() // re-fit the orbit camera
view.onAutoFrame = { [weak view] in // fires once framing positions the camera
view?.cameraDistance *= 1.2 // e.g. nudge the framed pose
}

// Picking + translate gizmo
view.isPickingEnabled = true
view.isGizmoEnabled = true
view.onPick = { hit in view.isGizmoVisible = (hit != nil) }

// Lifecycle
view.unloadAvatar()
note

To swap the default idle / talking / breathing clips, override the NAFDefaultAssets.* statics once at app startup — every subsequent loadAvatar picks them up.

tip

Pass slug: to loadAvatarProgressive(definition:slug:) to override the definition's slug; pass "" to force a static avatar with just the default idle.


Custom Path

This path uses NAFAvatarSession with your renderer to paint while the SDK does everything else.

One-time setup

import NAFKit
import NAFKitUI
import NAFKitUITools

let metalRenderer = NAFRenderer(device: nil)
let session = NAFAvatarSession(device: metalRenderer.device)

// Your renderer conforms to NAFAvatarRenderer (two methods).
let myRenderer = MyMetalRenderer(device: metalRenderer.device)

// Receive meshes when the SDK (re)builds them.
session.onMeshesRebuilt = { [weak myRenderer] meshes in
myRenderer?.applyMeshes(meshes)
}

// Gaze hooks — set once. `tick(...)` reads them per frame.
session.cameraPositionProvider = { [weak cam] in cam?.eyePosition ?? .zero }
session.avatarModelTransform = uprightTransform

Loading — same as Integrated

let avatar = try NAFAvatar(fromJSON: avatarJSON)
try await session.loadAvatar(avatar, mode: .behavior(silhouette: true), lods: [2, 0])

Per-frame — one call

metalRenderer.onUpdate = { [weak session, weak myRenderer] dt in
if let session, let myRenderer {
session.tick(deltaTime: Float(dt), renderer: myRenderer)
}
}

metalRenderer.onRender = { [weak myRenderer] encoder in
let frame = FrameContext(view: viewMatrix,
viewProjection: viewProjection,
aspect: aspect)
myRenderer?.render(encoder: encoder, context: frame)
}

Conforming to NAFAvatarRenderer

@MainActor
public protocol NAFAvatarRenderer: AnyObject {
func updateBoneMatrices(for meshIndex: Int, matrices: NSData)
func updateMorphWeights(for meshIndex: Int, weights: NSData)
}
info

The SDK calls the two methods per frame via tick(...). Your job: write each NSData blob into the matching MTLBuffer your shader reads. For the morph buffer layout the SDK expects (morphIdx * vertexCount + vid), use NAFMeshData.makeMorphPositionDeltaBuffer(device:) + makeMorphWeightsBuffer(device:) — both ship in NAFKitUITools and handle the no-morph placeholder for you.


Reference

tip

The two paths above cover ~90% of consumer use. The sections below are the per-type API reference for when you need more.

danger

There are some exposed APIs that currently do not work. Equipping assets, behavior, gestures, and emotions are not currently functional.

Please check out the Troubleshooting page for more information.

NAFView — UIView renderer

All public types in NAFKitUI are @MainActor-isolated unless noted.

A UIKit view that renders an avatar end-to-end. Owns its NAFAvatarSession, a Metal pipeline cache, and (when hdrEnabled) an HDR offscreen target with a linear tone-map post pass. SDR-only consumers can flip hdrEnabled = false to drop the second pass and render straight to the drawable.

public final class NAFView: UIView {
public convenience init(frame: CGRect = .zero,
resolverConfig: String = NAFKitConfig.resolverConfig,
device: MTLDevice? = nil) throws
public convenience init(frame: CGRect = .zero, containerApi: NAFContainerApi, device: MTLDevice? = nil) throws

public let session: NAFAvatarSession

// Avatar lifecycle (delegates to `session`; identical signatures).
// `mode` selects the pipeline (see `NAFLoadMode`); default is `.behavior()`.
public func loadAvatar(_ avatar: NAFAvatar,
mode: NAFLoadMode = .behavior(),
lods: [Int] = [0]) async throws

public func unloadAvatar()

// WARNING: Equipping assets, and playing gestures/emotions are not currently functional
public func equip(_ asset: NAFAssetRef, lod: Int = 0) async throws
public func unequip(assetId: String)
public func playGesture(tag: String)
public func playEmotion(tag: String, intensity: Int)

// Render configuration
public var preferredFramesPerSecond: Int // default 60; ceiling under adaptiveFrameRate
public var clearColor: MTLClearColor // mirrors UIView.backgroundColor
public override var isOpaque: Bool // forwarded to MTKView; flip false to composite a custom background under the view
public var isRenderingPaused: Bool
public var cullingDisabled: Bool // debug
public var lighting: SceneLighting // applies on next frame
public var hdrEnabled: Bool // default true; set false to skip the HDR pass + tone-map for SDR-only content
public var toneMapping: ToneMappingSettings // ignored when hdrEnabled = false
public var sampleCount: Int // MSAA: 1 (off), 2, 4 (default), 8

// Power / thermal knobs
public var renderScale: CGFloat // 0.25–1.0; multiplies contentScaleFactor. Default 1.0
public var rendersOnDemand: Bool // default false; true = submit only when state dirty
public var adaptiveFrameRate: Bool // default false; auto-modulate FPS between active/idle tiers
public var adaptiveActiveFPS: Int // default 60
public var adaptiveIdleFPS: Int // default 24
public func setNeedsRedraw() // wake the on-demand loop after a custom-driven state change

// Camera (orbit, value-bound to internal CameraControls)
public let cameraControls: CameraControls // read viewMatrix to drive a back-layer renderer
public var cameraTarget: SIMD3<Float>
public var cameraDistance: Float
public var cameraYaw: Float
public var cameraPitch: Float
public var cameraMinDistance: Float
public var cameraMaxDistance: Float
public var cameraRotateSensitivity: Float
public var cameraPanSensitivity: Float
public var cameraGesturesEnabled: Bool // disable to drive the camera yourself
public func frameAvatar() // re-run auto-framing
public var onAutoFrame: (() -> Void)? // fired after auto-framing positions the camera

// Debug overlays
public var showSkeleton: Bool
public var showBoundingBox: Bool

// Avatar transform — lets a translate gizmo (or your own code) move
// the avatar without round-tripping through the renderer.
public var avatarPosition: SIMD3<Float> // default .zero
public var avatarModelTransform: simd_float4x4 // bind-pose → world
public var meshSpaceBounds: (min: SIMD3<Float>, max: SIMD3<Float>)?

// Built-in picking — installs a tap recognizer only while enabled.
public var isPickingEnabled: Bool // default false
public var pickPrecision: AvatarPickPrecision // default .bounds
public var pickScope: PickScope // default .avatar
public var onPick: ((PickHit?) -> Void)? // nil payload = miss

// Built-in translate gizmo — installs a single-finger pan recognizer
// only while enabled. `isGizmoVisible` is consumer-driven (typically
// toggled from `onPick`).
public var isGizmoEnabled: Bool // default false
public var isGizmoVisible: Bool // default false
public var gizmoAxisLength: Float // default 0.3

// Read-through to session
public var isAvatarLoaded: Bool
public var gestureTags: [String]
public var emotionTags: [String]
}

Throws NAFViewError.metalDeviceUnavailable or .commandQueueUnavailable from initializers.

The device: parameter

NAFView and NAFAvatarSession both accept an optional MTLDevice. The rules are the same for both:

  • Pass nil (the default) for the standard drop-in case. The SDK falls back to MTLCreateSystemDefaultDevice() and manages everything internally.
  • Pass an explicit device only when you're already running your own Metal pipeline (e.g. driving NAFKit directly through NAFRenderer, or using your own MTKView) and want NAFKit's texture uploads to land on the same device your renderer draws from. Sharing the device is required — Metal resources are not portable across devices.
  • The first call to either initializer pins the SDK's shared Metal context to whichever device it sees. Subsequent device: arguments are ignored for the lifetime of the process. If you mix custom and built-in renderers, initialize with the explicit device first.

Picking and the translate gizmo

NAFView ships a turnkey tap-to-pick + translate-axis gizmo. Both are off by default; opting in is a one-line flip.

view.pickPrecision = .mesh        // ray vs CPU-skinned triangles
view.pickScope = .avatar // or .mesh for per-submesh hits
view.isPickingEnabled = true
view.isGizmoEnabled = true
view.onPick = { hit in
view.isGizmoVisible = (hit != nil) // consumer decides the linkage
}

onPick fires on every tap with a PickHit? (nil means a miss, so consumers can clear selection chrome).

Gesture-recognizer hygiene. The tap and gizmo-pan recognizers are attached to the view only while their property is true. Setting isPickingEnabled = false removes the tap recognizer; setting isGizmoEnabled = false removes the gizmo-pan recognizer. Until a consumer opts in, the only gesture recognizers on the view are the camera pan and pinch — your own gesture stack sees clean touch delivery.

Drop down to AvatarPicker and TransformGizmo (NAFKitUITools) directly when you want a different interaction model — e.g. a long-press picker, a marquee select, or a different gizmo overlay.

Custom 3D background

Drop a sibling MTKView underneath NAFView, make NAFView see-through, and mirror the camera. The two passes share neither depth nor render target — fine for skyboxes / scenic backdrops, not for cases where the avatar must be occluded by background geometry (drop to NAFAvatarSession for those).

let nafView = try NAFView(resolverConfig: configJSON)
nafView.backgroundColor = .clear // alpha 0 clear color
nafView.isOpaque = false // composites against what's below

backgroundView.frame = nafView.frame // your own MTKView-backed scene
container.addSubview(backgroundView)
container.addSubview(nafView)

// Sync the back layer's camera every frame.
backgroundRenderer.onRender = { encoder in
let view = nafView.cameraControls.viewMatrix
let projection = SceneMath.perspective(
fovY: SceneMath.fovY,
aspect: aspect,
near: 0.01, far: 100.0)
let frame = FrameContext(view: view,
viewProjection: projection * view,
aspect: aspect)
yourSceneRenderer.render(encoder: encoder, context: frame)
}

NAFView's built-in pan / pinch gestures already mutate cameraControls, so the back layer follows the user automatically. Use SceneMath.fovY as the FoV constant so auto-framing in NAFView agrees with what the back layer sees.

NAFAvatarView — SwiftUI bridge

public struct NAFAvatarView: UIViewRepresentable {
public init(view: NAFView)
}

Owns nothing. Pass an existing NAFView (typically held by your view-model) so its lifetime persists across SwiftUI re-renders.

NAFAvatarSession — headless pipeline

The stateful core that drives NAFView. Use directly when you want SDK-managed loading + animation but your own renderer.

public final class NAFAvatarSession {
public init(resolverConfig: String = NAFKitConfig.resolverConfig,
device: MTLDevice? = nil)
public init(containerApi: NAFContainerApi, device: MTLDevice? = nil)

public let containerApi: NAFContainerApi

// Single loadAvatar — `mode` selects the pipeline (see `NAFLoadMode`).
// Default is `.behavior()` (full smart-avatar pipeline, no silhouette).
public func loadAvatar(
_ avatar: NAFAvatar,
mode: NAFLoadMode = .behavior(),
lods: [Int] = [0]
) async throws

public func unloadAvatar()

// WARNING: Equipping wearables is not currently functional
// Wearables
public func equip(_ asset: NAFAssetRef, lod: Int = 0,
trackingID: UUID? = nil) async throws
public func unequip(assetId: String)
public var equippedAssetIds: [String]

// Per-frame — drive a `NAFAvatarRenderer` in one call:
// session.update(deltaTime: dt)
// session.uploadAnimationState(to: meshRenderer)
public func update(deltaTime: Float)
public func uploadAnimationState(to renderer: NAFAvatarRenderer)
public func boneMatrices(forMeshAt meshIndex: Int) -> Data?
public func morphTargetWeights(forMeshAt meshIndex: Int) -> Data?
public var bonePositions: Data?

// WARNING: Behavior weights are not currently functional
// Behaviour weights (0…1) — set on the session, applied to the controller.
@Published public var talkingWeight: Float
@Published public var breathWeight: Float
@Published public var lookWeight: Float

// Look-at hooks (BYO renderer only — NAFView wires these itself).
public var cameraPositionProvider: (() -> simd_float3)?
public var avatarModelTransform: simd_float4x4

// WARNING: Playing gestures and emotions are not currently functional
// Playback
public func playGesture(tag: String)
public func playEmotion(tag: String, intensity: Int)

// State (observe via callbacks for change events)
public private(set) var isLoading: Bool
public private(set) var isAvatarLoaded: Bool
public private(set) var childMeshes: [NAFMeshData]
public private(set) var boneParentIndices: [Int]
public private(set) var gestureTags: [String]
public private(set) var emotionTags: [String]
public private(set) var isSilhouetteActive: Bool // true between silhouette landing and first-LOD swap
public private(set) var silhouetteTint: SIMD4<Float>? // requested via `silhouetteTint:`; reset on first LOD

// Callbacks (main actor)
public var onMeshesRebuilt: (([NAFMeshData]) -> Void)?
public var onLodUpgrade: ((NAFLodUpgrade) -> Void)? // event.lod, event.assetIds
public var onError: ((NSError) -> Void)? // superset of `throws`
}

The same call can be repeated to swap avatars — the previous one is torn down. Errors are thrown from the call and published to onError (which also catches background failures the caller never awaited).

Picking the right NAFLoadMode

public enum NAFLoadMode {
case staticPose
case animated(silhouette: Bool = false)
case behavior(silhouette: Bool = false)
}
ModeWhat it doesWhen to use
.staticPoseRenders the avatar's first asset in T-pose. No animation, no behavior, no silhouette.Thumbnails, previewers, or any case where you'll drive the controller yourself. Caller manages visibility / any later animation attachment. Single-asset only — multi-asset definitions are not composed in this mode.
.animated(silhouette:)Loads with NAFDefaultAssets.idle bound. SDK gates the first frame on the idle being bound — no T-pose flash. silhouette: true previews a silhouette during the gate. The avatar's slug is intentionally ignored — pick this when you want idle motion without the smart-avatar behavior graph.Idle-only loops. Single avatar with default idle, no talking / breathing / behavior.
.behavior(silhouette:)Full pipeline: silhouette → progressive multi-asset load → default idle / talking / breathing (NAFDefaultAssets.*) → behavior graph (keyed on avatar.slug) → background per-asset LOD upgrades for every later entry in lods. A slugless avatar gracefully degrades to idle-only.The default mode. Required to use playGesture(tag:) / playEmotion(tag:), and the only mode that supports multi-asset definitions.
tip

To change which clips get bound across every load, override NAFDefaultAssets.idle / .talking / .breathing once at app startup.

NAFAssetRef, NAFBehaviorConfig, NAFLodUpgrade, NAFDefaultAssets

public struct NAFAssetRef: Equatable, Sendable {
public let assetId: String
public let version: String // default "1"
public init(assetId: String, version: String = "1")
}

// WARNING: NAFBehaviorConfig is currently not functional
public struct NAFBehaviorConfig: Equatable, Sendable {
public let slug: String // smart-avatar slug, required
public let talking: NAFAssetRef? // nil → no talking layer
public let breathing: NAFAssetRef? // nil → no breathing layer
public let useRightHanded: Bool // default false
public init(slug: String,
talking: NAFAssetRef? = nil,
breathing: NAFAssetRef? = nil,
useRightHanded: Bool = false)
}

/// One LOD step
public struct NAFLodUpgrade: Equatable, Sendable {
public let lod: Int
public let assetIds: [String]
}

/// Default animation clips that ship with the SDK. Reference these when
/// building a `NAFBehaviorConfig`, or override at app startup to point
/// at your own clips:
///
/// NAFDefaultAssets.talking = NAFAssetRef(assetId: "myCustomTalking")

// WARNING: NAFDefaultAssets is currently not functional
public enum NAFDefaultAssets {
public static var idle: NAFAssetRef
public static var talking: NAFAssetRef
public static var breathing: NAFAssetRef
}

NAFAvatarRenderer — protocol the BYO renderer conforms to

@MainActor
public protocol NAFAvatarRenderer: AnyObject {
func updateBoneMatrices(for meshIndex: Int, matrices: NSData)
func updateMorphWeights(for meshIndex: Int, weights: NSData)
}

Conform once and let NAFAvatarSession.uploadAnimationState(to:) do the per-frame pump for you (bone matrices + morph weights, all meshes, one call). For multi-avatar scenes call it once per session per frame.

The mesh data side has two Metal helpers (NAFKitUITools) that turn NAFMeshData.morphTargets into the GPU buffers your shader needs:

public extension NAFMeshData {
/// Packs every morph target's position deltas into a single buffer
/// indexed as `morphIdx * vertexCount + vid`. Returns a placeholder
/// (16 bytes) when the mesh has no morph targets, so your bind path
/// stays branch-free.
func makeMorphPositionDeltaBuffer(device: MTLDevice) -> MTLBuffer?

/// Per-frame weights buffer (`morphTargetCount` floats, zeroed).
/// Returns a placeholder when the mesh has no morph targets.
func makeMorphWeightsBuffer(device: MTLDevice) -> MTLBuffer?
}

Lighting

public struct SceneLighting: Equatable, Sendable {
public var ambient: AmbientLight
public var directionals: [DirectionalLight]
public static let standard: SceneLighting // 1 ambient + 1 directional at (5, 5, 5)
}

public struct AmbientLight: Equatable, Sendable {
public var color: SIMD3<Float> // default white
public var intensity: Float // default 0.3
}

public struct DirectionalLight: Equatable, Sendable {
public var color: SIMD3<Float>
public var intensity: Float
public var direction: SIMD3<Float> // surface-to-light, world space
}

Assign to view.lighting; the change applies on the next frame.

Tone mapping

By default, NAFView shades into an rgba16Float HDR target and runs a linear tone-map pass into the drawable. Set toneMapping.mode = .acesFilmic for the filmic curve (was the default before 0.1.23 — flipped to match the cartoon pipeline).

If your content stays in SDR range — flat-lit toon avatars, thumbnail grids, lock-screen-style cells — set view.hdrEnabled = false. The view then renders directly to the drawable in a single pass: no HDR allocation, no tone-map. toneMapping is ignored in this mode.

view.hdrEnabled = false                                   // single-pass SDR
view.toneMapping = ToneMappingSettings(mode: .acesFilmic) // opt in to ACES

Anti-aliasing

NAFView.sampleCount toggles MSAA on the scene pass. 1 disables it; 2 / 4 / 8 enable.

view.sampleCount = 1   // off
view.sampleCount = 4 // default
view.sampleCount = 8 // max quality

Also routed through NAFShaderManager.shared.setSampleCount(_:) so a settings sheet that already speaks to the shader manager can drive AA from the same place.

Power and thermal knobs

// Drawable scale. Lower than 1.0 = smaller MTKView drawable → fewer
// fragments. Clamped to [0.25, 1.0].
view.renderScale = 1.0 // default
view.renderScale = 0.5 // 4× fewer fragments

// Render only when state changes. Display link gates on
// `setNeedsDisplay()`. A T-posed avatar with no behaviour weights
// produces zero GPU work between user inputs.
view.rendersOnDemand = true

// Auto-modulate FPS based on animation state. Active tier on motion or
// recent camera input; idle tier after 0.5s of nothing happening.
view.adaptiveFrameRate = true
view.adaptiveActiveFPS = 60 // default
view.adaptiveIdleFPS = 24 // default

preferredFramesPerSecond acts as a ceiling under adaptiveFrameRate — the active tier is min(adaptiveActiveFPS, preferredFramesPerSecond). So setting preferredFramesPerSecond = 30 and adaptiveFrameRate = true gives you 30 (active) / 24 (idle), not 60 / 24.

If you drive render-affecting state the SDK doesn't own (custom camera path, externally-driven blendshapes, etc.) call view.setNeedsRedraw() after the mutation so rendersOnDemand knows to draw the next frame.

public enum ToneMapMode: UInt32, Sendable {
case linear = 0
case acesFilmic = 1
}

public struct ToneMappingSettings: Equatable, Sendable {
public var exposure: Float // linear multiplier before the curve
public var mode: ToneMapMode // default .linear
}

Colour-grading LUT

3D LUT applied in toon fragments after tone-map, before alpha output. Three loader overloads:

try view.setColorGradingLUT(cubeFile: bundleURL)            // Adobe .cube
try view.setColorGradingLUT(image: stripImage) // side² × side strip PNG
view.setColorGradingLUT(myTexture) // raw 3D MTLTexture

view.colorGradingLUTStrength = 0.0 // toggle off without unbinding
view.colorGradingLUTStrength = 1.0 // full LUT

ColorGradingLUTLoader.makeCubeFileTexture(url:device:) and .makeStripLayoutTexture(image:device:) are public if you want to bake the texture yourself.

Post-process outline

view.postOutline.enabled       = true
view.postOutline.depthThreshold = 0.1
view.postOutline.thickness = 1.0
view.postOutline.color = SIMD3<Float>(0, 0, 0)
view.postOutline.cameraNear = 0.01
view.postOutline.cameraFar = 100.0

Auto-driven from preset ppOutline* keys via apply(preset:).

NAFShaderManager — toon + outline shader control

Singleton entry point for the bundled toon material + inverted-hull outline pass.

note

NAFShaderManager is an internal singleton — most consumers should use the NAFView convenience methods (apply(_:), applyDefaultToonPreset(), disableToonShader()) instead of  driving it directly. The reference below is for advanced use cases like custom multi-avatar management or fine-grained per-mesh control.

public final class NAFShaderManager {
public static let shared: NAFShaderManager

// Multi-avatar lifecycle
public func attachAvatar(id: NAFShaderAvatarID, view: NAFView)
public func detachAvatar(id: NAFShaderAvatarID)
public func setActiveAvatar(id: NAFShaderAvatarID?)

// Per-mesh scoping (toon + outline together)
public func setActiveMesh(_ meshName: String?) // nil → all meshes
public var meshNames: [String] // from NAFMeshData.name

// Toon
public func setToonEnabled(_ enabled: Bool)
public var isToonEnabled: Bool
public var toonUniformNames: [String]
public func setToonFloat(_ name: String, _ value: Float) -> Bool
public func setToonVec3(_ name: String, _ value: SIMD3<Float>) -> Bool
public func setToonVec4(_ name: String, _ value: SIMD4<Float>) -> Bool
public func getToonFloat(_ name: String) -> Float?
public func getToonVec3(_ name: String) -> SIMD3<Float>?
public func getToonVec4(_ name: String) -> SIMD4<Float>?

// Outline (inverted-hull pass)
public var outline: OutlineNamespace { get }

// Snapshots — flat canonical state for UI sync / export
public func toonParams(forMesh meshName: String?) -> [String: ToonPreset.ParamValue]
public func outlineParams(forMesh meshName: String?) -> [String: ToonPreset.ParamValue]

// Preset application (single-pass; toon + outline + per-mesh together)
public func apply(_ preset: ToonPreset)
public func applyShaderPreset(_ preset: ToonPreset)
public func applyShaderPreset(json: String) -> Bool

// Anti-aliasing on the active avatar's NAFView. 1 = off; 2/4/8 = MSAA.
public var sampleCount: Int?
public func setSampleCount(_ count: Int) -> Bool
}

public struct OutlineNamespace {
public func setEnabled(_ enabled: Bool)
public var isEnabled: Bool
public func setActiveMesh(_ meshName: String?)
public func setThickness(_ value: Float)
public func setColor(_ rgb: SIMD3<Float>)
public func setUseAlbedo(_ on: Bool)
public func setConstantWidth(_ on: Bool)
public func setDepthBias(_ value: Float)
public func getThickness() -> Float?
public func getDepthBias() -> Float?
}

public typealias NAFShaderAvatarID = AnyHashable

toonUniformNames enumerates every name accepted by the typed setters — the API is self-describing.

Per-mesh shader overrides

Both toon and outline effects scope to one mesh via setActiveMesh. While active, every subsequent setter writes only to that mesh's per-mesh material; reverting to nil writes to base + every existing per-mesh override (matches WebGL).

mgr.setActiveMesh("avatar_body_geo")
mgr.setToonFloat("midShadowCoverage", 0.68)
mgr.outline.setThickness(0.003)
mgr.setActiveMesh(nil) // back to "all meshes"

In preset JSON, the same is expressed under a multiMesh block:

{
"name": "multiMaterial",
"toonParams": {
"cartoonMix": 1,
"shadowColor": "#ff7f7f",
"multiMesh": {
"avatar_body_geo": { "midShadowCoverage": 0.68, "useRoughness": 0 },
"avatar_eggshell_geo": { "shadowColor": "#ff4900", "highlightAlpha": 0 }
}
}
}

Per-mesh overrides merge with the top-level params in a single-pass walk — every material is written once with its final merged state, no transient wrong-state.

cartoonAzimuth + cartoonElevation are derived to cartoonLightDirection per scope. A per-mesh override that specifies only one of them falls back to the effect's stored base value for the other.

Unmatched per-mesh keys are logged with the available mesh names list.

Toon-preset extensions on NAFView

NAFKitUI wraps the singleton above with view-level convenience: attach + activate + enable + push parameters in one call. ToonPreset and ToonPresetLoader live in NAFKitUITools so BYO consumers can read presets without depending on NAFShaderManager.

@MainActor
public extension NAFView {
/// Attach, set active, enable the toon effect, and push every param.
/// Idempotent across reloads.
func apply(_ preset: ToonPreset)

/// Load NAFKitConfig's bundled default preset and apply it. No-op
/// when the bundled preset is missing.
func applyDefaultToonPreset()

/// Register with NAFShaderManager without enabling the effect.
func attachToonShader()

/// Disable the toon shader globally.
func disableToonShader()
}

NAFKitUITools

Optional helpers for the bring-your-own-renderer path. Drop in alongside NAFKit when you want the SDK's orbit camera, AABB framing, and debug overlays without writing them yourself. Every type is public Swift, and the same math runs inside NAFView, so behavior stays identical across the integrated and custom paths.

import NAFKitUITools

FrameContext and SceneMath

Shared per-frame camera state plus the projection / view / normal-matrix helpers. Compute one FrameContext per render pass and hand it to every renderer that draws into the pass — guarantees the mesh and the overlays can't disagree on view/projection.

public struct FrameContext {
public let view: simd_float4x4
public let viewProjection: simd_float4x4
public let aspect: Float
public init(view: simd_float4x4, viewProjection: simd_float4x4, aspect: Float)
}

public enum SceneMath {
public static let fovY: Float // π / 4 — matches CameraControls.frame
public static func perspective(fovY: Float, aspect: Float, near: Float, far: Float) -> simd_float4x4
public static func normalMatrix(from model: simd_float4x4) -> simd_float3x3
public static func lookAt(eye: simd_float3, center: simd_float3, up: simd_float3) -> simd_float4x4
}

CameraControls

Orbit camera with one-finger rotate, two-finger pan, and pinch zoom. Forward UIKit gestures with handlePan(_:) / handlePinch(_:); read viewMatrix per frame.

public final class CameraControls {
public init()

public var target: simd_float3 // orbit target, default .zero
public var distance: Float // default 3.0
public var yaw: Float, pitch: Float

public var minDistance: Float, maxDistance: Float
public var minPitch: Float, maxPitch: Float
public var rotateSensitivity: Float, panSensitivity: Float

public var viewMatrix: simd_float4x4 { get }
public var eyePosition: simd_float3 { get }

/// Auto-frame the AABB so it fits the current FOV + aspect (10% padding).
/// Use the same fovY constant your projection uses.
public func frame(minBound: simd_float3,
maxBound: simd_float3,
fovY: Float,
aspect: Float)

public func handlePan(_ gesture: UIPanGestureRecognizer)
public func handlePinch(_ gesture: UIPinchGestureRecognizer)
}

AvatarBounds

Mesh-space AABB helpers for handing bounds to CameraControls.frame(...) (and to BoundingBoxDebugRenderer.update). Compute the combined AABB yourself by reducing session.childMeshes over minBound / maxBound.

public enum AvatarBounds {
/// AABB of the 8 corners after applying a model transform. Use this
/// when your renderer applies a coordinate fix (e.g. Z-up → Y-up) so
/// the camera frames the post-transform space.
public static func transformed(min: simd_float3,
max: simd_float3,
by m: simd_float4x4) -> (min: simd_float3, max: simd_float3)
}

Two line-list renderers that draw into your existing render pass. Both share a process-wide pipeline cache; both take the same color/depth pixel formats as the pass they'll draw into. Render after your mesh draw so the overlay lines sit on top.

public final class SkeletonDebugRenderer {
public init(device: MTLDevice,
colorPixelFormat: MTLPixelFormat,
depthPixelFormat: MTLPixelFormat)

public var isEnabled: Bool // default false — cheap to flip
public var modelTransform: simd_float4x4 // match your mesh renderer
public var lineColor: SIMD4<Float> // default green

/// Pass `session.bonePositions` and `session.boneParentIndices`
/// after `session.update(deltaTime:)`.
public func update(bonePositions: NSData, parentIndices: [Int])

public func render(encoder: MTLRenderCommandEncoder, frame: FrameContext)
}

public final class BoundingBoxDebugRenderer {
public init(device: MTLDevice,
colorPixelFormat: MTLPixelFormat,
depthPixelFormat: MTLPixelFormat)

public var isEnabled: Bool
public var modelTransform: simd_float4x4
public var lineColor: SIMD4<Float> // default magenta

/// Pass the same min/max you fed `CameraControls.frame(...)` so what's
/// drawn is what was framed.
public func update(minBound: simd_float3, maxBound: simd_float3)
public func clear()

public func render(encoder: MTLRenderCommandEncoder, frame: FrameContext)
}

Picking and gizmo primitives

The same picking and gizmo math that backs the NAFView turnkey path is exposed as standalone Swift types — use them directly when you want a different interaction model (long-press, marquee select, custom gizmo overlay, etc.).

PickHit and PickScope

public enum PickScope { case avatar, mesh }

public struct PickHit {
public let scope: PickScope
public let meshIndex: Int? // populated when scope == .mesh
public let worldPoint: simd_float3
public let distance: Float // ray distance from camera
public init(scope: PickScope, meshIndex: Int?,
worldPoint: simd_float3, distance: Float)
}

AvatarPicker

Stateless screen-point → world-space ray helpers plus a closest-candidate picker. Build a [AvatarPickCandidate] from your own scene state and call pick(...) on every tap.

public enum AvatarPickPrecision {
case bounds // ray vs world-space AABB; constant time per candidate
case mesh // ray vs CPU-skinned triangles; reserve for tap, not hover
}

public struct AvatarPickCandidate {
public let id: AnyHashable // returned in the result
public let modelTransform: simd_float4x4 // mesh-local → world
public let meshSpaceBounds: (min: simd_float3, max: simd_float3)? // required for .bounds
public let submeshes: [Submesh] // required for .mesh

public struct Submesh {
public let mesh: NAFMeshData
public let boneMatrices: Data // session.boneMatrices(forMeshAt:)
}
}

public struct AvatarPickResult {
public let id: AnyHashable
public let worldPoint: simd_float3
public let distance: Float
}

public enum AvatarPicker {
public static func ray(screenPoint: CGPoint,
viewportSize: CGSize,
frame: FrameContext)
-> (origin: simd_float3, direction: simd_float3)

public static func pick(screenPoint: CGPoint,
viewportSize: CGSize,
frame: FrameContext,
candidates: [AvatarPickCandidate],
precision: AvatarPickPrecision) -> AvatarPickResult?
}

TransformGizmo

Translate-only X/Y/Z gizmo. Pure math — drives a center: simd_float3 from drag input. No GPU state, no UIKit. Pair with your own overlay (a CAShapeLayer, a MTKView line renderer, SwiftUI Path, …) for the actual axis lines.

public final class TransformGizmo {
public enum Axis: Int, CaseIterable {
case x, y, z
public var direction: simd_float3
public var color: SIMD4<Float>
}

public struct DragState {
public let axis: Axis
public let pivot: simd_float3
public let anchorOnAxis: Float
}

public var center: simd_float3 // updated by updateDrag
public var axisLength: Float // default 0.3
public var hitWidthPx: Float // pointer slop in pixels, default 18
public var isVisible: Bool

public init()

public func beginDrag(screenPoint: CGPoint,
viewportSize: CGSize,
frame: FrameContext) -> DragState?

@discardableResult
public func updateDrag(_ state: DragState,
screenPoint: CGPoint,
viewportSize: CGSize,
frame: FrameContext) -> simd_float3
}

End-to-end shape (bring your own renderer)

import NAFKit
import NAFKitUITools

let renderer = NAFRenderer(device: nil)
let camera = CameraControls()
let skeleton = SkeletonDebugRenderer(device: renderer.device,
colorPixelFormat: renderer.colorPixelFormat,
depthPixelFormat: renderer.depthStencilPixelFormat)
let bbox = BoundingBoxDebugRenderer(device: renderer.device,
colorPixelFormat: renderer.colorPixelFormat,
depthPixelFormat: renderer.depthStencilPixelFormat)

renderer.onRender = { [weak self] encoder in
guard let self else { return }
let size = renderer.drawableSize
let aspect = Float(size.width / max(size.height, 1))
let view = camera.viewMatrix
let proj = SceneMath.perspective(fovY: SceneMath.fovY, aspect: aspect,
near: 0.01, far: 100.0)
let frame = FrameContext(view: view, viewProjection: proj * view, aspect: aspect)

self.myMeshRenderer.render(encoder: encoder, context: frame)
skeleton.render(encoder: encoder, frame: frame)
bbox.render(encoder: encoder, frame: frame)
}

// After each (re)build — compute bounds from the meshes the session
// just published (via `onMeshesRebuilt` or `session.childMeshes`):
let meshes = session.childMeshes
if !meshes.isEmpty {
let lo = meshes.map(\.minBound).reduce(meshes[0].minBound, simd_min)
let hi = meshes.map(\.maxBound).reduce(meshes[0].maxBound, simd_max)
camera.frame(minBound: lo, maxBound: hi,
fovY: SceneMath.fovY, aspect: aspect)
bbox.update(minBound: lo, maxBound: hi)
}

renderer.onRender retains its closure — capture self and any helpers you own with [weak self] to avoid a self → renderer → closure → self cycle.


NAFKit — supporting types

Objective-C interface used by NAFKitUI and NAFKitUITools. The types here surface back to consumers as values (returned from NAFAvatarSession) or as small handles passed into the Swift APIs above — you don't drive this layer directly.

#import <NAFKit/NAFKit.h>

NAFRenderer — built-in MTKView driver

A managed Metal render loop with onUpdate / onRender block hooks. Used by the NAFAvatarSession BYO path; bridge into your existing renderer if you have one.

- (instancetype)initWithDevice:(nullable id<MTLDevice>)device;       // nil → system default
- (void)configureView:(MTKView *)view;

@property (nonatomic, readonly) id<MTLDevice> device;
@property (nonatomic, readonly) id<MTLCommandQueue> commandQueue;
@property (nonatomic, readonly) CGSize drawableSize; // updated each frame

@property (nonatomic, copy) void (^onUpdate)(NSTimeInterval deltaTime);
@property (nonatomic, copy) void (^onRender)(id<MTLRenderCommandEncoder> encoder);

@property (nonatomic, assign) MTLPixelFormat colorPixelFormat; // default BGRA8Unorm_sRGB
@property (nonatomic, assign) MTLPixelFormat depthStencilPixelFormat; // default Depth32Float
@property (nonatomic, assign) MTLClearColor clearColor; // default dark gray
@property (nonatomic, assign) NSInteger preferredFramesPerSecond; // default 60

When pairing with NAFKitUITools' debug overlays, construct each overlay with the same colorPixelFormat and depthStencilPixelFormat as the renderer — pipeline objects must match the render pass attachments.

NAFContainerApi — shared cache handle

Owns the resolver and the process-wide asset caches. Construct one and pass it into multiple NAFViews (or NAFAvatarSessions) when you want them to share a single cache — see the gallery / grid samples in the sandbox. The async loader methods on this class are driven internally by NAFAvatarSession; you don't call them directly.

- (instancetype)initWithResolverConfig:(NSString *)config;

// Cache management (process-wide)
+ (void)clearAllCaches;

NAFAvatar

Typed avatar: a base rig plus zero or more wearables, optionally with shape-attribute weights and a smart-avatar slug. Build one with the JSON convenience or compose it programmatically from NAFAssetEntrys; pass it into loadAvatar with the NAFLoadMode that matches the use case. LOD lives on the load call (lods:), not in the avatar — the same NAFAvatar can be loaded at different LODs.

@interface NAFAssetEntry : NSObject
@property (nonatomic, copy, readonly) NSString *assetId;
@property (nonatomic, copy, readonly) NSString *version;

- (instancetype)initWithAssetId:(NSString *)assetId
version:(NSString *)version;
@end

@interface NAFAvatar : NSObject
@property (nonatomic, copy, readonly) NSArray<NAFAssetEntry *> *assets;
@property (nonatomic, copy, readonly) NSDictionary<NSString *, NSNumber *> *shapeAttributes;
@property (nonatomic, copy, readonly, nullable) NSString *slug; // smart-avatar behavior slug

- (instancetype)initWithAssets:(NSArray<NAFAssetEntry *> *)assets
shapeAttributes:(nullable NSDictionary<NSString *, NSNumber *> *)shapeAttributes
slug:(nullable NSString *)slug;

- (instancetype)initWithAssets:(NSArray<NAFAssetEntry *> *)assets
shapeAttributes:(nullable NSDictionary<NSString *, NSNumber *> *)shapeAttributes;

+ (nullable instancetype)avatarFromJSON:(NSString *)jsonString
error:(NSError **)error;
@end

JSON shape:

{
"slug": "brock",
"assets": [
{ "id": "SubSpecies/...", "version": "2" }
],
"shapeAttributes": { }
}

NAFAvatarController

Drives animation, gaze, blink, and behavior on a loaded avatar. NAFAvatarSession owns one per loaded avatar and exposes it as session.avatarController (read-only). Most consumers don't construct one directly — loadAvatar(_:mode:lods:) wires it up. Reach for the controller when you need imperative control beyond what NAFView / NAFAvatarSession already surface (gestures, emotions, blink, look-at, idle scrubbing).

Idle / talking / breathing / emotions

// Idle
- (void)setIdle:(NAFAnimationClip *)clip;
@property (nonatomic, assign) float idleTime; // seek
@property (nonatomic, readonly) float idleWeight;

// Talking — typed properties, not setter-only methods
@property (nonatomic, strong, nullable) NAFAnimationClip *talkingClip;
@property (nonatomic, assign) float talkingWeight; // 0–1
@property (nonatomic, assign, getter=isTalkingEnabled) BOOL talkingEnabled;

// Breath
- (void)setBreath:(NAFAnimationClip *)clip;
- (void)setBreathWeight:(float)weight speedMultiplier:(float)speedMultiplier;

// Emotions
- (void)setEmotionMin:(NAFAnimationClip *)clipMin max:(NAFAnimationClip *)clipMax;
- (void)setEmotionWeight:(float)weight intensity:(float)intensity;
- (void)playEmotion:(NSString *)emotionTag intensity:(NSInteger)intensity;
- (void)setBaseEmotion:(NAFAnimationClip *)clip;
@property (nonatomic, readonly, nullable) NSString *currentEmotion;

For .behavior(...) loads, the SDK populates idle / talking / breathing / emotion clips from NAFDefaultAssets. Override the statics at app startup to swap the default clips globally.

Gestures and boredom

- (void)playGestureWithTag:(NSString *)gestureTag
completion:(nullable void (^)(void))completion;
@property (nonatomic, readonly) float gestureWeight;
@property (nonatomic, readonly) float specialGestureWeight;

@property (nonatomic, readonly) float boredomWeight;
@property (nonatomic, readonly, nullable) NSString *currentBoredGUID;
@property (nonatomic, assign, getter=isAutoBoredomEnabled) BOOL autoBoredomEnabled;

// Tags from the loaded smart-avatar behavior. Nil until a behavior loads.
@property (nonatomic, readonly, nullable) NSArray<NSString *> *gestureTags;
@property (nonatomic, readonly, nullable) NSArray<NSString *> *emotionTags;
- (void)setLookWeight:(float)weight;                    // 0–1
- (void)setLookAtCameraPosition:(simd_float3)cameraPos
headPosition:(simd_float3)headPos;
- (void)setLookAtTargetPosition:(simd_float3)target
eyeTargetPosition:(simd_float3)eyeTarget;
- (void)setAttentionMode:(NAFAttentionMode)mode blendTime:(float)blendTime;

@property (nonatomic, readonly) simd_float3 headPosition; // mesh-local
@property (nonatomic, readonly) BOOL hasHeadBone;

// Procedural blink. Default YES. The .staticPose load mode disables it
// automatically so a static avatar holds its eyes open.
@property (nonatomic, assign, getter=isBlinkEnabled) BOOL blinkEnabled;

// Manual blink (overrides procedural while a non-zero weight is held).
- (void)setEyeBlinkWeight:(float)weight leftEye:(float)leftEye rightEye:(float)rightEye;

@property (nonatomic, assign, getter=isFaceScanEnabled) BOOL faceScanEnabled;

NAFAttentionMode covers Listening, Speaking, Bored, Thinking, Angry, Custom.

Voice animation

- (void)playVoiceAnimation:(NSArray<NSNumber *> *)blendshapeValues gain:(float)gain;
- (void)stopVoiceAnimation;
@property (nonatomic, readonly) float voiceAnimationWeight;
@property (nonatomic, assign) float voiceAnimationTime; // seek

Skeleton + morph readback (BYO-renderer path)

@property (nonatomic, readonly) NSUInteger boneCount;
@property (nonatomic, readonly, nullable) NSArray<NSNumber *> *boneParentIndices;
@property (nonatomic, readonly, nullable) NSArray<NSString *> *boneNames;
@property (nonatomic, readonly, nullable) NSArray<NSData *> *jointMappings;

- (nullable NSData *)boneMatricesWithIBMs:(NSData *)inverseBindMatrices
jointMapping:(NSData *)jointMapping;
- (nullable NSData *)boneWorldPositions;

@property (nonatomic, readonly, nullable) NSData *morphTargetWeights;
@property (nonatomic, readonly) NSArray<NSString *> *morphTargetNames;

Bone-matrix and bone-position buffers are zero-copy onto a reusable scratch — only valid until the next call. Memcpy into your own GPU buffer on the render path. Most consumers reach this surface via NAFAvatarSession.boneMatrices(forMeshAt:) / morphTargetWeights(forMeshAt:) rather than the controller directly.

NAFMeshData

Zero-copy view onto a submesh. NSData properties point into C++-owned memory; the source entity is retained for lifetime so the buffers stay alive. Returned from NAFAvatarSession.childMeshes and onMeshesRebuilt.

@property (nonatomic, readonly) NSInteger vertexCount;
@property (nonatomic, readonly) NSInteger indexCount;
@property (nonatomic, readonly) NSData *positions; // float3 packed
@property (nonatomic, readonly) NSData *normals;
@property (nonatomic, readonly) NSData *tangents; // float4, glTF convention (xyz + handedness w)
@property (nonatomic, readonly) NSData *uvs;
@property (nonatomic, readonly) NSData *indices; // uint32
@property (nonatomic, readonly) NSData *jointIndices; // uint16 × 4 per vertex
@property (nonatomic, readonly) NSData *jointWeights; // float × 4 per vertex
@property (nonatomic, readonly) simd_float3 minBound;
@property (nonatomic, readonly) simd_float3 maxBound;
@property (nonatomic, readonly) NSArray<NAFMeshPrimitive *> *primitives;

NAFMeshPrimitive / NAFMaterial / NAFTexture

@interface NAFMeshPrimitive
@property (nonatomic, readonly) NSInteger indexOffset;
@property (nonatomic, readonly) NSInteger indexCount;
@property (nonatomic, readonly) NAFMaterial *material;
@end

@interface NAFMaterial
@property (nonatomic, readonly) NSString *name;
@property (nonatomic, readonly) NSString *model; // e.g. "UnityURP"
@property (nonatomic, readonly) NAFTexture *baseColorTexture;
@property (nonatomic, readonly) NAFTexture *normalTexture;
@property (nonatomic, readonly) NAFTexture *metallicSmoothnessTexture; // R=metallic, A=smoothness
@property (nonatomic, readonly) NAFTexture *aoTexture;
@property (nonatomic, readonly) NAFTexture *emissionTexture;
@property (nonatomic, readonly) simd_float4 baseColorFactor;
@property (nonatomic, readonly) float metallicFactor;
@property (nonatomic, readonly) float smoothness;
@property (nonatomic, readonly) BOOL isTransparent;
@property (nonatomic, readonly) BOOL isDoubleSided;
@end

@interface NAFTexture
@property (nonatomic, readonly) NSString *name;
@property (nonatomic, readonly) NSInteger width, height;
@property (nonatomic, readonly) id<MTLTexture> metalTexture; // do not retain past NAFTexture lifetime
@end

NAFEventBus — events

NAFAvatarSession's onMeshesRebuilt / onLodUpgrade / onError callbacks cover the common cases. For broader observation (asset lifecycle, gesture/emotion start-end, idle/talking transitions, errors from any subsystem) subscribe to the shared NAFEventBus. Subscriptions are held weakly via the returned token — keep it alive (or store in a Set<NAFEventSubscription>) to keep receiving events; release the token or call cancel() to unsubscribe.

@interface NAFEventBus : NSObject
+ (NAFEventBus *)shared;
- (NAFEventSubscription *)subscribe:(NAFEventType)type
handler:(void(^)(NAFEvent *))handler; // main queue
- (NAFEventSubscription *)subscribe:(NAFEventType)type
queue:(dispatch_queue_t)queue
handler:(void(^)(NAFEvent *))handler;
@end

@interface NAFEvent : NSObject
@property (nonatomic, readonly) NAFEventType type;
@property (nonatomic, readonly, nullable) NSDictionary<NSString *, id> *userInfo;
@end

Event types:

CodeTypeuserInfo
100NAFEventAssetLoadStartedassetId, trackingID
101NAFEventAssetLoadCompletedassetId, trackingID
102NAFEventAssetLoadFailedassetId, error, trackingID
200NAFEventAvatarReady
300 / 301NAFEventGestureStarted / Endedgesture tag
302NAFEventEmotionAppliedtag, intensity
303 / 304NAFEventBoredomStarted / Ended
305 / 306NAFEventVoiceAnimStarted / Stopped
400NAFEventIdleEntered
401 / 402NAFEventTalkingStarted / Stopped
900NAFEventErrorerror, uri, kind, trackingID

NAFEventBridge — string-keyed event bridge

Lower-level access to the unified C++ event bus (GnUtils::EventBus). Use this when you need to observe native events that don't have a typed NAFEventBus entry, or to fire events into the bus from Swift/Obj-C. Subscriptions are RAII — keep the returned token alive (release it or call cancel to unsubscribe).

@interface NAFEventBridge : NSObject
+ (NAFEventBridge *)shared;

// Caller-thread delivery
- (NAFEventBridgeSubscription *)subscribe:(NSString *)eventName
handler:(void(^)(NSDictionary<NSString *, id> *payload))handler;

// Async dispatch onto `queue`
- (NAFEventBridgeSubscription *)subscribe:(NSString *)eventName
queue:(dispatch_queue_t)queue
handler:(void(^)(NSDictionary<NSString *, id> *payload))handler;

// Wildcard — every event name
- (NAFEventBridgeSubscription *)subscribeAll:(void(^)(NSString *eventName,
NSDictionary<NSString *, id> *payload))handler;

- (void)fire:(NSString *)eventName payload:(nullable NSDictionary<NSString *, id> *)payload;

- (void)sendRequest:(NSString *)eventName
payload:(nullable NSDictionary<NSString *, id> *)payload
timeout:(NSTimeInterval)timeoutSeconds
completion:(void(^)(NAFEventResponse *response))completion;
@end

Payload values map between NSString / NSNumber / NSArray / NSNull and the C++ EventData variants. Numeric arrays are int64 unless any element's objCType is f/d (float/double). NAFEventBridge and the typed NAFEventBus coexist — pick whichever matches the granularity you need.

NAFError

Errors throughout NAFKit use NAFErrorDomain. Codes are grouped by subsystem:

RangeSubsystem
100–199Asset / loading
200–299Avatar / controller
300–399Animation
400–499Behavior graph
500–599Metal / GPU
900–999Native exceptions

userInfo keys: NAFErrorURIKey, NAFErrorKindKey, NAFErrorTrackingIDKey, NAFErrorNativeExceptionKey. Pass the same NSUUID as trackingID into the NAFAvatarSession load methods and you'll see it on every related event and error — useful for tracing one logical request through async fan-out.