Assets · guide
Getting content into a running game
What a build has to know, what it ships, and the two shapes a shipped chunk comes in.
Edit this page on GitHubDocuments
What it is
The three pieces that decide whether a file under Assets/ becomes something a game can load:
| Piece | Answers |
|---|---|
GameAssemblies | Which types the build knows about — including the ones the game itself declares |
ProjectPlugins | Which importers the build has — including the ones the project's plugins declare |
CompositorImporter | How a .vxcompositor becomes the chunk a host reads, rather than bytes |
RawPayload | What a dependency turns out to be when it was never a serialized object at all |
What it is for
Shipping a project whose levels name the project's own components, whose frame is its own, and whose models carry blobs no serializer should ever see. All three of those are ordinary, and each used to fail in a way that produced a running game and a wrong picture rather than an error.
You do not need any of this for a game that loads only textures and meshes by address. It is what the awkward cases need, and the awkward cases are where a content pipeline is judged.
Using it
A game's own scene components reach the build through its assembly. vixen import and
vixen content build both take --assembly, and Vixen.Sdk passes the project's own:
dotnet vixen content build --assembly bin/Debug/net10.0/MyGame.dll⚠ Loading is not enough; the module initializer has to be run. A [Component] beside a
[DataContract] reaches SceneComponentRegistry through a [ModuleInitializer] the generator wrote,
and the CLR runs one lazily — at the first access to a type in that module. An assembly loaded and
never otherwise touched has registered nothing. GameAssemblies.Load runs the module constructor
explicitly, then walks the assembly's Vixen references and does the same for each.
That walk is not thoroughness for its own sake. MeshRenderable lives in Vixen.Rendering, which the
build tool references and may never touch, so whether a level naming one compiled came down to which
importers happened to run — a full build worked and an incremental one did not.
A project's plugins reach the build through its Plugins/ folder. vixen import scans the same
folder the editor scans, loads the [Importer]s each plugin declares and nothing else, and withdraws
them again when the command ends. Nothing has to be passed on the command line, and a project with no
Plugins/ folder — which is most projects — pays nothing.
⚠ Without it a plugin-claimed asset built fine and shipped wrong. The registry an import runs against folds in what plugins contributed, and in a command-line process nothing had contributed anything: the asset fell through to the raw-bytes fallback, succeeded, and produced a chunk no typed reader resolves — the editor and the build disagreeing about the same file, silently, with both exiting zero.
⚠ The project's folder and not the user's, which is the one place this differs from the editor.
The editor scans a per-user plugin folder as well, so that somebody can install a tool globally. A
content build must not: two machines with the same checkout have to produce the same bytes, and an
asset imported differently because of what one of them happened to have installed is the divergence
this feature exists to end. A plugin a project's content depends on is one the project carries — and
enabled: false in a plugin.yaml is honoured here exactly as it is in the editor.
⚠ A plugin that will not load is a line and not a failure. A project with one broken plugin still has a build worth finishing; what it must not be is quiet, so the reason is printed and the sentence says which assets will arrive as raw bytes.
⚠ The import that runs before the compiler cannot see the assembly, because the compiler has not
produced it yet. Vixen.Sdk therefore treats that pass as advisory and lets the content build after
Build be the authority: it has the assembly, it re-imports incrementally, and it is the one that
fails a build.
A format with a compiler of its own gets an importer of its own. CompositorImporter reads the
YAML, checks the version, and writes the binary chunk a host loads:
using Vixen.App;public sealed class MyGame : Game { protected override void OnConfigure(AppConfig config) { ArgumentNullException.ThrowIfNull(config); // The address is the project-relative path, extension and all. config.Graphics.Compositor = "Assets/Frame.vxcompositor"; // Node kinds the builder cannot know itself. Also what first touches that assembly, which is // what registers the aliases the document names. config.Graphics.Factories.Add(new Vixen.Rendering.PostFx.PostEffectFactory()); }}⚠ Factories belongs in OnConfigure and nowhere else. The compositor is built inside
AppGraphics' constructor, which runs before OnInitialise — a factory added later is added to a
frame that has already been built and has already thrown.
A dedicated server's content
A server build is the client's build less what the project says a realm never asks for. It is one
flag on a .vxgroup and one on the build:
name: Artpacking: PackTogetherByLabelincludeInServerBuild: falsedotnet vixen content build --variant ServerVixen.Sdk passes it for you: dotnet publish -p:VixenVariant=Server reaches
vixen content build --variant Server, which is what the vixen-game and vixen-mmo Dockerfiles do.
A server build also compiles no shaders.effects at all — a dedicated server runs
Vixen.Graphics.Null and creates no pipeline — and the bundle is a sibling of the catalog rather than
an addressed chunk, so its absence resolves to the "No baked shaders" line a host already prints.
⚠ The build will not decide which assets a server needs, and that refusal is the feature. "A
server needs no textures" is wrong in the case that matters: a terrain heightmap is a texture, and
TerrainColliderSystem builds a shard's collision out of one. A build that stripped by asset type
would take the ground away and report success, and the failure would arrive as a null on a running
server. An author knows which of their groups are art; the build does not.
⚠ What is left out is checked, not trusted. An asset the server build still ships whose dependency it dropped is a build error naming both the asset and the group:
error: 'Materials/hero.vxmat' is addressable as 'materials/hero' and depends on asset …, which is ingroup 'Art' — a group this project says a dedicated server does not need. A server build cannot bothship this asset and leave that one out.That is the whole safety argument. The alternative — dropping the asset at packing time and leaving
the catalog naming it — is what a build with includeInBuild: false used to do, and it fails on a
device rather than at the desk of the person who caused it.
⚠ A project that marks no group gets a server build the size of its client's, minus the shader bundle. That is the safe default and it is also the honest one: nothing has been stripped because nothing has been declared strippable.
Loose content, and why an editor reads it
A content build packs bundles. An import does not: it leaves each asset's chunk in the project's
artefact store and a catalog beside it, and LooseContentSource.Open is what turns that pair into an
AssetManager.
var assets = LooseContentSource.Open(files, root, out var refusal);⚠ The same addresses a shipped build resolves. The catalog is written by the same planner from the same sidecars; what differs is that an entry names no bundle and the bytes stay where the import put them. A host that read content through a path of its own would agree with the game by coincidence, which is the property that makes testing against an editor worth anything.
⚠ The artefact store is mounted up front where a bundle is mounted on demand. An entry that names no bundle asks the asset manager for nothing, so a store opened lazily would be opened never.
⚠ A VirtualPath that is already mounted, not a directory on the host. Engine code addresses
files through the virtual file system and the architecture analyzer enforces it — turning a physical
directory into a mount is a head's job, and that division is why this lives here rather than inside
one host.
⚠ A refusal rather than an exception for a project that has never been imported. It is the state every new project is in, and a host that threw could not open one.
Examples
Reading a chunk that is not an object. A model's cluster hierarchy and page blob are byte spans
VirtualGeometrySystem reads directly; they carry no [DataContract] because nothing should hand
them to a serializer:
using Vixen.Assets;public static class Pages { public static async Task<byte[]> Read(AssetManager assets, string address) { ArgumentNullException.ThrowIfNull(assets); await using var stream = await assets.OpenAsync(address).ConfigureAwait(false); using var buffer = new MemoryStream(); await stream.CopyToAsync(buffer).ConfigureAwait(false); return buffer.ToArray(); }}Those are still dependencies, so they are still in an address's closure and their bundles are still
mounted. What the closure walk does not do is preload them into objects — it records a
RawPayload instead. Failing there made every model with a distance field unloadable by anything
that referenced it, which is to say by every scene in the project.
Naming an artefact's type. An importer's type string is the [DataContract] alias, not a
friendly name for it:
context.Write(context.DeclareSubAsset("Mesh", mesh.Name), "MeshData", Serializer.ToBytes(mesh));⚠ ImportPipeline.TypeIdOf resolves it through the type registry and falls back to
ImportedArtifact — an editor type — when it does not resolve. Writing "Mesh" there stamped every
mesh chunk in every content build with a type no game process has ever heard of, and the symptom was
"nothing registered in this process claims it" at load, about content the build had just declared
good.
Two sub-assets may not share a name. A sub-asset's address is built from its name alone, so a
mesh and its distance field both called Crate claim one address, BuildPlanner refuses the
collision, and the model ends up with no address at all — which fails every scene that references it.
ModelImporter.ClusterName, PageName and FieldName are why the others do not collide.
See also
- Players and possession — what loads a level and puts somebody in it.
The design record is docs/plan/08-asset-pipeline-and-addressables.md. Samples/13-ThirdPersonShooter
is the project that found every failure named on this page.