Every asset in Trident, whether it is a material, a particle system, a keymap, or a level, needs to be opened, edited, displayed in a property grid, serialised to disk, and tracked for unsaved changes. Without a shared foundation, each asset editor would reimplement all of that from scratch. The Document Object Model (DOM) is that foundation.
This is the first in a series of posts covering the DOM system. Part 1 explains the problem, the inspiration behind the design, and the core concepts.
#The problem
A game engine editor deals with many different asset types. A material has shaders, blend modes, and depth-stencil state. A particle system has emitters, emission volumes, and lifetime curves. A keymap has button bindings and axis mappings. Each type has its own shape, but the tooling around them is pretty similar:
- A property grid that displays fields with appropriate controls (sliders for floats, colour pickers for colours, dropdowns for enums).
- Change tracking so the editor knows when a document has unsaved modifications.
- Serialisation to and from JSON on disk.
- The ability to add, remove, and reorder child elements in collections.
Building this per asset type is tedious and error-prone. What I wanted was a way to declare the shape of an asset once, and have the editor infrastructure handle the rest.
#ATF as inspiration
Sony's Authoring Tools Framework (ATF) introduced me to this idea. ATF's DOM gives you a DomNode tree where all application data lives, with the tree structure reflecting how data is contained within other data. You define your data model as a schema, then work with it through adapters, typed wrappers that provide a clean API over the raw nodes. The framework handles persistence, property editing, and change notification.
The important part is that the data itself lives in a generic node tree, separate from the typed adapters that project it and the editor UI that binds to property descriptors.
That said, I had real frustrations with ATF's implementation. Schemas were defined as XSD files, and complex ones became genuinely painful to read and maintain. DomGen, the tool that turned those schemas into C# code, was an external build step that added friction to iteration. XmlSchemaSet was exposed directly in the API making the DOM, and it's definition language feel very coupled.
I took the core idea and rebuilt it for Trident's stack, dropping XSD in favour of a simpler custom schema format, replacing DomGen with a Roslyn source generator that runs as part of the normal build as well as adding a reflection driven runtime DOM provider based on game/engine defined components, and keeping serialisation concerns completely separate from the node tree. The result uses reactive observables instead of traditional events, and JSON instead of XML, but the fundamental architecture, nodes, adapters, and property descriptors, is directly descended from ATF.
#Core concepts
Trident's DOM has four fundamental pieces: type descriptions, nodes, properties, and adapters.
#Type descriptions
A DomNodeTypeDescription defines the shape of a node. It has a name, a display name, and a list of property descriptions. Each property description specifies a name, a type, a default value, and whether it is an array. Think of it as a runtime schema: it tells the system what properties a node of this type should have, without dictating how those properties are used.
Type descriptions are registered in a global DomRegistry at startup, so any part of the system can look up a type by name and create instances of it.
#Nodes
DomNode is the central data container. A node holds an array of IDomNodeProperty instances, one per property in its type description. Nodes form a tree: when a node is assigned as the value of another node's property, the parent-child relationship is tracked bidirectionally. Every node knows its parent and the property that owns it.
Nodes are generic in the sense that they are not subclassed per asset type. A material's root node and a particle system's root node are both plain DomNode instances. What distinguishes them is their type description and the properties it defines.
#Properties
DomNodeProperty<T> holds a single value of type T, backed by a BehaviorSubject. When the value changes, the property emits a notification. The node picks this up and bubbles a ValueChange event upward through the tree, so ancestors can observe changes anywhere in their subtree.
There are a few property flavours:
- Scalar properties hold a single value: a float, a string, a
Vector3, an enum, or a reference to another asset viaTypedContentUri<T>. - Child node properties hold a
DomNode, establishing a parent-child relationship. - Collection properties hold an
ObservableList<DomNode>, supporting add, remove, and reorder operations with change notifications. - Variant properties hold a
DomDescriptionVariant, a discriminated union that can switch between a fixed set of node types at runtime. This covers cases where a property could be one of several shapes, like a particle emission volume being a point, a sphere, or a box.
#Adapters
Working directly with DomNode and string-based property lookups would be fragile. Adapters solve this by wrapping a DomNode in a typed class with real C# properties.
An adapter implements IAdapter<DomNode>, holds a reference to the underlying node (the Adaptee), and exposes typed getters and setters that delegate to the node's properties. It also exposes Observable streams for each property, so the UI can subscribe to changes on specific fields.
The AdaptTo<T>() method on DomNode checks whether the node's type matches the adapter's expected type, and if so, returns a cached adapter instance. This keeps the API clean while maintaining the separation between data and typed access.
DomNode node = materialType.CreateInstance();
DomMaterial? material = node.AdaptTo<DomMaterial>();
// Typed property access through the adapter
material.BlendMode = BlendMode.Additive;
#A real schema
To give a concrete sense of what this looks like in practice, here is the schema that defines a planetary ring in PaxSolaris, the space game built on Trident:
<Schema Namespace="RingEditor.Models" Usings="PaxSolaris.AssetDocumentModels.Models;Tools.Core.Dom.Attributes;Trident.Core">
<Type Name="DomClearanceZone">
<Property Name="Distance" Type="float" Default="0.5f" ValueEditor="SliderValueEditor" Min="0" Max="1" Step="0.01" />
<Property Name="Width" Type="float" Default="0.05f" ValueEditor="SliderValueEditor" Min="0" Max="0.5" Step="0.005" />
</Type>
<Type Name="DomGradientStop">
<Property Name="Position" Type="float" Default="0f" ValueEditor="SliderValueEditor" Min="0" Max="1" Step="0.01" />
<Property Name="Colour" Type="Colour" />
</Type>
<Type Name="DomRingDefinition">
<Property Name="Seed" Type="int" />
<Property Name="Resolution" Type="int" Default="2048" />
<Property Name="BaseArchetype" Type="RingPresetArchetype" />
<Property Name="InnerRadius" Type="float" Default="0.5f" ValueEditor="SliderValueEditor" Min="0" Max="1" Step="0.01" />
<Property Name="ParticleDensity" Type="float" Default="0.8f" ValueEditor="SliderValueEditor" Min="0" Max="1" Step="0.01" />
<Property Name="RingThickness" Type="float" Default="0.02f" ValueEditor="SliderValueEditor" Min="0.001" Max="0.1" Step="0.001" />
<Property Name="BackscatterStrength" Type="float" Default="0.5f" ValueEditor="SliderValueEditor" Min="0" Max="1" Step="0.01" />
<Property Name="NoiseFrequency" Type="float" Default="40f" ValueEditor="SliderValueEditor" Min="1" Max="200" Step="1" />
<Property Name="NoiseAmplitude" Type="float" Default="0.3f" ValueEditor="SliderValueEditor" Min="0" Max="1" Step="0.01" />
<Property Name="ColourGradient" Type="DomNode[]" SubType="DomGradientStop" ValueEditor="MultiPointGradientValueEditor" />
<Property Name="ClearanceZones" Type="DomNode[]" SubType="DomClearanceZone" />
</Type>
</Schema>
This single file declares three DOM types. DomRingDefinition is the root, with scalar properties for ring geometry and noise, a collection of DomGradientStop nodes for the colour gradient, and a collection of DomClearanceZone nodes for gaps in the ring (like those carved by shepherd moons). The ValueEditor and Min/Max/Step attributes tell the property grid exactly which control to use and how to constrain it. From this schema, the source generator produces the type descriptions, the typed adapters, and the registry wiring. No handwritten boilerplate required.
Compare that to an equivalent XSD definition.