Avatars: the standard XNA avatar API on CNA's own avatars
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 3b43bd86; 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, the example and renderer CMake registrations and the asset converter at 3b43bd86. 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.
| Aspect | XNA avatar API (Microsoft::Xna::Framework::GamerServices) | SkinnedModelEXT (CNAEXT) |
|---|---|---|
| Purpose | XNA's avatar classes with real behaviour on CNA's own avatars | Load and draw a game's own GPU-skinned mesh, skeleton and clips |
| Skeleton | XNA's fixed 71-bone rig (AvatarRenderer::BoneCount) | Content-defined, up to SkinnedEffect::MaxBones (72) |
| Content | The avatar catalog, compiled in and linked only by programs that use avatars; no content files to copy | A .skinnedmodel.json manifest and binary sidecars loaded through ContentManager |
| Drawing | AvatarRenderer::Draw, through SkinnedEffect internally | The game's own SkinnedEffect draw with the computed palette |
| Availability | Whenever GamerServices is built (CNA_ENABLE_GAMER_SERVICES) | Always, in the graphics module |
The whole avatar family lives in the GamerServices module, so it exists only when CNA_ENABLE_GAMER_SERVICES is on (its default follows CNA_ENABLE_NET, which is on by default and requires it); see gamer services internals. Drawing goes through SkinnedEffect and vertex buffers, so an avatar is only as faithful as the selected renderer's SkinnedEffect route.
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.IsValidkeeps XNA's rule: true when byte 0 is non-zero. - Inside the buffer CNA stores its own versioned encoding (
AvatarDescriptionCodec.cpp): a format byte (1, 2 or 3), the lettersCNA, body type, height in millimetres (1,450–2,050), build, the catalog version the item ids refer to (3, the baseline, or a later one), seven colours and the hair, top, bottom, shoe, glasses and hat item ids; format 2 adds a facial-hair item and sixteen face-shape bytes, and format 3 an eyebrow style, an ear shape, earrings, wristwear, gloves and rings with the side they are worn on, makeup styles and their colours, a proportions byte and sixteen more face-shape bytes. Each format keeps the bytes of the one before; a description is written, and read only, in the lowest format that says everything it says. The rest is zero and the last four bytes are a CRC-32.Height(in metres) andBodyTypeare read from it. - A buffer that is not a CNA description (another format, a non-canonical one, a damaged checksum, non-zero reserved bytes, or a catalog version below 3) keeps XNA's
IsValidanswer, which looks only at the first byte, but reports height 0 and the female body type, and a renderer given it isUnavailable. In particular, a non-zero Xbox description is not a CNA avatar. CreateRandom()andCreateRandom(AvatarBodyType)return valid descriptions from the newest catalog compiled in: a face from a curated preset with a little noise, natural skin, hair and eye colours, an outfit or clothes in colours that go together, and now and then accessories, facial hair (by the catalog's per-body weights) and makeup; the second overload throwsArgumentOutOfRangeExceptionfor an undefined body type. Passing a description's bytes back to the constructor reproduces the same avatar, which is how games share avatars over aNetworkSessionthe XNA way, with no service involved.BeginGetFromGamerthrows for a null or disposed gamer and returns a caller-ownedIAsyncResult. 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 duringGamerServicesDispatcher::Updatewith 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.EndGetFromGamerthrowsArgumentExceptionfor a result it did not produce.Changed: each signed-in player keeps the descriptionEndGetFromGamerreturned until that avatar changes;Updatere-reads a kept avatar every 10 seconds and raisesChangedon the dispatcher thread when it differs. Because the C++ description is a value, every copy shares oneChangedevent (AvatarDescription.hpp).
AvatarRenderer
- Construction and state. Both constructors throw
ArgumentNullExceptionfor a null description (AvatarRenderer.cpp). A description without an avatar, or one CNA cannot read, isUnavailableand draws nothing. A valid one isLoadingwhile one shared background loader assembles the body, wardrobe and face from the catalog, thenReady; identical descriptions share the assembled model. If loading completes during aDraw, that frame still takes the loading path andStatereadsReadyafterwards, so a game that sets bone transforms only after it seesReadynever draws an uninitialised pose. ReadingStateafterDisposethrowsObjectDisposedException. - Skeleton and bind pose.
ParentBonesis XNA's 71-entry parent table andBoneCountis 71. Once the avatar is ready,BindPoseholds 71 local transforms relative to each parent, scaled to the description's height; before that it throwsInvalidOperationException. - Draw.
Draw(IAvatarAnimation*)throwsArgumentNullExceptionfor a null animation and forwards its transforms and expression;Draw(const std::vector<Matrix>&, AvatarExpression)throwsArgumentExceptionunless given exactly 71 matrices. Every bone must be decomposable (InvalidOperationExceptionotherwise). 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 theAvatarExpression. - 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
IGraphicsDeviceServiceof the service provider given to the dispatcher, as in XNA; drawing before gamer services are initialised, or without a device, throwsInvalidOperationException. A renderer's buffers and textures belong to that device: they are released when it is disposed, and a renderer drawn on a new device builds them again there.
// 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 CNA's own licence, a copy of which the cna-avatar-assets repository that holds the catalog carries (THIRD_PARTY_NOTICES.md). - Versions. Catalog v3 is CNA's baseline catalog, the oldest it supports and the only one it ships: it lives in
modules/gamer-services/assets/avatars/v3/, which is the cna-avatar-assets repository checked out as a Git submodule at the commit CNA records (4617a1e7503f), and is compiled into the library (cmake/EmbedBinaryFiles.cmake), deflated, in a separate catalog library that only programs using avatars link. Configuring with Gamer Services on fails until that submodule is initialized (git submodule update --init modules/gamer-services/assets/avatars); a source archive (a GitHub ZIP,git archive) contains no submodules and cannot build avatars, and a build withCNA_ENABLE_GAMER_SERVICES=OFFdoes not need the catalog (modules/gamer-services/CMakeLists.txt). Catalogs 1 and 2 were development catalogs; they are not shipped, and a description, manifest or pack naming one is refused. Its current content is revision 5 of the same version, which names each ear shape's lobes, so earrings hang from the lobe of the shape worn, and keeps trousers and skirts under the top in the deepest crouch; moving the catalog into the submodule changed none of its bytes. It holds a body per body type in stylized console-era adult proportions with a sculpted head, 143 wardrobe items and accessories (37 hairstyles, 28 tops, 17 bottoms, 14 shoes, 13 glasses, 15 headwear, 6 earrings, 6 wristwear, 4 gloves, 3 rings), 16 facial-hair styles, 6 ear shapes, 12 eyebrow styles and makeup styles drawn from the face atlas, 32 face controls with 59 named face presets, 10 outfits and the 31 animations. A revision may rework art and add entries, but every first-release id keeps its kind and its format 1, and everything added is format 3, so a first-release description stays valid. 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 later 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=0turns 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). A version below the baseline is never installed or asked for. - The service. With a CNA service, each account has one stored description and revision;
BeginGetFromGamertransfers 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_editoris the same screen as a standalone program, built with the examples whenever the default renderer draws (notHEADLESSorSTUB) and not for Emscripten. Saving writes the account's avatar to the service or the local profile store; running games see the change throughAvatarDescription::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 faint highlight, which each part's material scales and sharpens: metal small and bright, skin broad and soft, cloth barely); 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), 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) andInverseBindPoseGlobal(inverse of each bone's bind-pose world transform).Parts: a vector ofPartEXTdescriptors, each aName(the source mesh name the content pipeline wrote), a non-owningModelMeshPart*, an optionalTexture2D*and aDiffuseColor(Vector4: the source material's base colour and alpha, white when none was given), which the game's draw applies asSkinnedEffect'sDiffuseColorandAlphabeside the texture. 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 and that colour.Clips: anstd::unordered_mapfrom name toAnimationClipEXT, which holds aDuration, a vector ofBoneTrackEXT(aBoneIndexand ascendingKeyframeEXTkeys) and aTargetSpace(JointPaletteby default;SceneNodeis for rigid node animation elsewhere). AKeyframeEXTdefaults 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:
- Look up the clip. An unknown name throws
ArgumentException(“Unknown animation clip”). - Check the skeleton arrays.
ParentBoneIndices,BindPoseLocalandInverseBindPoseGlobalmust each have exactlyBoneCountentries, orArgumentExceptionis thrown before any indexing. - Normalise the time. A clip with a zero (or negative) duration is sampled at zero. Otherwise, with
loopthe position is reduced by tick modulo and a negative remainder is shifted into[0, Duration), so negative time wraps as floor-mod; withoutloopit is clamped to[0, Duration]. - Start from the bind pose. Every local transform is initialised from
BindPoseLocal, so a bone with no track holds its bind pose. - Sample the tracks. A track with no keys, or whose
BoneIndexis 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 useVector3::Lerpand rotation usesQuaternion::Slerp, with the interpolation amount computed in seconds. The local matrix isCreateScale(s) * CreateFromQuaternion(r) * CreateTranslation(t). - Compose in topological order. For bone
ithe parent index must be less thani; otherwiseArgumentExceptionis 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 inAnimationPlayer); a child's islocal * world[parent](row-vector convention). - Produce the palette. Each output entry is
InverseBindPoseGlobal[i] * world[i], ready forSetBoneTransforms; the output vector is resized toBoneCount.
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 snapshot | What it replaced | Pinned by |
|---|---|---|
| Looped time uses one tick modulo, including negative time | A subtract-one-duration-at-a-time loop whose cost grew with position / Duration | WrapsHugePositionInBoundedTime: a position 1,000,000,000.5 durations from the origin must be sampled in under 100 ms |
| Each parent must precede its child | A silent read of a not-yet-computed parent transform | ForwardReferencedParentThrows, SelfParentThrows |
Skeleton arrays must match BoneCount | Out-of-bounds std::vector reads from truncated content | MismatchedArraySizesThrows |
| Empty or out-of-range tracks are skipped | Indexing with a corrupt BoneIndex | OutOfRangeOrNegativeBoneIndexTrackIsSkippedSafely |
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 ContentModelLoaders.cpp loads Load<std::shared_ptr<SkinnedModelEXT>> from a small JSON manifest and binary sidecars:
| File | Layout |
|---|---|
*.skinnedmodel.json | {"skeleton": "…", "parts": [{"name", "vertices", "indices", "vertexStride", "texture", "diffuseColor"}], "animations": [{"name", "clip"}]}; vertexStride defaults to 52 and must be 52, the VertexPositionNormalTextureSkinned record the part's vertex buffer is declared as; diffuseColor is four finite numbers (RGBA), white when absent |
*.skeleton.bin | int32 boneCount, int32 parent[boneCount], then boneCount row-major 4×4 float bind-pose-local matrices and boneCount inverse-bind-pose-global matrices |
*.clip.bin | double 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, while a positive stride other than 52 or a malformed diffuseColor is a ContentLoadException (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: it writes each glTF primitive as a part of its own, carries the material's base-colour factor into diffuseColor and its base-colour image into the part texture, and swaps each triangle's last two corners so the parts have XNA's clockwise front faces (glTF's are counter-clockwise).
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 throughResolveContainedPathRelativeToFile, 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.
ComputeBoneTransformsEXTneeds parents before children, so the converter (convert_avatar.py) sorts bones topologically; glTF gives inverse bind matrices and each vertex'sJOINTS_0in 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 and the parts that sit in shade (the scalp under hair locks, the inside of the ear) are drawn darker, 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
AvatarRendererhas no input for occlusion, and CNA adds no public property for it. - Catalog v3 likeness. Original assets in a stylized, friendly Xbox 360-era style (a sculpted head, fitted clothes, decal faces and makeup). Garments carry soft low-frequency folds in their surface (hanging folds and gathers a few millimetres deep, folds at cuffs, elbows and knees) with hems, cuffs, pockets and quilting, but no fine creases and no normal-mapped detail (
SkinnedEffecthas no normal map); hair is shaped clumps and shells, so curls, coils and twists read as a stylized mass rather than strands; under a hat, hair is flattened and hidden where the hat passes through or over it, so a tail or a very wide style is cut at the brim and shows a ragged edge just under it from behind (an afro under a bucket hat); earrings hang from the lobe of the ear shape worn but keep the first shape's tilt on the others; makeup does not follow the eye expressions, except eyeliner; a preset keeps a planted foot exactly in place only on the body it was solved on: on the other body at its authored height the foot moves up to 7 mm, and at the ends of the height and proportion ranges up to 3 cm in the presets that move the root most (FemaleIdleFixShoe,Celebrate,MaleSurprised), because XNA'sBoneTransforms * BindPosecomposition makes a clip's root motion the same in metres on every avatar; in the deepest crouch the tops of trouser legs and a skirt's flare can come a few millimetres through some tops at the sides, most on the tight tank top (bounded byTheTrousersStayUnderTheTopInTheDeepestCrouch). - 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, its only registration; it needs CNA_ENABLE_GAMER_SERVICES 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, the ctest GamerServices_AvatarCatalogUpToDate regenerates the newest catalog and compares it byte for byte, and GamerServices_AvatarAcceptanceStatic checks that the catalog, the generator, the codec, CMake and CNA's avatar documents agree on the catalog version, revision, counts and size. Nothing on this page was built or executed; test names are source facts, not pass results.
Read the renderer evidence narrowly; every figure here is recorded by CNA (gamer-services-known-limitations.md, entry 33) and none was re-measured for this page. At catalog v3 revision 4, on 2026-10-24, the full avatar review (982 frames: views, hands, feet, every face control at both ends, every hairstyle from the front and from behind, every garment, headwear, facial-hair style, ear shape, accessory and makeup style, portraits, face presets, builds, key frames of every animation and every expression) rendered on OPENGL33, OPENGLES3, SOFTWARE, SDL_GPU (on SDL's Vulkan driver), WEBGPU and FNA3D (on its SDL_GPU Vulkan driver) from one multi-renderer build: against OPENGL33 the worst mean absolute pixel difference is 0.09/255 (WEBGPU) and the worst silhouette overlap 0.9992 (SOFTWARE), and OPENGLES3 is identical. At revision 5, on 2026-10-25, the acceptance command below passed on all eight renderers compiled into that build: the 233 avatar tests it names on each (one skip by design, three on STUB) and the 36-view visual regression on the six that draw. Through Windows translation layers, and not repeated for revision 5: revision 4's 258-frame review rendered on DIRECTX9 (Wine with DXVK) and on SDL_GPU with SDL's Direct3D 12 driver (Wine with vkd3d-proton) from the MinGW build on 2026-10-16. The XNA AvatarShadows sample ran on OPENGLES3 (Mesa software) on 2026-09-30 and was not re-run since. Not measured: SDL_GPU on SDL's Metal driver, and DIRECTX9, FNA3D or SDL_GPU on SDL's Direct3D 12 driver on native Windows drivers. 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, cna_demo_avatar_bone_state_boundary 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 when the default renderer is one of the three EasyGL identities, with GamerServices on and not for Emscripten. The review utility cna_avatar_review and the standalone cna_avatar_editor are built for any default renderer that draws (not HEADLESS or STUB), also not for Emscripten. All of them are demonstrations and tools, not registered tests.
The avatar visual review, the visual regression and the acceptance command
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 records, not re-measured for this page.
The gate is the checked-in visual regression in tools/avatar_builder/regression/: 36 views of nine described avatars and of three CreateRandom avatars from a fixed seed, covering both body types, a range of heights, builds, skin tones, face presets, hairstyles, facial hair, glasses, headwear, makeup, earrings on three ear shapes, hand accessories and outfits, from several angles and close-ups, in several animations including the deepest crouch. Its manifest.json is a cna_avatar_review job list and golden/ holds the renders on SOFTWARE, the canonical renderer. The CTest entry GamerServices_AvatarVisualRegression (labels avatar, visual; it needs Python 3 with Pillow and a display) draws them with the build's default renderer and compares each with its golden, SOFTWARE strictly and every other renderer within the differences measured between renderers (TOLERANCES in avatar_visual_regression.py). It also fails when a view is missing or the manifest no longer matches the catalog revision and catalog.json hash of the checkout. It replaced the earlier speckle-count script scripts/avatar_visual_regression_check.py, which CNA no longer carries.
One command says whether Avatar v3 is whole in a build tree (docs/avatars.md):
python3 tools/avatar_builder/avatar_acceptance.py <build-tree> [--all-renderers | --renderer X ...] [--service ../cna-gamer-services-server]
It refuses a checkout with uncommitted changes or a catalog submodule off its recorded commit (unless told to run anyway, and then names no validated commit), builds every binary it runs from that commit, checks that the catalog, generator, codec, CMake and documentation agree, runs the avatar tests listed in tools/avatar_builder/acceptance_tests.json on each renderer and the visual regression on each renderer that draws, and measures the catalog library's size, the link footprint of a game without avatars and the first, next and reused avatar load times against budgets. CNA's Debug figures for revision 5 are a 22.3 MB deflated catalog library (46.0 MB of files), the first avatar of a process in about 370–410 ms, the next in about 140–210 ms, and an avatar a renderer still draws reused in about 0.3 ms; an avatar and its textures are released once nothing draws them. Those are CNA's records, not measured here.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- User guide
- XNA compatibility: GamerServices · Tutorial 57: SkinnedEffect and skeletal animation · Tutorial 167: drawing and animating avatars
- Tests and validation
- GPU and renderer tests
- Reference
- Public headers: gamer services