Documentation · Concepts

From a .glb file to these types

GltfLoader.Load(bytes) is one line, and what comes back is an ordinary Scene you could have built by hand. This is which part of the file became which type — worth reading once, because it tells you what survives the trip and what does not. The reader is the library's own, and the table at the end is its contract.

The projection

How each part of a glTF file maps onto Ava3DControl's types A glTF scene becomes a Scene; each node becomes a Node with its transform composed; each mesh primitive becomes a MeshNode and one draw call; the POSITION, NORMAL, TEXCOORD_0, TEXCOORD_1 and TANGENT accessors become a Mesh, with tangents derived when the file omits them; a material becomes a Material; and a texture with its image and sampler becomes a Texture and a TextureWrap. Cameras, lights and the material extensions beyond metallic-roughness are not carried over. In the .glb file In your scene scene the default one, or the first Scene Children, Camera, Light, Environment node a matrix, or T / R / S Node composed transform, hierarchy intact mesh.primitives[i] one entry per material MeshNode one primitive, one node, one draw call POSITION NORMAL TEXCOORD_0,1 TANGENT Mesh tangents derived if the file omits them material pbrMetallicRoughness, and three more Material Metallic, Roughness and five maps texture → image → sampler embedded PNG or JPEG bytes Texture · TextureWrap left encoded until a backend needs it Not carried over cameras · lights · unlit, clearcoat and the other material extensions · Draco and meshopt

Read it as a projection, not a translation: the parts that describe geometry, surfaces and motion come across whole — animations, skins and morph targets arrive on the GltfModel — and the parts that describe how someone else chose to view the model do not come across at all.

Why one primitive becomes one node

A glTF mesh splits into primitives wherever the material changes. Since a draw call can only have one material, that split is already exactly the draw-call boundary — so a primitive becomes a MeshNode and the mapping needs no cleverness. A file with a four-material mesh arrives as four sibling nodes under one parent, which is also what you would get if you had authored it here by hand.

The practical consequence is that the draw-call count in RenderInfo is the number of primitives in your file. If it is higher than you expected, the file has more material splits than you thought, and the fix is in the exporter rather than here.

Tangents, and why the loader generates them

Normal mapping needs a tangent frame, and glTF stores one in an optional TANGENT accessor that most exporters omit unless explicitly asked. A loader that simply honoured the file would therefore ignore the normal map on most models in the wild, and — worse — would do it silently, producing a picture that is merely flat rather than obviously broken.

So when the accessor is missing, Mesh.WithGeneratedTangents(normalUvSet) derives the frame from the positions and UVs: per-triangle tangent and bitangent from the UV derivatives, accumulated per vertex, Gram-Schmidt orthogonalised against the normal, with the handedness recovered into the w component. Degenerate UV triangles — zero area, which a badly unwrapped model has plenty of — are skipped rather than producing a division by zero that would poison the whole vertex.

What the five channels become

pbrMetallicRoughness.baseColorFactor / Texture BaseColor, BaseColorTexture. The factor multiplies the texture, as the spec says.
metallicFactor, roughnessFactor, metallicRoughnessTexture Metallic, Roughness, MetallicRoughnessTexture — blue channel metallic, green channel roughness, exactly as glTF packs it. Roughness clamps to a floor of 0.03: a true zero makes the GGX highlight a single sub-pixel point that aliases into flicker.
normalTexture, scale NormalTexture, NormalScale. Tangent-space, two-channel reconstruction.
emissiveFactor, emissiveTexture EmissiveColor, EmissiveTexture. Added after tone mapping's input, so emissive surfaces stay lit in shadow.
occlusionTexture, strength OcclusionTexture, OcclusionStrength. Red channel, applied to the environment term only — never to the direct light, which is what the spec intends and what stops occlusion maps from looking like dirt.

Per-texture coordinates and transforms

A glTF material can read each of its maps from a different UV set, and move each one with KHR_texture_transform — an occlusion map baked on a second UV set, or a base colour tiled twice as often as its normal map. Each map on a Material therefore has its own TextureMapping: BaseColorMapping, MetallicRoughnessMapping, NormalMapping, EmissiveMapping and OcclusionMapping. UV set 0 is Mesh.TexCoords and set 1 is Mesh.TexCoords1. The loader fills in both, including the transform's texCoord override.

A mapping picks its UV set, then scales, rotates about the origin (glTF's top-left convention) and offsets it. The material's and node's shared UV transform apply after that. A mapping left at its default changes nothing. Two maps may share one image and still use different mappings. Batching.Fold leaves a node unmerged when one of its maps reads the second UV set or has a mapping of its own, because merging would change how its maps line up. A second UV set that no map reads does not stop it.

material.OcclusionMapping = new TextureMapping { TextureCoordinate = 1 };
material.BaseColorMapping = new TextureMapping
{
    Scale = new Vector2(2, 1),
    Rotation = MathF.PI / 8,
    Offset = new Vector2(0.1f, 0)
};

If you load glTF with your own importer — SharpGLTF, say — rather than GltfLoader, none of this happens until your importer does it: copy both coordinate arrays onto the mesh, and build each channel's mapping with a helper like this one. A normal map needs tangents for the UV set it reads, so generate missing ones with mesh.WithGeneratedTangents(material.NormalMapping.TextureCoordinate), or, if the file supplies tangents, set Mesh.TangentTextureCoordinate to that set.

static TextureMapping MappingOf(SharpGLTF.Schema2.MaterialChannel? channel)
{
    if (channel is not { } c) return TextureMapping.Identity;
    var transform = c.TextureTransform;
    return new TextureMapping
    {
        TextureCoordinate = transform?.TextureCoordinateOverride ?? c.TextureCoordinate,
        Scale = transform?.Scale ?? Vector2.One,
        Rotation = transform?.Rotation ?? 0,
        Offset = transform?.Offset ?? Vector2.Zero
    };
}

A map that cannot be drawn as asked is left off rather than drawn with the wrong coordinates, and RenderInfo.MaterialDiagnostics names it. That happens when its UV set is missing from the mesh, when it asks for set 2 or higher, when a transform is not a finite number, and when a mapping is combined with projected coordinates — keep UvSource.Mesh for imported models.

One thing that will bite you: UVs outside 0..1

Nothing in glTF constrains texture coordinates to the unit square, and real models routinely exceed it — Khronos's own DamagedHelmet has a V range of 1.0005 to 1.9987. Whether that tiles or clamps is decided by the sampler's wrap mode, so wrap mode is load-bearing rather than a detail, and TextureWrap is read from the file rather than assumed.

This is subtler than it sounds on OpenGL. Since GL 3.3, a sampler object bound to a texture unit overrides every filtering and wrap parameter set on the texture itself — and Skia leaves its own sampler objects bound when it hands over the context. A renderer that sets wrap mode on the texture and trusts it will therefore be quietly overruled, with no GL error, because GL is faithfully obeying someone else's instructions. The GL backend unbinds the sampler on every unit each frame for exactly this reason.

Multi-file .gltf, and where its files come from

A .glb is one file and loads from its bytes alone. A multi-file .gltf references its buffers and images by URI, relative to wherever the file itself came from — a directory, an archive, a web server — and the reader cannot know which. So it asks an IGltfResourceResolver for each one, and three are built in: a directory on disk, the application's avares:// resources and HTTP, which is how a browser build gets a model at all. Loading by Uri picks the right one from the scheme; loading from bytes takes one in GltfLoadOptions. data: URIs need none.

var asset = await GltfAsset.LoadAsync(new Uri("https://example.org/models/helmet.gltf"));
var fromBytes = GltfAsset.Load(bytes, new GltfLoadOptions { Resolver = new GltfFileResolver(directory) });

A file that cannot be resolved does not fail the load: the buffer or image is recorded as a GltfDiagnostic, everything that needed it is dropped with a diagnostic of its own, and the rest of the model comes through. That tolerance is the default because a model with one missing texture is worth having; GltfLoadOptions.Validate turns the list into a GltfException for a pipeline that would rather stop.

What is read, and what is not

The reader is the library's own, over System.Text.Json's source generator — which is what makes a trimmed or ahead-of-time compiled application carry no reflection for it. This is its contract. Anything in the second column that a file uses is named in GltfAsset.Diagnostics rather than silently left out.

ReadNot read
.glb containers and .gltf JSON, with data: URIs and external files through a resolver glTF 1.0
The default scene, or the first; nodes with a matrix or translation, rotation and scale; names; extras on the file, nodes, meshes and materials Other scenes; cameras; KHR_lights_punctual; node weights as an initial pose
TRIANGLES primitives: POSITION, NORMAL, TANGENT, TEXCOORD_0 and _1, COLOR_0 in every encoding, JOINTS_0/WEIGHTS_0 and _1; indices of any width or none; the authored min/max Points, lines, strips and fans; TEXCOORD_2 and up; KHR_draco_mesh_compression; EXT_meshopt_compression
Every accessor layout: all six component types, normalised integers, interleaved strides, sparse patches (KHR_mesh_quantization follows)
Morph targets' POSITION and NORMAL, named by the targetNames exporters write into a mesh's extras Morph target TANGENT
Metallic-roughness with all five maps and their factors, KHR_texture_transform including its texCoord override, KHR_materials_emissive_strength, alphaMode, alphaCutoff, doubleSided; the diffuse half of KHR_materials_pbrSpecularGlossiness as base colour KHR_materials_unlit, clearcoat, transmission, volume, ior, sheen, specular, iridescence, anisotropy, variants; the specular half of specular-glossiness
PNG and JPEG images, in the file or by URI; samplers' wrap modes; KTX2 through KHR_texture_basisu or as a plain image, in the formats Ktx2Loader takes WebP; Basis Universal payloads, which need a transcoder — the PNG or JPEG a file keeps beside one is used instead; sampler filters
Skins with any number of joints, inverse bind matrices or identity, the skeleton root
Animations of translation, rotation, scale and morph weights; STEP, LINEAR and CUBICSPLINE Channels aimed at a node outside the scene

One file read once is a GltfAsset; every copy on screen is an instance of it, with its own nodes, animator and morph state over the asset's meshes and textures. The animation chapter shows two.