Tutorial 111: The CNJ Format and the gltf_to_cnj Tool

CNA Tutorials  ·  3D Content

ℹ

What you’ll learn

  • What a .cnj file 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.
  • How a .cnj and its sidecars compile into a single .cnb (new in this snapshot).

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 human-readable content format. It is not itself a compiled binary container — that is .cnb, which this snapshot can compile a .cnj into — and it is not the build-time Content Pipeline, of which it is an input. 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.

FormDirectionWhat it is
.xnbRead; written by cna-contentThe compiled XNA container, produced by XNA, MonoGame or FNA. CNA reads it at run time, and the build-time cna-content tool (--format xnb) can also write it. Always wins over every other form.
.cnbRead; written by the CNA toolsCNA’s own compiled binary container: one asset per file, CRC-checked. Built by cna-content, cna_tool_cnj_to_cnb, cna_tool_gltf_to_cnb or cna_tool_source_to_cnb. Ranks just below .xnb and above the loose forms it was built from.
.cnjRead and writeCNA’s own JSON descriptor. One shared extension for every asset type; the "type" field inside says what it is. Also an input to the .cnb compilers.
Native filesRead-onlyOrdinary 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.

The full ladder for Load<T>("name") is name.xnb, then name.cnb, then the loose-file reader for T. Here is the current reader-extension map for that last step. 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:

TypeNative 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"
}
  • cnjVersion versions 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 of 1.5 is rejected rather than truncated to 1.
  • type is a cross-check, not the dispatch key. The dispatch key is still the T you name at the Load<T>() call site, exactly as in XNA. type exists so that asking for a SpriteFont and getting a Model fails with a clear message instead of a confusing field-parsing error — and so that tooling with no compile-time T (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:

ProblemMessage names
Not valid JSON, or the root is not an objectthe parse failure
cnjVersion missingthe required field
cnjVersion out of range, or not an integerthe value and the supported range
type missingthe required field
type present but wrongboth 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. sourceFile is resolved relative to the .cnj's own folder, so inside Textures/hero.cnj it is simply hero.png.

{
  "cnjVersion": 1,
  "type": "Texture2D",
  "sourceFile": "hero.png",
  "colorKey": [255, 0, 255]
}

Not every reader accepts sourceFile, and the ones that do not reject it loudly rather than ignoring it:

ReadersourceFileNotes
Texture2DSupportedPlus colorKey metadata.
SoundEffectSupportedDelegates to the native .wav decoder.
TextureCubeSupportedDelegates to the DDS decoder.
SpriteFontRejectedSelf-contained; the atlas is named by its own "texture" field.
EffectRejectedSelf-contained; shader sources are named by "vertex"/"fragment".
ModelRejectedSelf-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 when either of these workflow needs applies.

1. You want separate assets for each mesh group. The runtime importer now combines every group in one Model and exposes its complete skin mapping through getSkinsEXTProperty(). The converter instead 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:

GroupEmitted 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. CNA carries the first two distinct authored TEXCOORD sets into packed UV0/UV1 channels and preserves each map’s own selector and KHR_texture_transform. If a material needs a third distinct set, that map falls back to packed channel 0 and the model’s structured import report names the reduction.

What a conversion emits

FileContentsWhen
<name>.cnjThe Model descriptor — bones, meshes, materials, references to everything below.One per mesh group.
<name>_meshN_verts.binRaw interleaved vertex bytes.One per mesh part.
<name>_meshN_idx.binRaw index buffer.One per mesh part.
<name>.skeleton.binBone parents, bind poses, inverse bind poses, and the per-root prefix that carries the joints’ scene ancestry.Skinned groups only.
<name>_<clip>.cnjA standalone AnimationClip document, one per animation.Skinned groups (joint clips) and unskinned groups (rigid scene-node clips, marked by targetSpace); a skinned group drops, with a warning, rigid tracks its skin does not carry.
<name>_meshN_morph.binPer-target position deltas, optional normal deltas, and an optional versioned trailer of tangent 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 materialEffect chosen
Skinned metallic-roughness material, with or without mapsSkinnedPbrEffect
Unskinned metallic-roughness material, with or without mapsPbrEffect
Explicit unlit or non-PBR materialThe corresponding reduced/stock effect selected by the importer

Factor-only and vertex-coloured metallic-roughness materials stay on the PBR path (as they did at alpha.1); base-colour factors, alpha state and double-sidedness therefore survive even when no texture map is present. Tutorial 114 documents the material surface and renderer-qualified boundaries.

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. Load returns the Model by value.
Model hero = getContentProperty().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::AnimationClipEXT;

// AnimationClipEXT is the registered type; Graphics::AnimationClip is an alias of it.
AnimationClipEXT walk = getContentProperty().Load<AnimationClipEXT>("hero_Walk");

Compiling a .cnj into a .cnb

A converted model is a .cnj plus a handful of sidecars. New in this snapshot, CNA can fold all of that into one compiled file. cna_tool_cnj_to_cnb reads a .cnj and the binary sidecars it names and writes a single .cnb asset:

# cna_tool_cnj_to_cnb <input.cnj> [output.cnb] [--content-root <dir>] [--name <logical>] [--quiet]
cna_tool_cnj_to_cnb Content/hero.cnj Content/hero.cnb --content-root Content

The tool prints what it wrote, how many source files it absorbed (the vertex, index, skeleton and morph sidecars), and any external references it kept out of line (a texture stays a separate asset, referenced by logical name). --content-root is the directory sidecar names resolve against — by default the input file’s own directory, which matches the root-relative rule above when you convert straight into the content root. The output is published atomically, so a failed build leaves the previous .cnb untouched.

You rarely need to call it yourself: cna-content build accepts a typed .cnj as a source, and cna_tool_gltf_to_cnb goes from glTF to .cnb in one step (keeping the intermediate .cnj with --keep-cnj <dir>). Once Content/hero.cnb exists, Load<Model>("hero") finds it before the .cnj and .glb beside it, so you can leave the sources in place. Two limits of the .cnb model schema to know about: it cannot carry glTF material variants (the .cnj-to-.cnb compiler refuses them), and it carries neither cameras nor the import report. See CNB Format, Content Pipeline and Tutorial 146.

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.
getContentProperty().RegisterCnjLoader<GameData>("EnemyDefinition",
    [](const std::string& cnjJson, ContentManager& cm) { return ParseEnemy(cnjJson, cm); });

getContentProperty().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