Avatars: the standard XNA avatar API on CNA's own avatars

CNA snapshot c1c316b9  ·  Deep Dives › Input, audio, media & services  ·  source links pinned to c1c316b9

✓

Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit c1c316b9; unless a sentence says otherwise, nothing here was built or executed. Checked by reading the gamer-services, graphics and content sources, docs/avatars.md, docs/gamer-services-known-limitations.md, the avatar tests and the renderer CMake registrations at c1c316b9. The code fragments were not compiled for this page; the visual-review figures are CNA's own records, not re-measured.

CNA implements the XNA 4.0 avatar API, AvatarDescription, AvatarAnimation and AvatarRenderer, with real behaviour on avatars of its own. A ported title that uses the standard calls gets a drawn, animated avatar without any extension call: random or stored descriptions, XNA's 71-bone skeleton, the 31 animation presets, facial expressions and the renderer's light inputs. It is not an Xbox LIVE avatar system: it neither reads nor produces Xbox avatar data or assets, and every mesh, texture and motion is original CNA work. This page gives the exact contract of the standard API, where the avatars come from, how they are drawn, the separate SkinnedModelEXT extension that is often confused with them, and what the evidence does and does not prove; it is for anyone porting an avatar-using XNA title.

The avatar API and SkinnedModelEXT

Two unrelated pieces of CNA deal with skinned characters. The standard avatar API is XNA's; it draws CNA's avatar catalogs on XNA's own rig. SkinnedModelEXT is a CNAEXT extension in the graphics module for games that bring their own skinned characters; it has nothing to do with avatars and is covered further down because older material connected the two.

AspectXNA avatar API (Microsoft::Xna::Framework::GamerServices)SkinnedModelEXT (CNAEXT)
PurposeXNA's avatar classes with real behaviour on CNA's own avatarsLoad and draw a game's own GPU-skinned mesh, skeleton and clips
SkeletonXNA's fixed 71-bone rig (AvatarRenderer::BoneCount)Content-defined, up to SkinnedEffect::MaxBones (72)
ContentAvatar catalogs compiled into CNA_GamerServices; no content files to copyA .skinnedmodel.json manifest and binary sidecars loaded through ContentManager
DrawingAvatarRenderer::Draw, through SkinnedEffect internallyThe game's own SkinnedEffect draw with the computed palette
AvailabilityWhenever networking (and so GamerServices) is builtAlways, in the graphics module

The whole avatar family lives in the GamerServices module, so it exists only when CNA_ENABLE_NET is on (the default); see gamer services internals. Drawing needs a 3D-capable renderer: AvatarRenderer uses SkinnedEffect and vertex buffers, which the 2D-only renderers refuse.

The XNA avatar API on CNA's avatars

On Windows, XNA's avatar classes drew nothing, because the avatar data and assets came from Xbox LIVE. CNA implements the behaviour XNA's documentation describes for the Xbox 360 instead, with avatars that resemble that era in original art (avatars.md). That console behaviour was never traced on a console, so “faithful” here means faithful to the documented contract, pinned by tests, not measured Xbox parity.

AvatarDescription

  • The byte constructor requires exactly 1,021 bytes (ArgumentException, “Resource data must be exactly 1021 bytes.”) and copies them. IsValid keeps XNA's rule: true when byte 0 is non-zero.
  • Inside the buffer CNA stores its own versioned encoding (AvatarDescriptionCodec.cpp): a format byte, the letters CNA, body type, height in millimetres (1,450–2,050), build, the catalog version the item ids refer to, seven colours, the hair, top, bottom, shoe, glasses and hat item ids, and in format 2 a facial-hair item and sixteen face-shape bytes; the rest is zero and the last four bytes are a CRC-32. Height (in metres) and BodyType are read from it.
  • A buffer that is not a CNA description (another format, a damaged checksum, non-zero reserved bytes) keeps XNA's IsValid answer, which looks only at the first byte, but reports height 0 and the female body type, and a renderer given it is Unavailable. In particular, a non-zero Xbox description is not a CNA avatar.
  • CreateRandom() and CreateRandom(AvatarBodyType) return valid descriptions from the newest catalog compiled in, with a random face and, for male bodies, sometimes facial hair; the second overload throws ArgumentOutOfRangeException for an undefined body type. Passing a description's bytes back to the constructor reproduces the same avatar, which is how games share avatars over a NetworkSession the XNA way, with no service involved.
  • BeginGetFromGamer throws for a null or disposed gamer and returns a caller-owned IAsyncResult. For a signed-in local profile it completes at once with the avatar stored with the profile (created at random with the profile). For a gamer with a service identity it completes during GamerServicesDispatcher::Update with the account's stored avatar. A gamer without an avatar or without a service identity (a remote SystemLink gamer), or an unreachable service, yields an invalid all-zero description, so games take their usual “no avatar” path. EndGetFromGamer throws ArgumentException for a result it did not produce.
  • Changed: each signed-in player keeps the description EndGetFromGamer returned until that avatar changes; Update re-reads a kept avatar every 10 seconds and raises Changed on the dispatcher thread when it differs. Because the C++ description is a value, every copy shares one Changed event (AvatarDescription.hpp).

AvatarRenderer

  • Construction and state. Both constructors throw ArgumentNullException for a null description (AvatarRenderer.cpp). A description without an avatar, or one CNA cannot read, is Unavailable and draws nothing. A valid one is Loading while one shared background loader assembles the body, wardrobe and face from the catalog, then Ready; identical descriptions share the assembled model. If loading completes during a Draw, that frame still takes the loading path and State reads Ready afterwards, so a game that sets bone transforms only after it sees Ready never draws an uninitialised pose. On a renderer that cannot create vertex buffers (SDL_RENDERER), every Draw of a ready avatar throws the renderer's refusal, and the next call fails the same way instead of reading a half-built upload. Reading State after Dispose throws ObjectDisposedException.
  • Skeleton and bind pose. ParentBones is XNA's 71-entry parent table and BoneCount is 71. Once the avatar is ready, BindPose holds 71 local transforms relative to each parent, scaled to the description's height; before that it throws InvalidOperationException.
  • Draw. Draw(IAvatarAnimation*) throws ArgumentNullException for a null animation and forwards its transforms and expression; Draw(const std::vector<Matrix>&, AvatarExpression) throws ArgumentException unless given exactly 71 matrices. Every bone must be decomposable (InvalidOperationException otherwise). Each bone's rotation and scale apply relative to its parent; the root also takes the supplied translation, and every other bone keeps the avatar's own bind offset, so one standard animation fits every height and build. The face features are decals chosen by the AvatarExpression.
  • Loading effect. While loading, a renderer constructed with useLoadingEffect (the one-argument constructor's default) draws a softly pulsing translucent silhouette the height of the avatar.
  • Device. The graphics device comes from the IGraphicsDeviceService of the service provider given to the dispatcher, as in XNA; drawing before gamer services are initialised, or without a device, throws InvalidOperationException.
// In a Game subclass constructed with a GraphicsDeviceManager and a GamerServicesComponent;
// renderer_ is a std::unique_ptr<AvatarRenderer>, animation_ a std::unique_ptr<AvatarAnimation>.
void LoadContent() override {
    description_ = AvatarDescription::CreateRandom();              // a valid CNA avatar
    renderer_ = std::make_unique<AvatarRenderer>(&description_);  // Loading, then Ready
    animation_ = std::make_unique<AvatarAnimation>(AvatarAnimationPreset::Wave);
}

void Update(GameTime& gameTime) override {
    animation_->Update(gameTime.getElapsedGameTimeProperty(), /*loop=*/true);
    Game::Update(gameTime);
}

void Draw(GameTime& gameTime) override {
    renderer_->setWorldProperty(Matrix::getIdentityProperty());
    renderer_->setViewProperty(view_);
    renderer_->setProjectionProperty(projection_);
    renderer_->Draw(animation_.get());   // the loading effect until the avatar is Ready
    Game::Draw(gameTime);
}

The fragment shows the standard XNA call sequence; it was not compiled for this page. As in XNA, an avatar faces −Z, so a camera on the +Z axis looking at the origin sees its back unless the world matrix turns it (XNA's avatar samples use a half turn about Y).

AvatarAnimation and the presets

Each of the 31 AvatarAnimationPreset values (Stand0 to Stand7, Clap, Wave, Celebrate, ten Female* and ten Male*) is an original CNA clip on the 71-bone skeleton, 2.5 to 10 seconds long, with a keyed facial expression (idles blink). BoneTransforms holds 71 local animation deltas at CurrentPosition; non-root translations are zero, because the renderer's BindPose supplies the joint offsets. Update(elapsed, loop) follows the reference: negative time plays backward, looping wraps by the remainder of Length (a position equal to Length is kept), a non-looping animation clamps, and an overflowing step clamps to the nearer end. An undefined preset value is a zero-length bind pose.

XNA space and attachments

The catalog's meshes are authored facing +Z with the avatar's left on +X; XNA avatars face −Z with their left on −X. BoneTransforms and BindPose are exposed in XNA's space, and the renderer converts the caller's bones back to the asset space for skinning and applies the half turn before the caller's World. As a result BoneTransforms[i] * BindPose[i] * parentWorld computes the same joint the renderer draws, which is what XNA samples such as object placement on an avatar rely on to attach props to a hand.

Catalogs, the service and the editor

  • Origin and licence. All geometry, textures and motion are generated by tools/avatar_builder/generate_avatar_catalog.py (pure Python, deterministic), contain no third-party, downloaded or Xbox content, and fall under the repository's own licence (THIRD_PARTY_NOTICES.md).
  • Versions. Each catalog version lives in modules/gamer-services/assets/avatars/v<N>/ and is compiled into the library (cmake/EmbedBinaryFiles.cmake). Catalogs v1, v2 and v3 are released and frozen, pinned by hash tests; v3 is the current one: bodies in toy-like console proportions, 39 wardrobe items (hair, tops, bottoms, shoes, glasses, hats), four facial-hair styles, face-shape controls, hat variants of every hair style, a face-feature atlas and the 31 animations. A description is always drawn from exactly the catalog version it names; ids are never read against another version.
  • A catalog the build lacks. When a description names a catalog this build does not compile in, the loader can install that exact catalog once as a validated pack from the configured service (CNA_AVATAR_CATALOG_UPDATES=0 turns that off; 64 MiB by default at most). Otherwise the avatar is drawn from the service's marked projection onto a catalog the client has, or as the deterministic default avatar (the newest body with the description's body type, height, build and colours, every item at its slot default).
  • The service. With a CNA service, each account has one stored description and revision; BeginGetFromGamer transfers only the description, and the client draws it from its local catalog. No per-user model is ever downloaded.
  • The editor. The avatar editor is a system screen of the Guide (Edit avatar), for accounts and local profiles alike, and cna_avatar_editor is the same screen as a standalone program built with the examples of a 3D renderer. Saving writes the account's avatar to the service or the local profile store; running games see the change through AvatarDescription::Changed. No XNA API is added for it.

Drawing and lighting

The draw and its light rig

XNA's avatar renderer exposes one directional light and an ambient term, and AvatarRenderer configures its internal SkinnedEffect to match on every draw: DirectionalLight0 is enabled with the renderer's LightDirection and LightColor (with a small specular highlight); DirectionalLight1 and DirectionalLight2 are disabled; the emissive colour is zeroed; and AmbientLightColor is set from the renderer. The defaults (key light 0.65 grey from direction (0, 0, -1), ambient 0.35 grey) keep an avatar visible without any configuration. The model always renders solid, with clockwise front faces as in XNA, and the blend, depth-stencil, rasterizer and first sampler states are restored after the draw, so the title's own state survives it. There is no baked ambient occlusion or self-shadowing; shadows a game draws itself, as in the XNA AvatarShadows sample, work.

A lighting defect, historically

One fixed defect in the shared GL stock shaders is worth keeping because it teaches something about testing. The EasyGL skinned, vertex-lit and environment-mapped stock shader variants once multiplied the emissive colour (into which SkinnedEffect folds ambient) by the diffuse colour a second time, so ambient landed as ambient × diffuse², crushing dark materials. Repeated tuning of the ambient value did not help before the formula itself was compared with FNA's; GlStockShaderSources.hpp computes lightSum * diffuse + emissive. The earlier skinned and environment-map tests used diffuse components of exactly 0 or 1, where x * x equals x, so the double multiply was invisible to them. The discriminating test, easygl_emissive_ambient_composition_test.cpp (registered as EasyGL_EmissiveAmbientComposition, and for Vulkan as Vulkan_EmissiveAmbientComposition), pins the diffuse colour to (0.25, 0.5, 0.75) with all directional lights off, so the correct composition gives (64, 128, 191) and the wrong one (16, 64, 143). The general rules: when repeated tuning of a parameter does not move a defect, suspect the formula, not the value; and test with operands for which the wrong formula and the right one disagree.

SkinnedModelEXT, a separate extension

SkinnedModelEXT (SkinnedModelEXT.hpp) is a CNAEXT type in the graphics module for games that bring their own skinned characters; it is unrelated to the avatar API, whose 71-bone rig is AvatarRenderer's own. It is deliberately not built on Model/ModelBone/ModelMesh, whose parent-bone hierarchy describes rigid per-mesh animation rather than per-vertex GPU skinning. It is move-only (copy construction and assignment are deleted) because it owns GPU buffers. Its public data is plain:

  • BoneCount, ParentBoneIndices (root is -1), BindPoseLocal (each bone relative to its parent) and InverseBindPoseGlobal (inverse of each bone's bind-pose world transform).
  • Parts: a vector of PartEXT descriptors, each a Name (the source mesh name the content pipeline wrote), a non-owning ModelMeshPart* and an optional Texture2D*. The model owns the vertex buffers, index buffers, mesh parts and textures in private vectors; there is no per-part material object beyond the texture.
  • Clips: an std::unordered_map from name to AnimationClipEXT, which holds a Duration, a vector of BoneTrackEXT (a BoneIndex and ascending KeyframeEXT keys) and a TargetSpace (JointPalette by default; SceneNode is for rigid node animation elsewhere). A KeyframeEXT defaults its rotation to identity and its scale to one, so a key may carry translation only.

The skeleton is whatever the content defines, up to SkinnedEffect's 72-bone limit. A game draws a model itself: it computes the palette below, hands it to its own SkinnedEffect and draws each part's buffers.

Attaching and replacing parts

AttachPartEXT(SkinnedModelEXT&& other) moves every part of an independently loaded model onto this one, so a separately converted piece (clothing, hair, a prop) can be worn at run time. Its only compatibility check is other.BoneCount == BoneCount (ArgumentException otherwise); a different skeleton with the same count is not detected, so attach only pieces converted from the same skeleton, whose joint indices are then already correct. Before appending, it calls RemovePartEXT for every incoming part name, so re-attaching a same-named piece replaces the old one instead of drawing both. RemovePartEXT(name) removes every part with that name and frees its owned vertex buffer, index buffer, mesh part and texture; erasing directly from the public Parts vector would leak them, because Parts holds only non-owning descriptors. Tests: AttachingSameNamedPartReplacesTheOldOne, RemovePartFreesOwnedResources, RemovePartRemovesAllMatchingNamesNotJustFirst, and the pixel test EasyGL_SkinnedModel_AttachPart, which draws an attached part.

Animation sampling and the bone palette

SkinnedModelEXT::ComputeBoneTransformsEXT(clipName, position, loop, outWorldBones) (SkinnedModelEXT.cpp) turns one clip time into the matrices SkinnedEffect::SetBoneTransforms expects. The steps, in order:

  1. Look up the clip. An unknown name throws ArgumentException (“Unknown animation clip”).
  2. Check the skeleton arrays. ParentBoneIndices, BindPoseLocal and InverseBindPoseGlobal must each have exactly BoneCount entries, or ArgumentException is thrown before any indexing.
  3. Normalise the time. A clip with a zero (or negative) duration is sampled at zero. Otherwise, with loop the position is reduced by tick modulo and a negative remainder is shifted into [0, Duration), so negative time wraps as floor-mod; without loop it is clamped to [0, Duration].
  4. Start from the bind pose. Every local transform is initialised from BindPoseLocal, so a bone with no track holds its bind pose.
  5. Sample the tracks. A track with no keys, or whose BoneIndex is negative or not below the bone count, is skipped without touching memory. Before the first key the first key is used and after the last key the last; between keys, translation and scale use Vector3::Lerp and rotation uses Quaternion::Slerp, with the interpolation amount computed in seconds. The local matrix is CreateScale(s) * CreateFromQuaternion(r) * CreateTranslation(t).
  6. Compose in topological order. For bone i the parent index must be less than i; otherwise ArgumentException is thrown. That single rule also rejects a bone that is its own parent and every cycle, because the highest-index member of a cycle would need a parent with a higher index. A root's world transform is its local transform (any negative parent index means root, not only -1, exactly as in AnimationPlayer); a child's is local * world[parent] (row-vector convention).
  7. Produce the palette. Each output entry is InverseBindPoseGlobal[i] * world[i], ready for SetBoneTransforms; the output vector is resized to BoneCount.

A two-bone worked example

SkinnedModelEXT model;
model.BoneCount = 2;
model.ParentBoneIndices = {-1, 0};
model.BindPoseLocal = {Matrix::getIdentityProperty(),
                       Matrix::CreateTranslation(Vector3(0.0f, 1.0f, 0.0f))};
model.InverseBindPoseGlobal = {Matrix::getIdentityProperty(), Matrix::getIdentityProperty()};

BoneTrackEXT track;                     // drives bone 0 only
track.BoneIndex = 0;
track.Keys.push_back(KeyframeEXT{TimeSpan::FromSeconds(0.0), Vector3(0.0f, 0.0f, 0.0f)});
track.Keys.push_back(KeyframeEXT{TimeSpan::FromSeconds(1.0), Vector3(2.0f, 0.0f, 0.0f)});

AnimationClipEXT clip;
clip.Duration = TimeSpan::FromSeconds(1.0);
clip.Tracks.push_back(track);
model.Clips["Move"] = clip;

std::vector<Matrix> palette;
model.ComputeBoneTransformsEXT("Move", TimeSpan::FromSeconds(0.5), /*loop=*/false, palette);
// palette[0].getTranslationProperty().X == 1.0f  (halfway from 0 to 2)
// palette[1] translates by (1, 1, 0): its bind offset composed with the moved parent

The example follows the algorithm above; it was not compiled or executed for this page. The inverse bind poses are identity here, so the palette equals the world transforms; with real content the inverse bind pose cancels the bind pose, so a clip sampled exactly at rest reduces every palette entry to identity, which is a useful first check for converted content.

Four guards and the failures they replaced

Guard at the snapshotWhat it replacedPinned by
Looped time uses one tick modulo, including negative timeA subtract-one-duration-at-a-time loop whose cost grew with position / DurationWrapsHugePositionInBoundedTime: a position 1,000,000,000.5 durations from the origin must be sampled in under 100 ms
Each parent must precede its childA silent read of a not-yet-computed parent transformForwardReferencedParentThrows, SelfParentThrows
Skeleton arrays must match BoneCountOut-of-bounds std::vector reads from truncated contentMismatchedArraySizesThrows
Empty or out-of-range tracks are skippedIndexing with a corrupt BoneIndexOutOfRangeOrNegativeBoneIndexTrackIsSkippedSafely

All of these are in SkinnedModelEXTTests.cpp (present; not executed for this page). The generic skinning pipeline and SkinnedEffect's palette upload are explained in Tutorial 57: SkinnedEffect and skeletal animation.

The SkinnedModelEXT content format

SkinnedModelTypeReader in ContentManager.cpp loads Load<std::shared_ptr<SkinnedModelEXT>> from a small JSON manifest and binary sidecars:

FileLayout
*.skinnedmodel.json{"skeleton": "…", "parts": [{"name", "vertices", "indices", "vertexStride", "texture"}], "animations": [{"name", "clip"}]}; vertexStride defaults to 52
*.skeleton.binint32 boneCount, int32 parent[boneCount], then boneCount row-major 4×4 float bind-pose-local matrices and boneCount inverse-bind-pose-global matrices
*.clip.bindouble durationSeconds, int32 trackCount; per track int32 boneIndex, int32 keyCount; per key double time, translation (3 floats), rotation quaternion (4 floats, x y z w), scale (3 floats)

The reader validates what it can: a bone count outside 0–100,000 is a ContentLoadException (checked before any allocation); a vertex file whose size is not a multiple of the stride, an index file that is not whole 16-bit indices, or an index that references a missing vertex is rejected; a part entry without a vertices or indices field, or with a non-positive stride, is skipped (a named file that does not exist is an error); an animation entry's clip may also name a .cnj AnimationClip asset so several models can share clips. tools/avatar_asset_pipeline/convert_avatar.py converts a skinned glTF binary into this format.

Three traps that generalise to other importers

  • Resolve sidecars from the manifest's own directory. Every path in the manifest is resolved relative to the manifest, not to the content root, so a bundle in a subdirectory of Content/ is self-contained and relocatable. The resolution goes through ResolveContainedPathRelativeToFile, so a sidecar path cannot escape the content root (or, for a bundle loaded explicitly from outside the root, its own directory). The original reader resolved against the root and failed as soon as a manifest lived in a subdirectory.
  • Never pass several side-effecting reads as arguments of one call. Keyframe components were once built as Vector3(reader.Read<float>(), reader.Read<float>(), …); C++ does not specify the evaluation order of function arguments, the compiler reordered the reads, and components were scrambled. The current reader (CnjCanonicalRead.cpp, ReadCnjAnimationClipSidecar) reads each float into a named local first.
  • Reordering a hierarchy means remapping everything indexed by it. ComputeBoneTransformsEXT needs parents before children, so the converter (convert_avatar.py) sorts bones topologically; glTF gives inverse bind matrices and each vertex's JOINTS_0 in the skin's original joint order, and both must be remapped along with the parent indices, or bones skin with the wrong bind pose and weights.

Skinned glTF and .cnj models do not use this type: they load as an ordinary Model with SkinningData, as Tutorial 57 explains.

Visual limits of the standard avatars

The avatars are functionally complete but are not the console's look, and CNA lists where they fall short in gamer-services-known-limitations.md:

  • Shading. The renderer takes exactly XNA's three light inputs plus a per-material highlight; textures carry some painted shading, but there is no baked ambient occlusion, no contact shadowing between garments and body and no self-shadowing, so avatars look flatter in crevices than the console's. XNA's AvatarRenderer has no input for occlusion, and CNA adds no public property for it.
  • Catalog v3 likeness. Original assets in the Xbox 360-era style (large head, short limbs, fitted clothes, decal faces). Garments are fitted surfaces with hems, cuffs and pockets but no modelled folds; a preset authored for one body lets a planted foot drift 1–3 cm on the other; in the deepest crouch a few millimetres of trouser front can show above a shirt hem.
  • Not Microsoft's avatars. No Microsoft avatar asset is reproduced, there is no avatar ownership or unlock economy, and a description in an unknown future format draws as Unavailable.

None of these affects functional compatibility: a title that positions props from BoneTransforms and BindPose, plays the presets and switches expressions behaves as on the console; only the likeness differs.

Renderer coverage and evidence

avatar_standard_render_test.cpp exercises the standard API through public calls only: a Game with a GamerServicesComponent creates an AvatarRenderer for a fixed description, waits for it to become ready and renders into a 256×256 render target, checking that the loading effect draws while loading, that the ready avatar shows its shirt, trousers and skin colours where the body is, that a different AvatarExpression changes the face and only the face, that Wave lifts the right arm above the shoulder compared with Stand0, and that the background around the avatar is untouched. The EasyGL examples CMake file registers it as EasyGL_AvatarRenderer_Standard and the Vulkan examples CMake file as Vulkan_AvatarRenderer_Standard; both need CNA_ENABLE_NET and a display. The API's behaviour is pinned by the unit test files AvatarRendererTests.cpp, AvatarDescriptionTests.cpp, AvatarDescriptionCodecTests.cpp, AvatarAnimationTests.cpp, AvatarExpressionTests.cpp, AvatarCatalogTests.cpp, AvatarEditorTests.cpp, GuideAvatarEditorTests.cpp and ServiceAvatarTests.cpp in the gamer-services tests, and the ctest GamerServices_AvatarCatalogUpToDate regenerates the newest catalog and compares it byte for byte. Nothing on this page was built or executed; test names are source facts, not pass results.

Read the renderer evidence narrowly. CNA records a full avatar review on 2026-09-30 (257 frames of views, hands, feet, faces, every expression and key frames of every animation, on catalog v3) on OpenGL 3.3, Vulkan, SOFTWARE, SDL_GPU (Vulkan backend) and WebGPU, in one multi-renderer build: against OpenGL 3.3 the mean absolute pixel difference is at most 0.10/255 and the silhouette overlap at least 0.9995 in every frame. The XNA AvatarShadows sample runs on OpenGL ES 3 (Mesa software). Not measured: the Direct3D and Metal renderers and FNA3D; SDL_RENDERER cannot draw the 3D avatar at all. On one Apple M4, CNA's Apple campaign records the standard avatar GPU test passing on OpenGL 3.3, and the avatar model code is compiled without fused multiply-add contraction, so a released description assembles the same vertices on arm64 as on x86-64. Under Emscripten the avatar loader runs its jobs where they are submitted instead of on a background thread, so AvatarRenderer also loads in a single-threaded browser build; CNA records that from a C# browser port of the XNA InverseKinematics sample, not from a browser test of its own.

The demos cna_demo_avatar, cna_demo_avatar_animation_gallery, cna_demo_avatar_dual_compare and cna_demo_avatar_bone_state_boundary, the review utility cna_avatar_review, the standalone cna_avatar_editor and, in the net module, cna_demo_net_avatar_sync (two processes sharing description bytes over SystemLink) use only the standard API; they are built for the three EasyGL identities or VULKAN, with networking on and not for Emscripten, and they are demonstrations, not registered tests.

The avatar visual review

The renderer comparison above comes from tools/avatar_builder/avatar_review.py, which renders review sheets through the real renderer (cna_avatar_review) and through a CPU preview. It is an image review, not a gate: no CTest registration or workflow runs it, and the figures quoted are CNA's own record from 2026-09-30, not re-measured for this page.

An older structural check, avatar_visual_regression_check.py, is still in the tree. It runs cna_demo_avatar under xvfb-run for three pinned views and counts “speckle” (dark pixels touching bright ones) in fixed pixel boxes, against ceilings recorded on 2026-07-18. Those boxes and ceilings describe the avatar the demo drew then; the demo now draws a random standard avatar from the current catalog, so the script's floor is not a measure of today's avatars. Its own header says that passing does not mean the avatar looks correct. Read at c1c316b9; not executed.

The same subject is explained at several altitudes. These are the neighbouring pages at each one.

Tests and validation
GPU and renderer tests