Tutorial 111: The CNJ Format and the gltf_to_cnj Tool
What you’ll learn
- What a
.cnjfile is, and the two fields every single one of them carries. - When you actually need
cna_tool_gltf_to_cnj, and when the runtime path is enough. - Every file a conversion emits, and how the loader stitches them back together.
- The one path-resolution rule that decides where your sidecars have to live.
Before you start — Tutorial 110: Loading glTF 2.0 Models at Runtime. This tutorial is the offline half of the same story, and assumes you already know why the runtime path exists.
CNJ is CNA’s own content format. It is not a compiled binary container and it is not a content pipeline: a .cnj file is a plain JSON document that describes an asset, referring to bulk data (vertices, indices, skeletons, textures) in small binary sidecar files beside it. You can open one in a text editor, diff it in a code review, and generate it from a script.
cna_tool_gltf_to_cnj is the converter that turns a glTF 2.0 file into that shape. It is built unconditionally with the project — there is no CMake option, so it is already sitting in your build tree.
Formats CNA can read side by side
A single content root can hold all of these at once. ContentManager resolves them per asset name, not per project.
| Form | Direction | What it is |
|---|---|---|
.xnb | Read-only | The compiled XNA container, produced by XNA, MonoGame or FNA. CNA reads it and will never write it — there is no ContentImporter or ContentProcessor anywhere in the project. |
.cnj | Read and write | CNA’s own JSON descriptor. One shared extension for every asset type; the "type" field inside says what it is. |
| Native files | Read-only | Ordinary formats loaded straight by extension — .png, .jpg, .bmp, .dds, .wav, .gltf, .glb, and the audio and video containers. |
The one shared .cnj extension is deliberate, and it is the same trade original XNA made: one physical file per logical asset name, with the type resolved from the content rather than from the filename. The cost is opacity — a folder of .cnj files tells you nothing until you open one — exactly as a folder of .xnb files told you nothing.
Here is the current reader-extension map. Every one of these types also accepts a .cnj, because .cnj is tried ahead of a reader’s own native extensions for every registered type:
| Type | Native extensions |
|---|---|
Model | .cnj, .gltf, .glb |
Texture2D | .png, .jpg, .jpeg, .bmp, .gif, .tga, .tif, .tiff, .qoi |
TextureCube | .dds |
SoundEffect | .wav |
Song | .mp3, .ogg, .wav, .flac, .opus, .aac, .wma |
Video | .mp4, .ogv, .webm, .mkv, .avi, .mov |
SpriteFont, Effect, Texture3D, Curve, AnimationClip | .cnj only — these have no native file form |
The envelope: every .cnj carries cnjVersion and type
Whatever else is in the document, the top level always has these two fields:
{
"cnjVersion": 1,
"type": "SpriteFont"
}
cnjVersionversions the envelope, not the per-type schema. It must be an integer inside the range that type understands, and the check is strict: a value of1.5is rejected rather than truncated to1.typeis a cross-check, not the dispatch key. The dispatch key is still theTyou name at theLoad<T>()call site, exactly as in XNA.typeexists so that asking for aSpriteFontand getting aModelfails with a clear message instead of a confusing field-parsing error — and so that tooling with no compile-timeT(validators, editors, migration scripts) can still tell what a file is.
Model is the one type that has reached cnjVersion 2. Version 2 added the "bones" array and the per-mesh "parentBone" index, so that a converted asset rebuilds the same ModelBone tree the runtime glTF path builds directly. Every other type still accepts version 1 only — which means an unknown future version stays a hard error everywhere it has not been defined.
Each of these produces a ContentLoadException naming the file:
| Problem | Message names |
|---|---|
| Not valid JSON, or the root is not an object | the parse failure |
cnjVersion missing | the required field |
cnjVersion out of range, or not an integer | the value and the supported range |
type missing | the required field |
type present but wrong | both the found and the requested type |
Self-contained documents versus metadata sidecars
A .cnj plays one of two roles. Either it is a complete descriptor, or it carries an optional "sourceFile" field and acts as a metadata layer over an ordinary native file that keeps its own decoder.
That second role is why .cnj is checked before native extensions. Textures/hero.png can live entirely on its own; add Textures/hero.cnj beside it and you can attach metadata the PNG format cannot carry, without giving up native PNG decoding for the pixels.
{
"cnjVersion": 1,
"type": "Texture2D",
"sourceFile": "Textures/hero.png",
"colorKey": [255, 0, 255]
}
Not every reader accepts sourceFile, and the ones that do not reject it loudly rather than ignoring it:
| Reader | sourceFile | Notes |
|---|---|---|
Texture2D | Supported | Plus colorKey metadata. |
SoundEffect | Supported | Delegates to the native .wav decoder. |
TextureCube | Supported | Delegates to the DDS decoder. |
SpriteFont | Rejected | Self-contained; the atlas is named by its own "texture" field. |
Effect | Rejected | Self-contained; shader sources are named by "vertex"/"fragment". |
Model | Rejected | Self-contained; geometry is named by the "meshes" entries. |
When you need the converter
Tutorial 110 covered the runtime path: drop a .glb in the content root, call Load<Model>(), done. Reach for the offline converter only when one of these two things is true.
1. The file contains more than one mesh group. The runtime importer reads the first group and stops. The converter writes every group as its own Model asset.
A mesh group is not "a mesh" — it is a set of mesh instances that share one skin. Every mesh node bound to the same skin lands in one group; every unskinned mesh node in the file lands together in a single further group. So an exported scene containing a rigged hero, a rigged enemy and a pile of static scenery is three groups, not three hundred.
2. The file is not authored in metres. glTF mandates metres, and plenty of exporters ignore that. The runtime path hardcodes a unit scale of 1.0; the converter takes an explicit multiplier and applies it uniformly to every position and every bone translation — including the translation keys inside animation clips, so an animated bone cannot jump back into unscaled space mid-clip.
Neither reason is about speed. The converter does not make loading meaningfully faster — it makes loading possible for content the zero-configuration path deliberately does not handle.
Running the converter
# cna_tool_gltf_to_cnj <input.gltf|.glb> <outputDir> <baseName> [unitScale]
# A single-group model, straight into the content root
cna_tool_gltf_to_cnj art/hero.glb Content hero
# A centimetre-authored scene, scaled down by 100
cna_tool_gltf_to_cnj art/city.glb Content city 0.01
Both container forms are accepted. Only asset.version 2.0 is allowed; anything else is refused outright. A unitScale that is not a positive number is refused too, with its own message. The output directory is created if it does not exist.
When the file holds several groups, the base name is suffixed so each group gets a distinct asset name:
| Group | Emitted asset name |
|---|---|
| Only one group in the file | <baseName> — no suffix at all |
| The unskinned group | <baseName>_static |
| A skinned group | <baseName>_<skinName>, or <baseName>_skinN if the skin is unnamed |
The tool prints a line per group summarising mesh parts, bone count and clip count, then any warnings it collected. Warnings are worth reading: they cover things such as a PBR map sampling from a different TEXCOORD set than the base colour texture, which CNA cannot currently honour because its PBR effects sample every map from one shared UV channel.
What a conversion emits
| File | Contents | When |
|---|---|---|
<name>.cnj | The Model descriptor — bones, meshes, materials, references to everything below. | One per mesh group. |
<name>_meshN_verts.bin | Raw interleaved vertex bytes. | One per mesh part. |
<name>_meshN_idx.bin | Raw index buffer. | One per mesh part. |
<name>.skeleton.bin | Bone parents, bind poses, inverse bind poses, and the per-root prefix that carries the joints’ scene ancestry. | Skinned groups only. |
<name>_<clip>.cnj | A standalone AnimationClip document, one per animation. | Skinned groups only. |
<name>_meshN_morph.bin | Per-target position and normal deltas. | Mesh parts with morph targets. |
<name>_texN.<ext> | Textures extracted verbatim from the glTF, PNG or JPEG. | As referenced; shared textures are written once. |
<name>_texoccN.<ext> | An occlusion texture rewritten for the dual-texture lightmap convention. | Only on the DualTextureEffect path. |
The descriptor that ties them together looks like this — trimmed, but the field names are real:
{
"cnjVersion": 2,
"type": "Model",
"skeleton": "hero.skeleton.bin",
"bones": [
{ "name": "Root", "parent": -1, "transform": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] },
{ "name": "Hips", "parent": 0, "transform": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] }
],
"animations": [
{ "name": "Walk", "clip": "hero_Walk.cnj" }
],
"meshes": [
{
"vertices": "hero_mesh0_verts.bin",
"indices": "hero_mesh0_idx.bin",
"vertexStride": 68,
"effect": "SkinnedPbrEffect",
"parentBone": 0,
"texture": "hero_tex0.png",
"normalMap": "hero_tex1.png",
"metallicRoughnessMap": "hero_tex2.png",
"metallicFactor": 1,
"roughnessFactor": 1,
"emissiveFactor": [0, 0, 0]
}
]
}
Note the "effect" field. The converter picks it from the source material, and the choice also fixes the vertex layout written into the .bin:
| Source material | Effect chosen |
|---|---|
| Skinned, with a normal or metallic-roughness map | SkinnedPbrEffect |
| Unskinned, with a normal or metallic-roughness map | PbrEffect |
| Skinned, without those maps | SkinnedEffect |
| Unskinned, with both a base colour and an occlusion texture | DualTextureEffect |
| Everything else | BasicEffect |
That last row is the trap covered in Tutorial 114: a material that expresses itself purely as numbers, with no texture maps at all, is not treated as PBR content and loses its material properties.
Where the sidecars have to live
Every path inside a Model .cnj — skeleton, vertices, indices, texture, clip, morphTargets — is resolved relative to the ContentManager’s root directory, not relative to the .cnj file’s own folder.
The converter writes bare filenames with no directory component. That is exactly right if you convert straight into the content root:
cna_tool_gltf_to_cnj art/hero.glb Content hero
# Content/hero.cnj references "hero_mesh0_verts.bin"
# ContentManager looks for Content/hero_mesh0_verts.bin -- found
If you convert into a subfolder instead, the descriptor still names bare files, and the loader still looks for them at the root — so the load fails:
cna_tool_gltf_to_cnj art/hero.glb Content/models hero
# Content/models/hero.cnj references "hero_mesh0_verts.bin"
# ContentManager looks for Content/hero_mesh0_verts.bin -- not there
You have two clean fixes: convert into the content root, or convert into the subfolder and then rewrite the sidecar names in the JSON to be root-relative ("models/hero_mesh0_verts.bin"). A one-line script is enough, and it is easy precisely because the descriptor is text.
Paths that escape the content root are rejected outright, and so are absolute paths. That check is deliberate: a content file is untrusted input, and a .cnj must not be able to name ../../etc/passwd.
Loading the result
Nothing changes at the call site. Drop the extension, as always:
// Content/hero.cnj plus its sidecars on disk
Model* hero = Content->Load<Model>("hero");
hero->Draw(world, view, projection);
Because a .cnj outranks a same-named .gltf/.glb, you can keep the source file beside the converted one and the converted one keeps winning. That is the same "sidecar always wins" convention every other native format follows — and it means switching a single asset from the runtime path to the converted path is a matter of dropping the .cnj in, not renaming anything.
The clip documents are standalone. They are not tied to the model that shipped them, so a clip can be loaded on its own and shared between rigs with a matching bone layout:
using Microsoft::Xna::Framework::Graphics::AnimationClip;
AnimationClip walk = Content->Load<AnimationClip>("hero_Walk");
Your own .cnj types
The format is not reserved for CNA’s own types. A game can register a factory keyed by the "type" string, which is how two differently-shaped documents can both deserialize into one C++ type:
// Both produce a GameData, chosen by the document's own "type" field.
Content->RegisterCnjLoader<GameData>("EnemyDefinition",
[](const std::string& cnjJson, ContentManager& cm) { return ParseEnemy(cnjJson, cm); });
Content->RegisterCnjLoader<GameData>("LootTable",
[](const std::string& cnjJson, ContentManager& cm) { return ParseLoot(cnjJson, cm); });
Registration fails fast and never silently replaces anything: an empty type name or factory is rejected, registering the same (T, typeName) pair twice throws, and so does registering a loader for a T that already has an ordinary reader — because that reader would win and your factory would never be consulted. Two different type names for the same T are the whole point, and stay fully supported.
This is a different mechanism from RegisterTypeReader<T>(), which allows exactly one reader per T. See Tutorial 46 for that one.
Where to go next
- Tutorial 112: Skeletal Animation from glTF — playing the clips this tool emits.
- Tutorial 114: PBR Materials — what
PbrEffectdoes with those maps and factors. - Tutorial 45: The Content Manager
- Model Loading reference