Skip to content

Unity SerializedObject / SerializedProperty (C# / Unity Editor)

The corpus's sixth addressing model — a mutable cursor over a serialized mirror, not a node — and the only subject with a runtime visited set for cycles.

Language / toolkitC# / Unity's IMGUI (EditorGUI) and UIElements
LicenseUnity Reference-Only License — "May be used for reference purposes only" (README.md)
RepositoryUnity-Technologies/UnityCsReference (Unity 6000.7.0a4)
Revision read225b0fbd (2026-08-06)
CategorySerialized mirror + cursor; retained drawer objects
Metadata sourceruntime — the serialization system, plus C# attributes resolved into drawers
Undoautomatic on ApplyModifiedProperties()

IMPORTANT

Scope: the managed layer only. This repository is the published C# half of the editor; the native implementation behind every extern declaration is not in it. Where a behaviour is [NativeName]-bound (NextVisible, HasMultipleDifferentValues, GetIsExpanded, …) this page reports the contract the C# layer states and marks anything beyond it as INFERENCE. Unity was added in the Tier-2 pass specifically to get a source-readable subject on the axes where Unreal could only be read from documentation.

Overview

What it solves

The Inspector: an editor over one or many selected objects, driven by the same serialization system that writes scenes and prefabs to disk, with per-type and per-attribute customization (PropertyDrawer) and full undo integration.

Design philosophy

The inspector never touches the target object's fields. It edits a serialized mirror that is pulled before drawing and pushed after:

csharp
SerializedProperty property = obj.GetIterator();
bool expanded = true;
while (property.NextVisible(expanded))
{
    using (new EditorGUI.DisabledScope("m_Script" == property.propertyPath))
        EditorGUILayout.PropertyField(property, true);
    expanded = false;
}
obj.ApplyModifiedProperties();

Editor.cs:862

Two things in that loop are unlike anything in the Tier-1 corpus. The tree is walked by a single mutable cursor (NextVisible(enterChildren)), and the whole frame's edits are committed by one call at the end.

Model & addressing

SerializedProperty is an iterator, and the C# doc comment says so where it hands out copies:

Returns a copy of the SerializedProperty iterator in its current state. This is useful if you want to keep a reference to the current property but continue with the iteration.

SerializedProperty.bindings.cs:243

The consequences run through the whole design:

  • Advancing is the walk. NextVisible(bool enterChildren) (:522) moves the cursor through the visible sequence — it skips the children of a collapsed row, so laziness is a property of the traversal rather than of a materialisation step. depth reports the nesting level, and GetEndProperty() bounds a subtree.
  • A durable address is a string. propertyPath (:728) is the persistent identity (materials.Array.data[2].shader), cached against a native hash so it is only re-fetched when it actually changed. FindPropertyRelative(string) (:254) navigates by that path, Copy() snapshots the cursor.
  • Anything per-row is a side table keyed by that path. The reorderable-list wrappers are a static dictionary keyed by ReorderableListWrapper.GetPropertyIdentifier(property) (PropertyHandler.cs:49).

So Unity separates what Unreal fuses into one handle: the cursor is cheap and transient, the path string is the identity, and serializedObject (:151) is the mediated access. It is the same three jobs, unbundled.

Metadata

Runtime, from two sources. The serialization system supplies the structure and the SerializedPropertyType discriminant (Integer, ObjectReference, ManagedReference, …, :85); C# attributes supply presentation and are resolved into drawer types through a lazily-built dictionary, k_DrawerTypeForType (ScriptAttributeUtility.cs:49, built at :125).

Resolution (GetDrawerTypeForType, :164) walks the type's base chain and then its interfaces, honouring useForChildren on [CustomPropertyDrawer] — with a documented exception for managed references, whose dynamic type is only known at runtime:

The custom property drawers for those are defined with 'useForChildren=false' … so even if 's_DrawerTypeForType' is built (based on static types)

ScriptAttributeUtility.cs:316

Recursion

Descent is the cursor's enterChildren flag, and expansion decides it. Two mechanisms then matter more than the walk itself.

Expansion lives in the serialized data. isExpanded is a property of the SerializedProperty (:926), backed by native GetIsExpanded/SetIsExpanded — not by the window, not by a view-side set. This is Godot's "fold state on the edited object" taken one step further: in Unity the fold state is part of the object's serialized representation, so it survives a rebuild, a re-selection and a domain reload for free. INFERENCE: where it is persisted (scene file, editor-local database) is native and not visible here.

Nesting is capped by the drawer list. PropertyHandler holds a list of drawers indexed by nesting level, and returns null past its end:

csharp
internal PropertyDrawer propertyDrawer
{
    get
    {
        if (m_PropertyDrawers == null || m_NestingLevel >= m_PropertyDrawers.Count)
            return null;
        return m_PropertyDrawers[m_NestingLevel];
    }
}

PropertyHandler.cs:30

Every drawer invocation is wrapped in IncrementNestingContext() (PropertyHandler.cs:251), so a drawer that draws the same type again does not re-enter itself: the next level either uses the next registered drawer or falls back to the default field. That is a real, deliberate recursion guard — a per-type customization cannot loop forever by drawing itself.

Cycles

Unity has the only runtime visited set in the corpus, and its placement is the finding:

csharp
// Managed reference objects can form a cyclical graph, so need to track visited objects
if (visited == null)
    visited = new HashSet<long>();
long refId = search.managedReferenceId;
if (!visited.Add(refId))
{
    visitChild = false;
    continue;
}

EditorGUI.cs:7841, inside SetExpandedRecurse (:7832)

It guards SetExpandedRecurseexpand-all, an automatic walk — not painting. Painting stays click-driven and needs no guard, exactly as in Godot and WinForms. The rule the corpus was missing is therefore not "reflective editors need a visited set"; it is "a walk the user does not drive needs a visited set, and a walk they do drive does not." [SerializeReference] graphs are where Unity has to pay it, because those are the only serialized values that can alias.

Editing & mutation

  • DispatchPropertyHandler.OnGUI (:210): decorator drawers first ([Header], [Space]), then a PropertyDrawer if one resolved for the type or the attribute, then a reorderable list for arrays, then EditorGUI.DefaultPropertyField (EditorGUI.cs:7944). A drawer returning nothing collapses to the default — there is no error state for an unhandled type.
  • Mutation — writes land in the mirror, not the object. The commit boundary is SerializedObject.ApplyModifiedProperties() (SerializedObject.bindings.cs:122), which is also where undo is registered; ApplyModifiedPropertiesWithoutUndo() (:180) is the explicit opt-out. The pull direction is Update() (:131) / UpdateIfRequiredOrScript().
  • Commit semantics — per frame, not per keystroke: the IMGUI loop reads, draws, and applies once. The transient/committed distinction is absent from this layer (no equivalent of Unreal's InteractiveChange); the merge is instead a property of the undo system's recording. INFERENCE, since that machinery is native.
  • Change notification — none needed: Update() re-pulls the mirror every frame, so an external write appears on the next repaint. SetIsDifferentCacheDirty() (:125) invalidates the multi-object comparison cache — "Update hasMultipleDifferentValues cache on the next Update() call".
  • Validation — none in this layer; a setter's own clamping is all there is.

Type coverage

  • Collections — arrays are properties (arraySize, GetArrayElementAtIndex, :284) and get a ReorderableListWrapper with add/remove/drag, its state kept in a static dictionary keyed by the property path.

  • Polymorphic valuesSerializedPropertyType.ManagedReference is the sum type: [SerializeReference] stores a concrete type name (managedReferenceFullTypename) beside the value. The UIElements PropertyField treats a change of that type name as a change of property type, tearing down and rebuilding its children:

    csharp
    if (newPropertyType == SerializedPropertyType.ManagedReference)
        newPropertyTypeName = newProperty.managedReferenceFullTypename;
    
    newPropertyTypeIsDifferent = newPropertyTypeName != m_SerializedPropertyReferenceTypeName;

    PropertyField.cs:249

    This is the corpus's clearest statement of the variant-switch rule that comparison.md drew from bevy: the subtree is a function of the concrete type, and nothing survives its change.

  • Optional / nullable — an ObjectReference may be null and renders as an empty object field; there is no distinct "unset" beyond that.

  • Opaque types — a value the serializer does not know is simply not in the property stream. Invisible rather than shown-and-inert.

Multi-object editing

The axis Unity was added for, and it is native and per-property:

  • hasMultipleDifferentValues (:609) answers "do the selected objects disagree?", with hasMultipleDifferentValuesBitwise (:618) for flag/mask fields so a partially agreeing bitmask can be rendered per bit.
  • Rendering is a global mode, not a per-control argument: EditorGUI.showMixedValue (EditorGUI.cs:273) is a static property that every control consults, drawing an em-dash with the tooltip "Mixed Values" (:275) and using the sentinel string "<>" inside text fields (:189).
  • Writing through the cursor writes every target, so a mixed row becomes uniform on the first edit.

Compared with the corpus: WinForms answers the same question with a merged descriptor and allEqual, Unreal with per-object value lists, Godot not at all. Unity's version is the cheapest of the three for the view — one static flag — and the most demanding of the model, which must maintain a comparison cache it can be told to invalidate (SetIsDifferentCacheDirty).

Presentation & control

  • Grouping / ordering — serialization order, with [Header]/[Space] decorator drawers; no category model.
  • Conditional visibilityNextVisible skips what the serializer hides ([HideInInspector], non-serialized fields); anything value-dependent is a custom drawer or editor.
  • Search / filter — not in the inspector proper; SerializedPropertyFilters and SerializedPropertyTreeView serve the table-style windows instead.
  • Escape hatches, in increasing scope: [CustomPropertyDrawer] per attribute → per type (useForChildren) → [CustomEditor] replacing the whole inspector → UIElements PropertyField for a retained-element tree.
  • Virtualization — IMGUI draws only what the layout says is visible, and the reorderable list computes an explicit visibility rect so collapsed, off-screen array elements do not disturb the scrollbar (PropertyHandler.cs:287).

Strengths

  • The mirror decouples editing from the object: one pull, one push, undo attached to the push.
  • Cursor + path string separates cheap traversal from durable identity.
  • Mixed values are a first-class per-property answer, down to per-bit for masks.
  • The nesting-indexed drawer list stops per-type customizations from recursing into themselves — a guard nobody else has.
  • The one automatic walk that can meet a cyclic graph carries a visited set.

Weaknesses

  • Identity is a stringly-typed path; per-row state lives in static dictionaries keyed by it.
  • The mutable cursor is easy to misuse — Copy() exists because holding "the property" while continuing to iterate is otherwise wrong.
  • showMixedValue is global mutable state that every control must remember to consult and restore.
  • No transient/committed distinction and no validation seam at this layer.
  • Half the behaviour is native and unreadable, so several answers here are contracts rather than implementations.

Key design decisions and trade-offs

DecisionRationaleTrade-off
Edit a serialized mirror, not the objectOne commit point for undo, prefab overrides and multi-object writesThe mirror must be pulled every frame and invalidated explicitly
The property is a cursor, not a nodeTraversal is allocation-free; visibility is a traversal ruleCallers must Copy() to retain; identity has to be a separate string
isExpanded on the serialized propertySurvives rebuild, re-selection and domain reload with no view stateView state becomes model state, shared by every window on the object
Drawer list indexed by nesting levelA type's drawer cannot recurse into itselfDeep nesting silently degrades to the default field
Visited set only in SetExpandedRecurseThe cost is paid exactly where the walk is automaticClick-driven descent into a cyclic [SerializeReference] graph is still unbounded
showMixedValue as ambient stateEvery existing control supports multi-edit with no signature changeGlobal mutable flag; forgetting to restore it leaks into later rows

Sources

All line numbers are at 225b0fbd.